Files
biz_xfj_cloud/lib/common/widgets/app/terminal_shell.dart
T
2026-07-24 20:48:03 +08:00

804 lines
25 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,
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<TerminalMenuControl>();
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<UserSetting>(
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<TerminalNetworkStatus> createState() => _TerminalNetworkStatusState();
}
class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
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<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;
// 短周期流量天然会在 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<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.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 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()],
),
);
}
}