diff --git a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt index 3ec25ee..561388e 100644 --- a/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt +++ b/Kiosk/android/app/src/main/kotlin/com/secgo/kiosk/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.os.Build import android.provider.Settings import android.util.Log @@ -43,6 +44,55 @@ class MainActivity : FlutterActivity() { "getLatestWechatNotification" -> result.success(getLatestWechatNotification()) "getLatestWechatPaymentNotification" -> result.success(getLatestWechatPaymentNotification()) "getActiveWechatNotificationsSnapshot" -> result.success(getActiveWechatNotificationsSnapshot()) + "openLauncherHome" -> { + val intent = + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + result.success(true) + } + "openApp" -> { + val pkg = call.argument("packageName")?.trim() + if (pkg.isNullOrBlank()) { + result.success(false) + return@setMethodCallHandler + } + val launchIntent = packageManager.getLaunchIntentForPackage(pkg) + if (launchIntent == null) { + result.success(false) + return@setMethodCallHandler + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(launchIntent) + result.success(true) + } + "listLaunchableApps" -> { + val intent = + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + + val activities = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentActivities(intent, 0) + } + + val resultList = + activities.map { ri -> + val label = ri.loadLabel(packageManager)?.toString() ?: "" + val pkg = ri.activityInfo?.packageName ?: "" + mapOf( + "packageName" to pkg, + "label" to label, + ) + }.filter { it["packageName"]?.isNotBlank() == true } + result.success(resultList) + } "openNotificationListenerSettings" -> { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) result.success(true) diff --git a/Kiosk/lib/l10n/app_en.arb b/Kiosk/lib/l10n/app_en.arb index 7b2dbe0..bcfe823 100644 --- a/Kiosk/lib/l10n/app_en.arb +++ b/Kiosk/lib/l10n/app_en.arb @@ -53,5 +53,17 @@ "retry": "Retry", "serverNoIp": "Failed to start server: No IP address found", "restoreComplete": "Restore Complete", - "returningHome": "Refreshing data..." + "returningHome": "Refreshing data...", + "openLauncher": "Open Launcher", + "launcherTarget": "Target: {target}", + "launcherDefault": "Launcher", + "homeAppPackageTitle": "Home app package (optional)", + "homeAppPackageHint": "e.g. com.secgo.home", + "homeAppNotSet": "Not set (use Launcher)", + "homeAppTitle": "Home App (optional)", + "searchApps": "Search apps", + "noAppsFound": "No apps found", + "homeAppOpenFailed": "Can't open {package}. Opening Launcher instead.", + "clear": "Clear", + "save": "Save" } diff --git a/Kiosk/lib/l10n/app_localizations.dart b/Kiosk/lib/l10n/app_localizations.dart index 59d356c..495dfed 100644 --- a/Kiosk/lib/l10n/app_localizations.dart +++ b/Kiosk/lib/l10n/app_localizations.dart @@ -427,6 +427,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Refreshing data...'** String get returningHome; + + /// No description provided for @openLauncher. + /// + /// In en, this message translates to: + /// **'Open Launcher'** + String get openLauncher; + + /// No description provided for @launcherTarget. + /// + /// In en, this message translates to: + /// **'Target: {target}'** + String launcherTarget(Object target); + + /// No description provided for @launcherDefault. + /// + /// In en, this message translates to: + /// **'Launcher'** + String get launcherDefault; + + /// No description provided for @homeAppPackageTitle. + /// + /// In en, this message translates to: + /// **'Home app package (optional)'** + String get homeAppPackageTitle; + + /// No description provided for @homeAppPackageHint. + /// + /// In en, this message translates to: + /// **'e.g. com.secgo.home'** + String get homeAppPackageHint; + + /// No description provided for @homeAppNotSet. + /// + /// In en, this message translates to: + /// **'Not set (use Launcher)'** + String get homeAppNotSet; + + /// No description provided for @homeAppTitle. + /// + /// In en, this message translates to: + /// **'Home App (optional)'** + String get homeAppTitle; + + /// No description provided for @searchApps. + /// + /// In en, this message translates to: + /// **'Search apps'** + String get searchApps; + + /// No description provided for @noAppsFound. + /// + /// In en, this message translates to: + /// **'No apps found'** + String get noAppsFound; + + /// No description provided for @homeAppOpenFailed. + /// + /// In en, this message translates to: + /// **'Can\'t open {package}. Opening Launcher instead.'** + String homeAppOpenFailed(Object package); + + /// No description provided for @clear. + /// + /// In en, this message translates to: + /// **'Clear'** + String get clear; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; } class _AppLocalizationsDelegate diff --git a/Kiosk/lib/l10n/app_localizations_en.dart b/Kiosk/lib/l10n/app_localizations_en.dart index 59d6c0e..58823a7 100644 --- a/Kiosk/lib/l10n/app_localizations_en.dart +++ b/Kiosk/lib/l10n/app_localizations_en.dart @@ -194,4 +194,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get returningHome => 'Refreshing data...'; + + @override + String get openLauncher => 'Open Launcher'; + + @override + String launcherTarget(Object target) { + return 'Target: $target'; + } + + @override + String get launcherDefault => 'Launcher'; + + @override + String get homeAppPackageTitle => 'Home app package (optional)'; + + @override + String get homeAppPackageHint => 'e.g. com.secgo.home'; + + @override + String get homeAppNotSet => 'Not set (use Launcher)'; + + @override + String get homeAppTitle => 'Home App (optional)'; + + @override + String get searchApps => 'Search apps'; + + @override + String get noAppsFound => 'No apps found'; + + @override + String homeAppOpenFailed(Object package) { + return 'Can\'t open $package. Opening Launcher instead.'; + } + + @override + String get clear => 'Clear'; + + @override + String get save => 'Save'; } diff --git a/Kiosk/lib/l10n/app_localizations_zh.dart b/Kiosk/lib/l10n/app_localizations_zh.dart index 80206fc..fc6e174 100644 --- a/Kiosk/lib/l10n/app_localizations_zh.dart +++ b/Kiosk/lib/l10n/app_localizations_zh.dart @@ -190,4 +190,44 @@ class AppLocalizationsZh extends AppLocalizations { @override String get returningHome => '正在刷新数据...'; + + @override + String get openLauncher => '打开桌面启动器'; + + @override + String launcherTarget(Object target) { + return '目标:$target'; + } + + @override + String get launcherDefault => '桌面启动器'; + + @override + String get homeAppPackageTitle => '首页应用包名(可选)'; + + @override + String get homeAppPackageHint => '例如:com.secgo.home'; + + @override + String get homeAppNotSet => '未设置(使用桌面启动器)'; + + @override + String get homeAppTitle => '首页应用(可选)'; + + @override + String get searchApps => '搜索应用'; + + @override + String get noAppsFound => '未找到应用'; + + @override + String homeAppOpenFailed(Object package) { + return '无法打开 $package,将打开桌面启动器。'; + } + + @override + String get clear => '清除'; + + @override + String get save => '保存'; } diff --git a/Kiosk/lib/l10n/app_zh.arb b/Kiosk/lib/l10n/app_zh.arb index e06d87f..8222a95 100644 --- a/Kiosk/lib/l10n/app_zh.arb +++ b/Kiosk/lib/l10n/app_zh.arb @@ -53,5 +53,17 @@ "pinLength": "PIN码至少需要4位数字", "confirmPin": "确认PIN码", "pinMismatch": "两次输入的PIN码不一致", - "saveAndContinue": "保存并继续" + "saveAndContinue": "保存并继续", + "openLauncher": "打开桌面启动器", + "launcherTarget": "目标:{target}", + "launcherDefault": "桌面启动器", + "homeAppPackageTitle": "首页应用包名(可选)", + "homeAppPackageHint": "例如:com.secgo.home", + "homeAppNotSet": "未设置(使用桌面启动器)", + "homeAppTitle": "首页应用(可选)", + "searchApps": "搜索应用", + "noAppsFound": "未找到应用", + "homeAppOpenFailed": "无法打开 {package},将打开桌面启动器。", + "clear": "清除", + "save": "保存" } diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart index ac15736..2897db2 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -176,6 +176,48 @@ class _MainScreenState extends State { ); } + Future _confirmAdminPin() async { + final expected = _settingsService.getPin(); + if (expected == null || expected.isEmpty) return true; + + final l10n = AppLocalizations.of(context)!; + final controller = TextEditingController(); + final ok = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AlertDialog( + title: Text(l10n.adminConfirm), + content: TextField( + controller: controller, + obscureText: true, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: l10n.enterPin), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + if (controller.text.trim() == expected) { + Navigator.pop(dialogContext, true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.invalidPin)), + ); + } + }, + child: Text(l10n.confirm), + ), + ], + ); + }, + ); + return ok ?? false; + } + Future _resumePendingPaymentIfAny() async { if (!mounted) return; final pendingId = _settingsService.getPendingPaymentOrderId(); @@ -634,11 +676,18 @@ class _MainScreenState extends State { // Settings Button (Hidden access) IconButton( icon: const Icon(Icons.settings, color: Colors.grey), - onPressed: () { - Navigator.push( - context, + onPressed: () async { + final navigator = Navigator.of(context); + final ok = await _confirmAdminPin(); + if (!ok || !mounted) return; + final result = await navigator.push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ); + if (!mounted) return; + if (result == 'reset') { + _clearCart(); + setState(() => _isProcessing = false); + } }, ), ], diff --git a/Kiosk/lib/screens/settings_screen.dart b/Kiosk/lib/screens/settings_screen.dart index c0e37be..a051813 100644 --- a/Kiosk/lib/screens/settings_screen.dart +++ b/Kiosk/lib/screens/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:kiosk/services/server/kiosk_server.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:kiosk/l10n/app_localizations.dart'; +import 'package:kiosk/services/android_launcher_service.dart'; import 'package:kiosk/services/settings_service.dart'; import 'package:kiosk/services/restore_notifier.dart'; @@ -18,15 +19,20 @@ class _SettingsScreenState extends State { late final KioskServerService _serverService; final TextEditingController _pinController = TextEditingController(); final SettingsService _settingsService = SettingsService(); // Add SettingsService + final AndroidLauncherService _launcherService = AndroidLauncherService(); bool _isServerRunning = false; bool _isLoading = false; bool _showRestoreComplete = false; String? _qrData; + String? _homeAppPackage; + String? _homeAppLabel; @override void initState() { super.initState(); _serverService = KioskServerService(onRestoreComplete: _onRestoreComplete); + _homeAppPackage = _settingsService.getHomeAppPackage(); + _homeAppLabel = _settingsService.getHomeAppLabel(); // Pre-fill PIN if available final savedPin = _settingsService.getPin(); if (savedPin != null) { @@ -41,9 +47,207 @@ class _SettingsScreenState extends State { @override void dispose() { _serverService.stopServer(); + _pinController.dispose(); super.dispose(); } + Future _confirmPin() async { + final l10n = AppLocalizations.of(context)!; + final expected = _settingsService.getPin() ?? _pinController.text.trim(); + final controller = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(l10n.adminConfirm), + content: TextField( + controller: controller, + obscureText: true, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: l10n.enterPin), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + if (controller.text.trim() == expected) { + Navigator.pop(context, true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.invalidPin)), + ); + } + }, + child: Text(l10n.confirm), + ), + ], + ); + }, + ); + return ok ?? false; + } + + Future _pickHomeApp() async { + final l10n = AppLocalizations.of(context)!; + final apps = await _launcherService.listLaunchableApps(); + apps.sort((a, b) { + final al = (a['label'] ?? a['packageName'] ?? '').toLowerCase(); + final bl = (b['label'] ?? b['packageName'] ?? '').toLowerCase(); + return al.compareTo(bl); + }); + + if (!mounted) return; + final result = await showDialog?>( + context: context, + builder: (context) { + var query = ''; + final searchController = TextEditingController(); + return StatefulBuilder( + builder: (context, setState) { + final filtered = query.isEmpty + ? apps + : apps.where((e) { + final label = (e['label'] ?? '').toLowerCase(); + final pkg = (e['packageName'] ?? '').toLowerCase(); + final q = query.toLowerCase(); + return label.contains(q) || pkg.contains(q); + }).toList(); + + return AlertDialog( + title: Text(l10n.homeAppTitle), + content: SizedBox( + width: 520, + height: 520, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: l10n.searchApps, + prefixIcon: const Icon(Icons.search), + ), + onChanged: (v) => setState(() => query = v.trim()), + ), + const SizedBox(height: 12), + Expanded( + child: filtered.isEmpty + ? Center(child: Text(l10n.noAppsFound)) + : ListView.builder( + itemCount: filtered.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final selected = _homeAppPackage == null; + return ListTile( + leading: const Icon(Icons.home_outlined), + title: Text(l10n.launcherDefault), + trailing: selected ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(context, {'packageName': '', 'label': ''}), + ); + } + final app = filtered[index - 1]; + final pkg = app['packageName'] ?? ''; + final label = app['label'] ?? pkg; + final selected = _homeAppPackage == pkg; + return ListTile( + title: Text(label), + subtitle: Text(pkg), + trailing: selected ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(context, {'packageName': pkg, 'label': label}), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: Text(l10n.cancel), + ), + ], + ); + }, + ); + }, + ); + + if (!mounted) return; + if (result == null) return; + final pkg = (result['packageName'] ?? '').trim(); + final label = (result['label'] ?? '').trim(); + if (pkg.isEmpty) { + await _settingsService.clearHomeAppSelection(); + if (!mounted) return; + setState(() { + _homeAppPackage = null; + _homeAppLabel = null; + }); + return; + } + await _settingsService.setHomeAppPackage(pkg); + await _settingsService.setHomeAppLabel(label.isEmpty ? pkg : label); + if (!mounted) return; + setState(() { + _homeAppPackage = pkg; + _homeAppLabel = label.isEmpty ? pkg : label; + }); + } + + Future _openLauncher() async { + final l10n = AppLocalizations.of(context)!; + final ok = await _confirmPin(); + if (!ok) return; + + await _settingsService.setPendingPaymentOrderId(null); + + final pkg = _settingsService.getHomeAppPackage(); + bool launched = false; + if (pkg != null && pkg.isNotEmpty) { + launched = await _launcherService.openApp(pkg); + if (!launched && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.homeAppOpenFailed(pkg))), + ); + } + } + if (!launched) { + await _launcherService.openLauncherHome(); + } + + if (!mounted) return; + Navigator.pop(context, 'reset'); + } + + Widget _buildLauncherSection() { + final l10n = AppLocalizations.of(context)!; + final target = _homeAppLabel ?? _homeAppPackage ?? l10n.launcherDefault; + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.home_outlined), + title: Text(l10n.openLauncher), + subtitle: Text(l10n.launcherTarget(target)), + onTap: _openLauncher, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.apps_outlined), + title: Text(l10n.homeAppTitle), + subtitle: Text(_homeAppLabel ?? _homeAppPackage ?? l10n.homeAppNotSet), + onTap: _pickHomeApp, + ), + ], + ), + ); + } + void _onRestoreComplete() { if (_showRestoreComplete) return; _runRestoreCompleteFlow(); @@ -133,7 +337,12 @@ class _SettingsScreenState extends State { _serverService.port, ), ), - const SizedBox(height: 40), + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: _buildLauncherSection(), + ), + const SizedBox(height: 16), ElevatedButton( onPressed: () { // Restart server logic or close page @@ -157,7 +366,12 @@ class _SettingsScreenState extends State { l10n.serverStartFailedMessage, textAlign: TextAlign.center, ), - const SizedBox(height: 30), + const SizedBox(height: 24), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: _buildLauncherSection(), + ), + const SizedBox(height: 16), ElevatedButton( onPressed: _startServer, child: Text(l10n.retry), diff --git a/Kiosk/lib/services/android_launcher_service.dart b/Kiosk/lib/services/android_launcher_service.dart new file mode 100644 index 0000000..d29f24b --- /dev/null +++ b/Kiosk/lib/services/android_launcher_service.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class AndroidLauncherService { + static const MethodChannel _methodChannel = + MethodChannel('com.secgo.kiosk/notification_listener'); + + Future>> listLaunchableApps() async { + if (!Platform.isAndroid) return const []; + final data = await _methodChannel + .invokeMethod('listLaunchableApps') + .then((v) => (v ?? const []).map((e) => Map.from(e as Map)).toList()); + final result = >[]; + for (final e in data) { + final pkg = e['packageName']?.toString().trim(); + final label = e['label']?.toString().trim(); + if (pkg == null || pkg.isEmpty) continue; + result.add({'packageName': pkg, 'label': (label == null || label.isEmpty) ? pkg : label}); + } + return result; + } + + Future openLauncherHome() async { + if (!Platform.isAndroid) return false; + final ok = await _methodChannel.invokeMethod('openLauncherHome'); + return ok ?? false; + } + + Future openApp(String packageName) async { + if (!Platform.isAndroid) return false; + final ok = await _methodChannel.invokeMethod( + 'openApp', + {'packageName': packageName}, + ); + return ok ?? false; + } +} diff --git a/Kiosk/lib/services/settings_service.dart b/Kiosk/lib/services/settings_service.dart index aa0cb0f..a31e22e 100644 --- a/Kiosk/lib/services/settings_service.dart +++ b/Kiosk/lib/services/settings_service.dart @@ -8,6 +8,8 @@ class SettingsService { static const String _paymentQrKey = 'payment_qr'; static const String _paymentQrsKey = 'payment_qrs'; static const String _pendingPaymentOrderIdKey = 'pending_payment_order_id'; + static const String _homeAppPackageKey = 'home_app_package'; + static const String _homeAppLabelKey = 'home_app_label'; Future init() async { await Hive.openBox(_boxName); @@ -87,6 +89,47 @@ class SettingsService { await _box.put(_pendingPaymentOrderIdKey, id); } + String? getHomeAppPackage() { + final v = _box.get(_homeAppPackageKey); + if (v is String) { + final s = v.trim(); + return s.isEmpty ? null : s; + } + return null; + } + + Future setHomeAppPackage(String? packageName) async { + final v = packageName?.trim(); + if (v == null || v.isEmpty) { + await _box.delete(_homeAppPackageKey); + return; + } + await _box.put(_homeAppPackageKey, v); + } + + String? getHomeAppLabel() { + final v = _box.get(_homeAppLabelKey); + if (v is String) { + final s = v.trim(); + return s.isEmpty ? null : s; + } + return null; + } + + Future setHomeAppLabel(String? label) async { + final v = label?.trim(); + if (v == null || v.isEmpty) { + await _box.delete(_homeAppLabelKey); + return; + } + await _box.put(_homeAppLabelKey, v); + } + + Future clearHomeAppSelection() async { + await _box.delete(_homeAppPackageKey); + await _box.delete(_homeAppLabelKey); + } + Future getOrCreateDeviceId() async { final existing = getDeviceId();