diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java index b30484c..bd84331 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java @@ -86,6 +86,65 @@ public class DataBaseUtils { } } + // 状态检查专用轻量查询:只读取数量与 sampleId 集合所需列,不加载 faceModel 特征大字段。 + // face 表把特征以 JSON 文本存在行内(每条约数 KB),SELECT * 全表读取会反复撑爆 2MB + // CursorWindow 并把调用线程阻塞数秒;状态一致性比较不需要特征数据。 + static public List getFaceProfiles(Context context, String queryUserId) { + List result = new ArrayList<>(); + DatabaseHelper databaseHelper = null; + SQLiteDatabase database = null; + Cursor cursor = null; + try { + databaseHelper = new DatabaseHelper(context); + database = databaseHelper.getWritableDatabase(); + String sql = "SELECT userId, sampleId, userName, path FROM face"; + if (Objects.nonNull(queryUserId) && !Objects.equals(queryUserId, "")) { + sql = sql.concat(" WHERE userId = ?"); + cursor = database.rawQuery(sql, new String[]{queryUserId}); + } else { + cursor = database.rawQuery(sql, null); + } + Log.i(TAG, "getFaceProfiles,sql:" + sql + ";queryUserId:" + queryUserId + ";"); + while (cursor.moveToNext()) { + int userIdIndex = cursor.getColumnIndex("userId"); + int sampleIdIndex = cursor.getColumnIndex("sampleId"); + int userNameIndex = cursor.getColumnIndex("userName"); + int pathIndex = cursor.getColumnIndex("path"); + FaceEntity item = new FaceEntity(); + if (userIdIndex != -1) { + item.setUserId(cursor.getString(userIdIndex)); + } + if (sampleIdIndex != -1) { + int sampleId = cursor.getInt(sampleIdIndex); + item.setSampleId(sampleId); + item.setFaceId(sampleId); + } + if (userNameIndex != -1) { + item.setUserName(cursor.getString(userNameIndex)); + } + if (pathIndex != -1) { + item.setFaceFilePath(cursor.getString(pathIndex)); + } + result.add(item); + } + Log.i(TAG, "查询人脸概况成功总数:" + result.size() + ";"); + return result; + } catch (Exception e) { + Log.e(TAG, "查询人脸概况错误error:" + e.getMessage(), e); + return result; + } finally { + if (cursor != null) { + cursor.close(); + } + if (database != null) { + database.close(); + } + if (databaseHelper != null) { + databaseHelper.close(); + } + } + } + static public Boolean updateFaceModel(Context context, FaceEntity faceEntity) { DatabaseHelper databaseHelper = null; SQLiteDatabase database = null; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 6fcbaf3..0f1f5d4 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -298,7 +298,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me result.success(FlutterTool.getConfig(context)); break; case "getFaceStatus": - result.success(FlutterTool.getFaceStatus(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey"))); + runFaceStatus(call, result); break; case "activateFaceSdk": result.success(FlutterTool.activateFaceSdk(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey"))); @@ -344,6 +344,30 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me } } + /** + * 人脸状态查询:引擎空闲时在统一人脸单线程执行器上执行(内部含数据库读取, + * 不再占用平台主线程);注册/特征提取/质量审计占线时直接返回轻量状态, + * 避免状态查询在执行器队列里等待整库任务完成后才能应答。 + */ + private void runFaceStatus(MethodCall call, MethodChannel.Result result) { + String appId = stringArgument(call, "appId"); + String activeKey = stringArgument(call, "activeKey"); + String sdkKey = stringArgument(call, "sdkKey"); + if (FlutterTool.isRegistrationInProgress()) { + result.success(FlutterTool.getFaceStatusWhileRegistering(context, appId, activeKey, sdkKey)); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map status = FlutterTool.getFaceStatus(context, appId, activeKey, sdkKey); + MAIN_HANDLER.post(() -> result.success(status)); + } catch (Exception e) { + Log.e(TAG, "读取人脸状态失败:" + e.getMessage(), e); + MAIN_HANDLER.post(() -> result.error("FACE_STATUS_FAILED", "读取人脸状态失败,请稍后重试", null)); + } + }); + } + private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) { if (!FlutterTool.tryStartRegistration()) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index 7646083..d4560a3 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -3,6 +3,7 @@ package io.flutter.plugins.facediscern.flutter; import static com.arcsoft.face.FaceEngine.activeOnline; import static com.arcsoft.face.FaceEngine.getActiveFileInfo; +import static io.flutter.plugins.facediscern.database.DataBaseUtils.getFaceProfiles; import static io.flutter.plugins.facediscern.database.DataBaseUtils.getUserFace; import static io.flutter.plugins.facediscern.database.DataBaseUtils.updateFaceModel; @@ -115,7 +116,9 @@ public class FlutterTool { FaceServer faceServer = FaceServer.getInstance(); if (activated) faceServer.init(context); - List databaseFaces = getUserFace(context, null); + // 状态检查只需要数量与 sampleId 集合,走不含 faceModel 大字段的轻量查询, + // 避免整库特征 JSON 反序列化把调用线程(主线程)阻塞数秒。 + List databaseFaces = getFaceProfiles(context, null); int databaseCount = databaseFaces.size(); int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0; boolean registrationInProgress = REGISTRATION_IN_PROGRESS.get(); @@ -147,6 +150,38 @@ public class FlutterTool { return REGISTRATION_IN_PROGRESS.get(); } + // 注册/特征提取/质量审计占线期间的状态查询:只读授权文件与最新进度缓存, + // 不触碰 FaceServer 锁也不查数据库,供主线程即时返回,避免在单线程执行器里 + // 排队等待整库任务完成后才能应答。 + static Map getFaceStatusWhileRegistering(Context context, String appId, String activeKey, String sdkKey) { + updateConfig(appId, activeKey, sdkKey); + Map status = new HashMap<>(); + ActiveFileInfo activeFileInfo = new ActiveFileInfo(); + int activeCode = getActiveFileInfo(context, activeFileInfo); + boolean activated = activeCode == ErrorInfo.MOK; + Constants.IS_ACTIVE = activated; + Map progress = new HashMap<>(latestRegistrationProgress); + int total = progress.get("total") instanceof Number + ? ((Number) progress.get("total")).intValue() : -1; + int registered = progress.get("successCount") instanceof Number + ? ((Number) progress.get("successCount")).intValue() : -1; + status.put("activated", activated); + status.put("activationCode", activeCode); + status.put("parametersComplete", hasCompleteConfig()); + status.put("activationStartTime", activated ? activeFileInfo.getStartTime() : ""); + status.put("activationEndTime", activated ? activeFileInfo.getEndTime() : ""); + // 引擎正被整库任务占用,本轮一律按未就绪回报;数量用进度缓存近似,-1 表示尚未可知。 + status.put("engineReady", false); + status.put("engineCode", -1); + status.put("databaseCount", total); + status.put("memoryCount", registered); + status.put("registrationInProgress", true); + status.put("libraryConsistent", false); + status.put("registrationProgress", progress); + status.put("lastRegistrationResult", latestRegistrationResult == null ? null : new HashMap<>(latestRegistrationResult)); + return status; + } + static boolean tryStartRegistration() { return REGISTRATION_IN_PROGRESS.compareAndSet(false, true); } diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart index 591841d..5b1f237 100644 --- a/lib/common/utils/app/control/app_control.dart +++ b/lib/common/utils/app/control/app_control.dart @@ -1,13 +1,61 @@ import 'dart:async'; -import 'package:background_downloader/background_downloader.dart'; import 'package:event_bus/event_bus.dart'; -import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 单个启动步骤的执行状态。 +enum AppStartupStepStatus { + /// 尚未开始执行当前启动步骤。 + waiting, + + /// 当前启动步骤正在执行。 + running, + + /// 当前启动步骤已完整完成。 + success, + + /// 当前启动步骤受限完成,终端可继续使用但对应能力降级。 + warning, + + /// 当前启动步骤失败,需要重试或人工处理。 + failure, +} + +/// Load 页展示的单个启动步骤状态。 +class AppStartupStepState { + /// 创建一条不可变的启动步骤展示数据。 + const AppStartupStepState({ + required this.title, + this.detail = '', + this.status = AppStartupStepStatus.waiting, + }); + + /// 启动步骤标题。 + final String title; + + /// 当前步骤的实时说明或结果文案。 + final String detail; + + /// 当前步骤状态。 + final AppStartupStepStatus status; + + /// 复制当前步骤并替换可变的状态与说明。 + AppStartupStepState copyWith({ + String? title, + String? detail, + AppStartupStepStatus? status, + }) { + return AppStartupStepState( + title: title ?? this.title, + detail: detail ?? this.detail, + status: status ?? this.status, + ); + } +} + class AppControl extends GetxController { static const String _tag = "AppControl"; @@ -31,6 +79,52 @@ class AppControl extends GetxController { // 是否有网络 RxBool isNetwork = RxBool(false); + /// 启动步骤是否正在执行,防止 Load 页重试与应用入口微任务并发重复初始化。 + bool _initializing = false; + + /// 数据库是否已成功打开;失败重试时跳过重复打开,避免 late final 二次赋值与重复连接。 + bool _databaseReady = false; + + /// 收银外设是否已初始化;重试时跳过,避免重复构造串口与网络监听。 + bool _peripheralsReady = false; + + /// 后台服务是否已启动;重试时跳过,同步调度与心跳内部自行幂等但不必重复触发。 + bool _backgroundServicesReady = false; + + /// Load 页按执行顺序展示的启动步骤定义(标题, 等待说明)。 + static const List<(String, String)> _startupStepDefinitions = [ + ('运行环境与权限', '等待申请存储权限并读取终端配置'), + ('本地数据库', '等待打开本地数据库并完成备份'), + ('收银外设', '等待初始化打印机、读卡器、电子秤与语音'), + ('人脸识别库', '等待激活人脸插件并装载本地人脸库'), + ('后台服务', '等待启动同步调度与终端心跳'), + ]; + + /// Load 页按执行顺序展示的启动步骤集合。 + final RxList startupSteps = [ + for (final (title, detail) in _startupStepDefinitions) + AppStartupStepState(title: title, detail: detail), + ].obs; + + /// 所有启动步骤完成后为 true,失败重试前会被复位。 + final startupFinished = false.obs; + + /// 启动失败信息;非空时 Load 页展示失败原因与重试入口。 + final startupFailureMessage = ''.obs; + + /// 已完成(成功或受限可用)的启动步骤数量。 + int get startupCompletedCount => startupSteps + .where( + (item) => + item.status == AppStartupStepStatus.success || + item.status == AppStartupStepStatus.warning, + ) + .length; + + /// 启动整体进度 0 到 1,供 Load 页进度条使用。 + double get startupProgress => + startupSteps.isEmpty ? 0 : startupCompletedCount / startupSteps.length; + @override void onClose() { super.onClose(); @@ -43,23 +137,155 @@ class AppControl extends GetxController { TerminalHeartbeatTool.stop(); } - Future initAsync() async { + /// 按真实执行结果完成各启动步骤;任何步骤异常都不直接退出应用, + /// 而是标记失败并在 Load 页提供重试,避免收银终端开机即黑屏。 + Future initAsync({bool force = false}) async { + if (_initializing || (startupFinished.value && !force)) return; + _initializing = true; + startupFailureMessage.value = ''; + startupFinished.value = false; + _resetStartupSteps(); + try { + await _runStartup(); + startupFinished.value = true; + // 保留短暂收尾时间,让收银员看清整体启动结果后再进入登录页。 + await Future.delayed(const Duration(milliseconds: 500)); + Get.offAllNamed(RouteNames.login); + } catch (error, stack) { + logger.e('|$_tag|启动失败error:$error|stack:$stack|'); + startupFailureMessage.value = '启动中断:$error'; + _failRunningStep('执行异常,可点击重新检查重试'); + } finally { + _initializing = false; + } + } + + /// 依次执行启动步骤;步骤内失败尽量降级为 warning 继续后续步骤。 + Future _runStartup() async { + // 步骤 1:运行环境与权限。 + _setStartupStep( + 0, + status: AppStartupStepStatus.running, + detail: '正在申请存储权限并读取终端配置', + ); await AppSettingTool.removeSettingCache(); var granted = await Permission.manageExternalStorage.request().isGranted; if (!granted) { granted = await Permission.storage.request().isGranted; } - dataBase = await AppDatabase.getDataBase(); - printManage = PrintManage(); - serialCardManage = SerialCardManage(); - srialSteelyardManage = SerialSteelyardManage(); - AppNetworkTool.listenNetwork(); - await AppFileTool.backupSqlFile(); - await TtsManage.initialize(); + _setStartupStep( + 0, + status: granted + ? AppStartupStepStatus.success + : AppStartupStepStatus.warning, + detail: granted ? '存储权限已就绪' : '未授予存储权限,备份与日志能力受限', + ); + + // 步骤 2:本地数据库。 + _setStartupStep( + 1, + status: AppStartupStepStatus.running, + detail: '正在打开本地数据库并完成备份', + ); + if (!_databaseReady) { + dataBase = await AppDatabase.getDataBase(); + _databaseReady = true; + } + final backupSucceeded = await AppFileTool.backupSqlFile(); + _setStartupStep( + 1, + status: backupSucceeded + ? AppStartupStepStatus.success + : AppStartupStepStatus.warning, + detail: backupSucceeded ? '数据库已打开,自动备份完成' : '数据库已打开;本次自动备份未完成', + ); + + // 步骤 3:收银外设。 + _setStartupStep( + 2, + status: AppStartupStepStatus.running, + detail: '正在初始化打印机、读卡器、电子秤与语音', + ); + if (!_peripheralsReady) { + printManage = PrintManage(); + serialCardManage = SerialCardManage(); + srialSteelyardManage = SerialSteelyardManage(); + AppNetworkTool.listenNetwork(); + await TtsManage.initialize(); + _peripheralsReady = true; + } + _setStartupStep( + 2, + status: AppStartupStepStatus.success, + detail: '打印机、读卡器、电子秤与语音服务已就绪', + ); await Future.delayed(const Duration(milliseconds: 500)); - SyncCheckTool.instance.startAll(); - // 服务端按 180 秒心跳阈值判定设备在线,启动后必须开启周期上报,未绑定设备时上报内部会安全跳过。 - TerminalHeartbeatTool.start(); - Get.offAllNamed(RouteNames.login); + + // 步骤 4:人脸识别库。启动时一次性把本地人脸装载进虹软内存库, + // 避免运行期内存库与数据库不一致导致收银中被迫整库重建。 + _setStartupStep( + 3, + status: AppStartupStepStatus.running, + detail: '正在激活人脸插件并装载本地人脸库,样本较多时可能需要数十秒', + ); + final facePreparation = await AppFaceTool.prepareForStartup(); + _setStartupStep( + 3, + status: facePreparation.status == FaceStartupPreparationStatus.success + ? AppStartupStepStatus.success + : AppStartupStepStatus.warning, + detail: facePreparation.message, + ); + + // 步骤 5:后台服务。 + _setStartupStep( + 4, + status: AppStartupStepStatus.running, + detail: '正在启动同步调度与终端心跳', + ); + if (!_backgroundServicesReady) { + SyncCheckTool.instance.startAll(); + // 服务端按 180 秒心跳阈值判定设备在线,启动后必须开启周期上报,未绑定设备时上报内部会安全跳过。 + TerminalHeartbeatTool.start(); + _backgroundServicesReady = true; + } + _setStartupStep( + 4, + status: AppStartupStepStatus.success, + detail: '同步调度与终端心跳已启动', + ); + } + + /// 重置全部启动步骤为等待状态,供初始化与失败重试复用。 + void _resetStartupSteps() { + startupSteps.assignAll([ + for (final (title, detail) in _startupStepDefinitions) + AppStartupStepState(title: title, detail: detail), + ]); + } + + /// 更新指定步骤的状态与说明,索引越界时忽略避免页面崩溃。 + void _setStartupStep( + int index, { + required AppStartupStepStatus status, + required String detail, + }) { + if (index < 0 || index >= startupSteps.length) return; + startupSteps[index] = startupSteps[index].copyWith( + status: status, + detail: detail, + ); + } + + /// 将当前仍在执行中的步骤标记为失败。 + void _failRunningStep(String detail) { + final index = startupSteps.indexWhere( + (item) => item.status == AppStartupStepStatus.running, + ); + if (index < 0) return; + startupSteps[index] = startupSteps[index].copyWith( + status: AppStartupStepStatus.failure, + detail: detail, + ); } } diff --git a/lib/pages/load/controller.dart b/lib/pages/load/controller.dart index 6829ecc..0c72de4 100644 --- a/lib/pages/load/controller.dart +++ b/lib/pages/load/controller.dart @@ -1,27 +1,51 @@ +import 'dart:async'; + import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// Load 页控制器:只读取 AppControl 发布的真实启动步骤状态, +/// 业务初始化由应用入口微任务统一驱动,页面不重复打开数据库或设备。 class LoadController extends GetxController { - LoadController(); + static const String _tag = "load Controller"; - _initData() { - update(["load"]); - } + AppControl get appControl => Get.find(); - void onTap() {} + /// 虹软整库注册的最新进度文案;装载人脸库期间展示在步骤列表下方。 + final faceRegistrationMessage = ''.obs; + + StreamSubscription? _registrationSubscription; @override void onInit() { super.onInit(); - } - - @override - void onReady() { - super.onReady(); - _initData(); + // 注册进度事件通道订阅即回放最近一次进度, + // 启动装载人脸库时给收银员实时反馈,避免最长的步骤只有静态“进行中”。 + _registrationSubscription = FaceDiscernPlugs.registrationStream.listen( + (progress) { + faceRegistrationMessage.value = + progress.inProgress && progress.message.trim().isNotEmpty + ? progress.message.trim() + : ''; + }, + onError: (Object error, StackTrace stack) { + logger.w('|$_tag|注册进度监听失败error:$error|stack:$stack|'); + }, + ); } @override void onClose() { + _registrationSubscription?.cancel(); super.onClose(); } + + List get startupSteps => appControl.startupSteps; + + int get completedCount => appControl.startupCompletedCount; + + double get progress => appControl.startupProgress; + + /// 失败后重新执行启动链路;进行中的初始化会被 AppControl 自身防重入拦截。 + Future retry() => appControl.initAsync(force: true); } diff --git a/lib/pages/load/view.dart b/lib/pages/load/view.dart index 9541a27..aa78527 100644 --- a/lib/pages/load/view.dart +++ b/lib/pages/load/view.dart @@ -3,39 +3,693 @@ import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; -import 'index.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'controller.dart'; +import 'widgets/startup_step_card.dart'; + +/// 终端启动 Load 页:展示真实启动步骤执行结果,装载人脸库时同步显示注册进度。 class LoadPage extends GetView { const LoadPage({super.key}); - final double size = 50; - - // 主视图 - Widget _buildView(BuildContext context) { - final theme = FTheme.of(context); - return [ - CircularProgressIndicator().width(size).height(size), - SizedBox(height: 10.h), - Text( - "加载中", - style: theme.typography.xl, - ), - ].toColumn(mainAxisAlignment: MainAxisAlignment.center); - } - @override Widget build(BuildContext context) { return GetBuilder( init: LoadController(), - id: "load", - builder: (_) { - return FScaffold( - header: FHeader(title: SizedBox.shrink()), - content: _buildView(context), - contentPad: true, - resizeToAvoidBottomInset: false, + id: 'load', + builder: (controller) { + return ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(28.w, 24.h, 28.w, 16.h), + child: Column( + children: [ + _buildHeader(context, controller), + SizedBox(height: 16.h), + Center( + child: TerminalSurface( + width: 1260.w, + height: 820.h, + clipBehavior: Clip.hardEdge, + child: Row( + children: [ + _StartupEnvironmentPanel(controller: controller), + _StartupProgressPanel( + controller: controller, + ).expanded(), + ], + ), + ), + ).expanded(), + SizedBox(height: 10.h), + _StartupFooter(controller: controller), + ], + ), + ), + ), ); }, ); } + + /// 顶栏品牌区:沿用终端外壳的 PJ 斜体墨色字标与分隔线结构。 + Widget _buildHeader(BuildContext context, LoadController controller) { + final theme = FTheme.of(context); + return SizedBox( + height: 58.h, + child: Row( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(4.w, 4.h, 8.w, 4.h), + child: Text( + 'PJ', + style: theme.typography.xl4.copyWith( + color: XfjDesignTokens.ink, + fontSize: 38.w, + height: 1.1, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + letterSpacing: 0.4, + ), + ), + ), + SizedBox(width: 20.w), + Container(width: 1, height: 34.h, color: XfjDesignTokens.line), + SizedBox(width: 20.w), + Text( + '智慧超市收银终端', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Obx( + () => _HeaderStatus( + label: _overallStatusLabel(controller), + status: _overallStatus(controller), + ), + ), + ], + ), + ); + } +} + +/// 左侧运行环境面板:大号状态指示与环境说明,状态跟随第一个启动步骤。 +class _StartupEnvironmentPanel extends StatelessWidget { + const _StartupEnvironmentPanel({required this.controller}); + + final LoadController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + width: 460.w, + padding: EdgeInsets.all(34.w), + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border(right: BorderSide(color: XfjDesignTokens.line)), + ), + child: Obx(() { + final status = controller.startupSteps.isEmpty + ? AppStartupStepStatus.waiting + : controller.startupSteps.first.status; + final color = _statusColor(status); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _EnvironmentPill( + label: _environmentPillLabel(status), + status: status, + ), + const Spacer(), + Center( + // 圆形指示动画独立重绘,避免步骤文案刷新导致整面板重画。 + child: RepaintBoundary( + child: _EnvironmentIndicator(status: status), + ), + ), + const Spacer(), + Container( + width: double.infinity, + padding: EdgeInsets.all(22.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.82), + border: Border.all( + color: color.withValues(alpha: 0.22), + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.cardRadius, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FIcon( + _environmentIcon(status), + size: 24.w, + color: color, + ), + SizedBox(width: 12.w), + Text( + _environmentTitle(), + style: theme.typography.lg.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ).expanded(), + ], + ), + SizedBox(height: 12.h), + Text( + _environmentDetail(), + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + height: 1.55, + ), + ), + ], + ), + ), + ], + ); + }), + ); + } +} + +/// 右侧启动进度面板:整体说明、进度条、步骤列表与收尾提示。 +class _StartupProgressPanel extends StatelessWidget { + const _StartupProgressPanel({required this.controller}); + + final LoadController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Padding( + padding: EdgeInsets.symmetric(horizontal: 40.w, vertical: 28.h), + child: Obx(() { + final failureMessage = + controller.appControl.startupFailureMessage.value; + final registrationMessage = controller.faceRegistrationMessage.value; + final steps = controller.startupSteps; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + failureMessage.isEmpty ? '正在准备收银工作台' : '终端启动未完成', + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + Text( + failureMessage.isEmpty + ? '系统正在按实际执行结果准备本地数据、收银外设与识别服务,完成后将自动进入登录页面。' + : failureMessage, + style: theme.typography.base.copyWith( + color: failureMessage.isEmpty + ? XfjDesignTokens.muted + : XfjDesignTokens.danger, + height: 1.5, + ), + ), + ], + ).expanded(), + SizedBox(width: 24.w), + _ProgressCount( + current: controller.completedCount, + total: steps.length, + failed: failureMessage.isNotEmpty, + ), + ], + ), + SizedBox(height: 18.h), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: controller.progress, + minHeight: 8.h, + color: failureMessage.isEmpty + ? XfjDesignTokens.primary + : XfjDesignTokens.danger, + backgroundColor: const Color(0xFFEDEFF3), + ), + ), + SizedBox(height: 18.h), + ListView.separated( + primary: false, + padding: EdgeInsets.zero, + physics: const ClampingScrollPhysics(), + itemCount: steps.length, + separatorBuilder: (_, __) => SizedBox(height: 8.h), + itemBuilder: (_, index) { + return StartupStepCard( + icon: _stepIconAt(index), + state: steps[index], + ); + }, + ).expanded(), + SizedBox(height: 12.h), + if (registrationMessage.isNotEmpty) ...[ + _RegistrationMessage(message: registrationMessage), + SizedBox(height: 8.h), + ], + _StartupNotice( + failed: failureMessage.isNotEmpty, + onRetry: controller.retry, + ), + ], + ); + }), + ); + } +} + +/// 人脸库装载实时进度行:把最耗时步骤的原生注册进度透出给收银员。 +class _RegistrationMessage extends StatelessWidget { + const _RegistrationMessage({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 44.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + SizedBox( + width: 16.w, + height: 16.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10.w), + Text( + message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ).expanded(), + ], + ), + ); + } +} + +/// 收尾提示行:正常时展示版本,失败时提供重新检查入口。 +class _StartupNotice extends StatelessWidget { + const _StartupNotice({required this.failed, required this.onRetry}); + + final bool failed; + + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h), + decoration: BoxDecoration( + color: failed ? const Color(0xFFFFF3F2) : const Color(0xFFFAFBFC), + border: Border.all( + color: failed + ? XfjDesignTokens.danger.withValues(alpha: 0.28) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + FIcon( + failed ? FAssets.icons.circleAlert : FAssets.icons.shieldCheck, + size: 20.w, + color: failed ? XfjDesignTokens.danger : XfjDesignTokens.success, + ), + SizedBox(width: 10.w), + Text( + failed + ? '可重试初始化;如果仍失败,请检查本地数据库、权限和设备配置。' + : '启动过程按真实服务状态自动完成,请勿关闭应用或断开设备电源。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ).expanded(), + SizedBox(width: 12.w), + if (failed) + FButton( + style: FButtonStyle.outline, + label: const Text('重新检查'), + onPress: () => onRetry(), + ).height(48.h) + else + Text( + '版本 ${AppRuntimeTool.apkVersionLabel}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +/// 左侧面板中央的大号环境状态指示。 +class _EnvironmentIndicator extends StatelessWidget { + const _EnvironmentIndicator({required this.status}); + + final AppStartupStepStatus status; + + @override + Widget build(BuildContext context) { + final color = _statusColor(status); + return SizedBox( + width: 270.w, + height: 270.w, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 250.w, + height: 250.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.44), + border: Border.all(color: color.withValues(alpha: 0.08)), + ), + ), + SizedBox( + width: 224.w, + height: 224.w, + child: CircularProgressIndicator( + value: status == AppStartupStepStatus.failure ? 1 : null, + strokeWidth: 4.w, + color: color, + backgroundColor: color.withValues(alpha: 0.12), + ), + ), + Container( + width: 154.w, + height: 154.w, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: color.withValues(alpha: 0.16)), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.12), + blurRadius: 36, + offset: const Offset(0, 14), + ), + ], + ), + child: FIcon( + _environmentIcon(status), + size: 72.w, + color: color, + ), + ), + ], + ), + ); + } +} + +/// 环境面板左上角的状态胶囊。 +class _EnvironmentPill extends StatelessWidget { + const _EnvironmentPill({required this.label, required this.status}); + + final String label; + + final AppStartupStepStatus status; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final color = _statusColor(status); + return Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.88), + border: Border.all(color: color.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + status == AppStartupStepStatus.failure + ? FAssets.icons.circleAlert + : status == AppStartupStepStatus.running + ? FAssets.icons.refreshCw + : FAssets.icons.check, + size: 19.w, + color: color, + ), + SizedBox(width: 8.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// 顶栏右侧的整体状态胶囊。 +class _HeaderStatus extends StatelessWidget { + const _HeaderStatus({required this.label, required this.status}); + + final String label; + + final AppStartupStepStatus status; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final color = _statusColor(status); + return Container( + height: 40.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + border: Border.all(color: color.withValues(alpha: 0.24)), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + SizedBox(width: 8.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// 进度面板右上角的“已完成 / 总数”计数胶囊。 +class _ProgressCount extends StatelessWidget { + const _ProgressCount({ + required this.current, + required this.total, + required this.failed, + }); + + final int current; + + final int total; + + final bool failed; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minWidth: 72.w, minHeight: 54.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: failed ? const Color(0xFFFFF3F2) : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(999), + ), + child: Center( + child: Text( + '$current / $total', + style: theme.typography.sm.copyWith( + color: failed ? XfjDesignTokens.danger : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ); + } +} + +/// 底部状态行。 +class _StartupFooter extends StatelessWidget { + const _StartupFooter({required this.controller}); + + final LoadController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Obx(() { + final status = _overallStatus(controller); + final color = _statusColor(status); + return SizedBox( + height: 32.h, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + SizedBox(width: 8.w), + Text( + _footerLabel(controller), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); + }); + } +} + +/// Load 页启动步骤图标映射,顺序与 AppControl.startupSteps 保持一致。 +final _stepIcons = [ + FAssets.icons.shieldCheck, + FAssets.icons.database, + FAssets.icons.printer, + FAssets.icons.scanFace, + FAssets.icons.settings, +]; + +/// 按启动步骤索引取得图标;后续增加步骤但遗漏映射时使用通用设置图标避免页面越界崩溃。 +SvgAsset _stepIconAt(int index) { + if (index >= 0 && index < _stepIcons.length) return _stepIcons[index]; + return FAssets.icons.settings; +} + +AppStartupStepStatus _overallStatus(LoadController controller) { + if (controller.appControl.startupFailureMessage.value.isNotEmpty) { + return AppStartupStepStatus.failure; + } + if (controller.appControl.startupFinished.value) { + return controller.startupSteps.any( + (item) => item.status == AppStartupStepStatus.warning) + ? AppStartupStepStatus.warning + : AppStartupStepStatus.success; + } + return AppStartupStepStatus.running; +} + +String _overallStatusLabel(LoadController controller) { + return switch (_overallStatus(controller)) { + AppStartupStepStatus.running => '正在启动', + AppStartupStepStatus.success => '启动完成', + AppStartupStepStatus.warning => '受限启动', + AppStartupStepStatus.failure => '启动失败', + AppStartupStepStatus.waiting => '等待启动', + }; +} + +Color _statusColor(AppStartupStepStatus status) { + return switch (status) { + AppStartupStepStatus.running => XfjDesignTokens.primary, + AppStartupStepStatus.success => XfjDesignTokens.success, + AppStartupStepStatus.warning => XfjDesignTokens.warning, + AppStartupStepStatus.failure => XfjDesignTokens.danger, + AppStartupStepStatus.waiting => XfjDesignTokens.muted, + }; +} + +String _environmentPillLabel(AppStartupStepStatus status) { + return switch (status) { + AppStartupStepStatus.running => '正在检测运行环境', + AppStartupStepStatus.success => '运行环境检查完成', + AppStartupStepStatus.warning => '运行环境受限可用', + AppStartupStepStatus.failure => '运行环境检查失败', + AppStartupStepStatus.waiting => '等待环境检查', + }; +} + +SvgAsset _environmentIcon(AppStartupStepStatus status) { + if (status == AppStartupStepStatus.failure) { + return FAssets.icons.circleAlert; + } + if (AppRuntimeTool.useManualHardwareInput) { + return FAssets.icons.keyboard; + } + return FAssets.icons.monitor; +} + +String _environmentTitle() { + if (AppRuntimeTool.isEmulator) return '虚拟机开发环境'; + if (AppRuntimeTool.useManualHardwareInput) return '手动硬件输入模式'; + return '实体收银终端'; +} + +String _environmentDetail() { + if (AppRuntimeTool.isEmulator) { + return '当前运行在虚拟机,外设与人脸识别将以手动输入方式替代,便于开发调试。'; + } + if (AppRuntimeTool.useManualHardwareInput) { + return '已开启手动硬件输入,打印机、读卡器、电子秤等实体外设将在业务使用时按需连接。'; + } + return '已检测到实体设备环境,收银外设与人脸识别库将在启动阶段完成装载。'; +} + +String _footerLabel(LoadController controller) { + if (controller.appControl.startupFailureMessage.value.isNotEmpty) { + return 'PJ 智慧超市收银终端 · 本地服务启动失败'; + } + if (controller.appControl.startupFinished.value) { + return 'PJ 智慧超市收银终端 · 本地服务运行中'; + } + return 'PJ 智慧超市收银终端 · 正在启动本地服务'; } diff --git a/lib/pages/load/widgets/startup_step_card.dart b/lib/pages/load/widgets/startup_step_card.dart new file mode 100644 index 0000000..7ef4770 --- /dev/null +++ b/lib/pages/load/widgets/startup_step_card.dart @@ -0,0 +1,138 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 启动步骤卡片只消费 AppControl 发布的结果,不在组件内部猜测或推进业务状态。 +class StartupStepCard extends StatelessWidget { + const StartupStepCard({ + super.key, + required this.icon, + required this.state, + }); + + final SvgAsset icon; + + final AppStartupStepState state; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final color = _statusColor(state.status); + final active = state.status == AppStartupStepStatus.running; + final complete = state.status == AppStartupStepStatus.success; + final warning = state.status == AppStartupStepStatus.warning; + final failure = state.status == AppStartupStepStatus.failure; + final emphasized = active || complete || warning || failure; + return Container( + constraints: BoxConstraints(minHeight: 72.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + color: active + ? XfjDesignTokens.primarySoft + : warning + ? const Color(0xFFFFFAEC) + : failure + ? const Color(0xFFFFF3F2) + : complete + ? const Color(0xFFF7FCF9) + : XfjDesignTokens.surface, + border: Border.all( + color: emphasized + ? color.withValues(alpha: 0.34) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: emphasized ? color : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Center( + child: active + ? SizedBox( + width: 20.w, + height: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : FIcon( + complete + ? FAssets.icons.check + : failure + ? FAssets.icons.circleAlert + : warning + ? FAssets.icons.triangleAlert + : icon, + size: 22.w, + color: emphasized ? Colors.white : color, + ), + ), + ), + SizedBox(width: 14.w), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.title, + style: theme.typography.lg.copyWith( + color: active || failure ? color : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 2.h), + Text( + state.detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + height: 1.35, + ), + ), + ], + ).expanded(), + SizedBox(width: 12.w), + Text( + _statusLabel(state.status), + style: theme.typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +Color _statusColor(AppStartupStepStatus status) { + return switch (status) { + AppStartupStepStatus.running => XfjDesignTokens.primary, + AppStartupStepStatus.success => XfjDesignTokens.success, + AppStartupStepStatus.warning => XfjDesignTokens.warning, + AppStartupStepStatus.failure => XfjDesignTokens.danger, + AppStartupStepStatus.waiting => XfjDesignTokens.muted, + }; +} + +String _statusLabel(AppStartupStepStatus status) { + return switch (status) { + AppStartupStepStatus.running => '进行中', + AppStartupStepStatus.success => '已完成', + AppStartupStepStatus.warning => '受限可用', + AppStartupStepStatus.failure => '失败', + AppStartupStepStatus.waiting => '等待中', + }; +}