516 lines
16 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|