Files
biz_xfj_cloud/lib/pages/terminal/setting/widgets/setting_components.dart
T
2026-08-31 15:09:38 +08:00

516 lines
16 KiB
Dart

import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 设置页通用分组,统一标题层级、边框和圆角。
class SettingGroup extends StatelessWidget {
/// 创建设置分组;[dense] 仅用于需要提高信息密度的页面。
const SettingGroup({
super.key,
required this.title,
required this.child,
this.dense = false,
});
/// 分组标题。
final String title;
/// 分组卡片中的实际设置内容。
final Widget child;
/// 是否使用紧凑标题层级;只压缩排版,不改变分组内容的触控尺寸。
final bool dense;
/// 构建标题和白色分组卡片。
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: (dense ? theme.typography.base : theme.typography.lg).copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w800,
),
),
SizedBox(height: (dense ? 8 : 12).h),
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: XfjDesignTokens.surface,
border: Border.all(color: XfjDesignTokens.line),
borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius),
),
child: child,
),
],
);
}
}
class SettingSegmentedSelector<T> extends StatelessWidget {
const SettingSegmentedSelector({
super.key,
required this.label,
required this.value,
required this.options,
required this.onChanged,
this.showDivider = true,
});
final String label;
final T value;
final Map<T, String> options;
final ValueChanged<T> onChanged;
final bool showDivider;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final optionStyle = theme.typography.base.copyWith(
color: XfjDesignTokens.muted,
fontWeight: FontWeight.w600,
);
final entries = options.entries.toList(growable: false);
return <Widget>[
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w600,
),
).width(230.w),
LayoutBuilder(
builder: (context, constraints) {
if (entries.isEmpty) return const SizedBox.shrink();
final gap = 4.w;
final regularHorizontalPadding =
20.w * XfjDesignTokens.buttonSizeScale;
final preferredWidth = entries.fold<double>(8.w, (width, entry) {
final painter = TextPainter(
text: TextSpan(text: entry.value, style: optionStyle),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout();
final optionWidth =
(painter.width + regularHorizontalPadding * 2)
.clamp(112.w, double.infinity);
return width + optionWidth;
}) +
gap * (entries.length - 1);
final fillAvailableWidth = constraints.maxWidth.isFinite &&
preferredWidth > constraints.maxWidth;
// 支付方式较多时等分可用宽度,保留 48dp 点击高度并让长文案省略,
// 避免固定最小宽度在页面设置的窄内容区产生横向溢出。
final optionWidgets = entries
.map(
(entry) => _segmentedOption(
entry: entry,
active: value == entry.key,
style: optionStyle,
fillAvailableWidth: fillAvailableWidth,
),
)
.toList(growable: false);
return optionWidgets
.toRow(
mainAxisSize:
fillAvailableWidth ? MainAxisSize.max : MainAxisSize.min,
separator: SizedBox(width: gap),
)
.padding(all: 4.w)
.decorated(
color: const Color(0xFFF0F2F5),
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
)
.alignRight();
},
).expanded(),
]
.toRowSpace(space: 16.w)
.padding(horizontal: 20.w, vertical: 12.h)
.constrained(minHeight: 72.h)
.decorated(
border: showDivider
? const Border(bottom: BorderSide(color: XfjDesignTokens.line))
: null,
);
}
Widget _segmentedOption({
required MapEntry<T, String> entry,
required bool active,
required TextStyle style,
required bool fillAvailableWidth,
}) {
final horizontalPadding =
fillAvailableWidth ? 8.w : 20.w * XfjDesignTokens.buttonSizeScale;
final option = InkWell(
onTap: () => onChanged(entry.key),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Text(
entry.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(
color: active ? XfjDesignTokens.primary : XfjDesignTokens.muted,
),
)
.center()
.padding(
horizontal: horizontalPadding,
vertical: 6.h * XfjDesignTokens.buttonSizeScale,
)
.constrained(
minWidth: fillAvailableWidth ? 0 : 112.w,
minHeight: 48.h,
)
.decorated(
color: active ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
boxShadow: active ? XfjDesignTokens.surfaceShadow : null,
),
);
return fillAvailableWidth ? option.expanded() : option;
}
}
class SettingInfoRow extends StatelessWidget {
const SettingInfoRow({
super.key,
required this.icon,
required this.label,
this.value,
this.actionLabel,
this.onTap,
this.showDivider = true,
this.trailing,
this.subtitle,
});
final SvgAsset icon;
final String label;
final String? value;
final String? actionLabel;
final VoidCallback? onTap;
final bool showDivider;
final Widget? trailing;
final String? subtitle;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final hasAction = actionLabel != null;
return InkWell(
onTap: onTap,
child: Container(
constraints: BoxConstraints(minHeight: subtitle == null ? 68.h : 80.h),
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
decoration: BoxDecoration(
border: showDivider
? const Border(bottom: BorderSide(color: XfjDesignTokens.line))
: null,
),
child: Row(
children: [
Container(
width: 40.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: XfjDesignTokens.control,
borderRadius: BorderRadius.circular(
XfjDesignTokens.controlRadius,
),
),
child: FIcon(icon, color: XfjDesignTokens.muted, size: 22.sp),
),
SizedBox(width: 16.w),
SizedBox(
width: subtitle == null ? 230.w : 520.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null) ...[
SizedBox(height: 4.h),
Text(
subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.muted,
),
),
],
],
),
),
SizedBox(width: 16.w),
Expanded(
child: value == null
? const SizedBox.shrink()
: Text(
value!,
maxLines: subtitle == null ? 1 : 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.muted,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
if (trailing != null) ...[SizedBox(width: 16.w), trailing!],
if (hasAction) ...[
SizedBox(width: 16.w),
SizedBox(
width: 148.w,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Text(
actionLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.primary,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 6.w),
FIcon(
actionLabel == '复制'
? FAssets.icons.copy
: FAssets.icons.chevronRight,
color: XfjDesignTokens.primary,
size: 18.sp,
),
],
),
),
],
],
),
),
);
}
}
/// 设置页通用提示条,用图标、文字和语义颜色反馈说明或状态。
class SettingNotice extends StatelessWidget {
/// 创建提示条;[dense] 适用于紧凑表单,但不会删减提示内容。
const SettingNotice({
super.key,
required this.text,
this.color = XfjDesignTokens.muted,
this.background = XfjDesignTokens.control,
this.borderColor,
this.icon,
this.dense = false,
});
/// 提示正文。
final String text;
/// 提示正文和图标的前景色。
final Color color;
/// 提示条背景色。
final Color background;
/// 可选边框颜色,为空时不绘制边框。
final Color? borderColor;
/// 可选语义图标。
final SvgAsset? icon;
/// 是否使用紧凑提示样式;保留完整文案和语义,仅降低字号与内边距。
final bool dense;
/// 构建可随宽度换行的提示内容。
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: (dense ? 12 : 16).w,
vertical: (dense ? 10 : 16).h,
),
decoration: BoxDecoration(
color: background,
border: borderColor == null ? null : Border.all(color: borderColor!),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
children: [
if (icon != null) ...[
FIcon(icon!, color: color, size: (dense ? 18 : 20).sp),
SizedBox(width: (dense ? 8 : 10).w),
],
Expanded(
child: Text(
text,
style: (dense ? theme.typography.xs : theme.typography.sm)
.copyWith(color: color),
),
),
],
),
);
}
}
class SettingEmpty extends StatelessWidget {
const SettingEmpty({super.key, required this.message, this.icon});
final String message;
final SvgAsset? icon;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FIcon(
icon ?? FAssets.icons.inbox,
color: XfjDesignTokens.muted,
size: 48.sp,
),
SizedBox(height: 12.h),
Text(
message,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.muted,
),
),
],
),
);
}
}
/// 设置页通用操作按钮,提供主要、普通和危险三种层级。
class SettingActionButton extends StatelessWidget {
/// 创建具有真实禁用态和最小触控高度的操作按钮。
const SettingActionButton({
super.key,
required this.label,
required this.onTap,
this.icon,
this.primary = false,
this.danger = false,
this.enabled = true,
this.dense = false,
});
/// 按钮文字。
final String label;
/// 按钮处于可用状态时的点击回调。
final VoidCallback onTap;
/// 可选前置图标。
final SvgAsset? icon;
/// 是否按页面唯一主要操作展示。
final bool primary;
/// 是否按危险操作展示。
final bool danger;
/// 是否允许点击;禁用时同时移除回调并降低视觉强调。
final bool enabled;
/// 是否使用紧凑视觉样式;点击高度仍固定不小于 48dp。
final bool dense;
/// 构建保持至少 48dp 点击高度的操作按钮。
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
final foreground = danger
? XfjDesignTokens.danger
: primary
? Colors.white
: XfjDesignTokens.primary;
final background = primary
? XfjDesignTokens.primary
: danger
? const Color(0xFFFFF3F2)
: XfjDesignTokens.surface;
final sizeScale = XfjDesignTokens.buttonSizeScale;
return Opacity(
opacity: enabled ? 1 : 0.45,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
child: Container(
constraints: BoxConstraints(minHeight: 48.h),
padding: EdgeInsets.symmetric(
horizontal: (dense ? 14 : 18).w * sizeScale,
vertical: (dense ? 8 : 10).h * sizeScale,
),
decoration: BoxDecoration(
color: background,
border: Border.all(
color: primary
? XfjDesignTokens.primary
: danger
? XfjDesignTokens.danger.withValues(alpha: 0.35)
: XfjDesignTokens.line,
),
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
FIcon(
icon!,
color: foreground,
size: (dense ? 18 : 20).sp,
),
SizedBox(width: (dense ? 6 : 8).w),
],
Text(
label,
style: (dense ? theme.typography.sm : theme.typography.base)
.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}