feat:人脸快速注册
This commit is contained in:
1 parent
c75b441dea
commit
154122a847
7 files changed
+1210
-50
No files matched your search
@@ -86,6 +86,65 @@ public class DataBaseUtils {
|
||||
}
|
||||
}
|
||||
|
||||
// 状态检查专用轻量查询:只读取数量与 sampleId 集合所需列,不加载 faceModel 特征大字段。
|
||||
// face 表把特征以 JSON 文本存在行内(每条约数 KB),SELECT * 全表读取会反复撑爆 2MB
|
||||
// CursorWindow 并把调用线程阻塞数秒;状态一致性比较不需要特征数据。
|
||||
static public List<FaceEntity> getFaceProfiles(Context context, String queryUserId) {
|
||||
List<FaceEntity> 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;
|
||||
|
||||
+25
-1
@@ -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<String, Object> 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);
|
||||
|
||||
@@ -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<FaceEntity> databaseFaces = getUserFace(context, null);
|
||||
// 状态检查只需要数量与 sampleId 集合,走不含 faceModel 大字段的轻量查询,
|
||||
// 避免整库特征 JSON 反序列化把调用线程(主线程)阻塞数秒。
|
||||
List<FaceEntity> 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<String, Object> getFaceStatusWhileRegistering(Context context, String appId, String activeKey, String sdkKey) {
|
||||
updateConfig(appId, activeKey, sdkKey);
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
ActiveFileInfo activeFileInfo = new ActiveFileInfo();
|
||||
int activeCode = getActiveFileInfo(context, activeFileInfo);
|
||||
boolean activated = activeCode == ErrorInfo.MOK;
|
||||
Constants.IS_ACTIVE = activated;
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
@@ -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<AppStartupStepState> 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<void> initAsync() async {
|
||||
/// 按真实执行结果完成各启动步骤;任何步骤异常都不直接退出应用,
|
||||
/// 而是标记失败并在 Load 页提供重试,避免收银终端开机即黑屏。
|
||||
Future<void> 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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AppControl>();
|
||||
|
||||
void onTap() {}
|
||||
/// 虹软整库注册的最新进度文案;装载人脸库期间展示在步骤列表下方。
|
||||
final faceRegistrationMessage = ''.obs;
|
||||
|
||||
StreamSubscription<FaceRegistrationProgress>? _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<AppStartupStepState> get startupSteps => appControl.startupSteps;
|
||||
|
||||
int get completedCount => appControl.startupCompletedCount;
|
||||
|
||||
double get progress => appControl.startupProgress;
|
||||
|
||||
/// 失败后重新执行启动链路;进行中的初始化会被 AppControl 自身防重入拦截。
|
||||
Future<void> retry() => appControl.initAsync(force: true);
|
||||
}
|
||||
+677
-23
@@ -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<LoadController> {
|
||||
const LoadPage({super.key});
|
||||
|
||||
final double size = 50;
|
||||
|
||||
// 主视图
|
||||
Widget _buildView(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return <Widget>[
|
||||
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<LoadController>(
|
||||
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<void> 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 = <SvgAsset>[
|
||||
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 智慧超市收银终端 · 正在启动本地服务';
|
||||
}
|
||||
@@ -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 => '等待中',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user