Files
jiu/client/lib/widgets/ds/ds_atoms.dart
T
wangjia be9afcf68d feat(client): 移动壳基建——底部 5 tab + 我的 hub + 统一底部 sheet + 移动组件四件套
对齐移动原型体系(m-*.html)的 Stage 0 共享基建,桌面形态零改动:

- 导航:router 新增 branch 9(/me 我的 + /me/license 授权屏);窄屏 tab 根
  (库存/入库/出库/财务/我的)出底部 MTabBar,二级屏隐藏 tabbar + 顶栏返回箭头
  (PopScope 拦系统返回键防退 app);窄屏 Drawer/汉堡删除;窄屏顶栏对齐 m-top
  (标题 + 主题/通知铃/头像→我的)
- sheet:showMSheet(grip/标题/86vh/键盘避让)+ showAdaptiveSheet;六个共享
  弹层呈现函数加窄屏 sheet 分支(订单/往来/财务往来/商品编辑抽屉 + 退单/登记收支
  dialog 的 asSheet 形态)
- 徽章:DsBadge/StatusPill/StatusBadge 加图标变体(默认圆点零漂移)+
  status_icon_map 转录原型 BADGE_ICON 28 词
- 组件:MKpiGrid(2×2 可点 KPI)/ MSearchRow(搜索+状态钮+详搜钮)/
  MHubGroup(hub 入口组)/ showThemeSheet(主题外观 sheet)
- 授权:_LicensePanel/_SettingsCard 抽取为公共 LicensePanel/SettingsCard,
  窄屏授权跳转 /me/license
- golden:app_shell_mobile + me 各三主题(390×844@2x);全量 259 测试绿,
  check_ds_code / check-l1-sync 闸过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:56:04 +08:00

