837 lines
31 KiB
Dart
837 lines
31 KiB
Dart
import 'dart:async';
|
|
|
|
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> _refreshMqAfterBinding() async {
|
|
try {
|
|
await AppMqTool.mqRefresh();
|
|
} catch (e, stack) {
|
|
logger.e('|绑定后连接消息服务失败error:|$e|stack:|$stack|');
|
|
}
|
|
if (MqManager.isConnected) return;
|
|
final reason = MqManager.connectionFailureReason ?? '消息服务暂不可用';
|
|
logger.w('|绑定成功但消息服务未连接reason:|$reason|');
|
|
Toast.error(msg: '绑定成功,消息服务暂未连接');
|
|
}
|
|
|
|
/// 打开服务端设备绑定弹窗;输入框只回显本机当前值,不为调试包注入固定业务终端 ID。
|
|
Future<void> _bindServiceDialog(BuildContext context, SysSetting sys) async {
|
|
final theme = FTheme.of(context);
|
|
// 调试包不得注入固定业务终端 ID,否则不同机器会共用同一套心跳、MQTT 和打印代理身份。
|
|
final text = sys.serviceId ?? "";
|
|
final uniqueCodeController = TextEditingController(text: text);
|
|
await showAppDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) => AppDialog(
|
|
title: Text('绑定唯一编号', style: theme.typography.xl2),
|
|
width: context.width * 0.55,
|
|
maxWidth: context.width * 0.55,
|
|
onClose: () => Navigator.of(dialogContext).pop(),
|
|
semanticLabel: '绑定设备唯一编号弹框',
|
|
content: SizedBox(
|
|
width: double.infinity,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 设备唯一编号为纯数字;系统软键盘在定制 ROM 上不可用,弹框
|
|
// 高度有限,键盘直接内嵌跟随展示,实体键盘输入不受影响。
|
|
FTextField(
|
|
keyboardType: TextInputType.none,
|
|
controller: uniqueCodeController,
|
|
hint: '请输入设备唯一编号',
|
|
maxLines: 1,
|
|
).width(double.infinity),
|
|
SizedBox(height: 16.h),
|
|
AppSoftKeyboardPanel(
|
|
controller: uniqueCodeController,
|
|
mode: AppSoftKeyboardMode.digits,
|
|
allowPattern: RegExp('[0-9]'),
|
|
semanticLabel: '设备唯一编号数字键盘',
|
|
),
|
|
],
|
|
),
|
|
).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();
|
|
Toast.success(msg: '绑定成功');
|
|
unawaited(TerminalHeartbeatTool.report());
|
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
|
unawaited(_refreshMqAfterBinding());
|
|
} 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: FAssets.icons.smartphone,
|
|
label: 'Android 版本',
|
|
value: AppRuntimeTool.androidVersionLabel,
|
|
),
|
|
SettingInfoRow(
|
|
icon: FAssets.icons.package,
|
|
label: 'APK 版本',
|
|
value: AppRuntimeTool.apkVersionLabel,
|
|
),
|
|
SettingInfoRow(
|
|
icon: sys.rootAccessStatus == RootAccessStatus.available
|
|
? FAssets.icons.shieldCheck
|
|
: FAssets.icons.shieldAlert,
|
|
label: 'Root 权限',
|
|
subtitle: _rootAccessDescription(sys),
|
|
value: _rootAccessLabel(sys.rootAccessStatus),
|
|
trailing: Tooltip(
|
|
message: '重新检查 Root 权限',
|
|
child: IconButton(
|
|
tooltip: '重新检查 Root 权限',
|
|
onPressed: controller.rootRefreshing.value
|
|
? null
|
|
: controller.refreshRootAccess,
|
|
icon: controller.rootRefreshing.value
|
|
? SizedBox.square(
|
|
dimension: 20.w,
|
|
child: const CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: FIcon(
|
|
FAssets.icons.refreshCw,
|
|
color: XfjDesignTokens.primary,
|
|
size: 20.sp,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SettingInfoRow(
|
|
icon: controller.kioskStatus.value.available
|
|
? FAssets.icons.shieldCheck
|
|
: FAssets.icons.circleAlert,
|
|
label: '专用设备 / Kiosk',
|
|
subtitle: controller.kioskProvisionMessage.isNotEmpty
|
|
? controller.kioskProvisionMessage.value
|
|
: controller.kioskStatus.value.description,
|
|
value: controller.kioskStatus.value.statusLabel,
|
|
trailing: controller.kioskStatus.value.available
|
|
? FSwitch(
|
|
semanticLabel: '立即开启或关闭 Kiosk',
|
|
value: controller.kioskStatus.value.autoEnterEnabled,
|
|
enabled: !controller.kioskSwitching.value,
|
|
onChange: controller.setKioskAutoEnterEnabled,
|
|
)
|
|
: Tooltip(
|
|
message:
|
|
sys.rootAccessStatus == RootAccessStatus.available
|
|
? '使用 Root 配置 Device Owner 并进入 Kiosk'
|
|
: 'Root 不可用,无法自动配置 Device Owner',
|
|
child: SettingActionButton(
|
|
label: controller.kioskProvisioning.value
|
|
? '正在启用'
|
|
: '启用 Kiosk',
|
|
icon: FAssets.icons.shieldCheck,
|
|
primary: true,
|
|
enabled: sys.rootAccessStatus ==
|
|
RootAccessStatus.available &&
|
|
!controller.kioskProvisioning.value,
|
|
onTap: controller.enableKioskWithRoot,
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
_loginPasswordPolicySetting(),
|
|
SizedBox(height: XfjDesignTokens.cardGap.h),
|
|
_terminalDeviceStatusVisibilitySetting(),
|
|
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(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 登录密码的两个忘记时机开关,均默认开启,并提供一键恢复默认。
|
|
/// 忘记密码只清除已保存的密码,用户名仍会在登录页回填。
|
|
Widget _loginPasswordPolicySetting() {
|
|
final controller = Get.find<SettingController>();
|
|
return SettingGroup(
|
|
title: '登录密码',
|
|
child: Column(
|
|
children: [
|
|
Obx(() {
|
|
final enabled = controller.forgetPasswordOnManualLogout.value;
|
|
return SettingInfoRow(
|
|
icon: FAssets.icons.logOut,
|
|
label: '手动退出时忘记密码',
|
|
subtitle: '开启后退出登录将清除已保存的密码,下次登录需重新输入;离线退出时仍保留以便断网重进。',
|
|
value: enabled ? '已开启' : '已关闭',
|
|
trailing: FSwitch(
|
|
key: const ValueKey('setting-forget-password-manual-logout'),
|
|
semanticLabel: '手动退出时忘记登录密码',
|
|
value: enabled,
|
|
onChange: (value) => unawaited(
|
|
controller.setForgetPasswordOnManualLogout(value),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
Obx(() {
|
|
final enabled = controller.forgetPasswordOnPowerLoss.value;
|
|
return SettingInfoRow(
|
|
icon: FAssets.icons.powerOff,
|
|
label: '突然关机时忘记密码',
|
|
subtitle: '开启后断电或重启再次启动时需重新输入密码,防止他人开机后直接进入系统。',
|
|
value: enabled ? '已开启' : '已关闭',
|
|
trailing: FSwitch(
|
|
key: const ValueKey('setting-forget-password-power-loss'),
|
|
semanticLabel: '突然关机时忘记登录密码',
|
|
value: enabled,
|
|
onChange: (value) => unawaited(
|
|
controller.setForgetPasswordOnPowerLoss(value),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
SettingInfoRow(
|
|
icon: FAssets.icons.rotateCcw,
|
|
label: '恢复默认',
|
|
subtitle: '把两个忘记密码开关都恢复为默认开启。',
|
|
showDivider: false,
|
|
trailing: SettingActionButton(
|
|
label: '恢复默认',
|
|
icon: FAssets.icons.rotateCcw,
|
|
primary: true,
|
|
onTap: () => unawaited(controller.resetForgetPasswordPolicies()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 以数据驱动的五行开关提供顶部设备状态逐项配置,并与详情弹框共享应用级响应状态。
|
|
Widget _terminalDeviceStatusVisibilitySetting() {
|
|
final control = Get.find<TerminalDeviceStatusControl>();
|
|
final devices = TerminalDeviceType.values;
|
|
return SettingGroup(
|
|
title: '顶部状态栏',
|
|
child: Column(
|
|
children: [
|
|
for (var index = 0; index < devices.length; index++)
|
|
Obx(() {
|
|
final device = devices[index];
|
|
final visible = control.isHeaderDeviceVisible(device);
|
|
return SettingInfoRow(
|
|
icon: _terminalDeviceStatusIcon(device),
|
|
label: device.label,
|
|
subtitle: '控制${device.label}状态是否显示在所有业务页面顶部。',
|
|
value: visible ? '已显示' : '已隐藏',
|
|
showDivider: index < devices.length - 1,
|
|
trailing: FSwitch(
|
|
key: ValueKey(
|
|
'setting-terminal-device-status-${device.code}-switch',
|
|
),
|
|
semanticLabel: '在页面顶部显示${device.label}状态',
|
|
value: visible,
|
|
onChange: (value) => unawaited(
|
|
control.setHeaderDeviceVisible(device, value),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 返回设备状态配置行使用的统一 Forui 图标,映射顺序与终端顶部五类设备保持一致。
|
|
SvgAsset _terminalDeviceStatusIcon(TerminalDeviceType type) => switch (type) {
|
|
TerminalDeviceType.printer => FAssets.icons.printer,
|
|
TerminalDeviceType.steelyard => FAssets.icons.scale,
|
|
TerminalDeviceType.cardReader => FAssets.icons.idCard,
|
|
TerminalDeviceType.subScreen => FAssets.icons.monitor,
|
|
TerminalDeviceType.camera => FAssets.icons.camera,
|
|
};
|
|
|
|
/// 将 Root 持久化状态转换为基本信息页短标签。
|
|
String _rootAccessLabel(RootAccessStatus status) => switch (status) {
|
|
RootAccessStatus.unknown => '未检查',
|
|
RootAccessStatus.available => '可用',
|
|
RootAccessStatus.unavailable => '不可用',
|
|
};
|
|
|
|
/// 组合 Root 最近检查时间和诊断摘要,旧配置保持明确的未检查提示。
|
|
String _rootAccessDescription(SysSetting sys) {
|
|
final checkedAt = sys.rootAccessCheckedAt;
|
|
final checkedLabel = checkedAt == null
|
|
? '尚未执行真实 Root 命令检查'
|
|
: '最近检查 ${DateUtil.formatDate(checkedAt, format: DateFormats.full)}';
|
|
final diagnostic = sys.rootAccessDiagnostic?.trim();
|
|
return diagnostic == null || diagnostic.isEmpty
|
|
? checkedLabel
|
|
: '$checkedLabel · $diagnostic';
|
|
}
|
|
|
|
/// 左侧菜单只允许从手柄直接拖动,并提供上下移动按钮作为精确操作和无障碍替代。
|
|
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)}';
|
|
}
|