import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; /// 设置模块共享控制器,集中管理终端配置、同步任务和本地备份业务状态。 class SettingController extends GetxController with WidgetsBindingObserver { /// 设置模块统一日志标签,用于过滤本控制器产生的诊断日志。 static const String _tag = '设置 Controller'; /// 应用级控制器,提供 MQTT 配置变更事件总线。 final appC = Get.find(); /// 终端一级菜单控制器,提供可配置页面的实时排序。 final terminalMenuC = Get.find(); /// 当前设备系统设置;初始化完成前为空,修改深层字段后需主动 refresh。 final sysSetting = Rxn(); /// 当前登录用户的本地设置;初始化完成前为空。 final userSetting = Rxn(); /// 是否使用人工输入代替扫码、读卡和人脸等真实硬件输入。 final manualHardwareInput = false.obs; /// 手动退出登录时是否忘记已保存的登录密码。 final forgetPasswordOnManualLogout = true.obs; /// 机器突然关机(断电、强制重启)后是否忘记已保存的登录密码。 final forgetPasswordOnPowerLoss = true.obs; /// Android Kiosk 当前能力与运行状态,应用恢复前台时刷新。 final kioskStatus = AppRuntimeTool.kioskStatus.obs; /// Root 手动刷新是否正在执行,用于禁用重复点击并展示局部进度。 final rootRefreshing = false.obs; /// Root 一键配置 Device Owner 是否正在执行,命令期间禁止重复提交。 final kioskProvisioning = false.obs; /// Kiosk 开关是否正在同步原生 Lock Task,防止响应返回前被连续点击形成状态反转。 final kioskSwitching = false.obs; /// 最近一次 Root 配置 Kiosk 的失败处理建议;成功或重新加载设置后清空。 final kioskProvisionMessage = ''.obs; /// 收银支付方式的界面排序,初始顺序来自枚举并由本地持久化覆盖。 final payTypeOrder = [ ...OrderPayDisplayType.values, ].obs; /// 允许在收银弹框内重新识别人员的支付方式集合;初始化采用业务默认值,配置加载后以持久化结果为准。 final cashierReidentifiablePayTypes = AppSettingTool.defaultCashierReidentifiablePayTypes.toSet().obs; /// 页面设置当前编辑的终端一级页面,默认展示收银页面配置。 final pageSettingType = TerminalMenuItemType.cashier.obs; /// 收银页右下角快捷操作显隐独立观察,切换时不重建支付方式排序区域。 final cashierShowHoldAction = true.obs; /// 收银页右下角“更多”快捷操作是否展示。 final cashierShowMoreAction = true.obs; /// 收银页是否允许主动修改餐段;关闭后餐段严格按餐段配置时间自动切换。 final cashierAllowManualMealPeriodChange = true.obs; /// 预约查询识别方式的展示顺序,持久化到现有设置 JSON。 final reservationQueryMethodOrder = [ ...ReservationRecognitionMethod.values, ].obs; /// 预约查询默认识别方式,必须始终包含在当前识别方式顺序中。 final reservationQueryDefaultMethod = ReservationRecognitionMethod.card.obs; /// 查询菜单是否在进入页面和切换方式后自动启动当前识别设备。 final reservationQueryAutoRecognitionEnabled = false.obs; /// 自动识别正常等待超时后是否继续开启下一轮;设备故障和主动取消不自动重试。 final reservationQueryContinuousRecognitionEnabled = true.obs; /// 查询菜单是否要求服务端脱敏用户姓名。 final reservationQueryNameMaskEnabled = true.obs; /// 查询菜单是否启用服务端二次核销校验。 final reservationQueryVerificationEnabled = false.obs; /// 查询菜单是否在识别成功后直接打开今天预选餐段的核销弹框。 final reservationQueryQuickVerificationEnabled = false.obs; /// 查询菜单是否允许主动修改餐段;关闭后餐段严格按餐段配置时间自动切换。 final reservationQueryAllowManualMealPeriodChange = true.obs; /// 查询菜单核销允许的日期范围,默认仅限当天。 final reservationQueryVerificationDateScope = ReservationVerificationDateScope.today.obs; /// 查询菜单外接快捷小键盘的动作按键绑定;键为动作,值为最近一次绑定的物理按键。 final menuQueryShortcutKeyBindings = {}.obs; /// 查询菜单是否启用外接快捷小键盘;默认开启,关闭时查询菜单不响应任何已绑定按键。 final reservationQueryKeyboardModeEnabled = true.obs; /// 早餐、中餐和晚餐的本地时间范围,设置加载后由 sysJson 中的持久化值覆盖。 final productMealPeriodSchedule = ProductMealPeriodSchedule.defaults.obs; /// 餐段配置正在保存时禁用重复提交并展示按钮状态。 final productMealPeriodSaving = false.obs; /// 设置模块是否仍在初始化系统设置、备份时间和调度器。 final isLoading = true.obs; /// 当前终端是否已经绑定服务端商户设备。 final isBound = false.obs; /// 旧同步操作当前执行键;空字符串表示没有旧同步操作运行。 final syncingKey = ''.obs; /// 最近执行失败的旧同步操作键,用于对应按钮展示失败状态。 final syncFailedKeys = {}.obs; /// 最近一次本地数据库备份文件修改时间;尚无备份时为空。 final sqlBackupTime = Rxn(); /// 最近一次本地日志备份文件修改时间;尚无备份时为空。 final logBackupTime = Rxn(); /// 最近一次手动备份的结果与目标路径摘要。 final lastBackupMessage = ''.obs; /// 旧内联编辑器当前展开的任务类型;保留给兼容组件读取,为空表示全部收起。 final expandedScheduledTask = Rxn(); /// 各任务尚未保存的 Cron 输入草稿,以任务类型隔离编辑状态。 final scheduledTaskCronDrafts = {}.obs; /// 各任务 Cron 草稿的本地校验错误,保存成功或继续输入后移除。 final scheduledTaskCronErrors = {}.obs; /// 正在持久化配置的任务集合,用于禁止同任务重复提交并局部禁用控件。 final savingScheduledTasks = {}.obs; /// MQTT 配置更新事件订阅,在控制器关闭时释放。 StreamSubscription? _mqUpdateListen; /// 是否正在串行写入支付方式偏好,防止拖动操作并发覆盖。 bool _payPersisting = false; /// 支付方式保存期间是否又产生了新状态,需要在当前写入后继续保存。 bool _payPendingPersist = false; /// 是否正在串行写入收银页面快捷操作偏好。 bool _cashierPagePersisting = false; /// 收银页面偏好保存期间是否又产生了待写入状态。 bool _cashierPagePendingPersist = false; /// 是否正在串行写入预约查询页面偏好。 bool _reservationQueryPersisting = false; /// 预约查询偏好保存期间是否又产生了待写入状态。 bool _reservationQueryPendingPersist = false; /// 页面设置跟随主屏左侧菜单的实时顺序,但不继承隐藏状态;设置页自身不递归配置。 List get configurablePageTypes => terminalMenuC.menuOrder .toList() .where((type) => type != TerminalMenuItemType.settings) .toList(growable: false); /// 支付配置已迁入“页面设置 > 收银”,旧枚举和页面仅保留兼容,避免破坏既有代码契约。 /// Release 真机隐藏调试入口,模拟器即使使用 Release 也保留页面检查能力。 List get visibleMenuTypes => SettingMenuType.values .where( (type) => type != SettingMenuType.paySetting && (type != SettingMenuType.subScreenRoutes || AppRuntimeTool.isEmulator || AppSysTool.isDev()), ) .toList(growable: false); /// 注册应用生命周期观察并异步加载设置模块全部基础数据。 @override void onInit() { super.onInit(); WidgetsBinding.instance.addObserver(this); _initialize(); } /// 读取本地设置、备份时间和任务配置,并订阅服务端配置更新事件。 Future _initialize() async { try { await getSetting(); await refreshBackupTimes(); await SyncCheckTool.instance.startAll(); scheduledTaskCronDrafts.assignAll({ for (final entry in SyncCheckTool.instance.configs.entries) entry.key: entry.value.cron, }); _mqUpdateListen = appC.mqUpdateEventBus.on().listen( (event) async { if (event == MqtActionEnum.CONFIG) await getSetting(); }, ); } catch (e, stack) { logger.e('|$_tag|初始化失败error:|$e|stack:|$stack|'); Toast.error(msg: '设置加载失败,请稍后重试'); } finally { isLoading.value = false; } } /// 释放应用生命周期观察和 MQTT 配置事件订阅。 @override void onClose() { WidgetsBinding.instance.removeObserver(this); _mqUpdateListen?.cancel(); super.onClose(); } /// 应用恢复前台时重新读取 Kiosk 状态,反映外部系统设置变化。 @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) refreshKioskStatus(); } /// 从现有持久化服务恢复系统、用户、支付和页面偏好,并更新设备绑定状态。 Future getSetting() async { await refreshKioskStatus(); final setting = await AppSettingTool.getSysSetting(); final storedPayTypeOrderFuture = AppSettingTool.getPayTypeOrder(); final reidentifiablePayTypesFuture = AppSettingTool.getCashierReidentifiablePayTypes(); final cashierPagePreferencesFuture = AppSettingTool.getCashierPagePreferences(); final reservationQueryPreferencesFuture = AppSettingTool.getReservationQueryPagePreferences(); final productMealPeriodScheduleFuture = AppSettingTool.getProductMealPeriodSchedule(); final storedPayTypeOrder = await storedPayTypeOrderFuture; final reidentifiablePayTypes = await reidentifiablePayTypesFuture; final cashierPagePreferences = await cashierPagePreferencesFuture; final reservationQueryPreferences = await reservationQueryPreferencesFuture; final restoredProductMealPeriodSchedule = await productMealPeriodScheduleFuture; final byCode = { for (final item in OrderPayDisplayType.values) item.code: item, }; final restoredPayTypeOrder = []; for (final code in storedPayTypeOrder) { final item = byCode[code]; if (item != null && !restoredPayTypeOrder.contains(item)) { restoredPayTypeOrder.add(item); } } for (final item in OrderPayDisplayType.values) { if (!restoredPayTypeOrder.contains(item)) { restoredPayTypeOrder.add(item); } } payTypeOrder.assignAll(restoredPayTypeOrder); cashierReidentifiablePayTypes.assignAll(reidentifiablePayTypes); cashierShowHoldAction.value = cashierPagePreferences.showHoldAction; cashierShowMoreAction.value = cashierPagePreferences.showMoreAction; cashierAllowManualMealPeriodChange.value = cashierPagePreferences.allowManualMealPeriodChange; reservationQueryMethodOrder.assignAll( reservationQueryPreferences.methodOrder, ); reservationQueryDefaultMethod.value = reservationQueryPreferences.defaultMethod; reservationQueryAutoRecognitionEnabled.value = reservationQueryPreferences.autoRecognitionEnabled; reservationQueryContinuousRecognitionEnabled.value = reservationQueryPreferences.continuousRecognitionEnabled; reservationQueryNameMaskEnabled.value = reservationQueryPreferences.nameMaskEnabled; reservationQueryVerificationEnabled.value = reservationQueryPreferences.verificationEnabled; reservationQueryQuickVerificationEnabled.value = reservationQueryPreferences.quickVerificationEnabled; reservationQueryAllowManualMealPeriodChange.value = reservationQueryPreferences.allowManualMealPeriodChange; reservationQueryVerificationDateScope.value = reservationQueryPreferences.verificationDateScope; // GetX 的 assignAll 对 RxMap 是引用赋值,而偏好默认值可能是 const 空 Map, // 必须拷贝成可变副本,否则后续写入绑定会抛“Cannot modify unmodifiable map”。 menuQueryShortcutKeyBindings.assignAll( Map.of(reservationQueryPreferences.shortcutKeyBindings), ); reservationQueryKeyboardModeEnabled.value = reservationQueryPreferences.keyboardModeEnabled; productMealPeriodSchedule.value = restoredProductMealPeriodSchedule; if (setting.usePayDisplayType != null) { setting.usePayDisplayType = restoredPayTypeOrder .where(setting.usePayDisplayType!.contains) .toList(); } sysSetting.value = setting; userSetting.value = await AppSettingTool.getUserSetting(); isBound.value = ObjectUtil.isNotEmpty(setting.serviceMerchantId); manualHardwareInput.value = AppRuntimeTool.isEmulator || setting.manualHardwareInput; forgetPasswordOnManualLogout.value = setting.forgetPasswordOnManualLogout; forgetPasswordOnPowerLoss.value = setting.forgetPasswordOnPowerLoss; AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); } /// 切换页面设置当前编辑对象;不可配置的一级菜单请求会被忽略。 void selectPageSettingType(TerminalMenuItemType type) { if (!configurablePageTypes.contains(type)) return; pageSettingType.value = type; } /// 校验并保存餐段时间;空档允许保留,相邻或交叉范围由强类型模型统一处理。 Future saveProductMealPeriods(List periods) async { if (productMealPeriodSaving.value) return false; final error = ProductMealPeriodSchedule.validate(periods); if (error != null) { Toast.error(msg: error); return false; } productMealPeriodSaving.value = true; try { final schedule = ProductMealPeriodSchedule.checked(periods); await AppSettingTool.setProductMealPeriodSchedule(schedule); productMealPeriodSchedule.value = schedule; Toast.success(msg: '餐段配置已保存'); return true; } catch (e, stack) { logger.e('|$_tag|保存餐段配置失败error:|$e|stack:|$stack|'); Toast.error(msg: '餐段配置保存失败,请重试'); return false; } finally { productMealPeriodSaving.value = false; } } /// 调整预约查询识别方式顺序,并合并保存到最新页面偏好。 void reorderReservationQueryMethod(int oldIndex, int newIndex) { if (oldIndex == newIndex || oldIndex < 0 || oldIndex >= reservationQueryMethodOrder.length) { return; } if (newIndex > oldIndex) newIndex -= 1; if (newIndex < 0 || newIndex >= reservationQueryMethodOrder.length) return; final updated = reservationQueryMethodOrder.toList(); final method = updated.removeAt(oldIndex); updated.insert(newIndex, method); reservationQueryMethodOrder.assignAll(updated); _scheduleReservationQueryPreferencesPersist(); } /// 设置预约查询默认识别方式,仅接受当前列表内的方式。 void setReservationQueryDefaultMethod( ReservationRecognitionMethod method, ) { if (!reservationQueryMethodOrder.contains(method) || reservationQueryDefaultMethod.value == method) { return; } reservationQueryDefaultMethod.value = method; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单自动识别模式并安排持久化,当前已打开的查询页不被强制重启。 void setReservationQueryAutoRecognitionEnabled(bool enabled) { if (reservationQueryAutoRecognitionEnabled.value == enabled) return; reservationQueryAutoRecognitionEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单持续自动识别并安排持久化,关闭后保留硬件原有单轮超时行为。 void setReservationQueryContinuousRecognitionEnabled(bool enabled) { if (reservationQueryContinuousRecognitionEnabled.value == enabled) return; reservationQueryContinuousRecognitionEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单服务端二次核销校验并安排持久化。 void setReservationQueryVerificationEnabled(bool enabled) { if (reservationQueryVerificationEnabled.value == enabled) return; reservationQueryVerificationEnabled.value = enabled; // 快速核销从属于核销入口,关闭父开关时同步清理隐藏配置,避免再次进入查询页后误触发。 if (!enabled) reservationQueryQuickVerificationEnabled.value = false; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单快速核销并安排持久化,仅在核销入口已开启时接受开启操作。 void setReservationQueryQuickVerificationEnabled(bool enabled) { final normalized = reservationQueryVerificationEnabled.value && enabled; if (reservationQueryQuickVerificationEnabled.value == normalized) return; reservationQueryQuickVerificationEnabled.value = normalized; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询结果姓名脱敏并安排持久化。 void setReservationQueryNameMaskEnabled(bool enabled) { if (reservationQueryNameMaskEnabled.value == enabled) return; reservationQueryNameMaskEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } /// 更新预约核销日期范围并安排持久化。 void setReservationQueryVerificationDateScope( ReservationVerificationDateScope scope, ) { if (reservationQueryVerificationDateScope.value == scope) return; reservationQueryVerificationDateScope.value = scope; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单是否允许主动修改餐段并安排持久化。 void setReservationQueryAllowManualMealPeriodChange(bool allowed) { if (reservationQueryAllowManualMealPeriodChange.value == allowed) return; reservationQueryAllowManualMealPeriodChange.value = allowed; _scheduleReservationQueryPreferencesPersist(); } /// 切换查询菜单快捷小键盘模式并安排持久化。 void setReservationQueryKeyboardModeEnabled(bool enabled) { if (reservationQueryKeyboardModeEnabled.value == enabled) return; reservationQueryKeyboardModeEnabled.value = enabled; _scheduleReservationQueryPreferencesPersist(); } /// 绑定查询菜单快捷小键盘动作按键并安排持久化。 /// /// 同一物理按键已被其他动作占用时拒绝写入并提示,返回是否绑定成功; /// 冲突校验收敛在 Controller,保证设置页与后续其他入口执行同一规则。 bool setMenuQueryShortcutKeyBinding( MenuQueryShortcutAction action, MenuQueryShortcutKeyBinding binding, ) { final occupied = menuQueryShortcutKeyBindings.entries .where( (entry) => entry.key != action && entry.value.conflictsWith(binding), ) .firstOrNull; if (occupied != null) { Toast.error(msg: '该按键已绑定“${occupied.key.title}”,请先清除或改按其他按键'); return false; } menuQueryShortcutKeyBindings[action] = binding; _scheduleReservationQueryPreferencesPersist(); return true; } /// 清除指定动作的快捷按键绑定并安排持久化。 void clearMenuQueryShortcutKeyBinding(MenuQueryShortcutAction action) { if (!menuQueryShortcutKeyBindings.containsKey(action)) return; menuQueryShortcutKeyBindings.remove(action); _scheduleReservationQueryPreferencesPersist(); } /// 将快捷按键绑定还原为出厂默认字母按键并安排持久化。 void resetMenuQueryShortcutKeyBindings() { if (mapEquals( menuQueryShortcutKeyBindings, MenuQueryShortcutKeyBinding.defaults, )) { return; } // GetX 的 assignAll 是引用赋值,默认映射本身不可变,必须拷贝可变副本。 menuQueryShortcutKeyBindings.assignAll( Map.of(MenuQueryShortcutKeyBinding.defaults), ); _scheduleReservationQueryPreferencesPersist(); } /// 将预约查询页面偏好恢复为产品默认值并安排持久化。 void resetReservationQueryPreferences() { reservationQueryMethodOrder.assignAll( ReservationRecognitionMethod.values, ); reservationQueryDefaultMethod.value = ReservationRecognitionMethod.card; reservationQueryAutoRecognitionEnabled.value = false; reservationQueryContinuousRecognitionEnabled.value = true; reservationQueryNameMaskEnabled.value = true; reservationQueryVerificationEnabled.value = false; reservationQueryQuickVerificationEnabled.value = false; reservationQueryAllowManualMealPeriodChange.value = true; reservationQueryVerificationDateScope.value = ReservationVerificationDateScope.today; // 快捷按键绑定随查询菜单整体还原时回到出厂默认字母按键,键盘模式一并回到默认开启。 resetMenuQueryShortcutKeyBindings(); reservationQueryKeyboardModeEnabled.value = true; // 其余查询菜单字段仍由外层统一调度持久化,避免全部为默认值时跳过写入。 _scheduleReservationQueryPreferencesPersist(); } /// 连续上下移动或切换默认项时串行保存,最终以界面最新状态为准。 void _scheduleReservationQueryPreferencesPersist() { _reservationQueryPendingPersist = true; if (!_reservationQueryPersisting) { unawaited(_flushReservationQueryPreferences()); } } /// 串行写入预约查询页面偏好,保存期间的新变更会继续下一轮写入。 Future _flushReservationQueryPreferences() async { _reservationQueryPersisting = true; while (_reservationQueryPendingPersist) { _reservationQueryPendingPersist = false; try { await AppSettingTool.setReservationQueryPagePreferences( order: reservationQueryMethodOrder.toList(), defaultMethod: reservationQueryDefaultMethod.value, autoRecognitionEnabled: reservationQueryAutoRecognitionEnabled.value, continuousRecognitionEnabled: reservationQueryContinuousRecognitionEnabled.value, nameMaskEnabled: reservationQueryNameMaskEnabled.value, verificationEnabled: reservationQueryVerificationEnabled.value, quickVerificationEnabled: reservationQueryQuickVerificationEnabled.value, allowManualMealPeriodChange: reservationQueryAllowManualMealPeriodChange.value, verificationDateScope: reservationQueryVerificationDateScope.value, shortcutKeyBindings: menuQueryShortcutKeyBindings, keyboardModeEnabled: reservationQueryKeyboardModeEnabled.value, ); } catch (e, stack) { logger.e('|$_tag|保存查询菜单页面配置失败error:|$e|stack:|$stack|'); Toast.error(msg: '查询菜单设置保存失败,请重试'); } } _reservationQueryPersisting = false; } /// 从 Android 原生能力重新读取当前 Kiosk 状态。 Future refreshKioskStatus() async { kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); } /// 立即执行真实 Root 命令并局部持久化结果;失败状态也作为有效检查结果保存。 Future refreshRootAccess({bool showFeedback = true}) async { if (rootRefreshing.value) return; rootRefreshing.value = true; try { final result = await AppRuntimeTool.checkRootAccess(); await AppSettingTool.setRootAccessResult(result); final setting = sysSetting.value; if (setting != null) { setting.rootAccessStatus = result.status; setting.rootAccessCheckedAt = result.checkedAt; setting.rootAccessDiagnostic = result.diagnostic; sysSetting.refresh(); } if (showFeedback) { if (result.available) { Toast.success(msg: 'Root 权限可用'); } else { Toast.error(msg: result.diagnostic); } } } catch (e, stack) { logger.e('|$_tag|刷新Root状态失败error:|$e|stack:|$stack|'); if (showFeedback) Toast.error(msg: 'Root 状态检查失败,请稍后重试'); } finally { rootRefreshing.value = false; } } /// 经用户二次确认后通过 Root 配置 Device Owner;不删除账户、不移除其他 Owner 或修改系统策略文件。 Future enableKioskWithRoot() async { final setting = sysSetting.value; if (setting == null || kioskProvisioning.value) return; if (setting.rootAccessStatus != RootAccessStatus.available) { kioskProvisionMessage.value = 'Root 不可用,请先刷新 Root 状态并完成授权'; return; } final confirmed = await appMessageDialog( Get.context!, '将使用 Root 把当前应用配置为 Android Device Owner,并立即进入 Kiosk。该操作不会删除账户或移除其他 Device Owner,系统拒绝时只会返回原因。是否继续?', ); if (!confirmed) return; kioskProvisioning.value = true; kioskProvisionMessage.value = ''; EasyLoading.show( status: '正在启用专用设备模式...', maskType: EasyLoadingMaskType.black, ); try { final result = await AppRuntimeTool.enableKioskWithRoot(); kioskStatus.value = result.status; if (result.success) { kioskProvisionMessage.value = ''; Toast.success(msg: 'Kiosk 已启用并自动进入'); } else { kioskProvisionMessage.value = result.message; Toast.error(msg: result.message); } } catch (e, stack) { logger.e('|$_tag|Root启用Kiosk失败error:|$e|stack:|$stack|'); kioskProvisionMessage.value = '启用失败,请稍后重试并检查系统 Device Owner 状态'; Toast.error(msg: kioskProvisionMessage.value); } finally { EasyLoading.dismiss(); kioskProvisioning.value = false; } } /// 保存全局字体倍率并立即应用到运行中主题。 Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { final setting = sysSetting.value; if (setting == null || setting.globalFontSize == fontSize) return; setting.globalFontSize = fontSize; await AppSettingTool.setSysSetting(setting); Get.find().setSizeScalar(fontSize); sysSetting.refresh(); } /// 保存按钮圆角并立即应用到运行中主题。 Future setButtonRadius(SettingButtonRadius value) async { final setting = sysSetting.value; if (setting == null || setting.buttonRadius == value) return; setting.buttonRadius = value; await AppSettingTool.setSysSetting(setting); Get.find().setButtonRadius(value); sysSetting.refresh(); } /// 保存卡片圆角并立即应用到运行中主题。 Future setCardRadius(SettingCardRadius value) async { final setting = sysSetting.value; if (setting == null || setting.cardRadius == value) return; setting.cardRadius = value; await AppSettingTool.setSysSetting(setting); Get.find().setCardRadius(value); sysSetting.refresh(); } /// 保存按钮尺寸档位并立即应用到运行中主题。 Future setButtonSize(SettingButtonSize value) async { final setting = sysSetting.value; if (setting == null || setting.buttonSize == value) return; setting.buttonSize = value; await AppSettingTool.setSysSetting(setting); Get.find().setButtonSize(value); sysSetting.refresh(); } /// 启用或停用支付方式,始终至少保留一种并修正失效的默认方式。 void togglePayType(OrderPayDisplayType type) { final setting = sysSetting.value; if (setting == null) return; final enabled = List.from( setting.usePayDisplayType ?? OrderPayDisplayType.values, ); if (enabled.contains(type)) { if (enabled.length == 1) { Toast.error(msg: '至少保留一种支付方式'); return; } enabled.remove(type); } else { enabled.add(type); } final orderedEnabled = payTypeOrder.where(enabled.contains).toList(); // 默认支付方式必须属于启用集合,否则收银页会出现默认值不可选择的矛盾状态。 if (!orderedEnabled.contains(setting.defaultPayDisplayType)) { setting.defaultPayDisplayType = orderedEnabled.first; } setting.usePayDisplayType = orderedEnabled; sysSetting.refresh(); _schedulePayPreferencesPersist(); } /// 切换指定支付方式能否在收银弹框中重新识别人员,并立即安排本机持久化。 void togglePayTypeReidentification(OrderPayDisplayType type) { if (cashierReidentifiablePayTypes.contains(type)) { cashierReidentifiablePayTypes.remove(type); } else { cashierReidentifiablePayTypes.add(type); } _schedulePayPreferencesPersist(); } /// 调整支付方式展示顺序,同时保持启用集合与完整排序一致。 void reorderPayType(int oldIndex, int newIndex) { if (oldIndex == newIndex || oldIndex < 0 || oldIndex >= payTypeOrder.length) { return; } if (newIndex > oldIndex) newIndex -= 1; if (newIndex < 0 || newIndex >= payTypeOrder.length) return; final updated = payTypeOrder.toList(); final item = updated.removeAt(oldIndex); updated.insert(newIndex, item); payTypeOrder.assignAll(updated); final setting = sysSetting.value; if (setting != null) { final visible = setting.usePayDisplayType ?? OrderPayDisplayType.values; setting.usePayDisplayType = updated.where(visible.contains).toList(); sysSetting.refresh(); } _schedulePayPreferencesPersist(); } /// 恢复全部支付方式、默认枚举顺序和默认重新识别范围,并安排持久化。 void resetPayTypes() { payTypeOrder.assignAll(OrderPayDisplayType.values); cashierReidentifiablePayTypes.assignAll( AppSettingTool.defaultCashierReidentifiablePayTypes, ); final setting = sysSetting.value; if (setting != null) { setting.usePayDisplayType = OrderPayDisplayType.values.toList(); if (!setting.usePayDisplayType!.contains(setting.defaultPayDisplayType)) { setting.defaultPayDisplayType = setting.usePayDisplayType!.first; } sysSetting.refresh(); } _schedulePayPreferencesPersist(); } /// 设置默认支付方式;未启用的支付方式不能成为默认值。 void setDefaultPayType(OrderPayDisplayType type) { final setting = sysSetting.value; if (setting == null) return; final enabled = setting.usePayDisplayType ?? OrderPayDisplayType.values; if (!enabled.contains(type)) { Toast.error(msg: '请先启用该支付方式'); return; } setting.defaultPayDisplayType = type; sysSetting.refresh(); _schedulePayPreferencesPersist(); } /// 切换收银页“挂单”快捷操作显隐并安排持久化。 void setCashierShowHoldAction(bool visible) { if (cashierShowHoldAction.value == visible) return; cashierShowHoldAction.value = visible; _scheduleCashierPagePreferencesPersist(); } /// 切换收银页“更多”快捷操作显隐并安排持久化。 void setCashierShowMoreAction(bool visible) { if (cashierShowMoreAction.value == visible) return; cashierShowMoreAction.value = visible; _scheduleCashierPagePreferencesPersist(); } /// 切换收银页是否允许主动修改餐段并安排持久化。 void setCashierAllowManualMealPeriodChange(bool allowed) { if (cashierAllowManualMealPeriodChange.value == allowed) return; cashierAllowManualMealPeriodChange.value = allowed; _scheduleCashierPagePreferencesPersist(); } /// 将收银页面快捷操作恢复为默认显示状态。 void resetCashierPagePreferences() { cashierShowHoldAction.value = true; cashierShowMoreAction.value = true; cashierAllowManualMealPeriodChange.value = true; _scheduleCashierPagePreferencesPersist(); } /// 连续切换快捷操作显隐时串行保存,最终落盘界面最后一次选择。 void _scheduleCashierPagePreferencesPersist() { _cashierPagePendingPersist = true; if (!_cashierPagePersisting) { unawaited(_flushCashierPagePreferences()); } } /// 串行写入收银页面偏好,保存期间的新变更会继续下一轮写入。 Future _flushCashierPagePreferences() async { _cashierPagePersisting = true; while (_cashierPagePendingPersist) { _cashierPagePendingPersist = false; try { await AppSettingTool.setCashierPagePreferences( showHoldAction: cashierShowHoldAction.value, showMoreAction: cashierShowMoreAction.value, allowManualMealPeriodChange: cashierAllowManualMealPeriodChange.value, ); } catch (e, stack) { logger.e('|$_tag|保存收银页面配置失败error:|$e|stack:|$stack|'); Toast.error(msg: '收银页面设置保存失败,请重试'); } } _cashierPagePersisting = false; } /// 支付方式连续拖动或切换时串行保存,始终以最后一次界面状态为准。 void _schedulePayPreferencesPersist() { _payPendingPersist = true; if (!_payPersisting) unawaited(_flushPayPreferences()); } /// 串行写入支付方式偏好,保存期间的新变更会继续下一轮写入。 Future _flushPayPreferences() async { _payPersisting = true; while (_payPendingPersist) { _payPendingPersist = false; final setting = sysSetting.value; if (setting == null) continue; try { await AppSettingTool.setPayTypePreferences( sysInfo: setting, order: payTypeOrder, reidentifiableTypes: cashierReidentifiablePayTypes, ); } catch (e, stack) { logger.e('|$_tag|保存支付管理配置失败error:|$e|stack:|$stack|'); Toast.error(msg: '收银支付设置保存失败,请重试'); } } _payPersisting = false; } /// 通过旧同步入口立即拉取设备配置,并在成功后刷新系统设置。 Future syncConfig() async { await _runSync('config', '设备信息', () async { final setting = await SyncConfigTool.autoSyncSysInfo(); if (setting != null) sysSetting.value = setting; }); } /// 执行或取消设备状态检查,并维护旧同步按钮的局部结果状态。 Future syncDeviceStatus() async { if (AppDeviceStatusTool.isRefreshing) { AppDeviceStatusTool.cancelRefresh(); if (syncingKey.value == 'deviceStatus') syncingKey.value = ''; Toast.success(msg: '已取消设备状态检查'); return; } if (syncingKey.isNotEmpty) return; syncingKey.value = 'deviceStatus'; syncFailedKeys.remove('deviceStatus'); try { await AppDeviceStatusTool.refreshAll(); sysSetting.value = await AppSettingTool.getSysSetting(); Toast.success(msg: '设备状态检查完成'); } on TerminalDeviceCheckCancelled { // 用户主动取消不属于检查失败,保留上一次已经确认的设备状态。 } catch (e, stack) { syncFailedKeys.add('deviceStatus'); Toast.error(msg: '同步设备状态失败,请稍后重试'); logger.e('|$_tag|同步设备状态失败error:|$e|stack:|$stack|'); } finally { if (syncingKey.value == 'deviceStatus') syncingKey.value = ''; } } /// 通过旧同步入口关闭并重新初始化 MQTT 长连接。 Future syncMq() async { await _runSync('mq', '长链接', () async { await MqManager.closeMqtt(); await AppMqTool.mqRefresh(); }); } /// 通过旧同步入口立即同步商品分类。 Future syncClassify() async { await _runSync('classify', '分类', SyncProductTool.syncClassify); } /// 通过旧同步入口立即同步商品主数据。 Future syncProduct() async { await _runSync('product', '商品', SyncProductTool.syncProduct); } /// 通过旧同步入口立即补传本地离线订单。 Future syncOfflineOrder() async { await _runSync('offlineOrder', '离线订单', SyncOrderTool.syncOfflineOrder); } /// 展开或收起旧内联 Cron 编辑器,并用当前已保存规则重置草稿和错误。 void toggleScheduledTaskEditor(ScheduledTaskType type) { expandedScheduledTask.value = expandedScheduledTask.value == type ? null : type; scheduledTaskCronDrafts[type] = SyncCheckTool.instance.configs[type]?.cron ?? ''; scheduledTaskCronErrors.remove(type); } /// 更新指定任务的 Cron 草稿,输入变化后清除该任务旧校验错误。 void setScheduledTaskCronDraft(ScheduledTaskType type, String value) { scheduledTaskCronDrafts[type] = value; if (scheduledTaskCronErrors.containsKey(type)) { scheduledTaskCronErrors.remove(type); } } /// 切换任务自动调度开关;关闭运行保障任务前要求操作人员确认。 Future setScheduledTaskEnabled( ScheduledTaskType type, bool enabled, ) async { final config = SyncCheckTool.instance.configs[type]; if (config == null || config.enabled == enabled) return; if (!enabled && _isRuntimeGuardTask(type)) { final confirmed = await appMessageDialog( Get.context!, '关闭后终端将不再自动执行该运行保障任务,是否继续?', ); if (!confirmed) return; } await _saveScheduledTaskConfig(config.copyWith(enabled: enabled)); } /// 切换任务是否仅在完整空闲窗口自动执行,并持久化到结构化任务表。 Future setScheduledTaskIdleOnly( ScheduledTaskType type, bool idleOnly, ) async { final config = SyncCheckTool.instance.configs[type]; if (config == null || config.idleOnly == idleOnly) return; await _saveScheduledTaskConfig(config.copyWith(idleOnly: idleOnly)); } /// Cron 弹窗返回经过本地解析校验的表达式,Controller 仍在持久化边界再次校验, /// 避免其他调用入口绕过校验写入无法注册的任务规则。 Future setScheduledTaskCron( ScheduledTaskType type, String expression, ) async { final config = SyncCheckTool.instance.configs[type]; if (config == null) return; final cron = expression.trim(); if (!SyncCheckTool.isValidCron(cron)) { Toast.error(msg: '请输入有效的 6 段 Cron 表达式'); return; } if (cron == config.cron) return; await _saveScheduledTaskConfig(config.copyWith(cron: cron)); } /// 校验并保存旧内联编辑器中的 Cron 草稿,非法值只更新对应错误状态。 Future saveScheduledTaskCron(ScheduledTaskType type) async { final config = SyncCheckTool.instance.configs[type]; if (config == null) return; final cron = (scheduledTaskCronDrafts[type] ?? '').trim(); if (!SyncCheckTool.isValidCron(cron)) { scheduledTaskCronErrors[type] = '请输入有效的 6 段 Cron 表达式'; return; } await _saveScheduledTaskConfig(config.copyWith(cron: cron)); } /// 串联调度重建和 SQLite 保存,负责重复提交保护、草稿回退及用户反馈。 Future _saveScheduledTaskConfig(ScheduledTaskConfig config) async { if (savingScheduledTasks.contains(config.type)) return; final previous = SyncCheckTool.instance.configs[config.type]; savingScheduledTasks.add(config.type); try { await SyncCheckTool.instance.updateConfig(config); scheduledTaskCronDrafts[config.type] = config.cron; scheduledTaskCronErrors.remove(config.type); Toast.success(msg: '任务配置已保存'); } catch (e, stack) { if (previous != null) { scheduledTaskCronDrafts[config.type] = previous.cron; } logger .e('|$_tag|保存定时任务失败type:${config.type.name}|error:$e|stack:$stack|'); Toast.error(msg: '任务配置保存失败,请重试'); } finally { savingScheduledTasks.remove(config.type); } } /// 手动触发指定任务,并根据立即执行、排队或合并结果向操作人员反馈。 Future runScheduledTaskNow(ScheduledTaskType type) async { final scheduler = SyncCheckTool.instance; if (scheduler.runningTasks.contains(type) || scheduler.pendingTasks.contains(type)) { await scheduler.trigger(type); Toast.success(msg: '已合并到待执行任务'); return; } await scheduler.trigger(type); if (scheduler.pendingTasks.contains(type)) { Toast.success(msg: '任务已排队,将在安全窗口执行'); return; } final record = scheduler.records[type]; if (record?.success == true) { Toast.success(msg: record?.message ?? '任务执行完成'); } else { Toast.error(msg: record?.message ?? '任务执行失败'); } } /// 返回关闭自动调度前需要二次确认的 MQTT、打印、广播和设备状态保障任务。 bool _isRuntimeGuardTask(ScheduledTaskType type) => const { ScheduledTaskType.mqReconnect, ScheduledTaskType.printCheck, ScheduledTaskType.broadcastCheck, ScheduledTaskType.deviceStatus, }.contains(type); /// 执行旧同步动作的统一网络校验、遮罩、成功反馈和异常记录流程。 Future _runSync( String key, String label, Future Function() action, { bool requireNetwork = true, bool refreshSetting = true, }) async { if (syncingKey.isNotEmpty) return; if (requireNetwork && !AppNetworkTool.isAutoNetworkMsg()) return; syncingKey.value = key; syncFailedKeys.remove(key); EasyLoading.show(status: '同步中...', maskType: EasyLoadingMaskType.black); try { await action(); if (refreshSetting) await getSetting(); Toast.success(msg: '同步$label成功'); } catch (e, stack) { syncFailedKeys.add(key); Toast.error(msg: '同步$label失败,请稍后重试'); logger.e('|$_tag|同步$label失败error:|$e|stack:|$stack|'); } finally { syncingKey.value = ''; EasyLoading.dismiss(); } } /// 手动备份本地 SQLite 数据库并刷新最近备份时间。 Future backupsSql() async { await _runBackup('SQL', AppFileTool.backupSqlFilePath); } /// 手动备份本地日志文件并刷新最近备份时间。 Future backupsLog() async { await _runBackup('日志', AppFileTool.backupLogFile); } /// 执行单类文件备份,统一处理路径校验、状态提示和异常日志。 Future _runBackup( String label, Future Function() action, ) async { EasyLoading.show(status: '备份中...', maskType: EasyLoadingMaskType.black); try { final path = await action(); if (ObjectUtil.isEmpty(path)) throw Exception('未生成备份文件'); lastBackupMessage.value = '$label 备份已生成:$path'; await refreshBackupTimes(); Toast.success(msg: '$label 备份成功'); } catch (e, stack) { Toast.error(msg: '$label 备份失败,请检查存储权限和文件状态'); logger.e('|$_tag|$label备份失败error:|$e|stack:|$stack|'); } finally { EasyLoading.dismiss(); } } /// 扫描现有备份目录并更新数据库、日志最近修改时间。 Future refreshBackupTimes() async { final setting = sysSetting.value; if (setting == null) return; final basePath = await AppFileTool.getExternalPath( backupExternalPath: false, ); sqlBackupTime.value = await AppFileTool.getLatestModifiedTime( '$basePath/backup/database', ); logBackupTime.value = await AppFileTool.getLatestModifiedTime( '$basePath/backup/logger/manual', ); } /// 切换人工硬件输入模式,模拟器必须保持开启并在真机开启前二次确认。 Future setManualHardwareInput(bool enabled) async { if (AppRuntimeTool.isEmulator && !enabled) { Toast.error(msg: '虚拟机必须使用手动硬件输入模式'); manualHardwareInput.value = true; return; } if (enabled && !manualHardwareInput.value) { final confirmed = await appMessageDialog( Get.context!, '设置为主控后,扫码、饭卡读写和人脸识别将改为人工输入,是否继续?', ); if (!confirmed) return; } final setting = sysSetting.value; if (setting == null) return; setting.manualHardwareInput = enabled; await AppSettingTool.setSysSetting(setting); AppRuntimeTool.applyManualHardwareInput(enabled); manualHardwareInput.value = enabled; sysSetting.refresh(); Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式'); } /// 切换手动退出登录时是否忘记已保存的登录密码。 Future setForgetPasswordOnManualLogout(bool enabled) async { final setting = sysSetting.value; if (setting == null) return; setting.forgetPasswordOnManualLogout = enabled; await AppSettingTool.setSysSetting(setting); forgetPasswordOnManualLogout.value = enabled; sysSetting.refresh(); Toast.success(msg: enabled ? '手动退出时将忘记登录密码' : '手动退出时将保留登录密码'); } /// 切换机器突然关机后是否忘记已保存的登录密码。 Future setForgetPasswordOnPowerLoss(bool enabled) async { final setting = sysSetting.value; if (setting == null) return; setting.forgetPasswordOnPowerLoss = enabled; await AppSettingTool.setSysSetting(setting); forgetPasswordOnPowerLoss.value = enabled; sysSetting.refresh(); Toast.success(msg: enabled ? '突然关机后将忘记登录密码' : '突然关机后将保留登录密码'); } /// 把两个忘记密码开关恢复为默认开启。 Future resetForgetPasswordPolicies() async { final setting = sysSetting.value; if (setting == null) return; setting.forgetPasswordOnManualLogout = true; setting.forgetPasswordOnPowerLoss = true; await AppSettingTool.setSysSetting(setting); forgetPasswordOnManualLogout.value = true; forgetPasswordOnPowerLoss.value = true; sysSetting.refresh(); Toast.success(msg: '忘记密码开关已恢复默认'); } /// 立即启停当前 Kiosk,并同步保存以后进入 App 时是否自动恢复 Lock Task。 Future setKioskAutoEnterEnabled(bool enabled) async { if (!kioskStatus.value.available || kioskSwitching.value) return; kioskSwitching.value = true; try { final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled); kioskStatus.value = status; final applied = status.autoEnterEnabled == enabled && status.active == enabled; if (!applied) { Toast.error(msg: enabled ? 'Kiosk 未能立即开启,请检查设备策略' : 'Kiosk 未能立即关闭,请重试'); return; } Toast.success(msg: enabled ? 'Kiosk 已立即开启' : 'Kiosk 已立即关闭'); } finally { kioskSwitching.value = false; } } /// 管理密码验证由按钮组件负责,此处只处理原生退出结果与失败反馈。 Future exitKioskToAndroidHome() async { if (!kioskStatus.value.canExitToAndroidHome) return; EasyLoading.show( status: '正在退出专用设备模式...', maskType: EasyLoadingMaskType.black, ); try { final success = await AppRuntimeTool.exitKioskToAndroidHome(); kioskStatus.value = await AppRuntimeTool.refreshKioskStatus(); if (!success) { Toast.error(msg: '退出 Android 桌面失败,请检查设备策略'); } } catch (e, stack) { logger.e('|$_tag|退出Kiosk失败error:|$e|stack:|$stack|'); Toast.error(msg: '退出 Android 桌面失败,请稍后重试'); } finally { EasyLoading.dismiss(); } } }