// widgets/ds/ds_atoms.dart // Flutter 组件库(ds=design-system)——一对一镜像原型 atoms.css 的原子。 // 单一组件源:尺寸/间距/圆角/字号全引 AppDims,颜色全引 context.tokens(禁硬编码)。 // 屏只组合这些组件,不再自己堆样式(见代码端真相源闸 check_ds_code)。 import 'package:flutter/material.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; // ── .btn ────────────────────────────────────────────────────────────────── enum DsBtnVariant { ghost, primary, danger } /// 原型 .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 const DsButton( this.label, { super.key, this.icon, this.onPressed, this.variant = DsBtnVariant.ghost, this.small = 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), }; return Material( color: bg, borderRadius: BorderRadius.circular(AppDims.rMd), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( height: small ? 32 : 38, padding: EdgeInsets.symmetric(horizontal: small ? 12 : 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppDims.rMd), border: Border.all(color: border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 16, color: fg), const SizedBox(width: 7), ], Text(label, style: TextStyle( color: fg, fontSize: small ? AppDims.fsSm : 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; // 选中态下点 × 单独清除该筛选(不触发展开菜单) const DsChip( {super.key, required this.label, this.value, this.onTap, this.onClear}); @override Widget build(BuildContext context) { final t = context.tokens; final on = 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 (on) ...[ const SizedBox(width: 7), Text(value!, style: TextStyle( fontSize: AppDims.fsSm, color: t.primary, fontWeight: FontWeight.w600)), ], const SizedBox(width: 7), // 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头。 if (on && onClear != null) GestureDetector( behavior: HitTestBehavior.opaque, onTap: onClear, child: Icon(Icons.close, size: 14, color: t.muted), ) else Icon(Icons.keyboard_arrow_down, size: 14, color: t.faint), ], ), ), ); } } // ── .searchbox ────────────────────────────────────────────────────────────── /// 原型 .searchbox:h34 / bg / border r-md / gap8 / pad 0 11,前导放大镜 14(faint)。 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(Icons.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, border: 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(Icons.close, 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), ), ), ), ); } }