Files
pj_supermarket_cloud/lib/pages/load/view.dart
T
2026-09-10 14:32:01 +08:00

696 lines
22 KiB
Dart

import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'controller.dart';
import 'widgets/startup_step_card.dart';
/// 终端启动 Load 页:展示真实启动步骤执行结果,装载人脸库时同步显示注册进度。
class LoadPage extends GetView<LoadController> {
const LoadPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<LoadController>(
init: LoadController(),
id: 'load',
builder: (controller) {
return ColoredBox(
color: XfjDesignTokens.canvas,
child: SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(28.w, 24.h, 28.w, 16.h),
child: Column(
children: [
_buildHeader(context, controller),
SizedBox(height: 16.h),
Center(
child: TerminalSurface(
width: 1260.w,
height: 820.h,
clipBehavior: Clip.hardEdge,
child: Row(
children: [
_StartupEnvironmentPanel(controller: controller),
_StartupProgressPanel(
controller: controller,
).expanded(),
],
),
),
).expanded(),
SizedBox(height: 10.h),
_StartupFooter(controller: controller),
],
),
),
),
);
},
);
}
/// 顶栏品牌区:沿用终端外壳的 PJ 斜体墨色字标与分隔线结构。
Widget _buildHeader(BuildContext context, LoadController controller) {
final theme = FTheme.of(context);
return SizedBox(
height: 58.h,
child: Row(
children: [
Padding(
padding: EdgeInsets.fromLTRB(4.w, 4.h, 8.w, 4.h),
child: Text(
'PJ',
style: theme.typography.xl4.copyWith(
color: XfjDesignTokens.ink,
fontSize: 38.w,
height: 1.1,
fontWeight: FontWeight.w900,
fontStyle: FontStyle.italic,
letterSpacing: 0.4,
),
),
),
SizedBox(width: 20.w),
Container(width: 1, height: 34.h, color: XfjDesignTokens.line),
SizedBox(width: 20.w),
Text(
'智慧超市收银终端',
style: theme.typography.xl.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Obx(
() => _HeaderStatus(
label: _overallStatusLabel(controller),
status: _overallStatus(controller),
),
),
],
),
);
}
}
/// 左侧运行环境面板:大号状态指示与环境说明,状态跟随第一个启动步骤。
class _StartupEnvironmentPanel extends StatelessWidget {
const _StartupEnvironmentPanel({required this.controller});
final LoadController controller;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
width: 460.w,
padding: EdgeInsets.all(34.w),
decoration: const BoxDecoration(
color: XfjDesignTokens.primarySoft,
border: Border(right: BorderSide(color: XfjDesignTokens.line)),
),
child: Obx(() {
final status = controller.startupSteps.isEmpty
? AppStartupStepStatus.waiting
: controller.startupSteps.first.status;
final color = _statusColor(status);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_EnvironmentPill(
label: _environmentPillLabel(status),
status: status,
),
const Spacer(),
Center(
// 圆形指示动画独立重绘,避免步骤文案刷新导致整面板重画。
child: RepaintBoundary(
child: _EnvironmentIndicator(status: status),
),
),
const Spacer(),
Container(
width: double.infinity,
padding: EdgeInsets.all(22.w),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.82),
border: Border.all(
color: color.withValues(alpha: 0.22),
),
borderRadius: BorderRadius.circular(
XfjDesignTokens.cardRadius,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FIcon(
_environmentIcon(status),
size: 24.w,
color: color,
),
SizedBox(width: 12.w),
Text(
_environmentTitle(),
style: theme.typography.lg.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
).expanded(),
],
),
SizedBox(height: 12.h),
Text(
_environmentDetail(),
style: theme.typography.base.copyWith(
color: XfjDesignTokens.muted,
height: 1.55,
),
),
],
),
),
],
);
}),
);
}
}
/// 右侧启动进度面板:整体说明、进度条、步骤列表与收尾提示。
class _StartupProgressPanel extends StatelessWidget {
const _StartupProgressPanel({required this.controller});
final LoadController controller;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Padding(
padding: EdgeInsets.symmetric(horizontal: 40.w, vertical: 28.h),
child: Obx(() {
final failureMessage =
controller.appControl.startupFailureMessage.value;
final registrationMessage = controller.faceRegistrationMessage.value;
final steps = controller.startupSteps;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
failureMessage.isEmpty ? '正在准备收银工作台' : '终端启动未完成',
style: theme.typography.xl3.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 8.h),
Text(
failureMessage.isEmpty
? '系统正在按实际执行结果准备本地数据、收银外设与识别服务,完成后将自动进入登录页面。'
: failureMessage,
style: theme.typography.base.copyWith(
color: failureMessage.isEmpty
? XfjDesignTokens.muted
: XfjDesignTokens.danger,
height: 1.5,
),
),
],
).expanded(),
SizedBox(width: 24.w),
_ProgressCount(
current: controller.completedCount,
total: steps.length,
failed: failureMessage.isNotEmpty,
),
],
),
SizedBox(height: 18.h),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: controller.progress,
minHeight: 8.h,
color: failureMessage.isEmpty
? XfjDesignTokens.primary
: XfjDesignTokens.danger,
backgroundColor: const Color(0xFFEDEFF3),
),
),
SizedBox(height: 18.h),
ListView.separated(
primary: false,
padding: EdgeInsets.zero,
physics: const ClampingScrollPhysics(),
itemCount: steps.length,
separatorBuilder: (_, __) => SizedBox(height: 8.h),
itemBuilder: (_, index) {
return StartupStepCard(
icon: _stepIconAt(index),
state: steps[index],
);
},
).expanded(),
SizedBox(height: 12.h),
if (registrationMessage.isNotEmpty) ...[
_RegistrationMessage(message: registrationMessage),
SizedBox(height: 8.h),
],
_StartupNotice(
failed: failureMessage.isNotEmpty,
onRetry: controller.retry,
),
],
);
}),
);
}
}
/// 人脸库装载实时进度行:把最耗时步骤的原生注册进度透出给收银员。
class _RegistrationMessage extends StatelessWidget {
const _RegistrationMessage({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
height: 44.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
SizedBox(
width: 16.w,
height: 16.w,
child: const CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10.w),
Text(
message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w600,
),
).expanded(),
],
),
);
}
}
/// 收尾提示行:正常时展示版本,失败时提供重新检查入口。
class _StartupNotice extends StatelessWidget {
const _StartupNotice({required this.failed, required this.onRetry});
final bool failed;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
constraints: BoxConstraints(minHeight: 56.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h),
decoration: BoxDecoration(
color: failed ? const Color(0xFFFFF3F2) : const Color(0xFFFAFBFC),
border: Border.all(
color: failed
? XfjDesignTokens.danger.withValues(alpha: 0.28)
: XfjDesignTokens.line,
),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
FIcon(
failed ? FAssets.icons.circleAlert : FAssets.icons.shieldCheck,
size: 20.w,
color: failed ? XfjDesignTokens.danger : XfjDesignTokens.success,
),
SizedBox(width: 10.w),
Text(
failed
? '可重试初始化;如果仍失败,请检查本地数据库、权限和设备配置。'
: '启动过程按真实服务状态自动完成,请勿关闭应用或断开设备电源。',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
),
).expanded(),
SizedBox(width: 12.w),
if (failed)
FButton(
style: FButtonStyle.outline,
label: const Text('重新检查'),
onPress: () => onRetry(),
).height(48.h)
else
Text(
'版本 ${AppRuntimeTool.apkVersionLabel}',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
/// 左侧面板中央的大号环境状态指示。
class _EnvironmentIndicator extends StatelessWidget {
const _EnvironmentIndicator({required this.status});
final AppStartupStepStatus status;
@override
Widget build(BuildContext context) {
final color = _statusColor(status);
return SizedBox(
width: 270.w,
height: 270.w,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 250.w,
height: 250.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.44),
border: Border.all(color: color.withValues(alpha: 0.08)),
),
),
SizedBox(
width: 224.w,
height: 224.w,
child: CircularProgressIndicator(
value: status == AppStartupStepStatus.failure ? 1 : null,
strokeWidth: 4.w,
color: color,
backgroundColor: color.withValues(alpha: 0.12),
),
),
Container(
width: 154.w,
height: 154.w,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: color.withValues(alpha: 0.16)),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.12),
blurRadius: 36,
offset: const Offset(0, 14),
),
],
),
child: FIcon(
_environmentIcon(status),
size: 72.w,
color: color,
),
),
],
),
);
}
}
/// 环境面板左上角的状态胶囊。
class _EnvironmentPill extends StatelessWidget {
const _EnvironmentPill({required this.label, required this.status});
final String label;
final AppStartupStepStatus status;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final color = _statusColor(status);
return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.88),
border: Border.all(color: color.withValues(alpha: 0.25)),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FIcon(
status == AppStartupStepStatus.failure
? FAssets.icons.circleAlert
: status == AppStartupStepStatus.running
? FAssets.icons.refreshCw
: FAssets.icons.check,
size: 19.w,
color: color,
),
SizedBox(width: 8.w),
Text(
label,
style: theme.typography.sm.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// 顶栏右侧的整体状态胶囊。
class _HeaderStatus extends StatelessWidget {
const _HeaderStatus({required this.label, required this.status});
final String label;
final AppStartupStepStatus status;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final color = _statusColor(status);
return Container(
height: 40.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
border: Border.all(color: color.withValues(alpha: 0.24)),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: 8.w),
Text(
label,
style: theme.typography.sm.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// 进度面板右上角的“已完成 / 总数”计数胶囊。
class _ProgressCount extends StatelessWidget {
const _ProgressCount({
required this.current,
required this.total,
required this.failed,
});
final int current;
final int total;
final bool failed;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
constraints: BoxConstraints(minWidth: 72.w, minHeight: 54.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: BoxDecoration(
color: failed ? const Color(0xFFFFF3F2) : XfjDesignTokens.control,
borderRadius: BorderRadius.circular(999),
),
child: Center(
child: Text(
'$current / $total',
style: theme.typography.sm.copyWith(
color: failed ? XfjDesignTokens.danger : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
}
/// 底部状态行。
class _StartupFooter extends StatelessWidget {
const _StartupFooter({required this.controller});
final LoadController controller;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Obx(() {
final status = _overallStatus(controller);
final color = _statusColor(status);
return SizedBox(
height: 32.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: 8.w),
Text(
_footerLabel(controller),
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
),
),
],
),
);
});
}
}
/// Load 页启动步骤图标映射,顺序与 AppControl.startupSteps 保持一致。
final _stepIcons = <SvgAsset>[
FAssets.icons.shieldCheck,
FAssets.icons.database,
FAssets.icons.printer,
FAssets.icons.scanFace,
FAssets.icons.settings,
];
/// 按启动步骤索引取得图标;后续增加步骤但遗漏映射时使用通用设置图标避免页面越界崩溃。
SvgAsset _stepIconAt(int index) {
if (index >= 0 && index < _stepIcons.length) return _stepIcons[index];
return FAssets.icons.settings;
}
AppStartupStepStatus _overallStatus(LoadController controller) {
if (controller.appControl.startupFailureMessage.value.isNotEmpty) {
return AppStartupStepStatus.failure;
}
if (controller.appControl.startupFinished.value) {
return controller.startupSteps.any(
(item) => item.status == AppStartupStepStatus.warning)
? AppStartupStepStatus.warning
: AppStartupStepStatus.success;
}
return AppStartupStepStatus.running;
}
String _overallStatusLabel(LoadController controller) {
return switch (_overallStatus(controller)) {
AppStartupStepStatus.running => '正在启动',
AppStartupStepStatus.success => '启动完成',
AppStartupStepStatus.warning => '受限启动',
AppStartupStepStatus.failure => '启动失败',
AppStartupStepStatus.waiting => '等待启动',
};
}
Color _statusColor(AppStartupStepStatus status) {
return switch (status) {
AppStartupStepStatus.running => XfjDesignTokens.primary,
AppStartupStepStatus.success => XfjDesignTokens.success,
AppStartupStepStatus.warning => XfjDesignTokens.warning,
AppStartupStepStatus.failure => XfjDesignTokens.danger,
AppStartupStepStatus.waiting => XfjDesignTokens.muted,
};
}
String _environmentPillLabel(AppStartupStepStatus status) {
return switch (status) {
AppStartupStepStatus.running => '正在检测运行环境',
AppStartupStepStatus.success => '运行环境检查完成',
AppStartupStepStatus.warning => '运行环境受限可用',
AppStartupStepStatus.failure => '运行环境检查失败',
AppStartupStepStatus.waiting => '等待环境检查',
};
}
SvgAsset _environmentIcon(AppStartupStepStatus status) {
if (status == AppStartupStepStatus.failure) {
return FAssets.icons.circleAlert;
}
if (AppRuntimeTool.useManualHardwareInput) {
return FAssets.icons.keyboard;
}
return FAssets.icons.monitor;
}
String _environmentTitle() {
if (AppRuntimeTool.isEmulator) return '虚拟机开发环境';
if (AppRuntimeTool.useManualHardwareInput) return '手动硬件输入模式';
return '实体收银终端';
}
String _environmentDetail() {
if (AppRuntimeTool.isEmulator) {
return '当前运行在虚拟机,外设与人脸识别将以手动输入方式替代,便于开发调试。';
}
if (AppRuntimeTool.useManualHardwareInput) {
return '已开启手动硬件输入,打印机、读卡器、电子秤等实体外设将在业务使用时按需连接。';
}
return '已检测到实体设备环境,收银外设与人脸识别库将在启动阶段完成装载。';
}
String _footerLabel(LoadController controller) {
if (controller.appControl.startupFailureMessage.value.isNotEmpty) {
return 'PJ 智慧超市收银终端 · 本地服务启动失败';
}
if (controller.appControl.startupFinished.value) {
return 'PJ 智慧超市收银终端 · 本地服务运行中';
}
return 'PJ 智慧超市收银终端 · 正在启动本地服务';
}