Files
pj_supermarket_cloud/lib/pages/terminal/view.dart
T
2026-09-10 13:51:51 +08:00

346 lines
13 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/pages/canteen_order_total/controller.dart';
import 'package:pj_supermarket_cloud/pages/canteen_order_total/view.dart';
import 'package:pj_supermarket_cloud/pages/canteen_product/controller.dart';
import 'package:pj_supermarket_cloud/pages/canteen_product/view.dart';
import 'package:pj_supermarket_cloud/pages/canteen_reserve/controller.dart';
import 'package:pj_supermarket_cloud/pages/canteen_reserve/view.dart';
import 'package:pj_supermarket_cloud/pages/card_active/controller.dart';
import 'package:pj_supermarket_cloud/pages/card_active/view.dart';
import 'package:pj_supermarket_cloud/pages/order_history/controller.dart';
import 'package:pj_supermarket_cloud/pages/order_history/view.dart';
import 'package:pj_supermarket_cloud/pages/order_history_detail/controller.dart';
import 'package:pj_supermarket_cloud/pages/order_history_detail/view.dart';
import 'package:pj_supermarket_cloud/pages/setting/controller.dart';
import 'package:pj_supermarket_cloud/pages/setting/face_manage_controller.dart';
import 'package:pj_supermarket_cloud/pages/setting/face_setting_controller.dart';
import 'package:pj_supermarket_cloud/pages/setting/offline_user_controller.dart';
import 'package:pj_supermarket_cloud/pages/setting/sync_logs/controller.dart';
import 'package:pj_supermarket_cloud/pages/setting/sync_logs/view.dart';
import 'package:pj_supermarket_cloud/pages/setting/view.dart';
import 'package:pj_supermarket_cloud/pages/supermarket_order/controller.dart';
import 'package:pj_supermarket_cloud/pages/supermarket_order/view.dart';
import 'package:pj_supermarket_cloud/pages/take_food/controller.dart';
import 'package:pj_supermarket_cloud/pages/take_food/view.dart';
/// 终端一级路由,仅创建一次公共外壳,业务页面通过内层 Navigator 切换。
///
/// 本项目业务入口统一放在 panel 主菜单页,外壳只保留顶部状态栏;
/// 内层路由继续使用独立 Binding,保证 Controller、扫码监听、硬件资源和副屏回调
/// 仍跟随各业务页面创建与释放,布局复用不能改变原有生命周期边界。
class TerminalPage extends StatefulWidget {
/// 创建承载终端顶栏与独立业务导航栈的页面。
const TerminalPage({super.key});
/// 创建持有实例级 Navigator Key 与路由同步状态的页面状态。
@override
State<TerminalPage> createState() => _TerminalPageState();
}
/// 终端外壳的路由状态,确保新旧终端页重叠退场时各自持有独立 Navigator Key。
class _TerminalPageState extends State<TerminalPage> {
/// 当前页面实例的导航所有权标识,用于避免旧实例销毁时误解绑新实例。
final Object _navigatorOwner = Object();
/// 当前页面实例独占的终端工作区 Navigator Key。
late final GlobalKey<NavigatorState> _navigatorKey;
/// 终端首次创建时 panel 通过 Get.arguments 传入的二级路由。
late final String? _requestedRoute;
/// 当前终端页面首次创建时应装载的二级路由。
late final String _initialRoute;
/// 当前实际显示的终端二级路由配置。
late _TerminalRouteConfig _currentConfig;
/// 是否已完成初始路由解析;解析前仅展示轻量加载占位。
bool _ready = false;
/// 控制路由切换遮罩最短展示时长的计时器。
Timer? _routeLoadingTimer;
/// 是否显示阻止重复操作的短时路由切换遮罩。
bool _routeLoading = false;
/// 是否已经安排 Navigator 路由状态的帧后同步。
bool _routeStateSyncScheduled = false;
/// 同一帧内最后一次收到的终端二级路由名称。
String? _pendingRouteName;
/// 登记实例级导航 Key,并解析 panel 请求或按设备类型选择的初始工作区。
@override
void initState() {
super.initState();
_navigatorKey = TerminalNavigationTool.attachNavigator(_navigatorOwner);
// GetX 的路由参数只在路由装载初期可靠,必须在任何 await 之前同步取出。
_requestedRoute = Get.arguments is String ? Get.arguments as String : null;
unawaited(_resolveInitialRoute());
}
/// 解析初始二级路由:panel 指定优先,未指定时按系统类型装载对应收银页。
Future<void> _resolveInitialRoute() async {
var initial = _requestedRoute ?? '';
if (!_terminalRoutes.containsKey(initial)) {
final sysSetting = await AppSettingTool.getSysSetting();
initial = sysSetting.sysType == SysType.SUPERMARKET
? RouteNames.supermarketProduct
: RouteNames.canteenProduct;
}
if (!mounted) return;
setState(() {
_initialRoute = initial;
_currentConfig = _terminalRoutes[initial]!;
_ready = true;
});
}
/// 释放计时器和当前实例的导航登记。
@override
void dispose() {
_routeLoadingTimer?.cancel();
TerminalNavigationTool.detachNavigator(_navigatorOwner);
super.dispose();
}
/// 构建常驻终端外壳和由实例级 Key 管理的业务 Navigator。
@override
Widget build(BuildContext context) {
if (!_ready) {
return const ColoredBox(
color: XfjDesignTokens.canvas,
child: Center(
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 3,
color: XfjDesignTokens.primary,
),
),
),
);
}
return TerminalShell(
title: _currentConfig.title,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: XfjDesignTokens.canvas,
child: Navigator(
key: _navigatorKey,
initialRoute: _initialRoute,
// 内层路由名称包含 /terminal 前缀,默认生成逻辑会额外创建 / 和
// /terminal 中间路由;这里只创建目标页,避免误装载收银页及其 Binding。
onGenerateInitialRoutes: (_, initialRoute) => [
_generateRoute(RouteSettings(name: initialRoute)),
],
observers: [_TerminalRouteObserver(_handleRouteChanged)],
onGenerateRoute: _generateRoute,
),
),
if (_routeLoading) const _TerminalRouteLoading(),
],
),
);
}
/// 根据终端二级路由配置创建带独立 Binding 的无动画页面路由。
Route<dynamic> _generateRoute(RouteSettings settings) {
final config = _terminalRoutes[settings.name] ??
_terminalRoutes[RouteNames.supermarketProduct]!;
return GetPageRoute<void>(
routeName: config.name,
settings: RouteSettings(name: config.name, arguments: settings.arguments),
page: config.page,
binding: config.binding,
transition: Transition.noTransition,
transitionDuration: Duration.zero,
);
}
/// 接收 Navigator 的真实路由变化,并在帧后刷新标题与切换遮罩。
void _handleRouteChanged(String? routeName) {
TerminalNavigationTool.handleRouteChanged(_navigatorOwner, routeName);
if (routeName == null || !_terminalRoutes.containsKey(routeName)) return;
_pendingRouteName = routeName;
if (_routeStateSyncScheduled) return;
_routeStateSyncScheduled = true;
// Navigator 会在自身构建和历史栈刷新期间通知 Observer,父页面状态必须延后更新。
WidgetsBinding.instance.addPostFrameCallback((_) {
_routeStateSyncScheduled = false;
if (!mounted) return;
final pendingRouteName = _pendingRouteName;
_pendingRouteName = null;
final config = _terminalRoutes[pendingRouteName];
if (config == null || config == _currentConfig) return;
_routeLoadingTimer?.cancel();
setState(() {
_currentConfig = config;
_routeLoading = true;
});
// Loading 只遮住二级路由重建的短暂帧,页面自己的接口加载状态仍由 Controller 负责。
_routeLoadingTimer = Timer(const Duration(milliseconds: 180), () {
if (mounted) setState(() => _routeLoading = false);
});
});
}
}
class _TerminalRouteLoading extends StatelessWidget {
const _TerminalRouteLoading();
@override
Widget build(BuildContext context) {
return const AbsorbPointer(
child: ColoredBox(
color: XfjDesignTokens.canvas,
child: Center(
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(
strokeWidth: 3,
color: XfjDesignTokens.primary,
),
),
),
),
);
}
}
class _TerminalRouteObserver extends NavigatorObserver {
_TerminalRouteObserver(this.onChanged);
final ValueChanged<String?> onChanged;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
onChanged(route.settings.name);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
onChanged(newRoute?.settings.name);
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
onChanged(previousRoute?.settings.name);
}
}
class _TerminalRouteConfig {
const _TerminalRouteConfig({
required this.name,
required this.title,
required this.page,
this.binding,
});
final String name;
final String title;
final GetPageBuilder page;
final Bindings? binding;
}
final Map<String, _TerminalRouteConfig> _terminalRoutes = {
RouteNames.supermarketProduct: _TerminalRouteConfig(
name: RouteNames.supermarketProduct,
title: '超市收银',
page: () => const SupermarketOrderPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => SelectPayTypeDialogController(), fenix: true);
Get.lazyPut(() => SteelyardController(), fenix: true);
Get.lazyPut(() => SupermarketOrderController(), fenix: true);
}),
),
RouteNames.canteenProduct: _TerminalRouteConfig(
name: RouteNames.canteenProduct,
title: '点餐收银',
page: () => const CanteenProductPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => SelectPayTypeDialogController(), fenix: true);
Get.lazyPut(() => CanteenProductController(), fenix: true);
}),
),
RouteNames.canteenReserve: _TerminalRouteConfig(
name: RouteNames.canteenReserve,
title: '预订管理',
page: () => const CanteenReservePage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => CanteenReserveController(), fenix: true);
}),
),
RouteNames.takeFood: _TerminalRouteConfig(
name: RouteNames.takeFood,
title: '核销',
page: () => const TakeFoodPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => TakeFoodController(), fenix: true);
}),
),
RouteNames.canteenOrderTotal: _TerminalRouteConfig(
name: RouteNames.canteenOrderTotal,
title: '交易统计',
page: () => const CanteenOrderTotalPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => CanteenOrderTotalController(), fenix: true);
}),
),
RouteNames.orderHistory: _TerminalRouteConfig(
name: RouteNames.orderHistory,
title: '交易查询',
page: () => const OrderHistoryPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => OrderHistoryController(), fenix: true);
}),
),
RouteNames.orderHistoryDetail: _TerminalRouteConfig(
name: RouteNames.orderHistoryDetail,
title: '交易详情',
page: () => const OrderHistoryDetailPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => OrderHistoryController(), fenix: true);
Get.lazyPut(() => OrderHistoryDetailController(), fenix: true);
}),
),
RouteNames.cardActive: _TerminalRouteConfig(
name: RouteNames.cardActive,
title: '开卡管理',
page: () => const CardActivePage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => SettingController(), fenix: true);
Get.lazyPut(() => CardActiveController(), fenix: true);
}),
),
RouteNames.setting: _TerminalRouteConfig(
name: RouteNames.setting,
title: '设置',
page: () => const SettingPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => SettingController(), fenix: true);
Get.lazyPut(() => FaceManageController(), fenix: true);
Get.lazyPut(() => FaceSettingController(), fenix: true);
Get.lazyPut(() => OfflineUserController(), fenix: true);
}),
),
RouteNames.settingSyncLogs: _TerminalRouteConfig(
name: RouteNames.settingSyncLogs,
title: '定时任务日志',
page: () => const ScheduledTaskLogsPage(),
binding: BindingsBuilder(() {
// 任务类型由页面首帧从 RouteSettings 注入后再触发首查。
Get.lazyPut(() => ScheduledTaskLogsController(), fenix: true);
}),
),
};