Files
biz_xfj_cloud/lib/common/widgets/app/terminal_shell.dart
T
2026-10-12 18:46:25 +08:00

1299 lines
43 KiB
Dart

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<TerminalMenuControl>();
final sleepControl = Get.find<TerminalSleepControl>();
return ColoredBox(
color: XfjDesignTokens.canvas,
child: <Widget>[
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<TerminalMenuControl>();
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<UserSetting>(
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<TerminalDeviceStatusControl>();
return Obx(() {
if (!control.hasVisibleHeaderDevices) return const SizedBox.shrink();
return <Widget>[
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<TerminalNetworkStatus> createState() => _TerminalNetworkStatusState();
}
/// 网络状态组件的采样与展示状态,每三秒比较一次累计字节差值。
class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
/// 最近一次跨页面共享的原生累计流量快照。
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<void> _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<TerminalMenuControl>();
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<TerminalAdminSessionControl>();
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<TerminalSleepControl>();
return Semantics(
button: true,
label: '进入终端睡眠',
child: Tooltip(
message: '进入睡眠',
child: InkWell(
onTap: () {
sleepControl.enterSleep();
HapticFeedback.selectionClick();
},
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: <Widget>[
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<void> _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 <Widget>[
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 <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(24.w),
),
child: <Widget>[
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: <Widget>[
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),
<Widget>[
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<TerminalSleepControl>();
return PopScope(
canPop: false,
child: Semantics(
container: true,
scopesRoute: true,
explicitChildNodes: true,
label: '终端睡眠中',
child: ColoredBox(
color: XfjDesignTokens.canvas,
child: SafeArea(
child: <Widget>[
_buildBrandHeader(theme),
<Widget>[
_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 <Widget>[
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,
);
}
}