diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 26a1735..76ac970 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,12 +33,14 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" /> + + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize"> diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index 8339553..8c03e5c 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -48,6 +48,9 @@ class MainActivity : FlutterActivity() { ) } "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + "isSubScreenShowing" -> result.success( + FlutterSubScreenProvider.instance.presentation?.isShowing == true + ) "getKioskStatus" -> result.success(KioskModeManager.status(this)) "setKioskAutoEnterEnabled" -> { val enabled = call.argument("enabled") diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml index a3133ba..a501606 100644 --- a/android/app/src/main/res/values/attrs.xml +++ b/android/app/src/main/res/values/attrs.xml @@ -1,5 +1,5 @@ - false + true diff --git a/assets/images/customer_welcome/abstract-care.webp b/assets/images/customer_welcome/abstract-care.webp new file mode 100644 index 0000000..dc2b3fa Binary files /dev/null and b/assets/images/customer_welcome/abstract-care.webp differ diff --git a/assets/images/customer_welcome/abstract-flow.webp b/assets/images/customer_welcome/abstract-flow.webp new file mode 100644 index 0000000..1b68296 Binary files /dev/null and b/assets/images/customer_welcome/abstract-flow.webp differ diff --git a/assets/images/customer_welcome/abstract-welcome-hero.webp b/assets/images/customer_welcome/abstract-welcome-hero.webp new file mode 100644 index 0000000..a53316d Binary files /dev/null and b/assets/images/customer_welcome/abstract-welcome-hero.webp differ diff --git a/lib/app.dart b/lib/app.dart index 1d1feb0..147ee43 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -35,8 +35,13 @@ class _MainAppState extends State with WidgetsBindingObserver { await Get.find().suspend(); } catch (_) {} } + return; + } + if (state == AppLifecycleState.resumed) { + // 前台恢复需要重新确认原生 Presentation;若系统回收了副屏窗口才重新创建, + // 正常常驻时不会触发插件内部的“先关闭再打开”。 try { - AppSubScreenTool.hide(); + await AppSubScreenTool.show(verifyPresentation: true); } catch (_) {} } } diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index f29f277..cb11147 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -5,6 +5,10 @@ import 'package:pj_supermarket_cloud/common/index.dart'; abstract class UserApi { /// 硬件管理登录 static Future login(dynamic pa) async { + final password = pa["password"] as String; + final loginPassword = Constants.loginPasswordEncryptionEnabled + ? EncryptionTool.encryptLoginPassword(password) + : password; final res = await HttpManage().dio.post( "/auth/oauth2/token", queryParameters: { @@ -15,7 +19,7 @@ abstract class UserApi { "scope": pa["scope"], }, data: { - "password": pa["password"], + "password": loginPassword, }, options: dio.Options( headers: { diff --git a/lib/common/models/encryption/encryption_tool.dart b/lib/common/models/encryption/encryption_tool.dart index 29a2d2e..6ded758 100644 --- a/lib/common/models/encryption/encryption_tool.dart +++ b/lib/common/models/encryption/encryption_tool.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:common_utils/common_utils.dart'; +import 'package:pointycastle/export.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; @@ -7,4 +11,29 @@ class EncryptionTool { static String getLoginAuthorization() { return "Basic ${EncryptUtil.encodeBase64(Constants.oauth2AppClient)}"; } + + /// 按 Web 端约定使用 AES-CFB、Key 同值 IV、NoPadding 加密登录密码。 + static String encryptLoginPassword(String password) { + final keyBytes = Uint8List.fromList(utf8.encode(Constants.pwdEncKey)); + final passwordBytes = Uint8List.fromList(utf8.encode(password)); + final cipher = AESEngine()..init(true, KeyParameter(keyBytes)); + final encrypted = Uint8List(passwordBytes.length); + var feedback = Uint8List.fromList(keyBytes); + + for (var offset = 0; offset < passwordBytes.length; offset += 16) { + final streamBlock = Uint8List(16); + cipher.processBlock(feedback, 0, streamBlock, 0); + final blockLength = (passwordBytes.length - offset).clamp(0, 16).toInt(); + for (var index = 0; index < blockLength; index++) { + encrypted[offset + index] = + passwordBytes[offset + index] ^ streamBlock[index]; + } + if (blockLength == 16) { + feedback = Uint8List.fromList( + encrypted.sublist(offset, offset + blockLength), + ); + } + } + return base64Encode(encrypted); + } } diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index af55595..fb4ce70 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -10,6 +10,7 @@ enum SettingMenuType implements BaseEnum { faceSetting("50", "人脸管理"), facePluginSetting("55", "人脸设置"), productSetting("60", "商品管理"), + logPreview("62", "日志预览"), subScreenRoutes("65", "副屏路由"), versionSetting("70", "版本管理"), ; diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 9024fc6..b548242 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -8,6 +8,9 @@ class RouteNames { /// 顾客屏全窗口预览,不装载终端侧栏和顶栏。 static const customer = '/customer'; + /// 欢迎副屏全窗口预览。 + static const customerWelcome = '/customer-welcome'; + /// 预约查询副屏全窗口预览。 static const menuQueryCustomer = '/menu-query-customer'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index c9e2d0d..5d140c7 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/pages/index.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_preview.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_preview.dart'; class RoutePages { @@ -23,6 +24,10 @@ class RoutePages { name: RouteNames.customer, page: () => CustomerDisplayPreviewPage(arguments: Get.arguments), ), + GetPage( + name: RouteNames.customerWelcome, + page: () => const CustomerWelcomePreviewPage(), + ), GetPage( name: RouteNames.menuQueryCustomer, page: () => ReservationQueryPreviewPage(arguments: Get.arguments), diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index cfa19bb..c059625 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -11,9 +11,6 @@ abstract class AppFaceTool { if (AppRuntimeTool.useManualHardwareInput) return false; final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage(); if (!isJump) return false; - Future.delayed(const Duration(seconds: 2), () { - AppSubScreenTool.hide(); - }); return true; } diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart index 1e4d9a8..0fb8315 100644 --- a/lib/common/utils/app/app_runtime_tool.dart +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -208,6 +208,15 @@ abstract class AppRuntimeTool { } } + /// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。 + static Future isSubScreenShowing() async { + try { + return await _channel.invokeMethod('isSubScreenShowing') ?? false; + } on PlatformException { + return false; + } + } + static void applyManualHardwareInput(bool enabled) { _manualHardwareInput = enabled; } diff --git a/lib/common/utils/app/app_sub_screen_tool.dart b/lib/common/utils/app/app_sub_screen_tool.dart index 45050be..aae948a 100644 --- a/lib/common/utils/app/app_sub_screen_tool.dart +++ b/lib/common/utils/app/app_sub_screen_tool.dart @@ -2,9 +2,11 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -/// 统一控制副屏,确保虚拟机和单屏设备不会创建副屏 Presentation。 +/// 统一维护副屏常驻状态,确保业务切换只发送页面消息,不重复重建 Presentation。 abstract class AppSubScreenTool { static bool _available = false; + static bool _presentationRequested = false; + static Future? _showFuture; static bool get isAvailable => _available; @@ -12,6 +14,7 @@ abstract class AppSubScreenTool { await AppRuntimeTool.initializePlatform(); if (AppRuntimeTool.isEmulator || !AppRuntimeTool.hasMultipleDisplays) { _available = false; + _presentationRequested = false; if (reportStatus) AppDeviceStatusTool.updateSubScreen(false); return false; } @@ -19,32 +22,56 @@ abstract class AppSubScreenTool { final hasViceScreen = await SubScreenPlugin.isMultipleScreen; final hasOverlayPermission = await SubScreenPlugin.checkOverlayPermission; _available = hasViceScreen && hasOverlayPermission; + if (!_available) _presentationRequested = false; } catch (e) { _available = false; + _presentationRequested = false; logger.w("|副屏工具|检测副屏失败:$e"); } if (reportStatus) AppDeviceStatusTool.updateSubScreen(_available); return _available; } - static Future show() async { + static Future show({bool verifyPresentation = false}) async { + if (_presentationRequested && !verifyPresentation) return true; + final pending = _showFuture; + if (pending != null) return pending; + final future = _showOnce(verifyPresentation: verifyPresentation); + _showFuture = future; + try { + return await future; + } finally { + if (identical(_showFuture, future)) _showFuture = null; + } + } + + static Future _showOnce({required bool verifyPresentation}) async { final available = await refreshAvailability(); if (!available) return false; + if (_presentationRequested && !verifyPresentation) return true; try { + if (await AppRuntimeTool.isSubScreenShowing()) { + _presentationRequested = true; + return true; + } + // 插件 showSubDisplay() 会先关闭旧 Presentation;仅首次确认可用时调用, + // 后续业务入口只复用常驻副屏,避免顾客屏反复黑屏或闪烁。 SubScreenPlugin.doubleScreenShow(); + _presentationRequested = true; return true; } catch (e) { + _presentationRequested = false; + _available = false; + AppDeviceStatusTool.updateSubScreen(false); logger.w("|副屏工具|打开副屏失败:$e"); return false; } } + /// 兼容旧调用入口。应用运行期间禁止业务代码关闭 Presentation, + /// Android Activity 真正销毁时仍由原生层统一释放副屏窗口和 Engine。 static void hide() { if (!_available) return; - try { - SubScreenPlugin.doubleScreenCancel(); - } catch (e) { - logger.w("|副屏工具|关闭副屏失败:$e"); - } + logger.i("|副屏工具|忽略业务关闭请求,副屏保持常驻"); } } diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index ed63112..c27ac5f 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -153,6 +153,9 @@ class AppControl extends GetxController { detail: '正在读取运行环境与本地终端配置', ); await AppRuntimeTool.initializePlatform(); + // 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记, + // 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。 + unawaited(AppSubScreenTool.show()); await AppSettingTool.removeSettingCache(); _setStartupStep( diff --git a/lib/common/utils/app/terminal_navigation_tool.dart b/lib/common/utils/app/terminal_navigation_tool.dart index 7e29bcd..e06032f 100644 --- a/lib/common/utils/app/terminal_navigation_tool.dart +++ b/lib/common/utils/app/terminal_navigation_tool.dart @@ -40,7 +40,7 @@ abstract final class TerminalNavigationTool { if (!await _verifyAdminSession(actionLabel: '退出登录')) return; Get.find().lock(); if (AppNetworkTool.isAutoNetwork()) { - // await AppSettingTool.setUserSetting(UserSetting()); + await AppSettingTool.setUserSetting(UserSetting()); } Get.offAllNamed(RouteNames.login); } diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart index b1e55a3..497a438 100644 --- a/lib/common/utils/logger/logger_tool.dart +++ b/lib/common/utils/logger/logger_tool.dart @@ -7,10 +7,11 @@ import 'package:logger/logger.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +final _consoleOutput = ConsoleOutput(); final logger = Logger( filter: AppLogFilter(), printer: customPrinter, - output: ConsoleOutput(), + output: _consoleOutput, ); final customPrinter = PrettyPrinter( lineLength: 0, @@ -22,6 +23,76 @@ final customPrinter = PrettyPrinter( noBoxingByDefault: true, ); +/// 设置页使用的日志尾部快照,只返回脱敏后的有限内容,避免大文件阻塞收银主线程。 +Future readAppLogPreview({ + String? path, + int maxBytes = 256 * 1024, + int maxLines = 1000, +}) async { + await logger.init; + return _consoleOutput.readPreview( + path: path, + maxBytes: maxBytes, + maxLines: maxLines, + ); +} + +/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。 +Future listAppLogFiles() async { + await logger.init; + return _consoleOutput.listLogFiles(); +} + +class AppLogCatalog { + const AppLogCatalog({ + required this.rootPath, + required this.currentPath, + required this.files, + }); + + final String rootPath; + final String currentPath; + final List files; +} + +class AppLogFile { + const AppLogFile({ + required this.path, + required this.relativePath, + required this.name, + required this.fileSize, + required this.modifiedAt, + required this.isCurrent, + required this.exists, + }); + + final String path; + final String relativePath; + final String name; + final int fileSize; + final DateTime? modifiedAt; + final bool isCurrent; + final bool exists; +} + +class AppLogPreview { + const AppLogPreview({ + required this.path, + required this.content, + required this.fileSize, + required this.modifiedAt, + required this.lineCount, + required this.truncated, + }); + + final String path; + final String content; + final int fileSize; + final DateTime? modifiedAt; + final int lineCount; + final bool truncated; +} + class AppLogFilter extends LogFilter { @override bool shouldLog(LogEvent event) { @@ -42,6 +113,151 @@ class ConsoleOutput extends LogOutput { return _queue; } + /// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。 + Future readPreview({ + String? path, + required int maxBytes, + required int maxLines, + }) async { + if (maxBytes <= 0 || maxLines <= 0) { + throw ArgumentError('日志预览范围必须大于 0'); + } + final currentPath = await _resolveCurrentLogPath(); + final resolvedPath = path ?? currentPath; + await _validatePreviewPath(resolvedPath, currentPath); + if (_samePath(resolvedPath, currentPath)) { + await _enqueue(() async => _sink?.flush()); + } + final file = File(resolvedPath); + if (!await file.exists()) { + return AppLogPreview( + path: resolvedPath, + content: '', + fileSize: 0, + modifiedAt: null, + lineCount: 0, + truncated: false, + ); + } + + final stat = await file.stat(); + final start = stat.size > maxBytes ? stat.size - maxBytes : 0; + final handle = await file.open(); + late final List bytes; + try { + await handle.setPosition(start); + bytes = await handle.read(stat.size - start); + } finally { + await handle.close(); + } + + var content = utf8.decode(bytes, allowMalformed: true); + content = _sanitizePreviewContent(content); + if (start > 0) content = _removePartialLeadingEntry(content); + + var lines = const LineSplitter().convert(content); + var truncated = start > 0; + if (lines.length > maxLines) { + lines = lines.sublist(lines.length - maxLines); + truncated = true; + } + return AppLogPreview( + path: resolvedPath, + content: lines.join('\n').trimRight(), + fileSize: stat.size, + modifiedAt: stat.modified, + lineCount: lines.length, + truncated: truncated, + ); + } + + /// 只扫描应用日志根目录中的 `.log` 文件,避免设置页变成任意文件浏览器。 + Future listLogFiles() async { + final currentPath = await _resolveCurrentLogPath(); + await _enqueue(() async => _sink?.flush()); + final root = Directory(currentPath).parent.parent; + final files = []; + if (await root.exists()) { + await for (final entity + in root.list(recursive: true, followLinks: false)) { + if (entity is! File || !entity.path.toLowerCase().endsWith('.log')) { + continue; + } + try { + final stat = await entity.stat(); + files.add( + AppLogFile( + path: entity.path, + relativePath: _relativePath(root.path, entity.path), + name: _fileName(entity.path), + fileSize: stat.size, + modifiedAt: stat.modified, + isCurrent: _samePath(entity.path, currentPath), + exists: true, + ), + ); + } on FileSystemException { + // 扫描期间文件可能被外部清理,跳过失效项即可由下一次刷新恢复。 + } + } + } + + if (!files.any((file) => file.isCurrent)) { + files.add( + AppLogFile( + path: currentPath, + relativePath: _relativePath(root.path, currentPath), + name: _fileName(currentPath), + fileSize: 0, + modifiedAt: null, + isCurrent: true, + exists: false, + ), + ); + } + files.sort((a, b) { + if (a.isCurrent != b.isCurrent) return a.isCurrent ? -1 : 1; + final timeCompare = (b.modifiedAt ?? + DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo(a.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0)); + if (timeCompare != 0) return timeCompare; + return b.relativePath.compareTo(a.relativePath); + }); + return AppLogCatalog( + rootPath: root.path, + currentPath: currentPath, + files: List.unmodifiable(files), + ); + } + + Future _resolveCurrentLogPath() async { + final path = _filePath ?? await AppFileTool.getLoggerBackupPath(); + if (ObjectUtil.isEmpty(path)) { + throw const FileSystemException('无法获取日志文件路径'); + } + return path!; + } + + Future _validatePreviewPath( + String path, + String currentPath, + ) async { + if (!path.toLowerCase().endsWith('.log')) { + throw const FileSystemException('仅支持预览日志文件'); + } + final root = Directory(currentPath).parent.parent; + final candidate = File(path); + final rootPath = await root.exists() + ? await root.resolveSymbolicLinks() + : root.absolute.path; + final candidatePath = await candidate.exists() + ? await candidate.resolveSymbolicLinks() + : candidate.absolute.path; + if (!_isPathInside(rootPath, candidatePath)) { + throw const FileSystemException('日志文件不在允许的目录中'); + } + } + @override Future init() async { try { @@ -108,3 +324,68 @@ class ConsoleOutput extends LogOutput { } } } + +String _removePartialLeadingEntry(String content) { + final entryEnd = content.indexOf('\n\n'); + if (entryEnd >= 0) return content.substring(entryEnd + 2); + final lineEnd = content.indexOf('\n'); + return lineEnd >= 0 ? content.substring(lineEnd + 1) : ''; +} + +bool _samePath(String first, String second) { + return File(first).absolute.path == File(second).absolute.path; +} + +bool _isPathInside(String rootPath, String candidatePath) { + final root = Directory(rootPath).absolute.path; + final candidate = File(candidatePath).absolute.path; + return candidate == root || + candidate.startsWith('$root${Platform.pathSeparator}'); +} + +String _relativePath(String rootPath, String filePath) { + final root = Directory(rootPath).absolute.path.replaceAll('\\', '/'); + final file = File(filePath).absolute.path.replaceAll('\\', '/'); + final prefix = '$root/'; + return file.startsWith(prefix) + ? file.substring(prefix.length) + : _fileName(file); +} + +String _fileName(String path) { + return path.replaceAll('\\', '/').split('/').last; +} + +String _sanitizePreviewContent(String content) { + var value = content.replaceAll( + RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'), + '', + ); + value = value.replaceAllMapped( + RegExp( + r'^.*(?:用户信息|下单参数|存储用户|同步参数|同步设备信息\s+sysInfo|二维码识别.*json|绑定设备信息pa:|添加到.*记录.*json).*$', + caseSensitive: false, + multiLine: true, + ), + (_) => '[包含敏感业务数据的日志已隐藏]', + ); + value = value.replaceAllMapped( + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), + (_) => 'Bearer ***', + ); + value = value.replaceAllMapped( + RegExp( + r'((?:"|\b)?(?:token|accessToken|refreshToken|password|passwd|secret|authorization|paymentCode|payCode|authCode|qrCode|serviceCode|shortCode|cardId|cardNo|cardNumber|phone|mobile|idCard|certNo|realName|userName|nickName|address|email|userId|orderId|serviceOrderId)(?:"|\b)?\s*(?:[:=]|\|)\s*)("[^"]*"|[^,\s;|}\]]+)', + caseSensitive: false, + ), + (match) { + final source = match.group(2)!; + return '${match.group(1)}${source.startsWith('"') ? '"***"' : '***'}'; + }, + ); + // 未带字段名的长编号也可能是手机号、饭卡号或付款码,仅保留定位所需的首尾片段。 + return value.replaceAllMapped(RegExp(r'\d{8,}'), (match) { + final source = match.group(0)!; + return '${source.substring(0, 3)}****${source.substring(source.length - 4)}'; + }); +} diff --git a/lib/common/utils/manage/tts_manage.dart b/lib/common/utils/manage/tts_manage.dart index 0b670f4..c83b861 100644 --- a/lib/common/utils/manage/tts_manage.dart +++ b/lib/common/utils/manage/tts_manage.dart @@ -1,51 +1,84 @@ import 'package:flutter_tts/flutter_tts.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -// 语音转文字 +// 文字转语音 class TtsManage { - static const _tag = "语音转文字"; + static const _tag = "文字转语音"; + static const _iflytekEngine = "com.iflytek.speechcloud"; static final FlutterTts _flutterTts = FlutterTts(); static bool _isInitialized = false; + static bool _isAvailable = false; + static Future? _initializing; - // 初始化TTS + /// 初始化 TTS。 + /// + /// 收银终端只使用讯飞引擎;未安装或初始化失败时直接禁用语音, + /// 避免 TTS 插件异常阻断应用启动和收银流程。 static Future initialize() async { - if (!_isInitialized) { + if (_isInitialized) return; + if (_initializing != null) { + await _initializing; + return; + } + + final initializing = _initialize(); + _initializing = initializing; + try { + await initializing; + } finally { + _initializing = null; + } + } + + static Future _initialize() async { + try { final engines = await _flutterTts.getEngines; - if (engines.isEmpty) { - logger.e("|$_tag|没有可用的TTS引擎"); + final hasIflytekEngine = engines is List && + engines.any((engine) => engine.toString() == _iflytekEngine); + if (!hasIflytekEngine) { + logger.w("|$_tag|未安装讯飞TTS引擎,已禁用语音播报"); + return; } - if (engines.contains("com.iflytek.speechcloud")) { - await _flutterTts.setEngine("com.iflytek.speechcloud"); - } - // 设置语言为中文 + + await _flutterTts.setEngine(_iflytekEngine); await _flutterTts.setLanguage("zh-CN"); - // 设置音量 await _flutterTts.setVolume(1.0); - // 设置语速 await _flutterTts.setSpeechRate(0.95); - // 设置音调 await _flutterTts.setPitch(2.0); - // await _flutterTts.setQueueMode(1); + _isAvailable = true; + } catch (e, stack) { + logger.w("|$_tag|讯飞TTS初始化失败,已禁用语音播报error:|$e|stack:|$stack|"); + } finally { _isInitialized = true; } } - // 播放文本 + /// 播放文本;讯飞 TTS 不可用时静默跳过。 static Future speak(String text) async { await initialize(); - // await _flutterTts.awaitSpeakCompletion(true); - await _flutterTts.speak(text, focus: true); + if (!_isAvailable) return; + + try { + await _flutterTts.speak(text, focus: true); + } catch (e, stack) { + logger.w("|$_tag|语音播放失败error:|$e|stack:|$stack|"); + } } // 停止播放 static Future stop() async { - await _flutterTts.stop(); + if (!_isAvailable) return; + try { + await _flutterTts.stop(); + } catch (e, stack) { + logger.w("|$_tag|停止语音播放失败error:|$e|stack:|$stack|"); + } } // 释放资源 static Future dispose() async { - await _flutterTts.stop(); + await stop(); } /// 将数字转换为中文读法(手机号码格式) diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 98270b1..775d2ce 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -1,8 +1,11 @@ /// 常量 class Constants { - // oauth2 APP模式客户端信息 + // mini 客户端免验证码,且服务端不会解密登录密码。 static const oauth2AppClient = 'mini:mini'; + /// 登录密码加密开关,必须与 OAuth2 客户端的密码加密配置保持一致。 + static const loginPasswordEncryptionEnabled = false; + // oauth2 登录的url static const oauth2AppApiUrl = 'auth/oauth2/token'; @@ -10,8 +13,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index 12088ce..39ab696 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -27,6 +27,8 @@ class LoginController extends GetxController { // 密码 final passwordC = TextEditingController(); + bool _manualInputStarted = false; + _initData() { update(["login"]); } @@ -38,7 +40,7 @@ class LoginController extends GetxController { userSetting = await AppSettingTool.getUserSetting(); sysType.value = sysSetting.sysType; await getTenantList(); - await autoLogin(); + if (!_manualInputStarted) await autoLogin(); tenantC.addValueListener((v) async { final target = tenantList.firstWhereOrNull((i) => i.code == v.first); if (target == null) return; @@ -59,9 +61,12 @@ class LoginController extends GetxController { _initData(); } - Future doLogin(bool visibleMsg) async { + Future doLogin(bool visibleMsg, {bool automatic = false}) async { + if (automatic && _manualInputStarted) return; if (!_validateForm()) return; SysSetting sysSetting = await AppSettingTool.getSysSetting(); + // 自动登录等待本地配置期间,用户可能已经点入输入框;此时人工输入优先,不能再用 Loading 抢走焦点。 + if (automatic && _manualInputStarted) return; final pa = { 'username': usernameC.value.text, 'password': passwordC.value.text, @@ -108,12 +113,14 @@ class LoginController extends GetxController { } Future autoLogin() async { - if (ObjectUtil.isEmpty(userSetting.username) || + if (_manualInputStarted || + ObjectUtil.isEmpty(userSetting.username) || ObjectUtil.isEmpty(userSetting.password) || ObjectUtil.isEmpty(userSetting.sysType?.name)) { return; } final sysSetting = await AppSettingTool.getSysSetting(); + if (_manualInputStarted) return; usernameC.text = userSetting.username!; passwordC.text = userSetting.password!; // 无网络并且绑定过直接使用 @@ -130,7 +137,12 @@ class LoginController extends GetxController { Toast.error(msg: "未绑定机器,不能开启离线模式"); return; } - await doLogin(true); + await doLogin(true, automatic: true); + } + + /// 用户主动编辑账号或密码后,本轮初始化只保留人工登录,避免延迟的自动登录弹层关闭软键盘。 + void markManualInputStarted() { + _manualInputStarted = true; } bool _validateForm() { diff --git a/lib/pages/root/login/view.dart b/lib/pages/root/login/view.dart index 9318016..9d11a02 100644 --- a/lib/pages/root/login/view.dart +++ b/lib/pages/root/login/view.dart @@ -10,7 +10,237 @@ import 'controller.dart'; class LoginPage extends GetView { const LoginPage({super.key}); - Widget _buildBrandArea(BuildContext context) { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LoginController(), + id: 'login', + builder: (_) => FScaffold( + contentPad: false, + resizeToAvoidBottomInset: true, + content: ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final keyboardVisible = + MediaQuery.viewInsetsOf(context).bottom > 0; + return Padding( + padding: keyboardVisible + ? EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h) + : EdgeInsets.all(36.w), + child: Row( + children: [ + if (!keyboardVisible) ...[ + const Expanded( + key: ValueKey('login-brand-panel'), + flex: 11, + child: _LoginBrandPanel(), + ), + SizedBox(width: XfjDesignTokens.cardGap.w), + ], + Expanded( + key: const ValueKey('login-form-panel'), + flex: keyboardVisible ? 1 : 9, + child: _LoginFormPanel( + controller: controller, + compact: keyboardVisible, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ); + } +} + +/// 键盘弹出时保留表单子树并切换为内部滚动,避免横屏真机重建输入框后丢失焦点。 +class _LoginFormPanel extends StatefulWidget { + const _LoginFormPanel({required this.controller, required this.compact}); + + final LoginController controller; + final bool compact; + + @override + State<_LoginFormPanel> createState() => _LoginFormPanelState(); +} + +class _LoginFormPanelState extends State<_LoginFormPanel> { + bool _obscurePassword = true; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final content = Padding( + padding: widget.compact + ? EdgeInsets.symmetric(horizontal: 36.w, vertical: 20.h) + : EdgeInsets.symmetric(horizontal: 54.w, vertical: 42.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + widget.compact ? MainAxisAlignment.start : MainAxisAlignment.center, + children: [ + Container( + width: widget.compact ? 48.w : 58.w, + height: widget.compact ? 48.w : 58.w, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + FAssets.icons.lockKeyhole, + color: XfjDesignTokens.primary, + ), + ), + SizedBox(height: widget.compact ? 12.h : 22.h), + Text( + '登录收银终端', + style: theme.typography.xl4.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: widget.compact ? 6.h : 10.h), + Text( + '选择所属单位,并使用终端账号继续', + style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: widget.compact ? 16.h : 30.h), + Obx( + () => appTenantDialog( + context, + widget.controller.tenantC, + list: widget.controller.tenantList, + ), + ), + SizedBox(height: widget.compact ? 12.h : 20.h), + FTextField( + key: const ValueKey('login-username-field'), + textInputAction: TextInputAction.next, + keyboardType: TextInputType.text, + label: const Text('用户名'), + controller: widget.controller.usernameC, + hint: '请输入用户名', + maxLines: 1, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.username], + onTap: widget.controller.markManualInputStarted, + onChange: (_) => widget.controller.markManualInputStarted(), + onSubmit: (_) => FocusScope.of(context).nextFocus(), + ), + SizedBox(height: widget.compact ? 12.h : 20.h), + Builder( + builder: (context) { + final passwordActionLabel = _obscurePassword ? '显示密码' : '隐藏密码'; + return FTextField.password( + key: const ValueKey('login-password-field'), + textInputAction: TextInputAction.done, + keyboardType: TextInputType.visiblePassword, + label: const Text('密码'), + controller: widget.controller.passwordC, + hint: '请输入密码', + obscureText: _obscurePassword, + enableSuggestions: false, + autofillHints: const [AutofillHints.password], + onTap: widget.controller.markManualInputStarted, + onChange: (_) => widget.controller.markManualInputStarted(), + onSubmit: (_) => widget.controller.doLogin(true), + suffixBuilder: (_, __, ___) => Semantics( + button: true, + label: passwordActionLabel, + child: Tooltip( + message: passwordActionLabel, + child: InkWell( + onTap: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + _obscurePassword + ? FAssets.icons.eye + : FAssets.icons.eyeOff, + color: XfjDesignTokens.muted, + size: 22.sp, + ).center(), + ), + ), + ), + ), + ); + }, + ), + SizedBox(height: widget.compact ? 16.h : 30.h), + SizedBox( + width: double.infinity, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 60.h), + child: FButton( + label: Text( + '进入终端', + style: theme.typography.lg.copyWith(color: Colors.white), + ), + onPress: () => widget.controller.doLogin(true), + ), + ), + ), + SizedBox(height: widget.compact ? 12.h : 18.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon(FAssets.icons.shieldCheck, color: XfjDesignTokens.success), + SizedBox(width: 8.w), + Flexible( + child: Text( + '登录信息受本地安全策略保护', + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ], + ), + ); + + return TerminalSurface( + padding: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: widget.compact + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: content, + ), + ), + ), + ); + } +} + +class _LoginBrandPanel extends StatelessWidget { + const _LoginBrandPanel(); + + @override + Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( padding: EdgeInsets.all(48.w), @@ -50,9 +280,9 @@ class LoginPage extends GetView { Row( children: [ _Capability(icon: FAssets.icons.monitor, title: '双屏协作'), - SizedBox(width: 12), + SizedBox(width: 12.w), _Capability(icon: FAssets.icons.creditCard, title: '多种支付'), - SizedBox(width: 12), + SizedBox(width: 12.w), _Capability(icon: FAssets.icons.printer, title: '设备协同'), ], ), @@ -60,124 +290,6 @@ class LoginPage extends GetView { ), ); } - - Widget _buildLoginForm(BuildContext context) { - final theme = FTheme.of(context); - return TerminalSurface( - padding: EdgeInsets.symmetric(horizontal: 54.w, vertical: 42.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 58.w, - height: 58.w, - decoration: BoxDecoration( - color: XfjDesignTokens.primarySoft, - borderRadius: BorderRadius.circular(16), - ), - child: FIcon(FAssets.icons.lockKeyhole, - color: XfjDesignTokens.primary), - ), - SizedBox(height: 22.h), - Text( - '登录收银终端', - style: theme.typography.xl4.copyWith( - color: XfjDesignTokens.ink, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(height: 10.h), - Text( - '选择所属单位,并使用终端账号继续', - style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), - ), - SizedBox(height: 30.h), - Obx( - () => appTenantDialog( - context, - controller.tenantC, - list: controller.tenantList, - ), - ), - SizedBox(height: 20.h), - Obx( - () => FTextField( - textInputAction: controller.sysType.value == SysType.SUPERMARKET - ? TextInputAction.none - : TextInputAction.newline, - keyboardType: controller.sysType.value == SysType.SUPERMARKET - ? TextInputType.none - : TextInputType.multiline, - label: const Text('用户名'), - controller: controller.usernameC, - hint: '请输入用户名', - onSubmit: (_) => controller.doLogin(true), - ), - ), - SizedBox(height: 20.h), - Obx( - () => FTextField.password( - textInputAction: controller.sysType.value == SysType.SUPERMARKET - ? TextInputAction.none - : TextInputAction.newline, - keyboardType: controller.sysType.value == SysType.SUPERMARKET - ? TextInputType.none - : TextInputType.multiline, - label: const Text('密码'), - controller: controller.passwordC, - hint: '请输入密码', - onSubmit: (_) => controller.doLogin(true), - ), - ), - SizedBox(height: 30.h), - FButton( - label: Text( - '进入终端', - style: theme.typography.lg.copyWith(color: Colors.white), - ), - onPress: () => controller.doLogin(true), - ).height(60.h).width(double.infinity), - SizedBox(height: 18.h), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FIcon(FAssets.icons.shieldCheck, color: XfjDesignTokens.success), - SizedBox(width: 8.w), - Text( - '登录信息受本地安全策略保护', - style: - theme.typography.sm.copyWith(color: XfjDesignTokens.muted), - ), - ], - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return GetBuilder( - init: LoginController(), - id: 'login', - builder: (_) => ColoredBox( - color: XfjDesignTokens.canvas, - child: SafeArea( - child: Padding( - padding: EdgeInsets.all(36.w), - child: Row( - children: [ - _buildBrandArea(context).expanded(flex: 11), - SizedBox(width: XfjDesignTokens.cardGap.w), - _buildLoginForm(context).expanded(flex: 9), - ], - ), - ), - ), - ), - ); - } } /// 登录品牌区的能力标签仅表达终端能力,避免在页面内重复堆叠相同布局。 diff --git a/lib/pages/terminal/setting/view.dart b/lib/pages/terminal/setting/view.dart index a9bd763..58839d6 100644 --- a/lib/pages/terminal/setting/view.dart +++ b/lib/pages/terminal/setting/view.dart @@ -241,6 +241,7 @@ class _SettingContent extends StatelessWidget { if (!controller.isBound.value && type != SettingMenuType.baseInfo && type != SettingMenuType.backup && + type != SettingMenuType.logPreview && type != SettingMenuType.subScreenRoutes) { return const SettingEmpty( message: '设备尚未绑定,请先在“基本信息”中完成绑定', @@ -264,6 +265,7 @@ class _SettingContent extends StatelessWidget { SettingMenuType.faceSetting => settingFace(context), SettingMenuType.facePluginSetting => settingFacePlugin(context), SettingMenuType.productSetting => settingProduct(context), + SettingMenuType.logPreview => const SettingLogPreview(), SettingMenuType.subScreenRoutes => const SettingSubScreenRoutes(), SettingMenuType.versionSetting => settingVersionSetting(context), }; @@ -279,6 +281,7 @@ SvgAsset _menuIcon(SettingMenuType type) { SettingMenuType.faceSetting => FAssets.icons.scanFace, SettingMenuType.facePluginSetting => FAssets.icons.shieldCheck, SettingMenuType.productSetting => FAssets.icons.package, + SettingMenuType.logPreview => FAssets.icons.logs, SettingMenuType.subScreenRoutes => FAssets.icons.monitor, SettingMenuType.versionSetting => FAssets.icons.rocket, }; @@ -293,6 +296,7 @@ String _menuDescription(SettingMenuType type) { SettingMenuType.faceSetting => '查询、同步和维护本地人脸样本', SettingMenuType.facePluginSetting => '查看插件授权、引擎状态和内存人脸库注册进度', SettingMenuType.productSetting => '查询本地商品、同步分类商品和维护默认菜品', + SettingMenuType.logPreview => '按目录切换历史日志,查看脱敏后的最近内容', SettingMenuType.subScreenRoutes => '选择顾客屏演示状态并在当前应用窗口中全屏检查', SettingMenuType.versionSetting => '查看可用终端版本、更新内容和下载状态', }; diff --git a/lib/pages/terminal/setting/widgets/index.dart b/lib/pages/terminal/setting/widgets/index.dart index 47de730..4373d9f 100644 --- a/lib/pages/terminal/setting/widgets/index.dart +++ b/lib/pages/terminal/setting/widgets/index.dart @@ -5,6 +5,7 @@ export './setting_baseInfo.dart'; export './setting_face.dart'; export './setting_face_plugin.dart'; export './setting_kiosk_exit_button.dart'; +export './setting_log_preview.dart'; export './setting_pay.dart'; export './setting_pay_type_editor.dart'; export './setting_product.dart'; diff --git a/lib/pages/terminal/setting/widgets/setting_log_preview.dart b/lib/pages/terminal/setting/widgets/setting_log_preview.dart new file mode 100644 index 0000000..def0ca9 --- /dev/null +++ b/lib/pages/terminal/setting/widgets/setting_log_preview.dart @@ -0,0 +1,970 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +/// 日志预览采用独立生命周期,离开设置分区后立即停止轮询和输入监听。 +class SettingLogPreview extends StatefulWidget { + const SettingLogPreview({super.key}); + + @override + State createState() => _SettingLogPreviewState(); +} + +class _SettingLogPreviewState extends State { + final TextEditingController _keywordController = TextEditingController(); + final ScrollController _catalogScrollController = ScrollController(); + final ScrollController _previewScrollController = ScrollController(); + Timer? _autoRefreshTimer; + AppLogCatalog? _catalog; + AppLogPreview? _preview; + String? _selectedPath; + bool _loadingCatalog = true; + bool _loadingPreview = true; + bool _refreshing = false; + bool _autoRefresh = false; + String _keyword = ''; + String _errorMessage = ''; + int _previewRequestId = 0; + + @override + void initState() { + super.initState(); + _keywordController.addListener(_onKeywordChanged); + unawaited(_reload()); + } + + @override + void dispose() { + _autoRefreshTimer?.cancel(); + _keywordController + ..removeListener(_onKeywordChanged) + ..dispose(); + _catalogScrollController.dispose(); + _previewScrollController.dispose(); + super.dispose(); + } + + void _onKeywordChanged() { + final value = _keywordController.text.trim(); + if (value == _keyword || !mounted) return; + setState(() => _keyword = value); + } + + Future _reload({bool quiet = false}) async { + if (_refreshing) return; + _refreshing = true; + if (mounted) { + setState(() { + if (!quiet) _loadingCatalog = true; + }); + } + try { + final catalog = await listAppLogFiles(); + final selectedPath = catalog.files.any( + (file) => file.path == _selectedPath, + ) + ? _selectedPath! + : catalog.currentPath; + if (!mounted) return; + setState(() { + _catalog = catalog; + _selectedPath = selectedPath; + _loadingCatalog = false; + _errorMessage = ''; + }); + await _loadPreview(selectedPath, quiet: quiet); + } catch (e, stack) { + logger.e('|日志预览|读取失败error:|$e|stack:|$stack|'); + if (!mounted) return; + setState(() { + _loadingCatalog = false; + _loadingPreview = false; + _errorMessage = '日志目录读取失败,请检查存储权限后重试'; + }); + } finally { + if (mounted) { + setState(() => _refreshing = false); + } else { + _refreshing = false; + } + } + } + + Future _loadPreview(String path, {bool quiet = false}) async { + final requestId = ++_previewRequestId; + if (mounted) { + setState(() { + _selectedPath = path; + if (!quiet) { + _loadingPreview = true; + _preview = null; + } + _errorMessage = ''; + }); + } + try { + final preview = await readAppLogPreview(path: path); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _preview = preview; + _loadingPreview = false; + }); + } catch (e, stack) { + logger.e('|日志预览|读取文件失败error:|$e|stack:|$stack|'); + if (!mounted || requestId != _previewRequestId) return; + setState(() { + _loadingPreview = false; + _errorMessage = '日志文件读取失败,文件可能已被移动或清理'; + }); + } + } + + void _selectFile(AppLogFile file) { + if (_selectedPath == file.path) return; + if (_autoRefresh && !file.isCurrent) _toggleAutoRefresh(false); + unawaited(_loadPreview(file.path)); + } + + void _toggleAutoRefresh(bool value) { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + setState(() => _autoRefresh = value); + if (value) { + _autoRefreshTimer = Timer.periodic( + const Duration(seconds: 3), + (_) { + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + }, + ); + final path = _selectedPath; + if (path != null) unawaited(_loadPreview(path, quiet: true)); + } + } + + List get _visibleLines { + final content = _preview?.content ?? ''; + final lines = const LineSplitter().convert(content); + if (_keyword.isEmpty) return lines; + final keyword = _keyword.toLowerCase(); + return lines + .where((line) => line.toLowerCase().contains(keyword)) + .toList(growable: false); + } + + @override + Widget build(BuildContext context) { + final lines = _visibleLines; + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + SizedBox(width: 278.w, child: _buildLogTree(context)), + const VerticalDivider( + width: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + Expanded( + child: Column( + children: [ + _buildToolbar(context, lines), + Expanded(child: _buildPreview(context, lines)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLogTree(BuildContext context) { + final theme = FTheme.of(context); + final catalog = _catalog; + final groupedFiles = _groupedFiles; + return ColoredBox( + color: const Color(0xFFF8F9FB), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 10.h), + child: Row( + children: [ + FIcon( + FAssets.icons.folderTree, + color: XfjDesignTokens.primary, + size: 20.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '日志目录', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + _CompactIconButton( + label: '刷新日志目录', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + ], + ), + ), + if (catalog != null) + Padding( + padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 10.h), + child: Text( + '${catalog.files.length} 个日志文件', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + const Divider(height: 1, thickness: 1), + Expanded( + child: _loadingCatalog && catalog == null + ? const Center(child: CircularProgressIndicator()) + : groupedFiles.isEmpty + ? SettingEmpty( + message: '暂无日志文件', + icon: FAssets.icons.logs, + ) + : Scrollbar( + controller: _catalogScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: ListView( + controller: _catalogScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + children: groupedFiles.entries + .map( + (entry) => _LogTreeGroup( + label: entry.key, + files: entry.value, + selectedPath: _selectedPath, + onSelected: _selectFile, + ), + ) + .toList(growable: false), + ), + ), + ), + if (catalog != null) + Semantics( + button: true, + label: '复制日志目录路径', + child: InkWell( + onTap: () => KeywordTool.copy(catalog.rootPath), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + FIcon( + FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + catalog.rootPath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 8.w), + FIcon( + FAssets.icons.copy, + color: XfjDesignTokens.primary, + size: 17.sp, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Map> get _groupedFiles { + final groups = >{}; + for (final file in _catalog?.files ?? const []) { + final separator = file.relativePath.indexOf('/'); + final label = + separator < 0 ? '日志文件' : file.relativePath.substring(0, separator); + groups.putIfAbsent(label, () => []).add(file); + } + return groups; + } + + Widget _buildToolbar(BuildContext context, List lines) { + final theme = FTheme.of(context); + final preview = _preview; + final selectedFile = _selectedFile; + return Container( + padding: EdgeInsets.all(8.w), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + SizedBox( + width: 300.w, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + selectedFile?.name ?? '正在获取日志文件', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (selectedFile?.isCurrent ?? false) ...[ + SizedBox(width: 8.w), + _CurrentLogBadge(), + ], + ], + ), + SizedBox(height: 2.h), + Text( + preview == null + ? '正在读取文件信息' + : '${_formatBytes(preview.fileSize)} · ${preview.lineCount} 行 · ${_formatTime(preview.modifiedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + _CompactIconButton( + label: '复制当前日志路径', + icon: FAssets.icons.copy, + onTap: preview == null + ? null + : () => KeywordTool.copy(preview.path), + ), + ], + ), + ), + SizedBox(width: 8.w), + Container(width: 1, height: 36.h, color: XfjDesignTokens.line), + SizedBox(width: 10.w), + Expanded( + child: Semantics( + textField: true, + label: '筛选日志关键词', + // Forui 会按运行时字号和主题内边距计算实际高度,只设最小触控高度, + // 避免固定 48.h 在字号缩放后裁掉输入框底部并触发 RenderFlex 溢出。 + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: 48.h), + child: FTextField( + controller: _keywordController, + hint: '筛选关键词,如 error、支付、MQTT', + maxLines: 1, + textInputAction: TextInputAction.search, + prefixBuilder: (_, __, ___) => Padding( + padding: EdgeInsets.only(left: 12.w, right: 8.w), + child: FIcon( + FAssets.icons.search, + color: XfjDesignTokens.muted, + size: 19.sp, + ), + ), + ), + ), + ), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '刷新', + icon: FAssets.icons.refreshCw, + onTap: _refreshing ? null : () => unawaited(_reload()), + ), + SizedBox(width: 8.w), + _CompactActionButton( + label: '复制结果', + icon: FAssets.icons.copy, + onTap: lines.isEmpty + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: lines.join('\n')), + ); + Toast.success(msg: '已复制当前筛选结果'); + }, + ), + SizedBox(width: 8.w), + Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + Text( + '自动刷新', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 8.w), + FSwitch( + semanticLabel: '每 3 秒自动刷新当前日志', + value: _autoRefresh, + enabled: selectedFile?.isCurrent ?? false, + onChange: _toggleAutoRefresh, + ), + ], + ), + ), + ], + ), + ); + } + + AppLogFile? get _selectedFile { + for (final file in _catalog?.files ?? const []) { + if (file.path == _selectedPath) return file; + } + return null; + } + + Widget _buildPreview(BuildContext context, List lines) { + if (_loadingPreview && _preview == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage.isNotEmpty) { + return _LogPreviewState( + icon: FAssets.icons.circleAlert, + message: _errorMessage, + actionLabel: '重新读取', + onTap: _selectedPath == null + ? () => unawaited(_reload()) + : () => unawaited(_loadPreview(_selectedPath!)), + ); + } + if ((_preview?.content ?? '').isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.logs, + message: '当前还没有文件日志,发生运行事件后可手动刷新查看', + ); + } + if (lines.isEmpty) { + return _LogPreviewState( + icon: FAssets.icons.search, + message: '没有匹配当前关键词的日志,请调整筛选条件', + ); + } + + final theme = FTheme.of(context); + final preview = _preview!; + return Container( + color: const Color(0xFFF8F9FB), + child: Column( + children: [ + if (preview.truncated) + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + color: const Color(0xFFFFF9E8), + child: Text( + '文件较大,仅显示最近 ${preview.lineCount} 行;完整文件可在左侧切换或通过“资源备份”导出。', + style: theme.typography.sm.copyWith( + color: const Color(0xFF9A6700), + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Scrollbar( + controller: _previewScrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + child: SelectionArea( + child: ListView.builder( + controller: _previewScrollController, + padding: EdgeInsets.fromLTRB(0, 8.h, 10.w, 8.h), + itemCount: lines.length, + itemBuilder: (context, index) { + final line = lines[index]; + return Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 3.h, + ), + color: _lineBackground(line), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58.w, + child: Text( + '${index + 1}', + textAlign: TextAlign.right, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + line.isEmpty ? ' ' : line, + style: theme.typography.sm.copyWith( + color: _lineColor(line), + height: 1.55, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _LogTreeGroup extends StatelessWidget { + const _LogTreeGroup({ + required this.label, + required this.files, + required this.selectedPath, + required this.onSelected, + }); + + final String label; + final List files; + final String? selectedPath; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + child: Row( + children: [ + FIcon( + label == 'manual' + ? FAssets.icons.folderArchive + : FAssets.icons.folderOpen, + color: XfjDesignTokens.muted, + size: 18.sp, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + label == 'manual' ? '手动快照' : label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '${files.length}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ...files.map( + (file) => _LogTreeFileTile( + file: file, + selected: selectedPath == file.path, + onTap: () => onSelected(file), + ), + ), + ], + ), + ); + } +} + +class _LogTreeFileTile extends StatelessWidget { + const _LogTreeFileTile({ + required this.file, + required this.selected, + required this.onTap, + }); + + final AppLogFile file; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Semantics( + button: true, + selected: selected, + label: '${file.isCurrent ? '当前日志,' : ''}${file.name}', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + child: Material( + color: selected ? XfjDesignTokens.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + constraints: BoxConstraints(minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 7.h), + child: Row( + children: [ + FIcon( + FAssets.icons.fileText, + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + size: 19.sp, + ), + SizedBox(width: 9.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + if (file.isCurrent) ...[ + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 5.w, + vertical: 1.h, + ), + decoration: const BoxDecoration( + color: Color(0xFFECFBF3), + borderRadius: BorderRadius.all( + Radius.circular(999), + ), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + SizedBox(height: 2.h), + Text( + file.exists + ? '${_formatBytes(file.fileSize)} · ${_formatTreeTime(file.modifiedAt)}' + : '等待生成', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CompactIconButton extends StatelessWidget { + const _CompactIconButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: Center( + child: FIcon( + icon, + color: XfjDesignTokens.primary, + size: 19.sp, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _CompactActionButton extends StatelessWidget { + const _CompactActionButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + label: label, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 13.w), + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.ink, size: 18.sp), + SizedBox(width: 7.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _CurrentLogBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: BoxDecoration( + color: const Color(0xFFECFBF3), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '当前', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _LogPreviewState extends StatelessWidget { + const _LogPreviewState({ + required this.icon, + required this.message, + this.actionLabel, + this.onTap, + }); + + final SvgAsset icon; + final String message; + final String? actionLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, color: XfjDesignTokens.muted, size: 44.sp), + SizedBox(height: 12.h), + Text( + message, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onTap != null) ...[ + SizedBox(height: 16.h), + SettingActionButton( + label: actionLabel!, + icon: FAssets.icons.refreshCw, + onTap: onTap!, + ), + ], + ], + ), + ); + } +} + +String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; +} + +String _formatTime(DateTime? time) { + if (time == null) return '尚未生成'; + return DateUtil.formatDate(time, format: DateFormats.full); +} + +String _formatTreeTime(DateTime? time) { + if (time == null) return '未知时间'; + return DateUtil.formatDate(time, format: DateFormats.mo_d_h_m); +} + +Color _lineColor(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return XfjDesignTokens.danger; + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFF9A6700); + } + return XfjDesignTokens.ink; +} + +Color _lineBackground(String line) { + final value = line.toLowerCase(); + if (value.contains('error') || value.contains('失败')) { + return const Color(0xFFFFF5F4); + } + if (value.contains('warning') || value.contains('警告')) { + return const Color(0xFFFFFBEB); + } + return Colors.transparent; +} diff --git a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart index cedecdd..3029a6b 100644 --- a/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart +++ b/lib/pages/terminal/setting/widgets/setting_sub_screen_routes.dart @@ -7,6 +7,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/reservation_query_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_preview.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_preview.dart'; import 'setting_components.dart'; @@ -24,6 +25,12 @@ class _SettingSubScreenRoutesState extends State { ReservationQueryDisplayScenario.success; static final _routes = [ + _SubScreenRouteDefinition( + title: '欢迎副屏', + route: RouteNames.customerWelcome, + icon: FAssets.icons.sparkles, + type: _SubScreenPreviewType.welcome, + ), _SubScreenRouteDefinition( title: '顾客结算屏', route: RouteNames.customer, @@ -73,6 +80,7 @@ class _SettingSubScreenRoutesState extends State { _ScenarioGroup( title: '顾客结算屏演示状态', scenarios: CustomerDisplayScenario.values + .where((scenario) => scenario != CustomerDisplayScenario.idle) .map( (scenario) => _PreviewScenario( title: scenario.title, @@ -109,16 +117,21 @@ class _SettingSubScreenRoutesState extends State { } String _scenarioTitle(_SubScreenPreviewType type) { - return type == _SubScreenPreviewType.settlement - ? _scenario.title - : _reservationScenario.title; + return switch (type) { + _SubScreenPreviewType.welcome => '待机欢迎', + _SubScreenPreviewType.settlement => _scenario.title, + _SubScreenPreviewType.reservationQuery => _reservationScenario.title, + }; } void _openPreview(_SubScreenRouteDefinition definition) { - final scenarioName = definition.type == _SubScreenPreviewType.settlement - ? _scenario.name - : _reservationScenario.name; - final arguments = {'scenario': scenarioName}; + final arguments = switch (definition.type) { + _SubScreenPreviewType.welcome => const {}, + _SubScreenPreviewType.settlement => {'scenario': _scenario.name}, + _SubScreenPreviewType.reservationQuery => { + 'scenario': _reservationScenario.name, + }, + }; Navigator.of(context, rootNavigator: true).push( GetPageRoute( routeName: definition.route, @@ -126,9 +139,13 @@ class _SettingSubScreenRoutesState extends State { name: definition.route, arguments: arguments, ), - page: () => definition.type == _SubScreenPreviewType.settlement - ? CustomerDisplayPreviewPage(arguments: arguments) - : ReservationQueryPreviewPage(arguments: arguments), + page: () => switch (definition.type) { + _SubScreenPreviewType.welcome => const CustomerWelcomePreviewPage(), + _SubScreenPreviewType.settlement => + CustomerDisplayPreviewPage(arguments: arguments), + _SubScreenPreviewType.reservationQuery => + ReservationQueryPreviewPage(arguments: arguments), + }, transition: Transition.noTransition, transitionDuration: Duration.zero, ), @@ -150,7 +167,7 @@ class _SubScreenRouteDefinition { final _SubScreenPreviewType type; } -enum _SubScreenPreviewType { settlement, reservationQuery } +enum _SubScreenPreviewType { welcome, settlement, reservationQuery } class _PreviewScenario { const _PreviewScenario({ diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 3d73f9f..6c864c4 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -53,6 +53,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { } if (action == _SubScreenProtocol.jumpSubPage) { _stopCountdown(); + // 业务入口会在购物车创建前发送跳页消息;没有商品上下文时继续待机, + // 避免副屏提前露出空的结算框架。 + if (_state.products.isEmpty) return false; return _replace(_state.withScenario(CustomerDisplayScenario.review)); } if (action == _SubScreenProtocol.updateCard) { @@ -62,6 +65,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { return _replace(next); } if (action == _SubScreenProtocol.qrCodeScan) { + // 清屏后的迟到支付消息没有订单上下文,必须继续展示欢迎页, + // 不能重新构造一个无商品、无金额的支付框架。 + if (_state.products.isEmpty) return false; final qrCodeData = _parseQrCode(arguments); if (qrCodeData == null) return false; // 插件可能重复投递同一支付消息,不能因此重置顾客正在观察的倒计时。 @@ -117,6 +123,9 @@ class CustomerDisplayCoordinator extends ChangeNotifier { products.add(product); } + // 空购物车的有效快照等价于业务清屏,欢迎页不保留上一位顾客的信息。 + if (products.isEmpty) return CustomerDisplayState.idle(); + String? customerName; final rawPayUser = data['payUser']; if (rawPayUser != null) { diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index 7f9a465..b9de692 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -8,9 +8,9 @@ import 'customer_display_coordinator.dart'; import 'face_recognition_coordinator.dart'; import 'reservation_query_coordinator.dart'; -enum SubDisplayMode { settlement, reservationQuery, faceRecognition } +enum SubDisplayMode { welcome, settlement, reservationQuery, faceRecognition } -/// 副屏唯一消息入口,负责在既有结算屏和预约查询屏之间切换。 +/// 副屏唯一消息入口,负责在欢迎、结算、预约查询和人脸页面之间切换。 class SubDisplayCoordinator extends ChangeNotifier { SubDisplayCoordinator({ CustomerDisplayCoordinator? customerCoordinator, @@ -22,18 +22,19 @@ class SubDisplayCoordinator extends ChangeNotifier { reservationCoordinator ?? ReservationQueryCoordinator(), faceRecognitionCoordinator = faceRecognitionCoordinator ?? FaceRecognitionCoordinator() { + _lastReservationScenario = this.reservationCoordinator.state.scenario; this.customerCoordinator.addListener(_notifyFromChild); - this.reservationCoordinator.addListener(_notifyFromChild); + this.reservationCoordinator.addListener(_notifyFromReservation); this.faceRecognitionCoordinator.addListener(_notifyFromChild); } static const _reservationUpdate = 'reservationQueryUpdate'; static const _reservationClear = 'reservationQueryClear'; static const _faceStart = 'faceRecognitionStart'; - static const _settlementActions = { - 'updateCard', - 'jumpSubPage', - 'jumpSubBack', + static const _updateCard = 'updateCard'; + static const _jumpSubPage = 'jumpSubPage'; + static const _jumpSubBack = 'jumpSubBack'; + static const _paymentActions = { 'qrCodeScan', 'qrCodeScanSuccess', 'qrCodeScanCancel', @@ -43,13 +44,16 @@ class SubDisplayCoordinator extends ChangeNotifier { final ReservationQueryCoordinator reservationCoordinator; final FaceRecognitionCoordinator faceRecognitionCoordinator; - SubDisplayMode _mode = SubDisplayMode.settlement; - SubDisplayMode _modeBeforeFace = SubDisplayMode.settlement; + SubDisplayMode _mode = SubDisplayMode.welcome; + SubDisplayMode _modeBeforeFace = SubDisplayMode.welcome; Timer? _faceRestoreTimer; String? _reservationSessionAtFaceStart; int _reservationRevisionAtFaceStart = -1; bool _reservationSuccessDelayPassed = false; bool _reservationResultReady = false; + bool _reservationClearedDuringFace = false; + bool _reservationFaceHandoffActive = false; + late ReservationQueryDisplayScenario _lastReservationScenario; bool _disposed = false; SubDisplayMode get mode => _mode; @@ -80,6 +84,11 @@ class SubDisplayCoordinator extends ChangeNotifier { case FaceRecognitionDisplayTransition.restoreAfterSuccess: _faceRestoreTimer?.cancel(); if (faceState?.scene == FaceRecognitionDisplayScene.reservation) { + if (_reservationClearedDuringFace) { + _replaceMode(SubDisplayMode.welcome); + _resetReservationFaceHandoff(); + break; + } // 预约查询必须在人脸成功提示展示满 700ms 且查询拿到最终结果后再恢复, // 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。 _reservationSuccessDelayPassed = false; @@ -104,6 +113,7 @@ class SubDisplayCoordinator extends ChangeNotifier { if (action == _reservationUpdate) { final accepted = reservationCoordinator.handleUpdate(arguments); if (accepted) { + _reservationClearedDuringFace = false; _showOrRememberMode(SubDisplayMode.reservationQuery); _handleReservationUpdateDuringFace(); } @@ -112,14 +122,38 @@ class SubDisplayCoordinator extends ChangeNotifier { if (action == _reservationClear) { final accepted = reservationCoordinator.handleClear(arguments); if (accepted) { - _showOrRememberMode(SubDisplayMode.reservationQuery); + _showOrRememberMode(SubDisplayMode.welcome); _handleReservationClearDuringFace(); } return accepted; } - if (_settlementActions.contains(action)) { + if (action == _jumpSubBack) { final changed = customerCoordinator.handleAction(action, arguments); - final modeChanged = _showOrRememberMode(SubDisplayMode.settlement); + final modeChanged = _showOrRememberMode(SubDisplayMode.welcome); + return changed || modeChanged; + } + if (action == _jumpSubPage) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); + return changed || modeChanged; + } + if (action == _updateCard) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); + return changed || modeChanged; + } + if (_paymentActions.contains(action)) { + final changed = customerCoordinator.handleAction(action, arguments); + final target = customerCoordinator.state.products.isEmpty + ? SubDisplayMode.welcome + : SubDisplayMode.settlement; + final modeChanged = _showOrRememberMode(target); return changed || modeChanged; } return false; @@ -138,6 +172,7 @@ class SubDisplayCoordinator extends ChangeNotifier { _reservationRevisionAtFaceStart = reservationState.revision; _reservationSuccessDelayPassed = false; _reservationResultReady = false; + _reservationFaceHandoffActive = true; } void _handleReservationUpdateDuringFace() { @@ -163,15 +198,20 @@ class SubDisplayCoordinator extends ChangeNotifier { if (_mode != SubDisplayMode.faceRecognition) return; final faceState = faceRecognitionCoordinator.state; if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return; + _resetReservationFaceHandoff(preserveClear: true); + _reservationClearedDuringFace = true; if (faceState?.status != FaceRecognitionDisplayStatus.success && faceState?.status != FaceRecognitionDisplayStatus.failed) { return; } // 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。 - _restoreReservationAfterFace(); + _faceRestoreTimer?.cancel(); + _replaceMode(SubDisplayMode.welcome); + _resetReservationFaceHandoff(); } bool _hasNewReservationTerminalState() { + if (!_reservationFaceHandoffActive) return false; final state = reservationCoordinator.state; final isNewer = state.sessionId != _reservationSessionAtFaceStart || state.revision > _reservationRevisionAtFaceStart; @@ -192,11 +232,13 @@ class SubDisplayCoordinator extends ChangeNotifier { _resetReservationFaceHandoff(); } - void _resetReservationFaceHandoff() { + void _resetReservationFaceHandoff({bool preserveClear = false}) { _reservationSessionAtFaceStart = null; _reservationRevisionAtFaceStart = -1; _reservationSuccessDelayPassed = false; _reservationResultReady = false; + _reservationFaceHandoffActive = false; + if (!preserveClear) _reservationClearedDuringFace = false; } /// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。 @@ -214,6 +256,25 @@ class SubDisplayCoordinator extends ChangeNotifier { if (!_disposed) notifyListeners(); } + void _notifyFromReservation() { + if (_disposed) return; + final previous = _lastReservationScenario; + final current = reservationCoordinator.state.scenario; + _lastReservationScenario = current; + + if (previous != ReservationQueryDisplayScenario.idle && + current == ReservationQueryDisplayScenario.idle) { + final modeBeforeTransition = _mode; + // 预约结果可由副屏自己的隐私计时器清空;顶层必须同步回欢迎页, + // 否则 Coordinator 已无身份数据但仍会停留在预约查询框架。 + _showOrRememberMode(SubDisplayMode.welcome); + _handleReservationClearDuringFace(); + if (_mode == modeBeforeTransition) notifyListeners(); + return; + } + notifyListeners(); + } + @override void dispose() { if (_disposed) return; @@ -221,7 +282,7 @@ class SubDisplayCoordinator extends ChangeNotifier { _faceRestoreTimer?.cancel(); _resetReservationFaceHandoff(); customerCoordinator.removeListener(_notifyFromChild); - reservationCoordinator.removeListener(_notifyFromChild); + reservationCoordinator.removeListener(_notifyFromReservation); faceRecognitionCoordinator.removeListener(_notifyFromChild); customerCoordinator.dispose(); reservationCoordinator.dispose(); diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index 0d56b40..5c8241f 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -6,6 +6,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; /// 真实副屏和模拟器预览共用的顾客结算屏。 class CustomerDisplayPage extends StatelessWidget { @@ -29,9 +30,13 @@ class CustomerDisplayPage extends StatelessWidget { Positioned.fill( child: AnimatedBuilder( animation: coordinator, - builder: (context, _) => _CustomerDisplayView( - state: coordinator.state, - ), + builder: (context, _) { + final state = coordinator.state; + if (state.scenario == CustomerDisplayScenario.idle) { + return const CustomerWelcomePage(); + } + return _CustomerDisplayView(state: state); + }, ), ), if (previewMode) diff --git a/lib/sub_screen/page/customer_welcome.dart b/lib/sub_screen/page/customer_welcome.dart new file mode 100644 index 0000000..6477058 --- /dev/null +++ b/lib/sub_screen/page/customer_welcome.dart @@ -0,0 +1,659 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; + +/// 顾客副屏待机欢迎页,真实副屏与主屏全屏预览共用同一份布局和时钟生命周期。 +class CustomerWelcomePage extends StatefulWidget { + const CustomerWelcomePage({ + super.key, + this.previewMode = false, + this.onExit, + this.now, + this.clockTick = const Duration(minutes: 1), + }); + + final bool previewMode; + final VoidCallback? onExit; + final DateTime Function()? now; + final Duration clockTick; + + @override + State createState() => _CustomerWelcomePageState(); +} + +class _CustomerWelcomePageState extends State { + late DateTime _currentTime; + Timer? _clockTimer; + + @override + void initState() { + super.initState(); + _currentTime = _readNow(); + _startClock(); + } + + @override + void didUpdateWidget(CustomerWelcomePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clockTick != widget.clockTick || + oldWidget.now != widget.now) { + _currentTime = _readNow(); + _startClock(); + } + } + + DateTime _readNow() => widget.now?.call() ?? DateTime.now(); + + void _startClock() { + _clockTimer?.cancel(); + final tick = widget.clockTick > Duration.zero + ? widget.clockTick + : const Duration(minutes: 1); + _clockTimer = Timer.periodic(tick, (_) { + if (mounted) setState(() => _currentTime = _readNow()); + }); + } + + @override + void dispose() { + _clockTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: Stack( + children: [ + Positioned.fill( + child: _CustomerWelcomeContent(currentTime: _currentTime), + ), + if (widget.previewMode) + Positioned( + top: 16, + right: 16, + child: SafeArea( + child: _PreviewExitButton(onTap: widget.onExit), + ), + ), + ], + ), + ); + } +} + +class _CustomerWelcomeContent extends StatelessWidget { + const _CustomerWelcomeContent({required this.currentTime}); + + final DateTime currentTime; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final scale = math + .min(constraints.maxWidth / 1920, constraints.maxHeight / 1080) + .clamp(0.55, 1.12) + .toDouble(); + return ColoredBox( + key: const ValueKey('customer-welcome-page'), + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Column( + children: [ + _WelcomeHeader(currentTime: currentTime, scale: scale), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24 * scale, + 0, + 24 * scale, + 24 * scale, + ), + child: _WelcomeHero(scale: scale), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _WelcomeHeader extends StatelessWidget { + const _WelcomeHeader({required this.currentTime, required this.scale}); + + final DateTime currentTime; + final double scale; + + @override + Widget build(BuildContext context) { + final hour = currentTime.hour.toString().padLeft(2, '0'); + final minute = currentTime.minute.toString().padLeft(2, '0'); + return SizedBox( + height: 80 * scale, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32 * scale), + child: Row( + children: [ + Text( + 'ZAX', + style: TextStyle( + color: const Color(0xFF0865E8), + fontSize: 36 * scale, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + letterSpacing: -2 * scale, + ), + ), + SizedBox(width: 20 * scale), + Container( + width: 1, + height: 36 * scale, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20 * scale), + Text( + '欢迎光临', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 24 * scale, + fontWeight: FontWeight.w800, + letterSpacing: 1.2 * scale, + ), + ), + SizedBox(width: 28 * scale), + Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 9 * scale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12 * scale), + ), + child: Text( + '智慧收银', + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: 16 * scale, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + _ConnectionStatus(scale: scale), + SizedBox(width: 20 * scale), + Container( + width: 1, + height: 28 * scale, + color: XfjDesignTokens.line, + ), + SizedBox(width: 20 * scale), + Text( + '$hour:$minute', + key: const ValueKey('customer-welcome-clock'), + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 20 * scale, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ); + } +} + +class _ConnectionStatus extends StatelessWidget { + const _ConnectionStatus({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '设备已连接', + child: Row( + children: [ + Container( + width: 10 * scale, + height: 10 * scale, + decoration: BoxDecoration( + color: XfjDesignTokens.success, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: XfjDesignTokens.success.withValues(alpha: 0.12), + spreadRadius: 5 * scale, + ), + ], + ), + ), + SizedBox(width: 10 * scale), + Text( + '设备已连接', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 18 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _WelcomeHero extends StatelessWidget { + const _WelcomeHero({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(24 * scale); + return DecoratedBox( + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: radius, + boxShadow: const [ + BoxShadow( + color: Color(0x14111827), + blurRadius: 56, + offset: Offset(0, 18), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Stack( + fit: StackFit.expand, + children: [ + _WelcomeAssetImage( + key: const ValueKey('customer-welcome-hero'), + asset: + 'assets/images/customer_welcome/abstract-welcome-hero.webp', + semanticsLabel: '蓝色、薄荷绿与暖黄色卡通伙伴挥手欢迎', + fit: BoxFit.cover, + ), + Positioned( + left: 32 * scale, + top: 32 * scale, + child: _ReadyCard(scale: scale), + ), + Positioned( + right: 32 * scale, + top: 32 * scale, + child: _SyncBadge(scale: scale), + ), + Positioned( + left: 32 * scale, + bottom: 32 * scale, + child: Row( + children: [ + _WelcomeInfoCard( + imageKey: const ValueKey('customer-welcome-flow'), + asset: 'assets/images/customer_welcome/abstract-flow.webp', + semanticsLabel: '卡通伙伴与空白卡片组成的轻量信息流插图', + icon: Icons.auto_awesome_rounded, + title: '内容实时更新', + detail: '新的信息会自动显示', + scale: scale, + ), + SizedBox(width: 16 * scale), + _WelcomeInfoCard( + imageKey: const ValueKey('customer-welcome-care'), + asset: 'assets/images/customer_welcome/abstract-care.webp', + semanticsLabel: '卡通伙伴拥抱爱心盾牌的安心守护插图', + icon: Icons.shield_outlined, + title: '安心使用', + detail: '个人信息会妥善保护', + scale: scale, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ReadyCard extends StatelessWidget { + const _ReadyCard({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + width: 504 * scale, + padding: EdgeInsets.all(24 * scale), + decoration: _floatingDecoration(radius: 20 * scale), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + color: XfjDesignTokens.success, + size: 19 * scale, + ), + SizedBox(width: 8 * scale), + Text( + '终端服务已就绪', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + SizedBox(height: 16 * scale), + Text( + '欢迎使用', + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 48 * scale, + height: 1.05, + fontWeight: FontWeight.w900, + letterSpacing: -1.7 * scale, + ), + ), + SizedBox(height: 12 * scale), + Text( + '请稍候,接下来的内容会在这里清晰展示。', + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 18 * scale, + height: 1.55, + ), + ), + SizedBox(height: 20 * scale), + Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 12 * scale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12 * scale), + ), + child: Row( + children: [ + Container( + width: 10 * scale, + height: 10 * scale, + decoration: const BoxDecoration( + color: XfjDesignTokens.primary, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 12 * scale), + Text( + '正在等待服务开始', + style: TextStyle( + color: XfjDesignTokens.primary, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SyncBadge extends StatelessWidget { + const _SyncBadge({required this.scale}); + + final double scale; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 16 * scale, + vertical: 10 * scale, + ), + decoration: _floatingDecoration( + radius: 24 * scale, + shadow: const BoxShadow( + color: Color(0x0F111827), + blurRadius: 8, + offset: Offset(0, 2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_rounded, + color: XfjDesignTokens.success, + size: 19 * scale, + ), + SizedBox(width: 8 * scale), + Text( + '内容将自动同步', + style: TextStyle( + color: XfjDesignTokens.success, + fontSize: 14 * scale, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _WelcomeInfoCard extends StatelessWidget { + const _WelcomeInfoCard({ + required this.imageKey, + required this.asset, + required this.semanticsLabel, + required this.icon, + required this.title, + required this.detail, + required this.scale, + }); + + final Key imageKey; + final String asset; + final String semanticsLabel; + final IconData icon; + final String title; + final String detail; + final double scale; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(16 * scale); + return Container( + width: 248 * scale, + decoration: _floatingDecoration(radius: 16 * scale), + child: ClipRRect( + borderRadius: radius, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: double.infinity, + height: 120 * scale, + child: _WelcomeAssetImage( + key: imageKey, + asset: asset, + semanticsLabel: semanticsLabel, + fit: BoxFit.cover, + ), + ), + Container( + color: XfjDesignTokens.surface, + padding: EdgeInsets.all(14 * scale), + child: Row( + children: [ + Container( + width: 36 * scale, + height: 36 * scale, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(10 * scale), + ), + child: Icon( + icon, + color: XfjDesignTokens.primary, + size: 20 * scale, + ), + ), + SizedBox(width: 12 * scale), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.ink, + fontSize: 14 * scale, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4 * scale), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: XfjDesignTokens.muted, + fontSize: 12 * scale, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _WelcomeAssetImage extends StatelessWidget { + const _WelcomeAssetImage({ + super.key, + required this.asset, + required this.semanticsLabel, + required this.fit, + }); + + final String asset; + final String semanticsLabel; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + return Image.asset( + asset, + fit: fit, + semanticLabel: semanticsLabel, + errorBuilder: (context, error, stackTrace) => ColoredBox( + color: XfjDesignTokens.primarySoft, + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: XfjDesignTokens.primary, + size: 40, + semanticLabel: '欢迎插图暂不可用', + ), + ), + ), + ); + } +} + +class _PreviewExitButton extends StatelessWidget { + const _PreviewExitButton({required this.onTap}); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '退出欢迎副屏预览', + child: Material( + color: const Color(0xD9111827), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: const SizedBox( + width: 118, + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.close_fullscreen_rounded, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + '退出预览', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +BoxDecoration _floatingDecoration({ + required double radius, + BoxShadow shadow = const BoxShadow( + color: Color(0x1F111827), + blurRadius: 48, + offset: Offset(0, 18), + ), +}) { + return BoxDecoration( + color: const Color(0xF0FFFFFF), + border: Border.all(color: const Color(0xE6FFFFFF)), + borderRadius: BorderRadius.circular(radius), + boxShadow: [shadow], + ); +} diff --git a/lib/sub_screen/page/customer_welcome_preview.dart b/lib/sub_screen/page/customer_welcome_preview.dart new file mode 100644 index 0000000..ce7359c --- /dev/null +++ b/lib/sub_screen/page/customer_welcome_preview.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; + +/// 主屏调试路由包装页,只提供退出能力,不接入真实副屏消息或业务状态。 +class CustomerWelcomePreviewPage extends StatelessWidget { + const CustomerWelcomePreviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return CustomerWelcomePage( + previewMode: true, + onExit: () => Navigator.of(context).maybePop(), + ); + } +} diff --git a/lib/sub_screen/page/index.dart b/lib/sub_screen/page/index.dart index 4ca2345..4e1d46e 100644 --- a/lib/sub_screen/page/index.dart +++ b/lib/sub_screen/page/index.dart @@ -2,6 +2,8 @@ library sub_page; export './customer_display.dart'; export './customer_preview.dart'; +export './customer_welcome.dart'; +export './customer_welcome_preview.dart'; export './face_recognition_customer_page.dart'; export './reservation_query_display.dart'; export './reservation_query_preview.dart'; diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart index 8024b67..57ff916 100644 --- a/lib/sub_screen/page/sub_app.dart +++ b/lib/sub_screen/page/sub_app.dart @@ -7,6 +7,7 @@ import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_display.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/face_recognition_customer_page.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/reservation_query_display.dart'; @@ -57,6 +58,7 @@ class _SubAppPageState extends State { animation: _coordinator, builder: (context, _) { return switch (_coordinator.mode) { + SubDisplayMode.welcome => const CustomerWelcomePage(), SubDisplayMode.settlement => CustomerDisplayPage( coordinator: _coordinator.customerCoordinator, ), diff --git a/pubspec.lock b/pubspec.lock index 4dc0c38..e4f24b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1018,6 +1018,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.9.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9a0a236..84bee4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,8 @@ dependencies: logger: 2.3.0 # 工具类 common_utils: ^2.1.0 + # 登录密码 AES-CFB 加密 + pointycastle: 3.9.1 # 判断两个对象相等 equatable: ^2.0.7 # 打印机 @@ -91,4 +93,5 @@ flutter: assets: - assets/images/ + - assets/images/customer_welcome/ - assets/crt/ diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index f063330..36d323a 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -71,6 +71,7 @@ void main() { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 3); const qrEvent = {'qrCodeData': 'preview-payment'}; + coordinator.handleAction('updateCard', _productEvent()); expect(coordinator.handleAction('qrCodeScan', qrEvent), isTrue); await tester.pump(const Duration(seconds: 1)); expect(coordinator.state.countdownSeconds, 2); @@ -87,6 +88,7 @@ void main() { testWidgets('释放后停止倒计时并忽略后续消息', (tester) async { final coordinator = CustomerDisplayCoordinator(qrCountdownSeconds: 2); + coordinator.handleAction('updateCard', _productEvent()); coordinator.handleAction( 'qrCodeScan', const {'qrCodeData': 'preview-payment'}, @@ -117,6 +119,27 @@ void main() { coordinator.dispose(); }); + + test('空购物车跳页保持欢迎状态,空商品快照清除已有上下文', () { + final coordinator = CustomerDisplayCoordinator(); + + expect(coordinator.handleAction('jumpSubPage'), isFalse); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + + coordinator.handleAction('updateCard', _productEvent()); + expect(coordinator.state.products, hasLength(2)); + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isTrue, + ); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + expect(coordinator.state.products, isEmpty); + + coordinator.dispose(); + }); } Map _productEvent() { diff --git a/test/sub_screen/customer_display_widget_test.dart b/test/sub_screen/customer_display_widget_test.dart index b1b2fa0..5c0dd92 100644 --- a/test/sub_screen/customer_display_widget_test.dart +++ b/test/sub_screen/customer_display_widget_test.dart @@ -28,14 +28,15 @@ void main() { ); await tester.pump(); - expect( - find.byKey(ValueKey('customer-scenario-${scenario.name}')), - findsOneWidget, - ); + final scenarioKey = scenario == CustomerDisplayScenario.idle + ? const ValueKey('customer-welcome-page') + : ValueKey('customer-scenario-${scenario.name}'); + expect(find.byKey(scenarioKey), findsOneWidget); expect(tester.takeException(), isNull); if (scenario == CustomerDisplayScenario.idle) { - expect(find.text('等待添加商品'), findsOneWidget); + expect(find.text('终端服务已就绪'), findsOneWidget); + expect(find.text('等待添加商品'), findsNothing); } else { expect(find.text('张*明,您好'), findsOneWidget); expect(find.text('张小明'), findsNothing); diff --git a/test/sub_screen/customer_welcome_widget_test.dart b/test/sub_screen/customer_welcome_widget_test.dart new file mode 100644 index 0000000..8581172 --- /dev/null +++ b/test/sub_screen/customer_welcome_widget_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome_preview.dart'; + +void main() { + for (final size in [const Size(1920, 1080), const Size(1280, 720)]) { + testWidgets('${size.width.toInt()}x${size.height.toInt()} 欢迎页无布局异常', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: CustomerWelcomePage( + now: () => DateTime(2026, 7, 27, 10, 28), + ), + ), + ); + await tester.pump(); + + expect( + find.byKey(const ValueKey('customer-welcome-page')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-hero')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-flow')), findsOneWidget); + expect( + find.byKey(const ValueKey('customer-welcome-care')), findsOneWidget); + expect(find.text('终端服务已就绪'), findsOneWidget); + expect(find.text('正在等待服务开始'), findsOneWidget); + expect(find.text('内容将自动同步'), findsOneWidget); + expect(find.text('10:28'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('欢迎页时钟按配置刷新并在页面释放后停止', (tester) async { + var now = DateTime(2026, 7, 27, 10, 28); + await tester.pumpWidget( + MaterialApp( + home: CustomerWelcomePage( + now: () => now, + clockTick: const Duration(seconds: 1), + ), + ), + ); + expect(find.text('10:28'), findsOneWidget); + + now = DateTime(2026, 7, 27, 10, 29); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('10:29'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 2)); + expect(tester.takeException(), isNull); + }); + + testWidgets('欢迎副屏悬浮按钮和系统返回都能退出预览', (tester) async { + await tester.pumpWidget(const _WelcomePreviewHarness()); + + await tester.tap(find.text('打开欢迎预览')); + await tester.pumpAndSettle(); + expect(find.text('退出预览'), findsOneWidget); + + await tester.tap(find.text('退出预览')); + await tester.pumpAndSettle(); + expect(find.text('打开欢迎预览'), findsOneWidget); + + await tester.tap(find.text('打开欢迎预览')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('打开欢迎预览'), findsOneWidget); + }); +} + +class _WelcomePreviewHarness extends StatelessWidget { + const _WelcomePreviewHarness(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CustomerWelcomePreviewPage(), + ), + ), + child: const Text('打开欢迎预览'), + ), + ), + ), + ), + ); + } +} diff --git a/test/sub_screen/reservation_query_coordinator_test.dart b/test/sub_screen/reservation_query_coordinator_test.dart index 4d6721a..af348f4 100644 --- a/test/sub_screen/reservation_query_coordinator_test.dart +++ b/test/sub_screen/reservation_query_coordinator_test.dart @@ -72,6 +72,54 @@ void main() { coordinator.dispose(); }); + testWidgets('预约结果自行过期后顶层副屏返回欢迎页', (tester) async { + var now = DateTime(2026, 7, 24, 8); + final coordinator = SubDisplayCoordinator( + reservationCoordinator: ReservationQueryCoordinator(now: () => now), + ); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'reservationQueryUpdate', + _reservationEvent( + sessionId: '250', + revision: 1, + expiresAt: now.add(const Duration(seconds: 2)), + ), + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + + now = now.add(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 1)); + + expect( + coordinator.reservationCoordinator.state.scenario, + ReservationQueryDisplayScenario.idle, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + }); + + test('收到 idle 预约更新时仍进入查询页等待后续状态', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'reservationQueryUpdate', + const { + 'sessionId': '260', + 'revision': 1, + 'scenario': 'idle', + }, + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.reservationQuery); + }); + test('显式清屏也需要递增版本并保留最近有效状态', () { final now = DateTime(2026, 7, 24, 8); final coordinator = ReservationQueryCoordinator(now: () => now); @@ -294,7 +342,7 @@ void main() { ), isTrue, ); - expect(coordinator.mode, SubDisplayMode.reservationQuery); + expect(coordinator.mode, SubDisplayMode.welcome); }); } diff --git a/test/sub_screen/sub_display_coordinator_test.dart b/test/sub_screen/sub_display_coordinator_test.dart new file mode 100644 index 0000000..07e9cc9 --- /dev/null +++ b/test/sub_screen/sub_display_coordinator_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/common/utils/sub_display_coordinator.dart'; + +void main() { + test('默认欢迎,空购物车保持欢迎,商品更新后进入结算', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.handleAction('jumpSubPage'), isFalse); + expect(coordinator.mode, SubDisplayMode.welcome); + + expect(coordinator.handleAction('updateCard', _productEvent()), isTrue); + expect(coordinator.mode, SubDisplayMode.settlement); + expect(coordinator.customerCoordinator.state.products, hasLength(1)); + + expect(coordinator.handleAction('jumpSubBack'), isTrue); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + test('有效空购物车快照清除上一位顾客并返回欢迎页', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + coordinator.handleAction('updateCard', _productEvent()); + expect(coordinator.mode, SubDisplayMode.settlement); + + expect( + coordinator.handleAction( + 'updateCard', + _productEvent()..['shopList'] = >[], + ), + isTrue, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + test('清屏后的迟到支付消息不会重新露出空结算页', () { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + expect( + coordinator.handleAction( + 'qrCodeScan', + const {'qrCodeData': 'stale-payment-code'}, + ), + isFalse, + ); + expect(coordinator.mode, SubDisplayMode.welcome); + expect(coordinator.customerCoordinator.state.products, isEmpty); + }); + + testWidgets('收银人脸结束后恢复进入人脸前的页面', (tester) async { + final coordinator = SubDisplayCoordinator(); + addTearDown(coordinator.dispose); + + coordinator.handleAction( + 'faceRecognitionStart', + _faceEvent(sessionId: 'welcome-face', revision: 1), + ); + coordinator.handleAction( + 'faceRecognitionSuccess', + _faceEvent(sessionId: 'welcome-face', revision: 2), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(coordinator.mode, SubDisplayMode.welcome); + + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction( + 'faceRecognitionStart', + _faceEvent(sessionId: 'settlement-face', revision: 1), + ); + coordinator.handleAction( + 'faceRecognitionSuccess', + _faceEvent(sessionId: 'settlement-face', revision: 2), + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(coordinator.mode, SubDisplayMode.settlement); + }); +} + +Map _productEvent() { + return { + 'sysType': 'SUPERMARKET', + 'payUser': {'username': '张小明'}, + 'totalPrice': 480, + 'amountMealPrice': 0, + 'shopList': [ + { + 'productName': '纯牛奶 250ml', + 'productPrice': 480, + 'nature': 'DEFAULT', + 'productNumber': 1, + 'productWidget': 0, + 'totalPrice': 480, + }, + ], + }; +} + +Map _faceEvent({ + required String sessionId, + required int revision, +}) { + return { + 'sessionId': sessionId, + 'revision': revision, + 'scene': 'cashier', + 'message': revision == 1 ? '正在启动摄像头' : '识别成功,请稍候', + }; +}