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

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

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

179 lines
5.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/material.dart';
import '../core/theme/context_tokens.dart';
import '../core/theme/app_dims.g.dart';
/// KPI 卡(还原原型 `.kpi`):左上标题、右上图标芯片、下方大数值、可选同比/说明。
/// 色调走 token(禁硬编码 hex),尺寸走 AppDims(禁裸 px)。
/// 原型规格:surface 面 / 1px border / r-lg 圆角 / padding 15·16
/// `.ic` 30×30 r-md`.v` 24/700 mono`.d` fs-xshover(click) 描边 brand400。
enum KpiTone { info, ok, blue, alert, warn, danger }
enum KpiTrend { none, up, down }
class KpiCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final KpiTone tone;
/// 同比/说明文案(如「▲ 2.3% 较上月」「需补货 6 项」),null 不显示。
final String? delta;
final KpiTrend trend;
/// 点击(如清筛选 / 跳转);null 时为静态卡,不显示 hover。
final VoidCallback? onTap;
const KpiCard({
super.key,
required this.title,
required this.value,
required this.icon,
this.tone = KpiTone.info,
this.delta,
this.trend = KpiTrend.none,
this.onTap,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final (Color icBg, Color icFg) = switch (tone) {
KpiTone.info => (t.infoSoft, t.primary),
KpiTone.ok => (t.okSoft, t.success),
KpiTone.blue => (t.infoSoft, t.brand400),
KpiTone.alert => (t.accentSoft, t.accent),
KpiTone.warn => (t.warnBg, t.warn),
KpiTone.danger => (t.dangerBg, t.danger),
};
final valueColor = switch (tone) {
KpiTone.alert => t.accent,
KpiTone.danger => t.danger,
_ => t.heading,
};
final deltaColor = switch (trend) {
KpiTrend.up => t.success,
KpiTrend.down => t.danger,
KpiTrend.none => t.muted,
};
final card = Container(
padding:
const EdgeInsets.symmetric(horizontal: AppDims.sp4, vertical: 15),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(title,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: icBg,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Icon(icon, size: 17, color: icFg),
),
],
),
const SizedBox(height: 7),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: valueColor,
letterSpacing: .3,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (delta != null) ...[
const SizedBox(height: 5),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (trend != KpiTrend.none)
Icon(
trend == KpiTrend.up
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 16,
color: deltaColor,
),
Text(delta!,
style:
TextStyle(fontSize: AppDims.fsXs, color: deltaColor)),
],
),
],
],
),
);
if (onTap == null) return card;
return InkWell(
borderRadius: BorderRadius.circular(AppDims.rLg),
onTap: onTap,
child: card,
);
}
}
/// 状态徽章(还原原型 `.badge`:圆点 + 文字同色 + 软底 pill)。
/// 用于库存「在售/预警/缺货」等派生状态;色与软底全走 token。
/// [icon] 非空 → 图标徽章变体(原型 .badge.bi),默认圆点(存量 golden 零漂移)。
class StatusPill extends StatelessWidget {
final String label;
final Color color;
final Color background;
final IconData? icon;
const StatusPill(
{super.key,
required this.label,
required this.color,
required this.background,
this.icon});
@override
Widget build(BuildContext context) {
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 12, color: color)
else
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(label,
style: TextStyle(
color: color,
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600)),
],
),
);
}
}