feat:人脸快速注册

This commit is contained in:
dzc committed 2026-09-10 14:32:01 +08:00
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;
@@ -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);
}
+240 -14
View File
@@ -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,
);
}
}
+35 -11
View File
@@ -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
View File
@@ -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 => '等待中',
};
}