// 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 } /// 原型 .btn:h38 / 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.sm:h32 / pad 0 12 / fs-sm final bool large; // .btn.lg:h44 / 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), }; // disabled(onPressed=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 } /// 原型 .badge:h22 / pad 0 9 / r-pill / fs-sm fw600 + 前导圆点(6px, currentColor)。 class DsBadge extends StatelessWidget { final String label; final DsBadgeTone tone; const DsBadge(this.label, {super.key, this.tone = DsBadgeTone.ok}); @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: [ 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 ───────────────────────────────────────────────────────────────── /// 原型 .chip:h34 / pad 0 12 / border r-md / surface / fs-sm / gap7。 /// 选中值用 .cv(primary 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 → 展示可点 ×(清该筛选);否则展示下拉箭头; // 纯切换 chip(caret=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:分段 tab(inline-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 items; final int index; final ValueChanged 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 ────────────────────────────────────────────────────────────── /// 原型 .searchbox:h34 / bg / border r-md / gap8 / pad 0 11,前导放大镜 14(faint)。 /// 原型 .field:label(fs-sm muted)在控件上方、间距 6;required 缀红星。 /// 对话框/面板表单统一用它排字段,禁止 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 / .agree(login.html / register.html):18×18 盒 + 1.5px 边 + /// r-sm;选中 → primary 底/边 + 白勾(13, stroke2.4);文案 fs-body,间距 8。 /// [label] 用 Widget 以承载富文本(如注册页协议链接);[alignTop] 用于多行文案 /// (register .agree:align-items:flex-start + 盒 margin-top 1)。 class DsCheck extends StatelessWidget { final bool value; final ValueChanged 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), ], ), ), ); } } /// 原型 .input:h38 / 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? onChanged; final ValueChanged? 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 0:M3 默认字距比原型(无字距)宽,数字/拉丁明显 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 extends StatelessWidget { final T? value; final List<(T, String)> options; final ValueChanged? 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(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? onChanged; final ValueChanged? 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( 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), ), ), ), ); } }