834 lines
26 KiB
Dart
834 lines
26 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);
|
|
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(),
|
|
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, 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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()],
|
|
),
|
|
);
|
|
}
|
|
}
|