feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服 pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验; 登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段, 已记 CONTRACT,screens.mjs 留存根) - ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/ DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态 - 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、 h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、 BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast) - 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后 失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览 - 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/ SelectProductDialog/tabStateProvider - golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打; 修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -3,11 +3,13 @@
|
||||
// 单一组件源:尺寸/间距/圆角/字号全引 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 }
|
||||
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+白。
|
||||
@@ -17,6 +19,7 @@ class DsButton extends StatelessWidget {
|
||||
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, {
|
||||
@@ -25,6 +28,7 @@ class DsButton extends StatelessWidget {
|
||||
this.onPressed,
|
||||
this.variant = DsBtnVariant.ghost,
|
||||
this.small = false,
|
||||
this.large = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -34,37 +38,53 @@ class DsButton extends StatelessWidget {
|
||||
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),
|
||||
};
|
||||
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(
|
||||
// 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),
|
||||
border: Border.all(color: border),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +111,8 @@ class DsBadge extends StatelessWidget {
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9),
|
||||
decoration:
|
||||
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
|
||||
decoration: BoxDecoration(
|
||||
color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -120,17 +140,23 @@ class DsChip extends StatelessWidget {
|
||||
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.onClear,
|
||||
this.selected = false,
|
||||
this.caret = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final on = value != null && value!.isNotEmpty;
|
||||
final on = selected || (value != null && value!.isNotEmpty);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
@@ -147,7 +173,7 @@ class DsChip extends StatelessWidget {
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||||
if (on) ...[
|
||||
if (value != null && value!.isNotEmpty) ...[
|
||||
const SizedBox(width: 7),
|
||||
Text(value!,
|
||||
style: TextStyle(
|
||||
@@ -155,16 +181,19 @@ class DsChip extends StatelessWidget {
|
||||
color: t.primary,
|
||||
fontWeight: FontWeight.w600)),
|
||||
],
|
||||
const SizedBox(width: 7),
|
||||
// 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头。
|
||||
if (on && onClear != null)
|
||||
// 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头;
|
||||
// 纯切换 chip(caret=false)无尾部图标。
|
||||
if (on && onClear != null) ...[
|
||||
const SizedBox(width: 7),
|
||||
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),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
),
|
||||
] else if (caret) ...[
|
||||
const SizedBox(width: 7),
|
||||
Icon(LucideIcons.chevronDown, size: 14, color: t.faint),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -172,8 +201,290 @@ class DsChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ── .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<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 ──────────────────────────────────────────────────────────────
|
||||
/// 原型 .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<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 原型 .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<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 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<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;
|
||||
@@ -204,7 +515,7 @@ class DsSearchBox extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, size: 14, color: t.faint),
|
||||
Icon(LucideIcons.search, size: 14, color: t.faint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
@@ -214,7 +525,11 @@ class DsSearchBox extends StatelessWidget {
|
||||
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),
|
||||
@@ -236,7 +551,7 @@ class DsSearchBox extends StatelessWidget {
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: Icon(Icons.close, size: 14, color: t.muted),
|
||||
child: Icon(LucideIcons.x, size: 14, color: t.muted),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// widgets/ds/ds_bar_chart.dart — 原型 finance.html 收支趋势分组柱状图(镜像 .barchart)。
|
||||
// 度量照原型:图高 180 / 柱宽 26 / pair 内 gap 6 / 列间 gap 18 / 柱顶 mono 数值标签
|
||||
// (top:-17)/ 顶圆角 r-sm;收入柱=primary、支出柱=brand400;含图例(.chart-legend)。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
|
||||
class DsBarGroup {
|
||||
final String label; // 列底标签(如 1月)
|
||||
final double a; // 收入(primary 柱)
|
||||
final double b; // 支出(brand400 柱)
|
||||
const DsBarGroup(this.label, this.a, this.b);
|
||||
}
|
||||
|
||||
class DsBarChart extends StatelessWidget {
|
||||
final List<DsBarGroup> groups;
|
||||
final String legendA;
|
||||
final String legendB;
|
||||
|
||||
/// 柱顶数值标签格式化(原型显示「万元」整数)。
|
||||
final String Function(double v) format;
|
||||
|
||||
const DsBarChart({
|
||||
super.key,
|
||||
required this.groups,
|
||||
this.legendA = '收入',
|
||||
this.legendB = '支出',
|
||||
required this.format,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
var max = 0.0;
|
||||
for (final g in groups) {
|
||||
if (g.a > max) max = g.a;
|
||||
if (g.b > max) max = g.b;
|
||||
}
|
||||
if (max <= 0) max = 1;
|
||||
|
||||
return Container(
|
||||
// 原型 .chart-card:surface / border / r-lg / padding 18 20 14
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// .chart-legend
|
||||
Row(children: [
|
||||
_legend(t, t.primary, legendA),
|
||||
const SizedBox(width: 18),
|
||||
_legend(t, t.brand400, legendB),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// .barchart:高 180 + 顶部标签余量 6
|
||||
SizedBox(
|
||||
height: 186,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < groups.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 18),
|
||||
Expanded(child: _col(t, groups[i], max)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legend(dynamic t, Color color, String label) =>
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 11,
|
||||
height: 11,
|
||||
decoration: BoxDecoration(
|
||||
color: color, borderRadius: BorderRadius.circular(3))),
|
||||
const SizedBox(width: 7),
|
||||
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
]);
|
||||
|
||||
Widget _col(dynamic t, DsBarGroup g, double max) {
|
||||
return LayoutBuilder(builder: (ctx, cons) {
|
||||
// 原型柱宽 26;窄屏(列宽不足)按列宽收缩,防横向溢出
|
||||
final barW = ((cons.maxWidth - 6) / 2).clamp(8.0, 26.0);
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_bar(t, g.a, max, t.primary, barW),
|
||||
const SizedBox(width: 6),
|
||||
_bar(t, g.b, max, t.brand400, barW),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(g.label,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _bar(dynamic t, double v, double max, Color color, double barW) {
|
||||
// 最高柱 140 + 顶标签 ~18 ≈ 原型 .bar-pair 可用高(180 - 月份标签行)
|
||||
final h = (v / max * 140).clamp(2.0, 140.0);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// .bv 柱顶数值(mono fs-xs muted;原型绝对定位悬浮 → 布局占柱宽、绘制可溢出)
|
||||
SizedBox(
|
||||
width: barW,
|
||||
child: Center(
|
||||
child: Text(format(v),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.visible,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsXs,
|
||||
color: t.muted,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
width: barW,
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(AppDims.rSm)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
|
||||
/// 图标块 .ic 变体(软底 + 前景色)。
|
||||
enum DsKpiTone { info, ok, blue, alert }
|
||||
@@ -14,7 +15,8 @@ enum DsKpiDelta { up, down, neutral }
|
||||
class DsKpi extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
// 原型 users.html 的 KPI 无右上图标块 → icon 可空时不渲染 .ic
|
||||
final IconData? icon;
|
||||
final DsKpiTone tone;
|
||||
final String? delta;
|
||||
final DsKpiDelta deltaTone;
|
||||
@@ -24,7 +26,7 @@ class DsKpi extends StatelessWidget {
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.icon,
|
||||
this.tone = DsKpiTone.info,
|
||||
this.delta,
|
||||
this.deltaTone = DsKpiDelta.neutral,
|
||||
@@ -63,19 +65,19 @@ class DsKpi extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.muted)),
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: icBg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd)),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 17, color: icFg),
|
||||
),
|
||||
if (icon != null)
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: icBg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd)),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 17, color: icFg),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
@@ -84,23 +86,19 @@ class DsKpi extends StatelessWidget {
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
color: tone == DsKpiTone.alert ? t.accent : t.heading)),
|
||||
if (delta != null) ...[
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (deltaTone != DsKpiDelta.neutral)
|
||||
Icon(
|
||||
deltaTone == DsKpiDelta.up
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
size: 14,
|
||||
color: deltaColor),
|
||||
Text(delta!,
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: deltaColor)),
|
||||
],
|
||||
// 原型 .d 的箭头是文本字符 ▲/▼(非图标),gap4。
|
||||
Text(
|
||||
switch (deltaTone) {
|
||||
DsKpiDelta.up => '▲ ${delta!}',
|
||||
DsKpiDelta.down => '▼ ${delta!}',
|
||||
DsKpiDelta.neutral => delta!,
|
||||
},
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: deltaColor),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// widgets/ds/ds_menu.dart — 原型 .menu/.menu-item 下拉菜单(镜像 atoms.css + shell.js openMenu)。
|
||||
// 定位规则照抄 openMenu:宽 = max(锚宽, minW=168),左缘夹在视口内(留 8px),
|
||||
// 锚下 6px 展开、下方放不下且上方够高则向上翻。
|
||||
import 'dart:math' as math;
|
||||
|
||||
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';
|
||||
|
||||
/// 菜单项:sel → 前导 ✓(primary) + 文字 primary fw600;icon → 前导 16px 图标;
|
||||
/// 都没有 → 16px 占位(对齐 openMenu 的 lead 槽)。
|
||||
class DsMenuItem<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
const DsMenuItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.selected = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。
|
||||
Future<T?> showDsMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
required List<DsMenuItem<T>> items,
|
||||
double minWidth = 168,
|
||||
}) {
|
||||
final rect = _anchorRect(anchorContext);
|
||||
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
|
||||
anchorRect: rect,
|
||||
minWidth: minWidth,
|
||||
builder: (ctx) => _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in items)
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () => Navigator.of(ctx).pop(it.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
|
||||
/// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。
|
||||
Future<void> showDsMultiMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
required List<DsMenuItem<T>> Function() itemsBuilder,
|
||||
required ValueChanged<T> onToggle,
|
||||
double minWidth = 168,
|
||||
}) {
|
||||
final rect = _anchorRect(anchorContext);
|
||||
return Navigator.of(anchorContext).push<void>(_DsMenuRoute<void>(
|
||||
anchorRect: rect,
|
||||
minWidth: minWidth,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setState) => _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in itemsBuilder())
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () {
|
||||
onToggle(it.value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Rect _anchorRect(BuildContext anchorContext) {
|
||||
// 锚点矩形必须和菜单路由同坐标系:路由推到 Navigator.of(anchorContext),
|
||||
// 该导航器不一定占满屏幕(shell 分支导航器原点在侧栏右/顶栏下;
|
||||
// useRootNavigator:false 的抽屉同理),用全局坐标会整体漂移。
|
||||
final box = anchorContext.findRenderObject() as RenderBox;
|
||||
final navBox =
|
||||
Navigator.of(anchorContext).context.findRenderObject() as RenderBox?;
|
||||
final origin = box.localToGlobal(Offset.zero, ancestor: navBox);
|
||||
return origin & box.size;
|
||||
}
|
||||
|
||||
class _DsMenuRoute<T> extends PopupRoute<T> {
|
||||
final Rect anchorRect;
|
||||
final double minWidth;
|
||||
final WidgetBuilder builder;
|
||||
_DsMenuRoute({
|
||||
required this.anchorRect,
|
||||
required this.minWidth,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.transparent;
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
@override
|
||||
String? get barrierLabel => 'menu';
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _DsMenuLayout(anchorRect, minWidth),
|
||||
child: builder(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DsMenuLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
final double minWidth;
|
||||
_DsMenuLayout(this.anchor, this.minWidth);
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final maxW = math.max(minWidth, constraints.maxWidth - 16);
|
||||
return BoxConstraints(
|
||||
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
|
||||
maxWidth: maxW,
|
||||
maxHeight: math.max(0, constraints.maxHeight - 16),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final left = anchor.left
|
||||
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
|
||||
.toDouble();
|
||||
final below = anchor.bottom + 6;
|
||||
final above = anchor.top - childSize.height - 6;
|
||||
final top =
|
||||
(below + childSize.height > size.height && above > 6) ? above : below;
|
||||
return Offset(left, top);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_DsMenuLayout old) =>
|
||||
old.anchor != anchor || old.minWidth != minWidth;
|
||||
}
|
||||
|
||||
/// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
|
||||
class _DsMenuPanel extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _DsMenuPanel({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: t.shadow, offset: const Offset(0, 4), blurRadius: 14),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// .menu-item:gap9 / pad 8 10 / r-sm / fs-body,hover→bg;
|
||||
/// sel → 前导 ✓ + primary fw600;icon 颜色随文字(.mi-ic 继承 currentColor)。
|
||||
class _DsMenuItemTile extends StatelessWidget {
|
||||
final DsMenuItem<dynamic> item;
|
||||
final VoidCallback onTap;
|
||||
const _DsMenuItemTile({required this.item, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final fg = item.selected ? t.primary : t.text;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
hoverColor: t.bg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: item.selected
|
||||
? Icon(LucideIcons.check, size: 16, color: t.primary)
|
||||
: (item.icon != null
|
||||
? Icon(item.icon, size: 16, color: fg)
|
||||
: null),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(item.label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: fg,
|
||||
fontWeight:
|
||||
item.selected ? FontWeight.w600 : FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+310
-115
@@ -1,9 +1,15 @@
|
||||
// widgets/ds/ds_table.dart — 原型 .table + .toolbar + .pager(镜像 atoms.css)。
|
||||
// .toolbar(圆角上,无下边) 接 .table.flush-top(圆角下) 接 .pager,视觉连成一卡。
|
||||
// .toolbar(圆角上,无下边) 接 .table.flush-top(圆角下) 连成一卡;.pager 透明、在卡外。
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import 'ds_menu.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
|
||||
class DsColumn {
|
||||
final String key;
|
||||
@@ -36,6 +42,12 @@ class DsTable extends StatefulWidget {
|
||||
final ValueChanged<int>? onPageSizeChanged;
|
||||
// 窄屏卡片流(与 rows 同序);为空时窄屏也走表格横滚
|
||||
final List<Widget>? mobileCards;
|
||||
// 仅文案分页(原型 partners/products 的 .pager 只有一行 span 文案,无翻页控件);
|
||||
// 与 total 互斥,total 为空且此值非空时生效。
|
||||
final String? pagerInfoText;
|
||||
// 内嵌收缩模式(多区块滚动页里的表格卡,如 devices/settings):
|
||||
// 不用 Expanded 撑满、表格不带内部滚动,高度随内容。
|
||||
final bool shrinkWrap;
|
||||
|
||||
const DsTable({
|
||||
super.key,
|
||||
@@ -49,6 +61,8 @@ class DsTable extends StatefulWidget {
|
||||
this.onPageChanged,
|
||||
this.onPageSizeChanged,
|
||||
this.mobileCards,
|
||||
this.pagerInfoText,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -71,35 +85,61 @@ class _DsTableState extends State<DsTable> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
// 整宽拉伸:toolbar/表格/分页都填满卡片宽度(否则 toolbar 按内容宽居中→左侧留白)。
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.toolbar != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: widget.toolbar!,
|
||||
return Column(
|
||||
mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_maybeExpand(
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
// 整宽拉伸:toolbar/表格都填满卡片宽度(否则 toolbar 按内容宽居中→左侧留白)。
|
||||
mainAxisSize:
|
||||
widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.toolbar != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
child: widget.toolbar!,
|
||||
),
|
||||
Divider(height: 1, color: t.border),
|
||||
],
|
||||
_maybeExpand(
|
||||
(context.isMobile && widget.mobileCards != null)
|
||||
? (widget.shrinkWrap
|
||||
? _buildMobileShrink(t)
|
||||
: _buildMobile(t))
|
||||
: (widget.shrinkWrap
|
||||
? _buildTableShrink(t)
|
||||
: _buildTable(t)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(height: 1, color: t.border),
|
||||
],
|
||||
Expanded(
|
||||
child: (context.isMobile && widget.mobileCards != null)
|
||||
? _buildMobile(t)
|
||||
: _buildTable(t),
|
||||
),
|
||||
if (widget.total != null) _buildPager(t),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.total != null)
|
||||
_buildPager(t)
|
||||
else if (widget.pagerInfoText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 13, 4, 2),
|
||||
child: Text(widget.pagerInfoText!,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// shrinkWrap 模式下的 Expanded 兼容:收缩时原样返回,撑满时包 Expanded。
|
||||
Widget _maybeExpand(Widget child) =>
|
||||
widget.shrinkWrap ? child : Expanded(child: child);
|
||||
|
||||
Widget _buildMobile(dynamic t) {
|
||||
final cards = widget.mobileCards!;
|
||||
if (cards.isEmpty) return _empty(t);
|
||||
@@ -111,6 +151,44 @@ class _DsTableState extends State<DsTable> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 收缩版卡片流(外层页面自带滚动)。
|
||||
Widget _buildMobileShrink(dynamic t) {
|
||||
final cards = widget.mobileCards!;
|
||||
if (cards.isEmpty) return _empty(t);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < cards.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 10),
|
||||
cards[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收缩版表格:不带内部滚动,高度随内容(多区块滚动页用)。
|
||||
Widget _buildTableShrink(dynamic t) {
|
||||
if (widget.rows.isEmpty) return _empty(t);
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _hovered,
|
||||
builder: (context, hov, __) => Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
columnWidths: const {0: FlexColumnWidth()},
|
||||
// 原型 td vertical-align: middle——混高单元格(名称双行等)垂直居中
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
children: [
|
||||
_headerRow(t),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_dataRow(t, i, widget.rows[i], hov),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(dynamic t) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
@@ -136,13 +214,18 @@ class _DsTableState extends State<DsTable> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
children: [
|
||||
_headerRow(t),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_dataRow(t, i, widget.rows[i]),
|
||||
],
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _hovered,
|
||||
builder: (context, hov, __) => Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
// 原型 td vertical-align: middle
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
children: [
|
||||
_headerRow(t),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_dataRow(t, i, widget.rows[i], hov),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -169,8 +252,7 @@ class _DsTableState extends State<DsTable> {
|
||||
// 原型 thead th: pad 11 14
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Align(
|
||||
alignment:
|
||||
c.numeric ? Alignment.centerRight : Alignment.centerLeft,
|
||||
alignment: c.numeric ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
@@ -178,120 +260,233 @@ class _DsTableState extends State<DsTable> {
|
||||
);
|
||||
}
|
||||
|
||||
TableRow _dataRow(dynamic t, int index, DsRow row) {
|
||||
TableRow _dataRow(dynamic t, int index, DsRow row, int hovered) {
|
||||
// 背景 + 下边框挂在行级 decoration:单元格高度随内容各不相同(如名称双行 vs
|
||||
// 单行徽章),若把边框画在格子上,矮格子的下边框会浮在行中间「断线」,
|
||||
// hover 背景也会条纹化。行级画一次,整行齐平(原型 tbody td 的 border 视觉)。
|
||||
final bg = hovered == index ? t.rowHover : (row.highlight);
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
children: List.generate(row.cells.length, (col) {
|
||||
final c = widget.columns[col];
|
||||
return MouseRegion(
|
||||
cursor: row.onTap != null
|
||||
? SystemMouseCursors.click
|
||||
: MouseCursor.defer,
|
||||
cursor:
|
||||
row.onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => _hovered.value = index,
|
||||
onExit: (_) {
|
||||
if (_hovered.value == index) _hovered.value = -1;
|
||||
},
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _hovered,
|
||||
builder: (context, hov, __) {
|
||||
final bg = hov == index ? t.rowHover : (row.highlight);
|
||||
return GestureDetector(
|
||||
onTap: row.onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
// 原型 tbody td: pad 12 14,下边 border-subtle
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
alignment: c.numeric
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: t.text),
|
||||
child: row.cells[col],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: row.onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
// 原型 tbody td: pad 12 14
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
alignment:
|
||||
c.numeric ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
||||
child: row.cells[col],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 原型 .pager:每页 N 条 + 显示范围 + 翻页
|
||||
static const _pageSizes = [10, 20, 50, 100];
|
||||
|
||||
// 原型 .pager(卡外透明,pad 13 4 2,gap12):
|
||||
// 「每页 [pgsize-btn ▾] 条」 · 「显示 X–Y,共 N」 · 右侧 .pg 数字页码 ‹ 1 2 ›。
|
||||
Widget _buildPager(dynamic t) {
|
||||
final total = widget.total!;
|
||||
final pages = (total / widget.pageSize).ceil().clamp(1, 99999);
|
||||
final pages = math.max(1, (total / widget.pageSize).ceil());
|
||||
final start = total == 0 ? 0 : (widget.page - 1) * widget.pageSize + 1;
|
||||
final end = (widget.page * widget.pageSize).clamp(0, total);
|
||||
final labelStyle = TextStyle(fontSize: AppDims.fsSm, color: t.muted);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 13, 4, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onPageSizeChanged != null && !context.isMobile) ...[
|
||||
// .pg-size:每页 [btn] 条(组内 gap6)
|
||||
Text('每页', style: labelStyle),
|
||||
const SizedBox(width: 6),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: const [10, 20, 50, 100].contains(widget.pageSize)
|
||||
? widget.pageSize
|
||||
: 20,
|
||||
isDense: true,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.text,
|
||||
fontFamily: 'monospace'),
|
||||
items: const [10, 20, 50, 100]
|
||||
.map((n) =>
|
||||
DropdownMenuItem(value: n, child: Text('$n 条')))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) widget.onPageSizeChanged!(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_pgSizeBtn(t),
|
||||
const SizedBox(width: 6),
|
||||
Text('条', style: labelStyle),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Text('显示 $start–$end,共 $total', style: labelStyle),
|
||||
const Spacer(),
|
||||
_pgBtn(t, Icons.chevron_left,
|
||||
widget.page > 1 ? () => widget.onPageChanged?.call(widget.page - 1) : null),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('${widget.page} / $pages',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.text,
|
||||
fontFamily: 'monospace')),
|
||||
),
|
||||
_pgBtn(t, Icons.chevron_right,
|
||||
widget.page < pages ? () => widget.onPageChanged?.call(widget.page + 1) : null),
|
||||
_pgGroup(t, pages),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pgBtn(dynamic t, IconData icon, VoidCallback? onTap) {
|
||||
return SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Material(
|
||||
color: t.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
// .pgsize-btn:h30 / pad 0 8 0 10 / border r-sm / surface / fs-sm mono + caret 12(muted),
|
||||
// 点击 openMenu(minW 88)单选每页条数。
|
||||
Widget _pgSizeBtn(dynamic t) {
|
||||
return Builder(
|
||||
builder: (bctx) => InkWell(
|
||||
onTap: () async {
|
||||
final v = await showDsMenu<int>(
|
||||
bctx,
|
||||
minWidth: 88,
|
||||
items: [
|
||||
for (final n in _pageSizes)
|
||||
DsMenuItem(
|
||||
value: n, label: '$n', selected: n == widget.pageSize),
|
||||
],
|
||||
);
|
||||
if (v != null) widget.onPageSizeChanged!(v);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 8, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${widget.pageSize}',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.text,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
const SizedBox(width: 6),
|
||||
Icon(LucideIcons.chevronDown, size: 12, color: t.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Icon(icon,
|
||||
size: 16, color: onTap == null ? t.faint : t.text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// .pg:gap4;‹ 数字页码 ›;cur=primary。页数多时开窗(1 … 邻域 … 末页)。
|
||||
Widget _pgGroup(dynamic t, int pages) {
|
||||
final nums = _pageWindow(pages);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_pgBtn(t, '‹',
|
||||
onTap: widget.page > 1
|
||||
? () => widget.onPageChanged?.call(widget.page - 1)
|
||||
: null),
|
||||
for (final n in nums) ...[
|
||||
const SizedBox(width: 4),
|
||||
if (n == -1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text('…',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.muted,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
)
|
||||
else
|
||||
_pgBtn(t, '$n',
|
||||
cur: n == widget.page,
|
||||
onTap: n == widget.page
|
||||
? null
|
||||
: () => widget.onPageChanged?.call(n)),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
_pgBtn(t, '›',
|
||||
onTap: widget.page < pages
|
||||
? () => widget.onPageChanged?.call(widget.page + 1)
|
||||
: null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 页码开窗:≤7 页全显(同原型全量循环);更多时 1 … cur±1 … last,-1 表示省略号。
|
||||
List<int> _pageWindow(int pages) {
|
||||
if (pages <= 7) return [for (var p = 1; p <= pages; p++) p];
|
||||
final cur = widget.page.clamp(1, pages);
|
||||
final set = <int>{1, pages, cur - 1, cur, cur + 1}
|
||||
..removeWhere((p) => p < 1 || p > pages);
|
||||
final sorted = set.toList()..sort();
|
||||
final out = <int>[];
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) out.add(-1);
|
||||
out.add(sorted[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// .pg button:minW30 h30 pad 0 6 border r-sm mono fs-sm;cur→primary 底反白;disabled→faint。
|
||||
Widget _pgBtn(dynamic t, String label,
|
||||
{bool cur = false, VoidCallback? onTap}) {
|
||||
final fg = cur ? t.onPrimary : (onTap == null && !cur ? t.faint : t.text);
|
||||
return Material(
|
||||
color: cur ? t.primary : t.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: cur ? t.primary : t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 30),
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
alignment: Alignment.center,
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: fg,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 原型 thead th .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary。
|
||||
/// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)。
|
||||
class DsFilterHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final bool filtered;
|
||||
final void Function(BuildContext anchorContext) onOpen;
|
||||
const DsFilterHeader(
|
||||
{super.key,
|
||||
required this.label,
|
||||
required this.filtered,
|
||||
required this.onOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final color = filtered ? t.primary : t.muted;
|
||||
return Builder(
|
||||
builder: (bctx) => InkWell(
|
||||
onTap: () => onOpen(bctx),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
const SizedBox(width: 5),
|
||||
Icon(LucideIcons.funnel, size: 12, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// widgets/ds/ds_toast.dart — 原型 .toast(atoms.css)1:1:
|
||||
// fixed bottom:26 水平居中 / --toast-bg 底 + 白字 fs-body / pad 11 18 / r-md /
|
||||
// **内容自适应宽**(不定宽、不贯穿)/ .25s 淡入+上滑 / 2.2s 自动消失。
|
||||
// 单例复用:新消息顶替旧消息(对齐原型同一 #toast 节点 + 计时器重置),
|
||||
// 天然避免多条提示叠罗汉。语义底色(成功/失败)由调用点传 [bg] 覆盖。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
|
||||
OverlayEntry? _entry;
|
||||
Timer? _timer;
|
||||
|
||||
void showDsToast(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Color? bg,
|
||||
Duration duration = const Duration(milliseconds: 2200),
|
||||
}) {
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||
if (overlay == null) return;
|
||||
final color = bg ?? context.tokens.toastBg;
|
||||
|
||||
_timer?.cancel();
|
||||
if (_entry?.mounted ?? false) _entry!.remove();
|
||||
_entry = null;
|
||||
final entry = OverlayEntry(
|
||||
builder: (_) => _DsToast(message: message, bg: color),
|
||||
);
|
||||
_entry = entry;
|
||||
overlay.insert(entry);
|
||||
_timer = Timer(duration, () {
|
||||
if (_entry == entry) {
|
||||
if (entry.mounted) entry.remove();
|
||||
_entry = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _DsToast extends StatelessWidget {
|
||||
final String message;
|
||||
final Color bg;
|
||||
const _DsToast({required this.message, required this.bg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 26,
|
||||
child: IgnorePointer(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
builder: (_, v, child) => Opacity(
|
||||
opacity: v,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - v)), child: child),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: Colors
|
||||
.white, // ds-ignore: toast 白字固定(承 toast-bg/语义色底)
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/app_tokens.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../searchable_option_field.dart' show OptionItem;
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
|
||||
/// 内联可键盘操作的下拉选择格(对齐原型 `.combo` / `.combo.cell` + `.combo-pop`)。
|
||||
///
|
||||
/// - 聚焦即打开浮层;输入即过滤(名称 / 全拼 / 首字母 / 编码)。
|
||||
/// - ↑↓ 移动高亮,Enter 选中并回调 [onPickedByKeyboard](供上层推进到下一格),
|
||||
/// Esc 关闭,Tab 交由上层 [onTab] 决定前进/后退。
|
||||
/// - [allowCreate] 时,输入无匹配可「新增到基础数据」,走 [onCreate]。
|
||||
/// - [commonIds] 命中的选项在无关键字时置顶为「常用」组(对齐系列/规格默认)。
|
||||
class GridComboCell extends StatefulWidget {
|
||||
final FocusNode focusNode;
|
||||
final List<OptionItem> options;
|
||||
final int? selectedId;
|
||||
final String hint;
|
||||
|
||||
/// true=网格单元格样式(透明、32 高);false=独立表单 combo(描边、38 高)。
|
||||
final bool cell;
|
||||
|
||||
/// 浮层是否置顶持久搜索框(对齐原型 .combo-pop > .cp-search,触发框只读展示)。
|
||||
/// null=沿用默认(单据头 combo 用弹层搜索、网格 combo 用单元格内联输入);
|
||||
/// 传 true 可让网格单元格也走弹层搜索(如入库明细的商品名/系列/规格)。
|
||||
final bool? popupSearch;
|
||||
final bool hasError;
|
||||
final bool enabled;
|
||||
final bool allowCreate;
|
||||
|
||||
/// 无关键字时置顶显示的「常用」选项 id(如按商品名带出的默认系列/规格)。
|
||||
final Set<int> commonIds;
|
||||
|
||||
/// 选项数、供应商/客户等大字典时置底显示计数(对齐原型单据头 combo 的 cp-foot)。
|
||||
final bool showCount;
|
||||
|
||||
/// 单据头 combo(cell=false)浮层顶部 .cp-search 的占位文案;空则回退到 [hint]。
|
||||
final String? searchHint;
|
||||
|
||||
/// 服务端搜索(供供应商/客户/库存商品等);提供时输入 debounce 调它取结果。
|
||||
final Future<List<OptionItem>> Function(String keyword)? onSearch;
|
||||
|
||||
/// 新增到基础数据;返回新选项 id(自动选中)。
|
||||
final Future<int?> Function(String keyword)? onCreate;
|
||||
|
||||
final void Function(int? id) onChanged;
|
||||
|
||||
/// 通过键盘 Enter 选中后触发(上层据此推进到下一格 / 加行)。
|
||||
final VoidCallback? onPickedByKeyboard;
|
||||
|
||||
/// Tab(shift=后退);返回 true 表示已处理。
|
||||
final bool Function({required bool backward})? onTab;
|
||||
|
||||
const GridComboCell({
|
||||
super.key,
|
||||
required this.focusNode,
|
||||
required this.options,
|
||||
required this.selectedId,
|
||||
required this.hint,
|
||||
required this.onChanged,
|
||||
this.cell = false,
|
||||
this.popupSearch,
|
||||
this.hasError = false,
|
||||
this.enabled = true,
|
||||
this.allowCreate = false,
|
||||
this.commonIds = const {},
|
||||
this.showCount = false,
|
||||
this.searchHint,
|
||||
this.onSearch,
|
||||
this.onCreate,
|
||||
this.onPickedByKeyboard,
|
||||
this.onTab,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GridComboCell> createState() => _GridComboCellState();
|
||||
}
|
||||
|
||||
/// 浮层里可高亮/选中的一项:既有选项,或「新增」行。
|
||||
class _PopEntry {
|
||||
final OptionItem? opt; // null → 新增行
|
||||
final String? createKw;
|
||||
const _PopEntry.opt(this.opt) : createKw = null;
|
||||
const _PopEntry.create(this.createKw) : opt = null;
|
||||
bool get isCreate => opt == null;
|
||||
}
|
||||
|
||||
class _GridComboCellState extends State<GridComboCell> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _link = LayerLink();
|
||||
final _listScroll = ScrollController();
|
||||
// 单据头 combo:浮层顶部持久搜索框的焦点(网格 combo 不用,走单元格内联输入)。
|
||||
final _searchFocus = FocusNode();
|
||||
OverlayEntry? _entry;
|
||||
Timer? _debounce;
|
||||
|
||||
/// 弹层搜索模式:浮层置顶显式搜索框,触发框保持只读展示(对齐原型 scope==='doc');
|
||||
/// 反之走网格单元格内联输入过滤。默认单据头(cell=false)开、网格(cell=true)关,
|
||||
/// 可由 [GridComboCell.popupSearch] 显式覆盖。
|
||||
bool get _docHead => widget.popupSearch ?? !widget.cell;
|
||||
|
||||
bool _open = false;
|
||||
String _kw = '';
|
||||
int _cursor = -1;
|
||||
List<_PopEntry> _entries = const [];
|
||||
List<OptionItem>? _remote; // onSearch 结果;null=用本地 options
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GridComboCell old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.focusNode != widget.focusNode) {
|
||||
old.focusNode.removeListener(_onFocusChange);
|
||||
widget.focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
if (_open) _rebuildEntries();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
widget.focusNode.removeListener(_onFocusChange);
|
||||
_removeOverlay();
|
||||
_ctrl.dispose();
|
||||
_searchFocus.dispose();
|
||||
_listScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _selectedName =>
|
||||
widget.options
|
||||
.where((o) => o.id == widget.selectedId)
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'';
|
||||
|
||||
void _onFocusChange() {
|
||||
if (widget.focusNode.hasFocus) {
|
||||
_openPop();
|
||||
if (_docHead) {
|
||||
// 触发框获焦即打开浮层,随后把输入焦点移进浮层顶部搜索框。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_open && mounted) _searchFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
} else if (!_docHead) {
|
||||
// 网格 combo:单元格失焦即收起。弹层搜索 combo 的收起改由 选中 / Esc / Tab /
|
||||
// 点浮层外(TapRegion.onTapOutside)显式驱动——不再监听搜索框失焦,
|
||||
// 避免点选项时「按下即失焦→微任务提前拆浮层→onTap 落空」的竞态(选中不填充)。
|
||||
_closePop();
|
||||
}
|
||||
}
|
||||
|
||||
void _dismiss() {
|
||||
_searchFocus.unfocus();
|
||||
widget.focusNode.unfocus();
|
||||
_closePop();
|
||||
}
|
||||
|
||||
// ── 浮层开合 ────────────────────────────────────────────────────────────
|
||||
void _openPop() {
|
||||
if (_open || !widget.enabled) return;
|
||||
_open = true;
|
||||
_kw = '';
|
||||
_remote = null;
|
||||
_ctrl.text = '';
|
||||
_rebuildEntries();
|
||||
_entry = OverlayEntry(builder: _buildPop);
|
||||
Overlay.of(context).insert(_entry!);
|
||||
}
|
||||
|
||||
void _closePop() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_debounce?.cancel();
|
||||
_ctrl.text = '';
|
||||
_removeOverlay();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_entry?.remove();
|
||||
_entry = null;
|
||||
}
|
||||
|
||||
List<OptionItem> get _sourceOptions => _remote ?? widget.options;
|
||||
|
||||
void _rebuildEntries() {
|
||||
final kw = _kw.trim();
|
||||
final matched = kw.isEmpty
|
||||
? _sourceOptions
|
||||
: _sourceOptions.where((o) => o.matches(kw)).toList();
|
||||
final list = <_PopEntry>[];
|
||||
for (final o in matched) {
|
||||
list.add(_PopEntry.opt(o));
|
||||
}
|
||||
final canCreate = widget.allowCreate &&
|
||||
widget.onCreate != null &&
|
||||
kw.isNotEmpty &&
|
||||
!_sourceOptions.any((o) => o.name == kw);
|
||||
if (canCreate) list.add(_PopEntry.create(kw));
|
||||
_entries = list;
|
||||
// 当前高亮:选中项优先,否则首项
|
||||
_cursor =
|
||||
list.indexWhere((e) => !e.isCreate && e.opt!.id == widget.selectedId);
|
||||
if (_cursor < 0) _cursor = list.isEmpty ? -1 : 0;
|
||||
_entry?.markNeedsBuild();
|
||||
}
|
||||
|
||||
void _onInput(String v) {
|
||||
_kw = v;
|
||||
if (widget.onSearch != null) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 250), () async {
|
||||
final res = await widget.onSearch!(v.trim());
|
||||
if (!mounted || !_open) return;
|
||||
_remote = res;
|
||||
_rebuildEntries();
|
||||
});
|
||||
// 本地即时反馈(用已有 options 过滤)
|
||||
_rebuildEntries();
|
||||
} else {
|
||||
_rebuildEntries();
|
||||
}
|
||||
}
|
||||
|
||||
void _move(int d) {
|
||||
if (_entries.isEmpty) return;
|
||||
_cursor = (_cursor + d + _entries.length) % _entries.length;
|
||||
_entry?.markNeedsBuild();
|
||||
_scrollToCursor();
|
||||
}
|
||||
|
||||
void _scrollToCursor() {
|
||||
if (!_listScroll.hasClients || _cursor < 0) return;
|
||||
const itemH = 36.0;
|
||||
final target = _cursor * itemH;
|
||||
final vp = _listScroll.position.viewportDimension;
|
||||
final off = _listScroll.offset;
|
||||
if (target < off) {
|
||||
_listScroll.jumpTo(target);
|
||||
} else if (target + itemH > off + vp) {
|
||||
_listScroll.jumpTo(
|
||||
(target + itemH - vp).clamp(0, _listScroll.position.maxScrollExtent));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickCursor({required bool byKeyboard}) async {
|
||||
if (_cursor < 0 || _cursor >= _entries.length) return;
|
||||
final e = _entries[_cursor];
|
||||
if (e.isCreate) {
|
||||
final id = await widget.onCreate!(e.createKw!);
|
||||
if (id != null) {
|
||||
widget.onChanged(id);
|
||||
if (byKeyboard) widget.onPickedByKeyboard?.call();
|
||||
}
|
||||
_dismiss();
|
||||
return;
|
||||
}
|
||||
widget.onChanged(e.opt!.id);
|
||||
_dismiss();
|
||||
if (byKeyboard) widget.onPickedByKeyboard?.call();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final key = event.logicalKey;
|
||||
if (!_open) {
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.arrowDown) {
|
||||
_openPop();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
_move(1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
_move(-1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
_pickCursor(byKeyboard: true);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
_dismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
final backward = HardwareKeyboard.instance.isShiftPressed;
|
||||
// 先按当前高亮选中(若有),再收起本浮层并交给上层移动焦点。
|
||||
// 收起必须显式做:已移除搜索框失焦监听,Tab 走后不再自动关闭。
|
||||
if (_cursor >= 0 && !_entries[_cursor].isCreate) {
|
||||
widget.onChanged(_entries[_cursor].opt!.id);
|
||||
}
|
||||
_closePop();
|
||||
final handled = widget.onTab?.call(backward: backward) ?? false;
|
||||
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
widget.onChanged(null);
|
||||
}
|
||||
|
||||
// ── 浮层内容 ───────────────────────────────────────────────────────────
|
||||
Widget _buildPop(BuildContext ctx) {
|
||||
final t = context.tokens;
|
||||
final field = context.findRenderObject() as RenderBox?;
|
||||
final width = (field?.size.width ?? 200).clamp(200.0, 420.0);
|
||||
return Positioned(
|
||||
width: width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _link,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.bottomLeft,
|
||||
followerAnchor: Alignment.topLeft,
|
||||
offset: const Offset(0, 4),
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) => _dismiss(),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
color: t.surface,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_docHead) _buildSearch(t),
|
||||
Flexible(child: _buildList(t)),
|
||||
if (widget.showCount) _buildCount(t),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 浮层顶部持久搜索框(对齐原型 .combo-pop > .cp-search,仅单据头 combo)。
|
||||
Widget _buildSearch(AppTokens t) {
|
||||
final hint = widget.searchHint ?? widget.hint;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 9, 11, 9),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.search, size: 14, color: t.faint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: _onKey,
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _searchFocus,
|
||||
autofocus: true,
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
||||
cursorColor: t.primary,
|
||||
onChanged: _onInput,
|
||||
onSubmitted: (_) => _pickCursor(byKeyboard: true),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(AppTokens t) {
|
||||
if (_entries.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text('无匹配结果',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
);
|
||||
}
|
||||
final kw = _kw.trim();
|
||||
final commonMode = kw.isEmpty && widget.commonIds.isNotEmpty;
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 264),
|
||||
child: ListView.builder(
|
||||
controller: _listScroll,
|
||||
padding: const EdgeInsets.all(5),
|
||||
shrinkWrap: true,
|
||||
itemCount: _entries.length + (commonMode ? _groupHeaderCount() : 0),
|
||||
itemBuilder: (c, i) => _rowAt(t, i, commonMode, kw),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 「常用 / 全部」分组时插入两个 header,索引需换算。
|
||||
int _commonCount() => _entries
|
||||
.where((e) => !e.isCreate && widget.commonIds.contains(e.opt!.id))
|
||||
.length;
|
||||
int _groupHeaderCount() {
|
||||
final c = _commonCount();
|
||||
final r = _entries.where((e) => !e.isCreate).length - c;
|
||||
return (c > 0 ? 1 : 0) + (r > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
Widget _rowAt(AppTokens t, int i, bool commonMode, String kw) {
|
||||
if (!commonMode) return _entryRow(t, i, kw);
|
||||
// 分组渲染:常用组 header + 常用项,全部组 header + 其余项
|
||||
final common = <int>[];
|
||||
final rest = <int>[];
|
||||
for (var k = 0; k < _entries.length; k++) {
|
||||
final e = _entries[k];
|
||||
if (e.isCreate) {
|
||||
rest.add(k);
|
||||
} else if (widget.commonIds.contains(e.opt!.id)) {
|
||||
common.add(k);
|
||||
} else {
|
||||
rest.add(k);
|
||||
}
|
||||
}
|
||||
final seq = <Widget Function()>[];
|
||||
if (common.isNotEmpty) {
|
||||
seq.add(() => _groupHeader(t, '常用'));
|
||||
for (final k in common) {
|
||||
seq.add(() => _entryRow(t, k, kw));
|
||||
}
|
||||
}
|
||||
if (rest.isNotEmpty) {
|
||||
seq.add(() => _groupHeader(t, '全部'));
|
||||
for (final k in rest) {
|
||||
seq.add(() => _entryRow(t, k, kw));
|
||||
}
|
||||
}
|
||||
return seq[i]();
|
||||
}
|
||||
|
||||
Widget _groupHeader(AppTokens t, String label) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(9, 8, 9, 4),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsXs,
|
||||
color: t.faint,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5)),
|
||||
);
|
||||
|
||||
Widget _entryRow(AppTokens t, int idx, String kw) {
|
||||
final e = _entries[idx];
|
||||
final cur = idx == _cursor;
|
||||
if (e.isCreate) {
|
||||
return _PopRow(
|
||||
cur: cur,
|
||||
onTap: () => _pickCursor(byKeyboard: false),
|
||||
onHover: () => _setCursor(idx),
|
||||
topBorder: t.borderSubtle,
|
||||
child: Row(children: [
|
||||
Icon(LucideIcons.plus, size: 14, color: t.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('新增「${e.createKw}」到基础数据',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.primary,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
final o = e.opt!;
|
||||
final sel = o.id == widget.selectedId;
|
||||
return _PopRow(
|
||||
cur: cur,
|
||||
onTap: () {
|
||||
_setCursor(idx);
|
||||
_pickCursor(byKeyboard: false);
|
||||
},
|
||||
onHover: () => _setCursor(idx),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: _highlight(o.name, kw, t, sel ? t.primary : t.text,
|
||||
sel ? FontWeight.w600 : FontWeight.w400),
|
||||
),
|
||||
if (o.code != null && o.code!.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(o.code!,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsXs,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
color: t.faint)),
|
||||
],
|
||||
if (sel) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(LucideIcons.check, size: 15, color: t.primary),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _setCursor(int i) {
|
||||
if (_cursor == i) return;
|
||||
_cursor = i;
|
||||
_entry?.markNeedsBuild();
|
||||
}
|
||||
|
||||
Widget _highlight(
|
||||
String name, String kw, AppTokens t, Color base, FontWeight w) {
|
||||
if (kw.isEmpty) {
|
||||
return Text(name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w));
|
||||
}
|
||||
final lower = name.toLowerCase();
|
||||
final i = lower.indexOf(kw.toLowerCase());
|
||||
if (i < 0) {
|
||||
return Text(name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w));
|
||||
}
|
||||
return RichText(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w),
|
||||
children: [
|
||||
TextSpan(text: name.substring(0, i)),
|
||||
TextSpan(
|
||||
text: name.substring(i, i + kw.length),
|
||||
style: TextStyle(color: t.primary, fontWeight: FontWeight.w700)),
|
||||
TextSpan(text: name.substring(i + kw.length)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCount(AppTokens t) {
|
||||
final total = _sourceOptions.length;
|
||||
final kw = _kw.trim();
|
||||
final matched = _entries.where((e) => !e.isCreate).length;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: t.bg,
|
||||
border: Border(top: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Text(
|
||||
kw.isEmpty ? '共 $total 条结果' : '匹配 $matched 条 · 共 $total 条',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 输入框本体 ─────────────────────────────────────────────────────────
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final has = widget.selectedId != null;
|
||||
final height = widget.cell ? 32.0 : 38.0;
|
||||
final borderColor = widget.hasError
|
||||
? t.danger
|
||||
: (_open ? t.primary : (widget.cell ? Colors.transparent : t.border));
|
||||
return CompositedTransformTarget(
|
||||
link: _link,
|
||||
child: Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.cell
|
||||
? (_open ? t.surface : Colors.transparent)
|
||||
: t.surface,
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(widget.cell ? 5 : AppDims.rMd),
|
||||
boxShadow: _open
|
||||
? [
|
||||
BoxShadow(
|
||||
color: t.brand50,
|
||||
blurRadius: 0,
|
||||
spreadRadius: widget.cell ? 2 : 3)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Focus(
|
||||
focusNode: widget.focusNode,
|
||||
onKeyEvent: _onKey,
|
||||
child: Builder(builder: (context) {
|
||||
// 单据头 combo:触发框始终只读展示(输入在浮层顶部搜索框);
|
||||
// 网格 combo:聚焦时就地变为可输入过滤框。
|
||||
if (!_open || _docHead) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.enabled
|
||||
? () => widget.focusNode.requestFocus()
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.cell ? 8 : 11, right: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
has ? _selectedName : widget.hint,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: has ? t.text : t.faint),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return TextField(
|
||||
controller: _ctrl,
|
||||
autofocus: true,
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
||||
cursorColor: t.primary,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: widget.cell ? 8 : 11, right: 4),
|
||||
border: InputBorder.none,
|
||||
filled: false,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: has ? _selectedName : widget.hint,
|
||||
hintStyle:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
||||
),
|
||||
onChanged: _onInput,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
// caf:有值→X 清除;无值→chevron
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.enabled
|
||||
? () {
|
||||
if (has) {
|
||||
_clear();
|
||||
} else {
|
||||
widget.focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: widget.cell ? 6 : 8, left: 2),
|
||||
child: Icon(
|
||||
has ? LucideIcons.x : LucideIcons.chevronDown,
|
||||
size: 14,
|
||||
color: t.faint,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PopRow extends StatelessWidget {
|
||||
final bool cur;
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onHover;
|
||||
final Color? topBorder;
|
||||
const _PopRow({
|
||||
required this.cur,
|
||||
required this.child,
|
||||
required this.onTap,
|
||||
required this.onHover,
|
||||
this.topBorder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => onHover(),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: cur ? t.rowHover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
border: topBorder != null
|
||||
? Border(top: BorderSide(color: topBorder!))
|
||||
: null,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user