完整实现收银终端的Android专用Kiosk模式支持,包括: - 添加原生设备管理与Kiosk模式核心逻辑 - 新增Flutter设置页的Kiosk相关配置与控制UI - 更新README添加部署命令 - 补充Android权限与组件声明
612 lines
21 KiB
Dart
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)}';
|
|
}
|