448 lines
15 KiB
Dart
448 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'package:common_utils/common_utils.dart';
|
|
import 'package:event_bus/event_bus.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,
|
|
required this.detail,
|
|
this.status = AppStartupStepStatus.waiting,
|
|
});
|
|
|
|
/// 启动步骤标题。
|
|
final String title;
|
|
|
|
/// 当前进度或处理结果说明。
|
|
final String detail;
|
|
|
|
/// 当前步骤状态。
|
|
final AppStartupStepStatus status;
|
|
|
|
/// 复制当前步骤并替换可变的状态和说明。
|
|
AppStartupStepState copyWith({
|
|
String? detail,
|
|
AppStartupStepStatus? status,
|
|
}) {
|
|
return AppStartupStepState(
|
|
title: title,
|
|
detail: detail ?? this.detail,
|
|
status: status ?? this.status,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 应用启动、数据库和终端硬件生命周期的统一控制器。
|
|
class AppControl extends GetxController {
|
|
/// 启动流程日志标签。
|
|
static const String _tag = "AppControl";
|
|
|
|
/// Load 页按执行顺序展示的启动步骤集合。
|
|
final startupSteps = <AppStartupStepState>[
|
|
const AppStartupStepState(
|
|
title: '加载终端配置',
|
|
detail: '等待读取设备模式与本地设置',
|
|
),
|
|
const AppStartupStepState(
|
|
title: '打开业务数据库',
|
|
detail: '等待准备订单、商品与用户数据',
|
|
),
|
|
const AppStartupStepState(
|
|
title: '初始化设备服务',
|
|
detail: '等待准备打印、读卡与电子秤服务',
|
|
),
|
|
const AppStartupStepState(
|
|
title: '准备人脸识别',
|
|
detail: '等待检查激活参数与本地人脸库',
|
|
),
|
|
].obs;
|
|
|
|
/// 核心启动失败时面向用户展示的统一说明。
|
|
final startupFailureMessage = ''.obs;
|
|
|
|
/// 所有阻塞启动步骤完成后为 true。
|
|
final startupFinished = false.obs;
|
|
|
|
final startupEnvironmentDescription = '正在识别终端运行环境'.obs;
|
|
|
|
final startupEnvironmentDetail = '双屏、虚拟机与硬件输入模式将按设备能力自动适配'.obs;
|
|
|
|
final startupStatusLabel = '正在启动'.obs;
|
|
|
|
/// 最近一次启动阶段的人脸准备结果;尚未执行时为空。
|
|
final faceStartupPreparationResult = Rxn<FaceStartupPreparationResult>();
|
|
|
|
bool _initializing = false;
|
|
bool _databaseInitialized = false;
|
|
|
|
/// 当前账号是否允许启动需要鉴权或设备身份的 MQTT、心跳和后台同步任务。
|
|
bool _authenticatedServicesEnabled = false;
|
|
|
|
/// 网络监听和消息连接在执行前读取的会话开关;退出动作会先关闭它再释放资源。
|
|
bool get authenticatedServicesEnabled => _authenticatedServicesEnabled;
|
|
|
|
int get startupCompletedCount => startupSteps
|
|
.where((item) =>
|
|
item.status == AppStartupStepStatus.success ||
|
|
item.status == AppStartupStepStatus.warning)
|
|
.length;
|
|
|
|
double get startupProgress =>
|
|
startupSteps.isEmpty ? 0 : startupCompletedCount / startupSteps.length;
|
|
|
|
/// 打印机
|
|
late PrintManage printManage;
|
|
|
|
/// 读卡器串口
|
|
late SerialCardManage serialCardManage;
|
|
|
|
/// 称串口
|
|
late SerialSteelyardManage srialSteelyardManage;
|
|
|
|
/// mq
|
|
StreamSubscription? mqListen;
|
|
|
|
EventBus mqUpdateEventBus = EventBus();
|
|
|
|
/// 数据库
|
|
late final AppDatabase dataBase;
|
|
|
|
// 是否有网络
|
|
RxBool isNetwork = RxBool(false);
|
|
|
|
@override
|
|
void onClose() {
|
|
super.onClose();
|
|
_authenticatedServicesEnabled = false;
|
|
if (printManageInitialized) printManage.close();
|
|
if (serialCardManageInitialized) serialCardManage.close();
|
|
if (steelyardManageInitialized) srialSteelyardManage.close();
|
|
AppNetworkTool.closeListenNetwork();
|
|
TtsManage.dispose();
|
|
SyncCheckTool.instance.dispose();
|
|
TerminalHeartbeatTool.stop();
|
|
}
|
|
|
|
bool get printManageInitialized {
|
|
try {
|
|
printManage;
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool get serialCardManageInitialized {
|
|
try {
|
|
serialCardManage;
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool get steelyardManageInitialized {
|
|
try {
|
|
srialSteelyardManage;
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> initAsync({
|
|
String destination = RouteNames.login,
|
|
bool force = false,
|
|
}) async {
|
|
if (_initializing || (startupFinished.value && !force)) return;
|
|
_initializing = true;
|
|
startupFinished.value = false;
|
|
startupFailureMessage.value = '';
|
|
faceStartupPreparationResult.value = null;
|
|
startupStatusLabel.value = '正在启动';
|
|
_resetStartupSteps();
|
|
|
|
try {
|
|
_setStartupStep(
|
|
0,
|
|
status: AppStartupStepStatus.running,
|
|
detail: '正在读取运行环境与本地终端配置',
|
|
);
|
|
await AppRuntimeTool.initializePlatform();
|
|
await AppSettingTool.removeSettingCache();
|
|
|
|
_setStartupStep(
|
|
1,
|
|
status: AppStartupStepStatus.running,
|
|
detail: '正在打开本地业务数据库',
|
|
);
|
|
if (!_databaseInitialized) {
|
|
dataBase = await AppDatabase.getDataBase();
|
|
_databaseInitialized = true;
|
|
}
|
|
|
|
final setting = await AppSettingTool.getSysSetting();
|
|
// 插件会在 Android 注册阶段自动创建副屏;这里统一确认可用状态并记录常驻标记,
|
|
// 即使无副屏或权限不足也只影响设备状态,不阻塞后续启动流程。
|
|
unawaited(AppSubScreenTool.show());
|
|
var storageGranted =
|
|
await Permission.manageExternalStorage.request().isGranted;
|
|
if (!storageGranted) {
|
|
storageGranted = await Permission.storage.request().isGranted;
|
|
}
|
|
AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput);
|
|
final environment = _resolveEnvironment(setting);
|
|
startupEnvironmentDescription.value = environment.title;
|
|
startupEnvironmentDetail.value = environment.detail;
|
|
_setStartupStep(
|
|
0,
|
|
status: AppStartupStepStatus.success,
|
|
detail: environment.stepDetail,
|
|
);
|
|
_setStartupStep(
|
|
1,
|
|
status: storageGranted
|
|
? AppStartupStepStatus.success
|
|
: AppStartupStepStatus.warning,
|
|
detail:
|
|
storageGranted ? '数据库已打开,本地备份目录权限可用' : '数据库已打开;未获存储权限,本地备份可能不可用',
|
|
);
|
|
|
|
_setStartupStep(
|
|
2,
|
|
status: AppStartupStepStatus.running,
|
|
detail: '正在创建打印、读卡与电子秤管理服务',
|
|
);
|
|
if (!printManageInitialized) printManage = PrintManage();
|
|
await AppDeviceStatusTool.loadCachedStatus();
|
|
if (!serialCardManageInitialized) serialCardManage = SerialCardManage();
|
|
if (!steelyardManageInitialized) {
|
|
srialSteelyardManage = SerialSteelyardManage();
|
|
}
|
|
// 硬件检查失败只能影响状态展示,不能阻塞收银终端进入业务页面。
|
|
unawaited(
|
|
AppDeviceStatusTool.refreshAll()
|
|
.catchError((Object error, StackTrace stack) {
|
|
logger.w('|$_tag|首次检查设备状态失败error:|$error|stack:|$stack|');
|
|
return AppDeviceStatusTool.status.value;
|
|
}),
|
|
);
|
|
await TtsManage.initialize();
|
|
_setStartupStep(
|
|
2,
|
|
status: environment.manualHardwareInput
|
|
? AppStartupStepStatus.warning
|
|
: AppStartupStepStatus.success,
|
|
detail: environment.manualHardwareInput
|
|
? '当前为手动硬件输入模式,实体设备将在业务使用时按需连接'
|
|
: '设备管理服务已就绪,实体设备将在业务使用时按需连接',
|
|
);
|
|
|
|
_setStartupStep(
|
|
3,
|
|
status: AppStartupStepStatus.running,
|
|
detail: '正在检查激活参数并装载本地人脸库',
|
|
);
|
|
final facePreparation = await AppFaceTool.prepareForStartup();
|
|
faceStartupPreparationResult.value = facePreparation;
|
|
_setStartupStep(
|
|
3,
|
|
status: facePreparation.status == FaceStartupPreparationStatus.success
|
|
? AppStartupStepStatus.success
|
|
: AppStartupStepStatus.warning,
|
|
detail: _facePreparationDetail(facePreparation),
|
|
);
|
|
|
|
final backupSucceeded =
|
|
storageGranted ? await AppFileTool.backupSqlFile() : false;
|
|
if (storageGranted && !backupSucceeded) {
|
|
_setStartupStep(
|
|
1,
|
|
status: AppStartupStepStatus.warning,
|
|
detail: '数据库已打开;本次自动备份未完成,可稍后在设置中重试',
|
|
);
|
|
}
|
|
|
|
// 网络、MQTT 与后台同步属于运行期能力,不参与加载页检查或阻塞进入系统。
|
|
if (AppNetworkTool.subscription == null) {
|
|
AppNetworkTool.listenNetwork();
|
|
}
|
|
// 突然关机密码策略必须在读取用户凭据前执行,否则自动登录会抢先使用旧密码。
|
|
final clearedSavedPassword =
|
|
await AppSettingTool.applyStartupPowerLossPasswordPolicy();
|
|
var resolvedDestination = destination;
|
|
if (clearedSavedPassword && destination == RouteNames.terminal) {
|
|
// 凭据已按策略清除,跳过更新恢复直达终端的预留路径,回到登录页。
|
|
resolvedDestination = RouteNames.login;
|
|
}
|
|
final userSetting = await AppSettingTool.getUserSetting();
|
|
_authenticatedServicesEnabled =
|
|
ObjectUtil.isNotEmpty(userSetting.accessToken);
|
|
if (_authenticatedServicesEnabled) {
|
|
await SyncCheckTool.instance.startAll();
|
|
TerminalHeartbeatTool.start();
|
|
}
|
|
await TerminalAutoUpdateTool.recoverPendingUpdate();
|
|
|
|
startupFinished.value = true;
|
|
startupStatusLabel.value = startupSteps
|
|
.any((item) => item.status == AppStartupStepStatus.warning)
|
|
? '受限启动完成'
|
|
: '启动完成';
|
|
await Future.delayed(const Duration(milliseconds: 650));
|
|
if (Get.currentRoute == RouteNames.load) {
|
|
Get.offAllNamed(resolvedDestination);
|
|
}
|
|
} catch (e, stack) {
|
|
final currentIndex = startupSteps.indexWhere(
|
|
(item) => item.status == AppStartupStepStatus.running,
|
|
);
|
|
if (currentIndex >= 0) {
|
|
_setStartupStep(
|
|
currentIndex,
|
|
status: AppStartupStepStatus.failure,
|
|
detail: '启动失败,请检查终端配置或本地设备后重试',
|
|
);
|
|
}
|
|
startupStatusLabel.value = '启动失败';
|
|
startupFailureMessage.value = '终端初始化未完成,请检查设备状态后重试。';
|
|
_closePartiallyInitializedResources();
|
|
logger.e('|$_tag|初始化失败error:|$e|stack:|$stack|');
|
|
} finally {
|
|
_initializing = false;
|
|
}
|
|
}
|
|
|
|
Future<void> retryStartup({String destination = RouteNames.login}) async {
|
|
if (_initializing) return;
|
|
await initAsync(destination: destination, force: true);
|
|
}
|
|
|
|
/// 登录成功后恢复业务终端的后台任务与在线心跳。
|
|
Future<void> resumeAuthenticatedServices() async {
|
|
_authenticatedServicesEnabled = true;
|
|
await SyncCheckTool.instance.startAll();
|
|
TerminalHeartbeatTool.start();
|
|
}
|
|
|
|
/// 退出登录时停止所有需要账号或设备身份的后台任务,并断开消息订阅。
|
|
Future<void> suspendAuthenticatedServices() async {
|
|
_authenticatedServicesEnabled = false;
|
|
await SyncCheckTool.instance.dispose();
|
|
TerminalHeartbeatTool.stop();
|
|
AppMqTool.mqListenClose();
|
|
await MqManager.closeMqtt();
|
|
}
|
|
|
|
/// 关闭已创建的设备和后台资源,供核心启动异常后的重试流程重新初始化。
|
|
void _closePartiallyInitializedResources() {
|
|
_authenticatedServicesEnabled = false;
|
|
if (printManageInitialized) printManage.close();
|
|
if (serialCardManageInitialized) serialCardManage.close();
|
|
if (steelyardManageInitialized) srialSteelyardManage.close();
|
|
AppNetworkTool.closeListenNetwork();
|
|
SyncCheckTool.instance.dispose();
|
|
TerminalHeartbeatTool.stop();
|
|
}
|
|
|
|
/// 将全部启动步骤恢复到初始文案,供首次启动和人工重试共用。
|
|
void _resetStartupSteps() {
|
|
for (var index = 0; index < startupSteps.length; index++) {
|
|
final item = startupSteps[index];
|
|
startupSteps[index] = item.copyWith(
|
|
status: AppStartupStepStatus.waiting,
|
|
detail: switch (index) {
|
|
0 => '等待读取设备模式与本地设置',
|
|
1 => '等待准备订单、商品与用户数据',
|
|
2 => '等待准备打印、读卡与电子秤服务',
|
|
_ => '等待检查激活参数与本地人脸库',
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 更新指定启动步骤的状态和结果文案。
|
|
void _setStartupStep(
|
|
int index, {
|
|
required AppStartupStepStatus status,
|
|
required String detail,
|
|
}) {
|
|
startupSteps[index] = startupSteps[index].copyWith(
|
|
status: status,
|
|
detail: detail,
|
|
);
|
|
}
|
|
|
|
/// 将结构化人脸准备结果转换为不暴露密钥的 Load 页简短说明。
|
|
String _facePreparationDetail(FaceStartupPreparationResult result) {
|
|
if (result.total <= 0) return result.message;
|
|
if (result.failedCount <= 0) {
|
|
return '${result.message},已装载 ${result.successCount} 个人脸样本';
|
|
}
|
|
return '${result.message},成功 ${result.successCount},失败 ${result.failedCount}';
|
|
}
|
|
|
|
({
|
|
String title,
|
|
String detail,
|
|
String stepDetail,
|
|
bool manualHardwareInput,
|
|
}) _resolveEnvironment(SysSetting setting) {
|
|
final manualHardwareInput =
|
|
AppRuntimeTool.isEmulator || setting.manualHardwareInput;
|
|
if (AppRuntimeTool.isEmulator) {
|
|
return (
|
|
title: '虚拟机调试模式',
|
|
detail: '已切换为手动硬件输入,不启动真实副屏和实体串口设备。',
|
|
stepDetail: '已识别虚拟机,终端配置读取完成',
|
|
manualHardwareInput: true,
|
|
);
|
|
}
|
|
if (manualHardwareInput) {
|
|
return (
|
|
title: '手动硬件输入模式',
|
|
detail: '终端配置为手动录入硬件结果,真实副屏仍按设备能力启用。',
|
|
stepDetail: '终端配置读取完成,已启用手动硬件输入',
|
|
manualHardwareInput: true,
|
|
);
|
|
}
|
|
if (AppRuntimeTool.hasMultipleDisplays) {
|
|
return (
|
|
title: '双屏终端模式',
|
|
detail: '已检测到 ${AppRuntimeTool.displayCount} 个显示设备,副屏将在业务页面按需启动。',
|
|
stepDetail: '终端配置读取完成,已检测到可用双屏环境',
|
|
manualHardwareInput: false,
|
|
);
|
|
}
|
|
return (
|
|
title: '单屏终端模式',
|
|
detail: '当前仅检测到主屏,收银核心流程可继续使用,副屏功能不会启动。',
|
|
stepDetail: '终端配置读取完成,当前为单屏环境',
|
|
manualHardwareInput: false,
|
|
);
|
|
}
|
|
}
|