a3a722689e
- 列表重建:KPI 卡、状态+时间列、操作改眼睛开右侧详情抽屉、重置右置 - 详情抽屉(order_detail_drawer) 100% 还原原型 + SelectionArea 可选中复制 - 详细搜索弹窗:ComboSearchField/滚轮日期、字段重构、白底盒子按钮 - 工具栏:搜索框×清除+占位精简、供应商/客户下拉取全部、状态菜单收窄 - 加载不白屏(半透明遮罩)、输入框与下拉等高、windows/macOS 默认窗口尺寸调大 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
9.7 KiB
Dart
275 lines
9.7 KiB
Dart
// 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<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(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<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(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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|