Files
biz_xfj_cloud/lib/pages/setting/widgets/setting_baseInfo.dart
T
dzc a0ad3a06ff feat(kiosk): add android kiosk terminal mode support
完整实现收银终端的Android专用Kiosk模式支持,包括:
- 添加原生设备管理与Kiosk模式核心逻辑
- 新增Flutter设置页的Kiosk相关配置与控制UI
- 更新README添加部署命令
- 补充Android权限与组件声明
2026-07-24 09:53:51 +08:00

612 lines
21 KiB
Dart

import 'package:common_utils/common_utils.dart';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import '../controller.dart';
import 'setting_components.dart';
Future<void> _bindServiceDialog(BuildContext context, SysSetting sys) async {
final theme = FTheme.of(context);
final uniqueCodeController = TextEditingController(text: sys.serviceId ?? '');
await showAdaptiveDialog(
context: context,
builder: (dialogContext) => FDialog(
direction: Axis.horizontal,
title: Text('绑定唯一编号', style: theme.typography.xl2),
body: FTextField(
textInputAction: TextInputAction.none,
controller: uniqueCodeController,
hint: '请输入设备唯一编号',
maxLines: 1,
).width(context.width * 0.5).marginSymmetric(vertical: 20.h),
actions: [
FButton(
style: FButtonStyle.outline,
label: Text('取消', style: theme.typography.base),
onPress: () => Navigator.of(dialogContext).pop(),
),
FButton(
label: Text(
'绑定',
style: theme.typography.base.copyWith(color: Colors.white),
),
onPress: () async {
final serverId = int.tryParse(uniqueCodeController.text.trim());
if (serverId == null) {
Toast.error(msg: '请输入正确的设备唯一编号');
return;
}
if (!AppNetworkTool.isAutoNetworkMsg()) return;
EasyLoading.show(
status: '绑定中...',
maskType: EasyLoadingMaskType.black,
);
try {
final success = await AppSettingTool.bindSys(serverId);
if (!success) {
Toast.error(msg: '绑定失败,请核对唯一编号');
return;
}
final controller = Get.find<SettingController>();
await controller.getSetting();
await AppMqTool.mqRefresh();
Toast.success(msg: '绑定成功');
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
} catch (e, stack) {
logger.e('|绑定服务端设备信息error:|$e|stack:|$stack|');
Toast.error(msg: '绑定失败,请稍后重试');
} finally {
EasyLoading.dismiss();
}
},
),
],
),
);
uniqueCodeController.dispose();
}
Widget settingBaseInfo(BuildContext context) {
final controller = Get.find<SettingController>();
return Obx(() {
final sys = controller.sysSetting.value;
if (sys == null) return const SettingEmpty(message: '暂无终端信息');
return SingleChildScrollView(
child: Column(
children: [
SettingGroup(
title: '基本信息',
child: Column(
children: [
_copyRow(
icon: FAssets.icons.store,
label: '设备类型',
value: sys.sysType.cnStr,
),
_copyRow(
icon: FAssets.icons.hardDrive,
label: '设备名称',
value: sys.serviceName,
),
_copyRow(
icon: FAssets.icons.idCard,
label: 'Android ID',
value: sys.androidId,
masked: true,
),
SettingInfoRow(
icon: FAssets.icons.idCard,
label: '唯一编号',
value: _maskIdentifier(sys.serviceId),
actionLabel:
ObjectUtil.isEmpty(sys.serviceId) ? '立即绑定' : '重新绑定',
onTap: () => _bindServiceDialog(context, sys),
),
_copyRow(
icon: FAssets.icons.store,
label: '商户编号',
value: sys.serviceMerchantId,
masked: true,
),
_copyRow(
icon: FAssets.icons.building,
label: '商户部门编号',
value: sys.merchantDeptId,
masked: true,
showDivider: false,
),
],
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: '运行环境',
child: Column(
children: [
SettingInfoRow(
icon: FAssets.icons.monitor,
label: '当前设备',
subtitle: '检测到 ${AppRuntimeTool.displayCount} 个显示屏',
value: AppRuntimeTool.isEmulator
? 'Android 虚拟机'
: 'Android 真实设备',
),
SettingInfoRow(
icon: controller.kioskStatus.value.available
? FAssets.icons.shieldCheck
: FAssets.icons.circleAlert,
label: '专用设备 / Kiosk',
subtitle: controller.kioskStatus.value.description,
value: controller.kioskStatus.value.statusLabel,
trailing: FSwitch(
semanticLabel: '进入 App 自动开启 Kiosk',
value: controller.kioskStatus.value.autoEnterEnabled,
enabled: controller.kioskStatus.value.available,
onChange: controller.setKioskAutoEnterEnabled,
),
),
SettingInfoRow(
icon: FAssets.icons.keyboard,
label: '设置为主控',
subtitle: AppRuntimeTool.isEmulator
? '虚拟机强制使用人工输入,不连接扫码、饭卡和人脸硬件'
: '开启后扫码、饭卡读写和人脸识别由操作员输入结果',
showDivider: false,
trailing: FSwitch(
semanticLabel: '设置为主控手动硬件输入模式',
value: controller.manualHardwareInput.value,
enabled: !AppRuntimeTool.isEmulator,
onChange: controller.setManualHardwareInput,
),
),
],
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: '主题样式',
child: Column(
children: [
SettingSegmentedSelector<SettingGlobalFontSize>(
label: '字体大小',
value: sys.globalFontSize ?? SettingGlobalFontSize.xl3,
options: const {
SettingGlobalFontSize.xl2: '紧凑',
SettingGlobalFontSize.xl3: '标准',
SettingGlobalFontSize.xl4: '大号',
},
onChanged: controller.setGlobalFontSize,
),
SettingSegmentedSelector<SettingButtonRadius>(
label: '按钮圆角',
value: sys.buttonRadius,
options: const {
SettingButtonRadius.compact: '紧凑',
SettingButtonRadius.standard: '标准',
SettingButtonRadius.large: '大号',
},
onChanged: controller.setButtonRadius,
),
SettingSegmentedSelector<SettingCardRadius>(
label: '卡片圆角',
value: sys.cardRadius,
options: const {
SettingCardRadius.compact: '紧凑',
SettingCardRadius.standard: '标准',
SettingCardRadius.large: '大号',
},
onChanged: controller.setCardRadius,
),
SettingSegmentedSelector<SettingButtonSize>(
label: '按钮大小',
value: sys.buttonSize,
options: const {
SettingButtonSize.compact: '紧凑',
SettingButtonSize.standard: '标准',
SettingButtonSize.large: '大号',
},
onChanged: controller.setButtonSize,
),
const _TerminalMenuEditor(),
],
),
),
],
),
);
});
}
/// 左侧菜单只允许从手柄直接拖动,并提供上下移动按钮作为精确操作和无障碍替代。
class _TerminalMenuEditor extends StatelessWidget {
const _TerminalMenuEditor();
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final menuController = Get.find<TerminalMenuControl>();
return Container(
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'左侧菜单',
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4.h),
Text(
'拖动手柄调整顺序,退出固定在底部;关闭后仅隐藏左侧入口。',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
),
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w * XfjDesignTokens.buttonSizeScale,
vertical: 8.h * XfjDesignTokens.buttonSizeScale,
),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Row(
children: [
FIcon(
FAssets.icons.gripVertical,
color: XfjDesignTokens.primary,
size: 18.sp,
),
SizedBox(width: 8.w),
Text(
'拖动手柄',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
SizedBox(width: 8.w),
FButton(
style: FButtonStyle.outline,
prefix: FIcon(
FAssets.icons.rotateCcw,
color: XfjDesignTokens.ink,
size: 18.sp,
),
label: Text(
'还原',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
onPress: () {
menuController.reset();
Toast.success(msg: '左侧菜单已还原');
},
),
],
),
],
),
SizedBox(height: 12.h),
Obx(
() => ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: menuController.menuOrder.length,
onReorder: menuController.reorder,
proxyDecorator: (child, _, animation) => AnimatedBuilder(
animation: animation,
builder: (context, child) => Material(
color: Colors.transparent,
elevation: 6 * animation.value,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
child: child,
),
child: child,
),
itemBuilder: (context, index) {
final item = menuController.menuOrder[index];
return Padding(
key: ValueKey(item.code),
padding: EdgeInsets.only(
bottom: index == menuController.menuOrder.length - 1
? 0
: XfjDesignTokens.cardGap.h,
),
child: Obx(
() => _TerminalMenuRow(
item: item,
index: index,
visible: menuController.isVisible(item),
reorderable: true,
visibilityEnabled: true,
onVisibleChanged: (visible) =>
menuController.setVisible(item, visible),
onMoveUp: index == 0
? null
: () => menuController.reorder(index, index - 1),
onMoveDown: index == menuController.menuOrder.length - 1
? null
: () => menuController.reorder(index, index + 2),
),
),
);
},
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
Obx(
() => _TerminalMenuRow(
item: TerminalMenuItemType.logout,
index: menuController.menuOrder.length,
visible: menuController.isVisible(TerminalMenuItemType.logout),
reorderable: false,
visibilityEnabled: true,
onVisibleChanged: (visible) => menuController.setVisible(
TerminalMenuItemType.logout,
visible,
),
),
),
],
),
);
}
}
class _TerminalMenuRow extends StatelessWidget {
const _TerminalMenuRow({
required this.item,
required this.index,
required this.visible,
required this.reorderable,
required this.visibilityEnabled,
required this.onVisibleChanged,
this.onMoveUp,
this.onMoveDown,
});
final TerminalMenuItemType item;
final int index;
final bool visible;
final bool reorderable;
final bool visibilityEnabled;
final ValueChanged<bool> onVisibleChanged;
final VoidCallback? onMoveUp;
final VoidCallback? onMoveDown;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final leadingHandle = SizedBox(
width: 48.w,
height: 48.h,
child: FIcon(
reorderable ? FAssets.icons.gripVertical : FAssets.icons.lock,
color: reorderable ? XfjDesignTokens.muted : XfjDesignTokens.primary,
size: 22.sp,
).center(),
);
return Container(
constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h),
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: visible ? XfjDesignTokens.surface : XfjDesignTokens.control,
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
if (reorderable)
ReorderableDragStartListener(index: index, child: leadingHandle)
else
leadingHandle,
SizedBox(width: 8.w),
if (reorderable) ...[
Container(
width: 32.w,
height: 32.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: visible
? XfjDesignTokens.primarySoft
: XfjDesignTokens.surface,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Text(
'${index + 1}',
style: theme.typography.sm.copyWith(
color:
visible ? XfjDesignTokens.primary : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
SizedBox(width: 12.w),
FIcon(
item.icon,
color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
size: 22.sp,
),
SizedBox(width: 10.w),
Expanded(
child: Text(
item.title,
style: theme.typography.base.copyWith(
color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
if (reorderable) ...[
_MenuMoveButton(
semanticLabel: '${item.title}上移',
icon: FAssets.icons.chevronUp,
onTap: onMoveUp,
),
SizedBox(width: 8.w),
_MenuMoveButton(
semanticLabel: '${item.title}下移',
icon: FAssets.icons.chevronDown,
onTap: onMoveDown,
),
SizedBox(width: 16.w),
] else ...[
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: XfjDesignTokens.primarySoft,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: Text(
'固定底部',
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 16.w),
],
SizedBox(
width: 64.w,
child: Text(
visible ? '显示' : '隐藏',
textAlign: TextAlign.right,
style: theme.typography.sm.copyWith(
color:
visible ? XfjDesignTokens.success : XfjDesignTokens.muted,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(width: 12.w),
FSwitch(
semanticLabel: '${item.title}${visible ? '已显示' : '已隐藏'}',
value: visible,
enabled: visibilityEnabled,
onChange: onVisibleChanged,
),
],
),
);
}
}
class _MenuMoveButton extends StatelessWidget {
const _MenuMoveButton({
required this.semanticLabel,
required this.icon,
this.onTap,
});
final String semanticLabel;
final SvgAsset icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final buttonSize =
(48 * XfjDesignTokens.buttonSizeScale).clamp(48, 60).toDouble();
return Semantics(
button: true,
enabled: enabled,
label: semanticLabel,
child: Opacity(
opacity: enabled ? 1 : 0.35,
child: Material(
color: XfjDesignTokens.surface,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Container(
width: buttonSize.w,
height: buttonSize.h,
decoration: BoxDecoration(
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: FIcon(
icon,
color: XfjDesignTokens.ink,
size: 20.sp,
).center(),
),
),
),
),
);
}
}
Widget _copyRow({
required SvgAsset icon,
required String label,
String? value,
bool masked = false,
bool showDivider = true,
}) {
return SettingInfoRow(
icon: icon,
label: label,
value: masked ? _maskIdentifier(value) : _safeValue(value),
actionLabel: '复制',
showDivider: showDivider,
onTap: ObjectUtil.isEmpty(value) ? null : () => KeywordTool.copy(value),
);
}
String _safeValue(String? value) {
return ObjectUtil.isEmpty(value) ? '--' : value!;
}
String _maskIdentifier(String? value) {
if (ObjectUtil.isEmpty(value)) return '--';
final text = value!;
if (text.length <= 8) return '${text.substring(0, 2)}••••';
final prefixLength = text.length >= 16 ? 4 : 3;
final suffixLength = text.length >= 16 ? 4 : 3;
return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}';
}