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, 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.statistics => TerminalMenuItemType.statistics, TerminalNavType.card => TerminalMenuItemType.card, TerminalNavType.settings => TerminalMenuItemType.settings, }; } /// 对应 React 原型 `TerminalLayout` 的 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(); 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(), ], ), ), ); } } 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); 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: [ const TerminalBrand(), 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(), const TerminalDeviceStatus(), const _TerminalAdminLockStatus(), ], ), ), SizedBox(width: 24.w), FutureBuilder( future: AppSettingTool.getUserSetting(), builder: (context, snapshot) => Text( snapshot.data?.username ?? '收银员', 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), TerminalSoftButton( label: '设置', icon: FAssets.icons.settings, onTap: () => TerminalNavigationTool.open(RouteNames.setting), ), SizedBox(width: 8.w), TerminalSoftButton( label: '退出', icon: FAssets.icons.logOut, danger: true, onTap: TerminalNavigationTool.logout, ), ], ), ), ), ), ), ); } } /// 顶栏品牌字标需要保持固定比例,不能跟随全局字号缩放无限放大。 /// /// 这里使用受约束的 `FittedBox` 保留 React 原型中的斜体、紧字距效果,同时避免 /// 大字号设置把品牌挤出 80 高的终端顶栏。 class TerminalBrand extends StatelessWidget { const TerminalBrand({super.key}); @override Widget build(BuildContext context) { final theme = FTheme.of(context); return 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, ), ), ), ); } } class TerminalNetworkStatus extends StatefulWidget { const TerminalNetworkStatus({super.key}); @override State createState() => _TerminalNetworkStatusState(); } class _TerminalNetworkStatusState extends State { static AppNetworkSnapshot? _lastSnapshot; static DateTime? _lastSampleTime; static String _cachedDownloadSpeed = '0 B/s'; static String _cachedUploadSpeed = '0 B/s'; static double _smoothedDownload = 0; static double _smoothedUpload = 0; Timer? _timer; String _networkType = '网络'; String _downloadSpeed = '0 B/s'; String _uploadSpeed = '0 B/s'; bool _connected = false; @override void initState() { super.initState(); _refresh(); _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); } @override void dispose() { _timer?.cancel(); super.dispose(); } 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; // 短周期流量天然会在 0 和峰值间跳变,平滑后更适合终端状态栏阅读。 _smoothedDownload = _smoothSpeed(_smoothedDownload, download); _smoothedUpload = _smoothSpeed(_smoothedUpload, upload); _cachedDownloadSpeed = _formatSpeed(_smoothedDownload); _cachedUploadSpeed = _formatSpeed(_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: 270.w, constraints: BoxConstraints(minHeight: 48.h), padding: EdgeInsets.symmetric(horizontal: 12.w), child: Row( mainAxisSize: MainAxisSize.min, children: [ FIcon( _networkIcon(_networkType), size: 19.w, color: _connected ? XfjDesignTokens.ink : XfjDesignTokens.warning, ), SizedBox(width: 7.w), Container( width: 8.w, height: 8.w, decoration: BoxDecoration( shape: BoxShape.circle, color: _connected ? XfjDesignTokens.success : XfjDesignTokens.warning, ), ), SizedBox(width: 7.w), SizedBox( width: 100.w, child: Text( _connected ? _networkType : '网络离线', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink), ), ), SizedBox(width: 8.w), Expanded( child: Text( '↓$_downloadSpeed\n↑$_uploadSpeed', maxLines: 2, 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()], ), ), ), ], ), ); } String _networkLabel(String type) => switch (type) { 'ethernet' => '网线', 'wifi' => 'WiFi', '2g' => '2G', '3g' => '3G', '4g' => '4G', '5g' => '5G', 'mobile' => '移动网络', _ => '网络', }; SvgAsset _networkIcon(String label) { if (label == 'WiFi') return FAssets.icons.wifi; if (label == '网线') return FAssets.icons.network; return FAssets.icons.signal; } String _formatSpeed(double bytesPerSecond) { final speed = bytesPerSecond < 0 ? 0 : bytesPerSecond; if (speed >= 1024 * 1024) { return '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'; } if (speed >= 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s'; return '${speed.toStringAsFixed(0)} B/s'; } double _smoothSpeed(double previous, double current) { final normalized = current < 0 ? 0.0 : current; if (previous == 0) return normalized; return previous * 0.65 + normalized * 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.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 TerminalSoftButton extends StatelessWidget { const TerminalSoftButton({ super.key, required this.label, required this.onTap, this.icon, this.danger = false, }); final String label; final SvgAsset? icon; final VoidCallback onTap; final bool danger; @override Widget build(BuildContext context) { final theme = FTheme.of(context); final sizeScale = XfjDesignTokens.buttonSizeScale; final foreground = danger ? XfjDesignTokens.danger : XfjDesignTokens.ink; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), child: Container( constraints: BoxConstraints(minHeight: 48.h), padding: EdgeInsets.symmetric(horizontal: 16.w * sizeScale), decoration: BoxDecoration( 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), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ FIcon(icon!, size: 21.w, color: foreground), SizedBox(width: 8.w), ], Text( label, style: theme.typography.base.copyWith( color: foreground, fontWeight: FontWeight.w600, ), ), ], ), ), ); } } /// 收银页顶部只展示当前设备模式,不在页面内动态切换两套硬件 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(); @override State<_TerminalClock> createState() => _TerminalClockState(); } class _TerminalClockState extends State<_TerminalClock> { late DateTime _now; 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(); } @override Widget build(BuildContext context) { final hour = _now.hour.toString().padLeft(2, '0'); final minute = _now.minute.toString().padLeft(2, '0'); return Text( '$hour:$minute', style: FTheme.of(context).typography.lg.copyWith( color: XfjDesignTokens.ink, fontFeatures: const [FontFeature.tabularFigures()], ), ); } }