1299 lines
43 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|