596 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// widgets/ds/ds_atoms.dart
// Flutter 组件库(ds=design-system)——一对一镜像原型 atoms.css 的原子。
// 单一组件源:尺寸/间距/圆角/字号全引 AppDims,颜色全引 context.tokens(禁硬编码)。
// 屏只组合这些组件,不再自己堆样式(见代码端真相源闸 check_ds_code)。
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import 'ds_menu.dart';
// ── .btn ──────────────────────────────────────────────────────────────────
enum DsBtnVariant { ghost, primary, danger, success, accent }
/// 原型 .btnh38 / pad 0 16 / r-md / fs-body / fw600 / gap7,图标 16。
/// ghost=surface+border / primary=primary+on-primary / danger=danger+白。
class DsButton extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final DsBtnVariant variant;
final bool small; // .btn.smh32 / pad 0 12 / fs-sm
final bool large; // .btn.lgh44 / pad 0 22 / fs14(登录/注册主按钮)
const DsButton(
this.label, {
super.key,
this.icon,
this.onPressed,
this.variant = DsBtnVariant.ghost,
this.small = false,
this.large = false,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final (bg, fg, border) = switch (variant) {
DsBtnVariant.ghost => (t.surface, t.text, t.border),
DsBtnVariant.primary => (t.primary, t.onPrimary, t.primary),
DsBtnVariant.danger => (t.danger, t.onPrimary, t.danger),
// 语义确认键(审核通过/撤回等),色取 token
DsBtnVariant.success => (t.success, t.onPrimary, t.success),
DsBtnVariant.accent => (t.accent, t.onPrimary, t.accent),
};
// disabledonPressed=null)置灰 55%:批量替换 Material 按钮后不能丢禁用反馈
return Opacity(
opacity: onPressed == null ? 0.55 : 1,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: small ? 32 : (large ? 44 : 38),
padding: EdgeInsets.symmetric(
horizontal: small ? 12 : (large ? 22 : 16)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border.all(color: border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
const SizedBox(width: 7),
],
// Flexible+ellipsis:宿主给紧约束(如 dev-card 底部 flex:1)时不溢出
Flexible(
child: Text(label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: small
? AppDims.fsSm
: (large ? 14 : AppDims.fsBody),
fontWeight: FontWeight.w600)),
),
],
),
),
),
));
}
}
// ── .badge ────────────────────────────────────────────────────────────────
enum DsBadgeTone { ok, danger, warn, info, accent, muted }
/// 原型 .badgeh22 / pad 0 9 / r-pill / fs-sm fw600 + 前导圆点(6px, currentColor)。
/// [icon] 非空 → 图标徽章变体(原型 .badge.bi12px 图标替圆点,icons.js BADGE_ICON
/// 拍板「圆点→代表图标」);默认 null 保持圆点形态(桌面存量 golden 零漂移)。
class DsBadge extends StatelessWidget {
final String label;
final DsBadgeTone tone;
final IconData? icon;
const DsBadge(this.label, {super.key, this.tone = DsBadgeTone.ok, this.icon});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final (bg, fg) = switch (tone) {
DsBadgeTone.ok => (t.successBg, t.success),
DsBadgeTone.danger => (t.dangerBg, t.danger),
DsBadgeTone.warn => (t.warnBg, t.warn),
DsBadgeTone.info => (t.infoSoft, t.primary),
DsBadgeTone.accent => (t.accentSoft, t.accent),
DsBadgeTone.muted => (t.borderSubtle, t.muted),
};
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 12, color: fg)
else
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: fg, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text(label,
style: TextStyle(
color: fg,
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600)),
],
),
);
}
}
// ── .chip ─────────────────────────────────────────────────────────────────
/// 原型 .chiph34 / pad 0 12 / border r-md / surface / fs-sm / gap7。
/// 选中值用 .cvprimary fw600);激活(on)边框 primary。配 PopupMenuButton 用。
class DsChip extends StatelessWidget {
final String label;
final String? value; // 选中值(.cv);非空即 on 态
final VoidCallback? onTap;
final VoidCallback? onClear; // 选中态下点 × 单独清除该筛选(不触发展开菜单)
// 纯切换 chip(原型 .chip.on,如 partners 类型筛选):selected 直接给 on 态,
// caret=false 去掉下拉箭头。
final bool selected;
final bool caret;
const DsChip(
{super.key,
required this.label,
this.value,
this.onTap,
this.onClear,
this.selected = false,
this.caret = true});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final on = selected || (value != null && value!.isNotEmpty);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: on ? t.primary : t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
if (value != null && value!.isNotEmpty) ...[
const SizedBox(width: 7),
Text(value!,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.primary,
fontWeight: FontWeight.w600)),
],
// 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头;
// 纯切换 chipcaret=false)无尾部图标。
if (on && onClear != null) ...[
const SizedBox(width: 7),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear,
child: Icon(LucideIcons.x, size: 14, color: t.muted),
),
] else if (caret) ...[
const SizedBox(width: 7),
Icon(LucideIcons.chevronDown, size: 14, color: t.faint),
],
],
),
),
);
}
}
// ── .seg ─────────────────────────────────────────────────────────────────
/// 原型 .seg:分段 tabinline-flex / bg / border r-md / pad 3 gap 3)。
/// button h30 / pad 0 16 / fs-sm fw600 muted;选中=surface 底 + primary 字 + sh-1。
class DsSeg extends StatelessWidget {
final List<String> items;
final int index;
final ValueChanged<int> onChanged;
const DsSeg(
{super.key,
required this.items,
required this.index,
required this.onChanged});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) const SizedBox(width: 3),
InkWell(
onTap: () => onChanged(i),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.center,
decoration: BoxDecoration(
color: i == index ? t.surface : null,
borderRadius: BorderRadius.circular(AppDims.rSm),
// 原型 --sh-1: 0 1px 3px var(--shadow)
boxShadow: i == index
? [
BoxShadow(
color: t.shadow,
blurRadius: 3,
offset: const Offset(0, 1)),
]
: null,
),
child: Text(items[i],
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: i == index ? t.primary : t.muted)),
),
),
],
],
),
);
}
}
// ── .searchbox ──────────────────────────────────────────────────────────────
/// 原型 .searchboxh34 / bg / border r-md / gap8 / pad 0 11,前导放大镜 14(faint)。
/// 原型 .fieldlabelfs-sm muted)在控件上方、间距 6required 缀红星。
/// 对话框/面板表单统一用它排字段,禁止 Material labelText 浮动标签。
class DsField extends StatelessWidget {
final String label;
final bool required;
final Widget child;
const DsField(this.label,
{super.key, this.required = false, required Widget input})
: child = input;
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
if (required)
Text(' *',
style: TextStyle(fontSize: AppDims.fsSm, color: t.danger)),
]),
const SizedBox(height: 6),
child,
],
);
}
}
/// 原型 .check / .agreelogin.html / register.html):18×18 盒 + 1.5px 边 +
/// r-sm;选中 → primary 底/边 + 白勾(13, stroke2.4);文案 fs-body,间距 8。
/// [label] 用 Widget 以承载富文本(如注册页协议链接);[alignTop] 用于多行文案
/// register .agreealign-items:flex-start + 盒 margin-top 1)。
class DsCheck extends StatelessWidget {
final bool value;
final ValueChanged<bool> onChanged;
final Widget label;
final bool alignTop;
const DsCheck({
super.key,
required this.value,
required this.onChanged,
required this.label,
this.alignTop = false,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
alignTop ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Container(
width: 18,
height: 18,
margin:
alignTop ? const EdgeInsets.only(top: 1) : EdgeInsets.zero,
alignment: Alignment.center,
decoration: BoxDecoration(
color: value ? t.primary : t.surface,
border:
Border.all(color: value ? t.primary : t.border, width: 1.5),
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: value
? Icon(LucideIcons.check, size: 13, color: t.onPrimary)
: null,
),
SizedBox(width: alignTop ? 9 : 8),
Flexible(child: label),
],
),
),
);
}
}
/// 原型 .inputh38 / surface / 1px border / r-md / pad 0 11 / fs-body
/// focus → primary 边;readonly/disabled → bg 底 + muted 字。
class DsInput extends StatelessWidget {
final TextEditingController? controller;
final bool enabled;
final String? hintText;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final TextInputType? keyboardType;
final FocusNode? focusNode;
final bool obscureText; // 密码框
final Widget? suffix; // 行内后缀(历史下拉箭头 / 密码可见切换)
const DsInput({
super.key,
this.controller,
this.enabled = true,
this.hintText,
this.onChanged,
this.onSubmitted,
this.keyboardType,
this.focusNode,
this.obscureText = false,
this.suffix,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
OutlineInputBorder line(Color c) => OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: c),
);
return SizedBox(
height: 38,
child: TextField(
controller: controller,
enabled: enabled,
onChanged: onChanged,
onSubmitted: onSubmitted,
keyboardType: keyboardType,
focusNode: focusNode,
obscureText: obscureText,
// letterSpacing 0M3 默认字距比原型(无字距)宽,数字/拉丁明显
style: TextStyle(
fontSize: AppDims.fsBody,
letterSpacing: 0,
color: enabled ? t.text : t.muted),
decoration: InputDecoration(
isDense: true,
// 装饰盒锁死 .input 的 38 高(不随 visualDensity 漂移)
constraints: const BoxConstraints.tightFor(height: 38),
filled: true,
fillColor: enabled ? t.surface : t.bg,
hintText: hintText,
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
contentPadding:
const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
suffixIcon: suffix == null
? null
: Padding(
padding: const EdgeInsets.only(right: 9), child: suffix),
suffixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
enabledBorder: line(t.border),
disabledBorder: line(t.border),
focusedBorder: line(t.primary),
border: line(t.border),
),
),
);
}
}
/// 原型 select.input 盒式外观(h38/surface/border/r-md/pad 0 11 + 右侧
/// chevron),点击弹 DsMenu.menu 弹层,含 ✓ 选中态、锚定坐标已对齐)。
/// 统一替代 Material DropdownButton——其默认灰底方角弹层不属 ds 体系。
class DsSelect<T> extends StatelessWidget {
final T? value;
final List<(T, String)> options;
final ValueChanged<T>? onChanged;
final String hint;
const DsSelect({
super.key,
required this.value,
required this.options,
required this.onChanged,
this.hint = '请选择…',
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final label =
options.where((o) => o.$1 == value).map((o) => o.$2).firstOrNull;
final enabled = onChanged != null;
return Builder(
builder: (bctx) => InkWell(
onTap: enabled
? () async {
final picked = await showDsMenu<T>(bctx, items: [
for (final o in options)
DsMenuItem(
value: o.$1, label: o.$2, selected: o.$1 == value),
]);
if (picked != null) onChanged!(picked);
}
: null,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: enabled ? t.surface : t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(label ?? hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: label == null
? t.faint
: (enabled ? t.text : t.muted))),
),
const SizedBox(width: 8),
Icon(LucideIcons.chevronDown, size: 14, color: t.muted),
]),
),
),
);
}
}
class DsSearchBox extends StatelessWidget {
final TextEditingController? controller;
final String hint;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final double? width;
const DsSearchBox({
super.key,
this.controller,
this.hint = '',
this.onChanged,
this.onSubmitted,
this.width,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return SizedBox(
width: width,
height: 34,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: t.faint),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
onSubmitted: onSubmitted,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
filled: false,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hint,
hintStyle:
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
),
),
),
// 有输入时显示 × 清除(清空并触发一次空关键词搜索)。
if (controller != null)
ValueListenableBuilder<TextEditingValue>(
valueListenable: controller!,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller!.clear();
onChanged?.call('');
onSubmitted?.call('');
},
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
),
);
},
),
],
),
),
);
}
}
/// 加载遮罩(reload 时叠在旧内容之上):轻微半透明底 + 屏幕中心进度圈。
/// 搜索/筛选刷新时用它替代整屏白屏(见列表屏 skipLoadingOnReload)。
class DsLoadingScrim extends StatelessWidget {
const DsLoadingScrim({super.key});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return GestureDetector(
behavior: HitTestBehavior.opaque, // 加载期间拦截点击,避免误触
onTap: () {},
child: ColoredBox(
color: t.bg.withValues(alpha: 0.55),
child: Center(
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(strokeWidth: 3, color: t.primary),
),
),
),
);
}
}