import 'dart:async'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:forui/forui.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; export 'terminal_device_status.dart'; enum TerminalNavType { cashier, menuQuery, reservation, verification, transactions, verificationRecords, statistics, card, settings, } extension on TerminalNavType { TerminalMenuItemType get menuType => switch (this) { TerminalNavType.cashier => TerminalMenuItemType.cashier, TerminalNavType.menuQuery => TerminalMenuItemType.menuQuery, TerminalNavType.reservation => TerminalMenuItemType.reservation, TerminalNavType.verification => TerminalMenuItemType.verification, TerminalNavType.transactions => TerminalMenuItemType.transactions, TerminalNavType.verificationRecords => TerminalMenuItemType.verificationRecords, TerminalNavType.statistics => TerminalMenuItemType.statistics, TerminalNavType.card => TerminalMenuItemType.card, TerminalNavType.settings => TerminalMenuItemType.settings, }; } /// 主屏各业务页面共用的 Flutter 终端框架。 /// /// 顶部系统操作和左侧一级导航必须常驻,设备状态按本机偏好显示;业务页面只负责 /// 右侧工作区,避免每个页面自己创建返回标题栏后破坏收银终端的连续操作体验。 class TerminalShell extends StatelessWidget { /// 创建一个保留终端顶栏、侧栏和业务页面生命周期的主屏外壳。 const TerminalShell({ super.key, required this.title, required this.activeNav, required this.child, this.headerLeading, }); /// 当前内层业务路由显示在顶栏的标题。 final String title; /// 当前内层业务路由对应的侧栏选中项。 final TerminalNavType activeNav; /// 持续保留在睡眠遮罩下方的业务页面。 final Widget child; /// 标题右侧按业务页面需要插入的附加控件。 final Widget? headerLeading; /// 构建终端常驻框架,并仅在最上层响应式切换睡眠遮罩。 @override Widget build(BuildContext context) { final menuController = Get.find(); final sleepControl = Get.find(); return ColoredBox( color: XfjDesignTokens.canvas, child: [ SafeArea( child: Column( children: [ TerminalHeader(title: title, leading: headerLeading), Padding( padding: EdgeInsets.all(8.w), child: Obx( () => Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (menuController.shouldShowSideNav) ...[ TerminalSideNav(active: activeNav), SizedBox(width: XfjDesignTokens.cardGap.w), ], child.expanded(), ], ), ), ).expanded(), ], ), ), Obx( () { if (!sleepControl.isSleeping.value) { return const SizedBox.shrink(); } // 只叠加不透明交互层,底下的页面、Controller 和硬件监听继续保持原生命周期。 return const TerminalSleepOverlay(); }, ), ].toStack(fit: StackFit.expand), ); } } class TerminalHeader extends StatelessWidget { const TerminalHeader({super.key, required this.title, this.leading}); final String title; final Widget? leading; @override Widget build(BuildContext context) { final theme = FTheme.of(context); final menuController = Get.find(); return Container( height: 80.h, padding: EdgeInsets.symmetric(horizontal: 20.w), decoration: const BoxDecoration( color: XfjDesignTokens.surface, border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), ), child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), // 宽屏时保留原有的左右对齐,窄屏时则让从 ZAX 到退出的整段顶栏可横向滚动。 child: IntrinsicWidth( child: Row( children: [ Obx(() { final soleMenu = menuController.soleVisibleBusinessMenu; return TerminalBrand( targetMenu: soleMenu, onTap: soleMenu == null ? null : () => TerminalNavigationTool.open( _terminalNavItem(soleMenu).route, ), ); }), SizedBox(width: 20.w), Container( width: 1, height: 36.h, color: XfjDesignTokens.line, ), SizedBox(width: 20.w), Text( title, style: theme.typography.xl.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, ), ), if (leading != null) ...[SizedBox(width: 36.w), leading!], const Spacer(), Container( padding: EdgeInsets.all(4.w), decoration: BoxDecoration( color: XfjDesignTokens.surface, border: Border.all(color: XfjDesignTokens.line), borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), ), child: Row( children: [ const TerminalNetworkStatus(), _terminalDeviceStatusHeaderSlot(), const _TerminalHeaderDivider(), const TerminalPerformanceStatus(), const _TerminalHeaderDivider(), const _TerminalAdminLockStatus(), const _TerminalHeaderDivider(), const _TerminalSleepStatus(), ], ), ), SizedBox(width: 24.w), SizedBox( width: 100.w, child: FutureBuilder( future: AppSettingTool.getUserSetting(), builder: (context, snapshot) => Text( snapshot.data?.username ?? '收银员', maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: theme.typography.base.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w600, ), ), ), ), SizedBox(width: 20.w), Container( width: 1, height: 32.h, color: XfjDesignTokens.line, ), SizedBox(width: 20.w), FIcon(FAssets.icons.clock, color: XfjDesignTokens.ink), SizedBox(width: 8.w), const _TerminalClock(), SizedBox(width: 16.w), TerminalHeaderIconButton( tooltip: '设置', icon: FAssets.icons.settings, onTap: () => TerminalNavigationTool.open(RouteNames.setting), ), SizedBox(width: 8.w), TerminalHeaderIconButton( tooltip: '退出登录', icon: FAssets.icons.logOut, danger: true, onTap: TerminalNavigationTool.logout, ), ], ), ), ), ), ), ); } } /// 仅响应顶部逐项显隐;五项全部隐藏时连同左侧分隔线移除,避免状态区出现重复分隔线。 Widget _terminalDeviceStatusHeaderSlot() { final control = Get.find(); return Obx(() { if (!control.hasVisibleHeaderDevices) return const SizedBox.shrink(); return [ const _TerminalHeaderDivider(), const TerminalDeviceStatus(), ].toRow( key: const ValueKey('terminal-device-status-header-slot'), mainAxisSize: MainAxisSize.min, ); }); } /// 顶栏品牌字标需要保持固定比例,不能跟随全局字号缩放无限放大。 /// /// 这里使用受约束的 `FittedBox` 保留既有品牌字标的斜体、紧字距效果,同时避免 /// 大字号设置把品牌挤出 80 高的终端顶栏。 class TerminalBrand extends StatelessWidget { const TerminalBrand({super.key, this.targetMenu, this.onTap}); final TerminalMenuItemType? targetMenu; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = FTheme.of(context); final brand = SizedBox( width: 100.w, height: 55.h, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.center, child: Text( 'ZAX', textAlign: TextAlign.center, style: theme.typography.xl6.copyWith( color: const Color(0xFF1268E8), fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, ), ), ), ); if (onTap == null || targetMenu == null) return brand; // 侧栏因仅剩一个业务菜单而收起时,品牌作为返回该入口的固定兜底; // 实际跳转仍交给统一导航守卫处理绑定、联网和设置密码校验。 return Semantics( button: true, label: '返回${targetMenu!.title}', child: Tooltip( message: '返回${targetMenu!.title}', child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: brand, ), ), ); } } /// 网络无有效流量或原生累计计数器暂不可用时使用的稳定展示值。 const String _zeroNetworkSpeedLabel = '0 KB/s'; /// 将每秒字节数格式化为终端顶栏使用的自适应速率文本。 /// /// 零值、负增量、计数器异常产生的非有限值都按无流量处理,避免只剩单位或显示无效数字。 String formatTerminalNetworkSpeed(double bytesPerSecond) { if (!bytesPerSecond.isFinite || bytesPerSecond <= 0) { return _zeroNetworkSpeedLabel; } if (bytesPerSecond >= 1024 * 1024) { return '${(bytesPerSecond / 1024 / 1024).toStringAsFixed(1)} MB/s'; } if (bytesPerSecond >= 1024) { return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; } return '${bytesPerSecond.toStringAsFixed(0)} B/s'; } /// 终端顶栏网络类型、连接状态及实时上下行速率入口。 class TerminalNetworkStatus extends StatefulWidget { /// 创建一个使用 Android 累计流量快照进行轻量采样的网络状态组件。 const TerminalNetworkStatus({super.key}); /// 创建并持有网络采样定时器与当前展示状态。 @override State createState() => _TerminalNetworkStatusState(); } /// 网络状态组件的采样与展示状态,每三秒比较一次累计字节差值。 class _TerminalNetworkStatusState extends State { /// 最近一次跨页面共享的原生累计流量快照。 static AppNetworkSnapshot? _lastSnapshot; /// 最近一次快照的本机采样时间,用于将累计字节差换算为每秒速率。 static DateTime? _lastSampleTime; /// 页面重建时复用的最近下行速率文案。 static String _cachedDownloadSpeed = _zeroNetworkSpeedLabel; /// 页面重建时复用的最近上行速率文案。 static String _cachedUploadSpeed = _zeroNetworkSpeedLabel; /// 最近一次平滑后的下行速率,单位为字节每秒。 static double _smoothedDownload = 0; /// 最近一次平滑后的上行速率,单位为字节每秒。 static double _smoothedUpload = 0; /// 周期采样定时器,组件销毁时必须取消。 Timer? _timer; /// 当前网络类型的中文展示名称。 String _networkType = '网络'; /// 当前下行速率文案,无流量时为 `0 KB/s`。 String _downloadSpeed = _zeroNetworkSpeedLabel; /// 当前上行速率文案,无流量时为 `0 KB/s`。 String _uploadSpeed = _zeroNetworkSpeedLabel; /// 原生网络或 Flutter 连接探测任一确认在线时为 true。 bool _connected = false; /// 进入顶栏后立即采样,并启动三秒一次的轻量刷新任务。 @override void initState() { super.initState(); _refresh(); _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); } /// 取消周期采样,避免路由切换后旧顶栏继续访问平台通道。 @override void dispose() { _timer?.cancel(); super.dispose(); } /// 读取原生累计流量并计算相邻快照差值,同时使用 Flutter 网络状态兜底连接类型。 Future _refresh() async { final current = await AppRuntimeTool.getNetworkSnapshot(); final flutterConnected = await AppNetworkTool.isConnectedNow(); final flutterType = await AppNetworkTool.getNetworkTypeNow(); final now = DateTime.now(); final previous = _lastSnapshot; final previousTime = _lastSampleTime; if (!mounted) return; setState(() { // 原生 activeNetwork 在部分模拟器中可能为空,在线状态以 Flutter 监听结果兜底。 _connected = current.connected || flutterConnected; _networkType = _networkLabel( current.type == 'offline' && flutterConnected ? flutterType : current.type, ); if (previous != null && previousTime != null && _connected) { final seconds = now.difference(previousTime).inMilliseconds / 1000; if (seconds > 0) { final download = (current.receivedBytes - previous.receivedBytes) / seconds; final upload = (current.transmittedBytes - previous.transmittedBytes) / seconds; // 正增量做平滑以降低峰值跳动;零、负数或异常采样立即归零,不能沿用陈旧上行值。 _smoothedDownload = _smoothSpeed(_smoothedDownload, download); _smoothedUpload = _smoothSpeed(_smoothedUpload, upload); _cachedDownloadSpeed = formatTerminalNetworkSpeed( _smoothedDownload, ); _cachedUploadSpeed = formatTerminalNetworkSpeed(_smoothedUpload); } _downloadSpeed = _cachedDownloadSpeed; _uploadSpeed = _cachedUploadSpeed; } else { // 页面切换重建顶栏时继续显示最近一次测速,避免第一帧错误归零。 _downloadSpeed = _cachedDownloadSpeed; _uploadSpeed = _cachedUploadSpeed; } _lastSnapshot = current; _lastSampleTime = now; }); } /// 构建状态行;离线时只展示“网络离线”并隐藏速率区,在线时展示固定双行上下行速率, /// 禁止长下行文案自动折行占用上行所在行。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); return Container( width: 230.w, constraints: BoxConstraints(minHeight: 48.h), padding: EdgeInsets.symmetric(horizontal: 10.w), child: Row( mainAxisSize: MainAxisSize.min, children: [ FIcon( _networkIcon(_networkType), size: 19.w, color: _connected ? XfjDesignTokens.ink : XfjDesignTokens.danger, ), SizedBox(width: 7.w), Container( width: 8.w, height: 8.w, decoration: BoxDecoration( shape: BoxShape.circle, color: _connected ? XfjDesignTokens.success : XfjDesignTokens.danger, ), ), SizedBox(width: 7.w), if (_connected) ...[ SizedBox( width: 72.w, child: Text( _networkType, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink), ), ), SizedBox(width: 8.w), Expanded( child: Text( '↓$_downloadSpeed\n↑$_uploadSpeed', key: const ValueKey('terminal-network-speed'), maxLines: 2, softWrap: false, overflow: TextOverflow.clip, style: theme.typography.xs.copyWith( color: XfjDesignTokens.muted, fontSize: (theme.typography.xs.fontSize ?? 12) * 0.82, height: 1.05, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ] else Expanded( child: Text( '网络离线', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink), ), ), ], ), ); } /// 将原生网络类型编码转换为顶栏短文案,未知类型降级为“网络”。 String _networkLabel(String type) => switch (type) { 'ethernet' => '网线', 'wifi' => 'WiFi', '2g' => '2G', '3g' => '3G', '4g' => '4G', '5g' => '5G', 'mobile' => '移动网络', _ => '网络', }; /// 根据网络类型选择有线、WiFi 或移动信号图标。 SvgAsset _networkIcon(String label) { if (label == 'WiFi') return FAssets.icons.wifi; if (label == '网线') return FAssets.icons.network; return FAssets.icons.signal; } /// 仅平滑有效正增量;无流量、计数器回退和非有限值立即归零,防止显示陈旧速率。 double _smoothSpeed(double previous, double current) { if (!current.isFinite || current <= 0) return 0; if (!previous.isFinite || previous <= 0) return current; return previous * 0.65 + current * 0.35; } } class TerminalSideNav extends StatelessWidget { const TerminalSideNav({super.key, required this.active}); final TerminalNavType active; @override Widget build(BuildContext context) { final controller = Get.find(); return Obx( () { final visibleItems = controller.visibleBusinessMenus.map(_terminalNavItem).toList(); final showLogout = controller.isVisible(TerminalMenuItemType.logout); return TerminalSurface( // 侧栏小幅加宽,为四字菜单和全局字号缩放保留稳定空间。 width: 176.w, padding: EdgeInsets.all(12.w), child: Column( children: [ ...visibleItems.map((item) { final selected = item.type == active.menuType; return _TerminalNavButton( item: item, selected: selected, onTap: selected ? null : () => TerminalNavigationTool.open(item.route), ).marginOnly(bottom: 8.h); }), const Spacer(), if (showLogout) _TerminalNavButton( item: _terminalNavItem(TerminalMenuItemType.logout), danger: true, onTap: TerminalNavigationTool.logout, ), ], ), ); }, ); } } _TerminalNavItem _terminalNavItem(TerminalMenuItemType type) => switch (type) { TerminalMenuItemType.cashier => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.cashier, ), TerminalMenuItemType.menuQuery => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.menuQuery, ), TerminalMenuItemType.reservation => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.canteenReserve, ), TerminalMenuItemType.verification => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.takeFood, ), TerminalMenuItemType.transactions => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.orderHistory, ), TerminalMenuItemType.verificationRecords => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.verificationRecords, ), TerminalMenuItemType.statistics => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.canteenOrderTotal, ), TerminalMenuItemType.card => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.cardActive, ), TerminalMenuItemType.settings => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.setting, ), TerminalMenuItemType.logout => _TerminalNavItem( type: type, label: type.title, icon: type.icon, route: RouteNames.login, ), }; class TerminalSurface extends StatelessWidget { const TerminalSurface({ super.key, required this.child, this.padding, this.width, this.height, this.color = XfjDesignTokens.surface, this.clipBehavior = Clip.none, }); final Widget child; final EdgeInsetsGeometry? padding; final double? width; final double? height; final Color color; final Clip clipBehavior; @override Widget build(BuildContext context) { return Container( width: width, height: height, padding: padding, clipBehavior: clipBehavior, decoration: BoxDecoration( color: color, border: Border.all(color: XfjDesignTokens.line), borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), boxShadow: XfjDesignTokens.surfaceShadow, ), child: child, ); } } /// 顶栏仅监听管理会话秒数,避免倒计时导致整个终端页面每秒重建。 class _TerminalAdminLockStatus extends StatelessWidget { const _TerminalAdminLockStatus(); @override Widget build(BuildContext context) { final theme = FTheme.of(context); final session = Get.find(); return Obx(() { // 读取响应式秒数触发局部刷新,真实有效性仍以绝对过期时间为准。 final seconds = session.remainingSeconds.value; final unlocked = session.isUnlocked && seconds > 0; final stateColor = unlocked ? XfjDesignTokens.success : XfjDesignTokens.muted; final stateIcon = unlocked ? FAssets.icons.lockOpen : FAssets.icons.lock; final actionIcon = unlocked ? FAssets.icons.lock : FAssets.icons.lockOpen; final label = unlocked ? session.remainingLabel : '已锁定'; return Semantics( container: true, label: unlocked ? '管理操作已解锁,剩余$label' : '管理操作已锁定', child: Container( width: 164.w, height: 40.h, padding: EdgeInsets.only(left: 10.w, right: 4.w), decoration: BoxDecoration( color: unlocked ? const Color(0xFFF0FAF6) : XfjDesignTokens.control, borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), ), child: Row( children: [ FIcon(stateIcon, size: 19.w, color: stateColor), SizedBox(width: 8.w), Expanded( child: Text( label, maxLines: 1, textAlign: TextAlign.center, style: theme.typography.sm.copyWith( color: unlocked ? XfjDesignTokens.ink : stateColor, fontWeight: FontWeight.w700, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), SizedBox(width: 4.w), Semantics( button: true, label: unlocked ? '立即锁定管理操作' : '解锁管理操作', child: Material( color: XfjDesignTokens.surface, borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), child: InkWell( onTap: () { if (unlocked) { session.lock(); HapticFeedback.selectionClick(); return; } TerminalNavigationTool.unlockAdminSession(); }, borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), child: SizedBox( width: 40.w, height: 40.h, child: FIcon( actionIcon, size: 19.w, color: unlocked ? XfjDesignTokens.danger : XfjDesignTokens.primary, ).center(), ), ), ), ), ], ), ), ); }); } } /// 顶栏睡眠入口,仅切换主屏遮罩状态,不触碰当前页面和硬件资源。 class _TerminalSleepStatus extends StatelessWidget { /// 创建固定尺寸的主屏睡眠入口。 const _TerminalSleepStatus(); /// 构建带提示和触觉反馈的睡眠按钮。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final sleepControl = Get.find(); return Semantics( button: true, label: '进入终端睡眠', child: Tooltip( message: '进入睡眠', child: InkWell( onTap: () { sleepControl.enterSleep(); HapticFeedback.selectionClick(); }, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: [ FIcon( FAssets.icons.moonStar, size: 19.w, color: XfjDesignTokens.primary, ), Text( '睡眠', style: theme.typography.sm.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w700, ), ), ] .toRowSpace( space: 8.w, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, ) .padding(horizontal: 12.w) .tight(height: 40.h), ), ), ); } } /// 覆盖主屏交互的睡眠层,业务页面仍保留在同一棵 Widget 树中持续运行。 class TerminalSleepOverlay extends StatelessWidget { /// 创建不可通过系统返回键绕过的全屏睡眠层。 const TerminalSleepOverlay({super.key}); /// 与顾客欢迎副屏共用的安心守护插图,保证主副屏品牌视觉一致。 static const _illustrationAsset = 'assets/images/customer_welcome/abstract-care.webp'; /// 请求管理密码校验,只有验证成功才移除睡眠层。 Future _requestWake( BuildContext context, TerminalSleepControl sleepControl, ) async { try { final awakened = await sleepControl.requestWake( () => terminalAdminPasswordDialog( context, actionLabel: '唤醒终端', ), ); if (awakened) await HapticFeedback.selectionClick(); } catch (error, stack) { logger.e('|终端睡眠|唤醒验证失败error:|$error|stack:|$stack|'); Toast.error(msg: '暂时无法唤醒终端,请重试'); } } /// 构建与顾客欢迎副屏一致的品牌栏,并明确后台识别服务不会随主屏锁定暂停。 Widget _buildBrandHeader(FThemeData theme) { return [ Text( 'ZAX', style: theme.typography.xl2.copyWith( color: XfjDesignTokens.primary, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, ), ), ColoredBox(color: XfjDesignTokens.line).tight(width: 1.w, height: 32.h), Text( '智慧收银', style: theme.typography.lg.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, ), ), DecoratedBox( decoration: BoxDecoration( color: XfjDesignTokens.primarySoft, border: Border.all( color: XfjDesignTokens.primary.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12.w), ), child: Text( '安全锁屏', style: theme.typography.sm.copyWith( color: XfjDesignTokens.primary, fontWeight: FontWeight.w700, ), ).padding(horizontal: 14.w, vertical: 8.h), ), const Spacer(), FIcon( FAssets.icons.circleCheck, size: 19.sp, color: XfjDesignTokens.success, ), Text( '人脸与饭卡服务保持运行', style: theme.typography.sm.copyWith( color: XfjDesignTokens.success, fontWeight: FontWeight.w700, ), ), ] .toRowSpace(space: 18.w, crossAxisAlignment: CrossAxisAlignment.center) .padding(horizontal: 32.w) .tight(height: 80.h); } /// 构建睡眠状态、时间与唯一唤醒操作,信息层级按大屏远距离阅读优化。 Widget _buildSleepSummary( BuildContext context, FThemeData theme, TerminalSleepControl sleepControl, ) { return [ DecoratedBox( decoration: BoxDecoration( color: XfjDesignTokens.primarySoft, borderRadius: BorderRadius.circular(24.w), ), child: [ FIcon( FAssets.icons.moonStar, size: 20.sp, color: XfjDesignTokens.primary, ), Text( '主屏已锁定', style: theme.typography.sm.copyWith( color: XfjDesignTokens.primary, fontWeight: FontWeight.w800, ), ), ] .toRowSpace(space: 10.w, mainAxisSize: MainAxisSize.min) .padding(horizontal: 18.w, vertical: 11.h), ), const _TerminalClock(prominent: true), Text( '终端正在休息', style: theme.typography.xl3.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w900, ), ), Text( '屏幕已安全锁定,查询、识别与设备服务仍在后台持续运行。', style: theme.typography.lg.copyWith( color: XfjDesignTokens.muted, height: 1.55, ), ).constrained(maxWidth: 620.w), FButton.raw( key: const ValueKey('terminal-sleep-wake-button'), child: [ FIcon( FAssets.icons.lockKeyholeOpen, size: 22.sp, color: Colors.white, ), Text( '唤醒终端', style: theme.typography.lg.copyWith( color: Colors.white, fontWeight: FontWeight.w800, ), ), ] .toRowSpace( space: 12.w, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, ) .center(), onPress: () => unawaited(_requestWake(context, sleepControl)), ).tight(width: 300.w, height: 72.h), [ FIcon( FAssets.icons.shieldCheck, size: 18.sp, color: XfjDesignTokens.muted, ), Text( '唤醒需要验证管理员密码', style: theme.typography.sm.copyWith( color: XfjDesignTokens.muted, ), ), ].toRowSpace(space: 8.w, mainAxisSize: MainAxisSize.min), ].toColumnSpace( space: 22.h, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, ); } /// 构建欢迎副屏同源的守护插图,资源异常时保留稳定占位而不暴露底层页面。 Widget _buildIllustration() { final radius = BorderRadius.circular(24.w); return DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration( border: Border.all(color: XfjDesignTokens.line), borderRadius: radius, boxShadow: [ BoxShadow( color: XfjDesignTokens.ink.withValues(alpha: 0.08), blurRadius: 44, offset: Offset(0, 16), ), ], ), child: ClipRRect( borderRadius: radius, child: Image.asset( _illustrationAsset, key: const ValueKey('terminal-sleep-illustration'), fit: BoxFit.cover, semanticLabel: '安心守护终端的蓝绿色卡通插图', errorBuilder: (context, error, stackTrace) => ColoredBox( color: XfjDesignTokens.primarySoft, child: FIcon( FAssets.icons.shieldCheck, size: 72.sp, color: XfjDesignTokens.primary, ).center(), ), ), ), ); } /// 构建不透明锁屏与唯一的密码唤醒入口,拦截对底层页面的所有触摸。 @override Widget build(BuildContext context) { final theme = FTheme.of(context); final sleepControl = Get.find(); return PopScope( canPop: false, child: Semantics( container: true, scopesRoute: true, explicitChildNodes: true, label: '终端睡眠中', child: ColoredBox( color: XfjDesignTokens.canvas, child: SafeArea( child: [ _buildBrandHeader(theme), [ _buildSleepSummary(context, theme, sleepControl) .alignLeft() .expanded(flex: 9), _buildIllustration() .aspectRatio(aspectRatio: 4 / 3) .center() .expanded(flex: 11), ] .toRowSpace( space: 64.w, crossAxisAlignment: CrossAxisAlignment.stretch, ) .padding(horizontal: 64.w, vertical: 32.h) .expanded(), ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), ), ), ), ); } } /// 顶栏设置与退出使用的固定尺寸图标按钮,完整命令名称通过 Tooltip 和语义暴露。 class TerminalHeaderIconButton extends StatelessWidget { /// 创建一个固定 48dp 的顶栏命令按钮。 const TerminalHeaderIconButton({ super.key, required this.tooltip, required this.onTap, required this.icon, this.danger = false, }); /// 悬停和无障碍读屏使用的完整命令名称。 final String tooltip; /// 命令使用的 Forui SVG 图标。 final SvgAsset icon; /// 点击后执行的设置跳转或退出登录命令。 final VoidCallback onTap; /// 是否使用危险操作颜色,退出登录时为真。 final bool danger; /// 构建带 Tooltip、语义和危险态颜色的固定尺寸图标按钮。 @override Widget build(BuildContext context) { final foreground = danger ? XfjDesignTokens.danger : XfjDesignTokens.ink; return Semantics( button: true, label: tooltip, child: Tooltip( message: tooltip, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: SizedBox.square( dimension: 48.w, child: FIcon(icon, size: 21.w, color: foreground).center(), ).decorated( color: danger ? const Color(0xFFFFF3F2) : const Color(0xFFF7F8FA), border: Border.all( color: danger ? XfjDesignTokens.danger.withValues(alpha: 0.35) : XfjDesignTokens.line, ), borderRadius: BorderRadius.circular( XfjDesignTokens.controlRadius, ), ), ), ), ); } } /// 顶栏网络、硬件、性能和管理员状态之间的轻量垂直分隔线。 class _TerminalHeaderDivider extends StatelessWidget { /// 创建不参与交互的固定高度分隔线。 const _TerminalHeaderDivider(); /// 构建使用全局边框色的单像素分隔线。 @override Widget build(BuildContext context) => Container( width: 1, height: 28.h, margin: EdgeInsets.symmetric(horizontal: 4.w), color: XfjDesignTokens.line, ); } /// 收银页顶部只展示当前设备模式,不在页面内动态切换两套硬件 Controller。 class TerminalModeBadge extends StatelessWidget { const TerminalModeBadge({super.key, required this.label}); final String label; @override Widget build(BuildContext context) { final theme = FTheme.of(context); final sizeScale = XfjDesignTokens.buttonSizeScale; return Container( height: 44.h, padding: EdgeInsets.symmetric(horizontal: 18.w * sizeScale), alignment: Alignment.center, decoration: BoxDecoration( color: XfjDesignTokens.primarySoft, border: Border.all(color: XfjDesignTokens.primary), borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), ), child: Text( label, style: theme.typography.base.copyWith( color: XfjDesignTokens.primary, fontWeight: FontWeight.w700, ), ), ); } } class _TerminalNavItem { const _TerminalNavItem({ required this.type, required this.label, required this.icon, required this.route, }); final TerminalMenuItemType type; final String label; final SvgAsset icon; final String route; } class _TerminalNavButton extends StatelessWidget { const _TerminalNavButton({ required this.item, required this.onTap, this.selected = false, this.danger = false, }); final _TerminalNavItem item; final VoidCallback? onTap; final bool selected; final bool danger; @override Widget build(BuildContext context) { final theme = FTheme.of(context); final sizeScale = XfjDesignTokens.buttonSizeScale; final color = danger ? XfjDesignTokens.danger : selected ? XfjDesignTokens.primary : const Color(0xFF344054); return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: Container( width: double.infinity, constraints: BoxConstraints(minHeight: 60.h), padding: EdgeInsets.symmetric(horizontal: 10.w * sizeScale), decoration: BoxDecoration( color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, border: danger ? Border.all(color: XfjDesignTokens.line) : null, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), ), child: Row( children: [ FIcon(item.icon, size: 22.w, color: color), SizedBox(width: 10.w), Expanded( child: Text( item.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.typography.sm.copyWith( color: color, fontWeight: FontWeight.w600, ), ), ), ], ), ), ); } } /// 顶栏与睡眠层共用的本地时间组件。 class _TerminalClock extends StatefulWidget { /// 创建顶栏或睡眠页时钟;醒目模式会增加日期,但不改变刷新频率。 const _TerminalClock({this.prominent = false}); /// 是否使用睡眠页的大号时间与日期组合样式。 final bool prominent; /// 创建负责按分钟边界附近刷新时间的状态对象。 @override State<_TerminalClock> createState() => _TerminalClockState(); } /// 终端时钟的当前时间与周期刷新资源。 class _TerminalClockState extends State<_TerminalClock> { /// 最近一次用于渲染的本地时间。 late DateTime _now; /// 每 30 秒校准一次显示的计时器,页面销毁时必须取消。 Timer? _timer; /// 初始化当前时间并启动低频刷新。 @override void initState() { super.initState(); _now = DateTime.now(); _timer = Timer.periodic(const Duration(seconds: 30), (_) { if (mounted) setState(() => _now = DateTime.now()); }); } /// 释放时钟计时器,避免终端页面销毁后继续触发刷新。 @override void dispose() { _timer?.cancel(); super.dispose(); } /// 将 Dart 的周序号转换为终端中文日期文案,不依赖额外国际化包。 String _weekdayLabel() => switch (_now.weekday) { DateTime.monday => '星期一', DateTime.tuesday => '星期二', DateTime.wednesday => '星期三', DateTime.thursday => '星期四', DateTime.friday => '星期五', DateTime.saturday => '星期六', DateTime.sunday => '星期日', _ => '', }; /// 按使用场景构建紧凑顶栏时间或醒目睡眠时间。 @override Widget build(BuildContext context) { final hour = _now.hour.toString().padLeft(2, '0'); final minute = _now.minute.toString().padLeft(2, '0'); final theme = FTheme.of(context); final time = Text( '$hour:$minute', key: widget.prominent ? const ValueKey('terminal-sleep-clock') : null, style: (widget.prominent ? theme.typography.xl6 : theme.typography.lg) .copyWith( color: XfjDesignTokens.ink, fontWeight: widget.prominent ? FontWeight.w700 : null, fontFeatures: const [FontFeature.tabularFigures()], ), ); if (!widget.prominent) return time; return [ time, Text( '${_now.month}月${_now.day}日 ${_weekdayLabel()}', style: theme.typography.lg.copyWith( color: XfjDesignTokens.muted, fontWeight: FontWeight.w600, ), ), ].toColumnSpace( space: 8.h, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, ); } }