72 KiB
ops-console(爱视云管理)手机端 UI 优化方案
⛔ 本文档已作废,请勿据此实施
唯一有效版本:
手机端UI优化方案-v2.md本文件仅作历史存档保留。v2 相对本文的变更:
- 基调从「重新定义品牌视觉语言」改为「回归旧版橙白 + 合理化改进」;
- 新增「新旧版延续性对照」章节;
- 图标章节按冻结区重写 —— 用户要求「首页应用图标及与应用显示相关的图标维持原有设计不做任何修改」,本文 §5「图标风格」已作废,请勿执行(尤其不要把
Icons.dns改成Icons.cloud_*);- 新增「导航栏 / 设置页 / 详情页 / 弹窗」四类代码级落地指引。
本文的配色体系(§3)、圆角与阴影(§4)、布局与触控(§6)、
theme.dart改造代码(§7.4)仍然有效,v2 已完整沿用。
| 项 | 内容 |
|---|---|
| 项目 | ops-console(包名 com.ainetv.aishicloud) |
| 技术栈 | Flutter 3.27.4 / Dart 3.6.2 / Material 3 / go_router / Riverpod |
| 目标设备 | Android 手机;实测横屏 1600×900(rotation=1),同时兼容竖屏 |
| 用户画像 | 门店店员 / 现场运维人员,常在现场单手操作 |
| 文档作者 | 许清楚(产品经理) |
| 对标基准 | WCAG 2.1 AA;Material 3 规范;Android 无障碍触控建议 |
| 状态 | 历史存档(v1) —— 有效版本见文首链接 |
1. 一句话结论
当前主题主色 #2E7DFF(科技蓝)与 logo 主色 #FF914D(暖橙)直接冲突,这是"视觉不一致"的根源。
本方案以 logo 实测色 #FF914D / #FFBE58 / #FEFEFE 为唯一取色来源,重建一套暖橙 + 近白云感的品牌设计语言,并同步解决三个被源码证实的硬伤:
- 层次缺失:
Cardelevation 仅 1、圆角无梯度 → 整屏扁平,与"云朵漂浮"意象无关; - 触控与横屏未适配:全项目 0 处
OrientationBuilder、0 处响应式断点,GridView 写死crossAxisCount: 2→ 1600px 横屏下卡片被拉伸到 780px,信息密度崩溃; - 30 处底部弹窗全部裸奔:
showModalBottomSheet未设useSafeArea、未限高、未加拖拽把手,门店新增/编辑长表单的保存按钮埋在滚动区末尾。
2. 设计语言定义(由 logo 实测数据推导)
logo assets/images/logo.png(192×192,RGBA)实测数据:
| 维度 | 实测值 | 推导出的设计语言 |
|---|---|---|
| 容器形状 | 圆角矩形,圆角 ≈ 6px / 192px = 3% | 克制的小圆角,非胶囊、非圆形 |
主色 #FF914D |
占非透明像素 4.4%,图形中心像素 | 品牌主色(暖橙) |
金黄 #FFBE58 |
1.9% | 品牌辅助色(暖金) |
| 渐变带 | #FFA36A / #FFB487 / #FFC56C / #FCE2CB |
橙→金渐变,是"云朵受光"的表达 |
中性 #DBBB88 |
3.5% | 暖调中间色 → 中性色阶需偏暖,不可用纯灰 |
近白 #FEFEFE |
85.0% | 大面积留白,界面是"亮、轻、通透"的 |
| 明度 | 均值 245/255,最暗仅 163 | 无深色成分 → 浅色模式为品牌主场 |
| 橙色行分布 | [0,0,0,18,34,28,13,85,103,71,45,11,35,0,0,0] |
中部双峰起伏 → 云朵多圆弧叠加轮廓 |
视觉关键词(后续所有决策的判据)
云 · 轻盈 · 暖橙 · 明亮 · 圆润弧线 · 大留白
派生出 4 条可执行的判据:
- 中性色一律带暖色偏移(R>G>B),禁止使用纯灰
#9E9E9E/ 纯黑#000000; - 阴影用暖棕低透明而非纯黑,模糊半径大、偏移量小 → "托起"而非"压住";
- 圆角中等偏大但不成胶囊,且顶部圆角 ≥ 底部圆角(呼应云朵上缘多弧、下缘平缓);
- 品牌亮橙
#FF914D只做大色块/渐变/插画/图表,不做文字与图标色(原因见 §3.2 实测)。
3. 配色体系(重点)
3.1 关键约束:logo 亮橙不能配白字(实测)
用 WCAG 2.1 相对亮度公式对 logo 原色实测(脚本核算,非估算):
| 组合 | 对比度 | 结论 |
|---|---|---|
白字 #FFFFFF on #FF914D |
2.23 | ❌ 不达标(正文需 4.5,大字需 3.0) |
白字 #FFFFFF on #FFBE58 |
1.65 | ❌ 严重不达标 |
白字 #FFFFFF on #FFB487 |
1.73 | ❌ 不达标 |
黑字 #000000 on #FF914D |
9.41 | ✅ 达标 |
深棕 #3D1A00 on #FF914D |
6.99 | ✅ 达标(推荐,比纯黑更柔和、更品牌) |
结论(必须写进设计约束):
在
#FF914D系亮橙上,永远不要用白字;统一用深棕#3D1A00。 反过来,亮橙在浅色底上作为图标/文字色也不可用(#FFB487on#FCFAF8仅 1.66)。
因此采用 "双橙策略",这是本方案配色部分的核心决策:
| 角色 | 色值 | 用途 | 上/下字色 |
|---|---|---|---|
| 品牌色(Brand) | #FF914D |
插画、渐变、图表、Banner 大色块、Splash、进度条填充 | 深棕 #3D1A00 |
| 行动色(Primary) | #C2410C |
主按钮、导航选中、可点击文字、图标(需承载白字) | 白 #FFFFFF |
| 辅金色(Accent) | #FFBE58 |
高亮强调、到期提醒、星级、推荐位 | 深棕 #3D1A00 |
#C2410C = #FF914D 沿同色相(H≈23°)压暗到 tone-45,既保住 logo 的橙的色相识别,又把亮度压到能承载白字(vs 白 = 5.18 ✅)。
一句话记忆:"看"用亮橙
#FF914D,"点"用深橙#C2410C。
3.2 品牌色板(Brand Palette)
// lib/app/theme.dart — 品牌原色(直接取自 logo,不参与 ColorScheme 角色分工)
class AppBrand {
// —— logo 原色(仅用于大色块 / 渐变 / 插画 / 图表,禁止作文字色 ——
static const Color cloud = Color(0xFFFF914D); // logo 主色(图形中心像素)
static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄
static const Color cloudLight = Color(0xFFFFB487); // logo 渐变带
static const Color cloudMid = Color(0xFFFFA36A); // logo 渐变带
static const Color cloudWarm = Color(0xFFFFC56C); // logo 渐变带
static const Color cloudCream = Color(0xFFFCE2CB); // logo 渐变带(最浅)
static const Color cloudPaper = Color(0xFFFEFEFE); // logo 近白底
// —— 亮橙上的唯一合法文字色 ——
static const Color onCloud = Color(0xFF3D1A00); // vs #FF914D = 6.99 ✅
// —— 品牌渐变(搬运 logo 的橙→金渐变)——
static const LinearGradient cloudGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF914D), Color(0xFFFFBE58)],
);
// —— 暖调阴影基色(关键:阴影不用纯黑)——
static const Color shadowTint = Color(0xFF5C2E12);
}
3.3 浅色 ColorScheme(Light)—— 完整角色映射
static const ColorScheme _lightScheme = ColorScheme(
brightness: Brightness.light,
// 主色(行动色)
primary: Color(0xFFC2410C),
onPrimary: Color(0xFFFFFFFF), // 5.18 ✅
primaryContainer: Color(0xFFFFD3B3),
onPrimaryContainer: Color(0xFF4A1D00), // 10.38 ✅
// 辅助色(暖赭,来自 logo #DBBB88 的深调)
secondary: Color(0xFF8A5A3A),
onSecondary: Color(0xFFFFFFFF), // 5.83 ✅
secondaryContainer: Color(0xFFF5DFCB),
onSecondaryContainer:Color(0xFF5A3A22), // 7.90 ✅
// 第三色(琥珀金,来自 logo #FFBE58 的深调)
tertiary: Color(0xFF9A6000),
onTertiary: Color(0xFFFFFFFF), // 5.19 ✅
tertiaryContainer: Color(0xFFFFE0A3),
onTertiaryContainer: Color(0xFF5C3D00), // 7.75 ✅
// 错误
error: Color(0xFFB02A20),
onError: Color(0xFFFFFFFF), // 6.57 ✅
errorContainer: Color(0xFFFFDDD8),
onErrorContainer: Color(0xFF7A2A22), // 7.60 ✅
// 表面(暖中性,全部 R>G>B)
surface: Color(0xFFFCFAF8),
onSurface: Color(0xFF1A1614), // 17.26 ✅
onSurfaceVariant: Color(0xFF6E655C), // 5.48 ✅
surfaceContainerLowest: Color(0xFFFFFFFF),
surfaceContainerLow: Color(0xFFF7F3EF),
surfaceContainer: Color(0xFFF2EDE8),
surfaceContainerHigh: Color(0xFFECE5DF),
surfaceContainerHighest: Color(0xFFE5DED7),
outline: Color(0xFF8A8178), // 3.67 ✅(非文本元素需 ≥3.0)
outlineVariant: Color(0xFFDCCFC2), // 装饰性分隔线,无强制对比要求
surfaceTint: Color(0xFFC2410C), // M3 高度染色 → 卡片自带暖调
shadow: Color(0xFF5C2E12),
scrim: Color(0xFF1A1614),
inverseSurface: Color(0xFF2E2823),
onInverseSurface: Color(0xFFF7EFE9),
inversePrimary: Color(0xFFFFB487),
);
3.4 深色 ColorScheme(Dark)—— 完整角色映射
深色模式下 M3 规则是主色提亮到 tone-80、文字改深色,#FFB487 恰好就是 logo 渐变带的原色,直接复用,品牌一致性最高。
static const ColorScheme _darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFFFB487), // ← logo 渐变带原色
onPrimary: Color(0xFF542000), // 7.66 ✅(白字仅 1.73 ❌,必须用深棕)
primaryContainer: Color(0xFF63300D),
onPrimaryContainer: Color(0xFFFFDCC2), // 8.30 ✅
secondary: Color(0xFFE8C39B),
onSecondary: Color(0xFF542000), // 8.03 ✅
secondaryContainer: Color(0xFF5A3A22),
onSecondaryContainer:Color(0xFFFFDCC2), // 7.89 ✅
tertiary: Color(0xFFF2BE62),
onTertiary: Color(0xFF542000), // 7.78 ✅
tertiaryContainer: Color(0xFF5C3D00),
onTertiaryContainer: Color(0xFFFFDCC2), // 7.66 ✅
error: Color(0xFFFF8474),
onError: Color(0xFF5A1610), // 5.65 ✅
errorContainer: Color(0xFF7A2A22),
onErrorContainer: Color(0xFFFFC9C2), // 6.59 ✅
surface: Color(0xFF16130F), // 暖黑,非 #121212
onSurface: Color(0xFFF3E9E2), // 15.49 ✅
onSurfaceVariant: Color(0xFFC4B8AE), // 9.54 ✅
surfaceContainerLowest: Color(0xFF0F0D0A),
surfaceContainerLow: Color(0xFF1E1A16),
surfaceContainer: Color(0xFF23201B),
surfaceContainerHigh: Color(0xFF2D2924),
surfaceContainerHighest: Color(0xFF38332D),
outline: Color(0xFF7A7068), // 3.83 ✅
outlineVariant: Color(0xFF453F39),
surfaceTint: Color(0xFFFFB487),
shadow: Color(0xFF000000), // 深色下改用纯黑,暖色阴影不可见
scrim: Color(0xFF0F0D0A),
inverseSurface: Color(0xFFF3E9E2),
onInverseSurface: Color(0xFF2E2823),
inversePrimary: Color(0xFFC2410C),
);
3.5 语义色(成功 / 警告 / 错误 / 信息)
设计决策:警告色不用橙。 因为品牌主色本身就是橙,若警告也用橙会与"可点击/品牌"语义混淆。警告改用琥珀黄(#8A5C00 / #F0BE4E),与橙拉开色相差(约 20°),仍在同一暖色家族内。
| 语义 | Light 主色 | Light 容器 / 文字 | Dark 主色 | Dark 容器 / 文字 |
|---|---|---|---|---|
| 成功 | #1E7A4E (5.11 ✅) |
#D3EDDD / #1E5138 (7.39 ✅) |
#5FD398 (9.93 ✅) |
#1E5138 / #A8E6C4 (6.46 ✅) |
| 警告 | #8A5C00 (5.58 ✅) |
#FFE9B8 / #5C4100 (7.96 ✅) |
#F0BE4E (10.74 ✅) |
#5C4100 / #FFDCA8 (7.26 ✅) |
| 错误 | #B02A20 (6.31 ✅) |
#FFDDD8 / #7A2A22 (7.60 ✅) |
#FF8474 (7.76 ✅) |
#7A2A22 / #FFC9C2 (6.59 ✅) |
| 信息 | #0B6397 (6.21 ✅) |
#D7E9FA / #1A4763 (7.96 ✅) |
#7FC4F0 (9.75 ✅) |
#1A4763 / #B8DCF5 (6.87 ✅) |
括号中为对比度实测值(主色 vs surface;容器内文字 vs 容器)。
通过 ThemeExtension 暴露,避免散落硬编码:
class AppSemanticColors extends ThemeExtension<AppSemanticColors> {
const AppSemanticColors({
required this.success, required this.onSuccessContainer,
required this.successContainer,
required this.warning, required this.warningContainer,
required this.onWarningContainer,
required this.info, required this.infoContainer,
required this.onInfoContainer,
});
final Color success, successContainer, onSuccessContainer;
final Color warning, warningContainer, onWarningContainer;
final Color info, infoContainer, onInfoContainer;
static const light = AppSemanticColors(
success: Color(0xFF1E7A4E), successContainer: Color(0xFFD3EDDD),
onSuccessContainer: Color(0xFF1E5138),
warning: Color(0xFF8A5C00), warningContainer: Color(0xFFFFE9B8),
onWarningContainer: Color(0xFF5C4100),
info: Color(0xFF0B6397), infoContainer: Color(0xFFD7E9FA),
onInfoContainer: Color(0xFF1A4763),
);
static const dark = AppSemanticColors(
success: Color(0xFF5FD398), successContainer: Color(0xFF1E5138),
onSuccessContainer: Color(0xFFA8E6C4),
warning: Color(0xFFF0BE4E), warningContainer: Color(0xFF5C4100),
onWarningContainer: Color(0xFFFFDCA8),
info: Color(0xFF7FC4F0), infoContainer: Color(0xFF1A4763),
onInfoContainer: Color(0xFFB8DCF5),
);
@override AppSemanticColors copyWith({/* … */}) => this;
@override AppSemanticColors lerp(covariant AppSemanticColors? o, double t) =>
o ?? this;
}
/// 品牌色扩展:把「只能看不能点」的亮橙单独隔离,防止被误用为文字色。
class AppBrandExtension extends ThemeExtension<AppBrandExtension> {
const AppBrandExtension({
this.cloud = AppBrand.cloud,
this.cloudGold = AppBrand.cloudGold,
this.onCloud = AppBrand.onCloud,
this.gradient = AppBrand.cloudGradient,
});
final Color cloud, cloudGold, onCloud;
final LinearGradient gradient;
@override ThemeExtension<AppBrandExtension> copyWith() => this;
@override ThemeExtension<AppBrandExtension> lerp(
covariant ThemeExtension<AppBrandExtension>? other, double t) => this;
}
/// 使用方式:
/// final brand = Theme.of(context).extension<AppBrandExtension>()!;
/// final semantic = Theme.of(context).extension<AppSemanticColors>()!;
3.6 暖中性色阶(Neutral Ramp)
全部带暖偏移(R>G>B),与 logo 的 #DBBB88 同源。实测亮度严格单调:
| Token | Light | 相对亮度 | 用途 |
|---|---|---|---|
| n0 | #FFFFFF |
1.000 | 弹窗/输入填充 |
| n10 | #FCFAF8 |
0.958 | surface |
| n20 | #F7F3EF |
0.901 | surfaceContainerLow |
| n30 | #F2EDE8 |
0.853 | surfaceContainer |
| n40 | #ECE5DF |
0.792 | surfaceContainerHigh |
| n50 | #E5DED7 |
0.738 | surfaceContainerHighest |
| n60 | #DCCFC2 |
0.637 | outlineVariant / 分隔线 |
| n70 | #C8BFB4 |
0.528 | 禁用态填充 |
| n80 | #8A8178 |
0.225 | outline |
| n90 | #6E655C |
0.134 | onSurfaceVariant |
| n100 | #1A1614 |
0.008 | onSurface |
| n110 | #0F0D0A |
0.004 | surfaceContainerLowest(dark) |
| Token | Dark | 相对亮度 | 用途 |
|---|---|---|---|
| d10 | #F3E9E2 |
0.828 | onSurface |
| d20 | #C4B8AE |
0.491 | onSurfaceVariant |
| d30 | #7A7068 |
0.167 | outline |
| d40 | #453F39 |
0.051 | outlineVariant |
| d50 | #38332D |
0.034 | surfaceContainerHighest |
| d60 | #2D2924 |
0.023 | surfaceContainerHigh |
| d70 | #23201B |
0.015 | surfaceContainer |
| d80 | #1E1A16 |
0.011 | surfaceContainerLow |
| d90 | #16130F |
0.007 | surface |
| d100 | #0F0D0A |
0.004 | surfaceContainerLowest |
3.7 如何替换 #2E7DFF,及对可读性的影响
替换路径(3 步)
lib/app/theme.dart:8primaryBlue→ 改名brandPrimary,值#2E7DFF→#C2410C; 同时废弃ColorScheme.fromSeed改为显式ColorScheme(...)全量声明。原因:
fromSeed会按 HCT 算法生成容器色与辅助色,得到的是带紫/蓝偏移的派生色(蓝色种子尤其明显),无法精确控制品牌色;显式声明才能保证#FFD3B3这类 logo 同源的容器色落地。- 全局
primaryBlue引用点逐一替换(清单见 §8.1); - 亮橙
#FF914D通过ThemeExtension<AppBrand>暴露,不进入 ColorScheme,避免被组件误用为文字色。
替换后对可读性的影响(实测,非估算)
| 场景 | 旧(蓝 #2E7DFF) |
新(深橙 #C2410C) |
影响 |
|---|---|---|---|
| 主按钮白字 | 3.68(未达 AA) | 5.18 ✅ | 可读性提升 |
| 主色作文字/图标 | 3.68 | 4.97 ✅ | 可读性提升 |
| 选中态图标 on 容器 | — | 7.90 ✅ | 达标 |
| 亮橙作图标(禁止) | — | 1.66 ❌ | 新增约束,必须遵守 |
值得强调:换成深橙后,主按钮的对比度反而从 3.68 提升到 5.18,旧的科技蓝
#2E7DFF配白字其实一直未达 AA——这是一次换色顺带修复的无障碍问题。
3.8 对比度实测总表(全部通过)
[LIGHT]
✅ 5.18 #FFFFFF / #C2410C onPrimary / primary
✅ 10.38 #4A1D00 / #FFD3B3 onPrimaryContainer / primaryContainer
✅ 5.83 #FFFFFF / #8A5A3A onSecondary / secondary
✅ 7.90 #5A3A22 / #F5DFCB onSecondaryContainer / secondaryContainer
✅ 5.19 #FFFFFF / #9A6000 onTertiary / tertiary
✅ 7.75 #5C3D00 / #FFE0A3 onTertiaryContainer / tertiaryContainer
✅ 6.57 #FFFFFF / #B02A20 onError / error
✅ 7.60 #7A2A22 / #FFDDD8 onErrorContainer / errorContainer
✅ 17.26 #1A1614 / #FCFAF8 onSurface / surface
✅ 5.48 #6E655C / #FCFAF8 onSurfaceVariant / surface
✅ 3.67 #8A8178 / #FCFAF8 outline / surface (需 ≥3.0)
✅ 4.97 #C2410C / #FCFAF8 primary 作文本/图标
✅ 6.99 #3D1A00 / #FF914D 深棕字 on 品牌橙(Banner/大色块)
✅ 7.90 #5A3A22 / #F5DFCB 底部导航选中图标 on indicator
[DARK]
✅ 7.66 #542000 / #FFB487 onPrimary / primary
✅ 8.30 #FFDCC2 / #63300D onPrimaryContainer / primaryContainer
✅ 8.03 #542000 / #E8C39B onSecondary / secondary
✅ 7.89 #FFDCC2 / #5A3A22 onSecondaryContainer / secondaryContainer
✅ 7.78 #542000 / #F2BE62 onTertiary / tertiary
✅ 7.66 #FFDCC2 / #5C3D00 onTertiaryContainer / tertiaryContainer
✅ 5.65 #5A1610 / #FF8474 onError / error
✅ 6.59 #FFC9C2 / #7A2A22 onErrorContainer / errorContainer
✅ 15.49 #F3E9E2 / #16130F onSurface / surface
✅ 9.54 #C4B8AE / #16130F onSurfaceVariant / surface
✅ 3.83 #7A7068 / #16130F outline / surface (需 ≥3.0)
✅ 10.70 #FFB487 / #16130F primary 作文本/图标
✅ 1.73 #63300D / #16130F primaryContainer 与 surface 可区分(需 ≥1.3)
4. 圆角与阴影(呼应"云"意象)
4.1 与 logo 小圆角(3%)的取舍:为什么不全盘照搬
logo 圆角 6px / 192px = 3%。若等比映射到 UI 控件:
| 控件 | 尺寸 | 3% 等比结果 | 评价 |
|---|---|---|---|
| 主按钮 | 320×48 | 1.4dp | ❌ 生硬,几乎直角 |
| 标准卡片 | 360×140 | 4.2dp | ❌ 尖锐,与"云"完全无关 |
| 底部弹窗 | 360×600 | 10.8dp | ⚠️ 偏小 |
结论:3% 不能直接等比继承。 从 logo 中真正可继承的是三条定性特征:
- 非胶囊 —— logo 是圆角矩形,不是圆形/胶囊,所以 UI 主体不采用 999 全圆;
- 四角一致 —— 不做"左上大、右下小"这类不规则圆角(弹窗除外,见 4.2);
- 克制感 —— 圆角明显但不夸张,落在移动端舒适区间的中下段。
定量上把落点定为**"容器短边的 5%10%"**,正好落在 1228dp 区间。这样既保住 logo 的克制基因,又满足移动端拇指触控的视觉引导需求。
4.2 圆角梯度(AppRadius)
class AppRadius {
static const double xs = 4; // 进度条、内嵌小色块、图片缩略图角
static const double sm = 8; // Chip、SnackBar、Tooltip、小标签
static const double md = 12; // 输入框、表单控件(沿用现值,保持手感连续)
static const double lg = 16; // ★ 标准卡片、按钮(FilledButton/OutlinedButton)
static const double xl = 20; // 大卡片、列表分组容器、AppBar 底部两角
static const double xxl = 28; // ★ 底部弹窗顶部两角(云朵弧线的核心表达)
static const double full = 999; // 仅用于状态 Chip / 头像 / pill 标签
/// 云朵轮廓:上缘多圆弧、下缘平缓。
static const BorderRadius sheetTop = BorderRadius.vertical(
top: Radius.circular(xxl), // 28
);
/// 卡片式弹窗:上 28 / 下 12,形成"云朵被托起"的轮廓。
static const BorderRadius cloudCard = BorderRadius.only(
topLeft: Radius.circular(xxl), // 28
topRight: Radius.circular(xxl), // 28
bottomLeft: Radius.circular(md), // 12
bottomRight: Radius.circular(md), // 12
);
}
适用场景速查
| 圆角 | 组件 |
|---|---|
xs 4 |
LinearProgressIndicator、ClipRRect 缩略图、内嵌色块 |
sm 8 |
Chip、SnackBar、ActionChip、小徽标 |
md 12 |
InputDecoration(现值保留)、InputDecorator、TextFormField |
lg 16 |
Card(列表项、统计卡、指标卡)、FilledButton、OutlinedButton、TextButton |
xl 20 |
分组容器、ExpansionTile、大尺寸 Banner 卡 |
xxl 28 |
底部弹窗顶部两角、全屏 Dialog |
full 999 |
StatusChip、CircleAvatar、状态 pill |
关键改动:M3 的
FilledButton默认是全圆角(20dp,实际渲染接近胶囊)。必须显式覆盖为lg(16),否则按钮与卡片的圆角会脱节。
4.3 云感阴影(替代 elevation: 1)
现状问题(lib/app/theme.dart:41):CardTheme(elevation: 1)。
M3 中 elevation 1 只对应 5% 的 surfaceTint 染色 + 一层极淡引擎阴影,几乎看不出层次;且 Flutter 的 Material elevation 阴影由引擎按固定曲线绘制,颜色可改(shadowColor)、模糊/偏移曲线不可控。
方案:A/B 双轨
方案 A(全局生效,低风险)—— 改 ThemeData / CardThemeData
// 暖调阴影色替代默认纯黑
ThemeData(
shadowColor: AppBrand.shadowTint, // #5C2E12
)
CardThemeData(
elevation: 0, // 交给自定义 BoxShadow
surfaceTintColor: scheme.surfaceTint, // 保留暖调染色
shadowColor: AppBrand.shadowTint,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)),
)
方案 B(推荐用于核心卡片/弹窗)—— CloudCard 自定义组件
新建 lib/widgets/cloud_card.dart,用 BoxShadow 精确控制,这是"云朵漂浮"的真正实现:
class AppShadows {
static const Color _tint = Color(0xFF5C2E12); // 暖棕,非纯黑
/// 极轻:列表项默认态(替代 elevation:1)
static const List<BoxShadow> xs = [
BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1), // α=6%
];
/// 轻:卡片 hover/按压
static const List<BoxShadow> sm = [
BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2), // α=8%
];
/// 中:★ 标准卡片、指标卡
static const List<BoxShadow> md = [
BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4), // α=10%
];
/// 强:悬浮层、下拉菜单、SnackBar
static const List<BoxShadow> lg = [
BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6), // α=14%
];
/// 最强:★ 底部弹窗、FAB、Dialog
static const List<BoxShadow> xl = [
BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8), // α=14%
];
/// 深色模式:暖色阴影在暗底上不可见(实测 6% 暖棕在 #16130F 上对比仅 1.05),
/// 改用纯黑 + 1px 暖色描边来表达边界。
static const List<BoxShadow> dark = [
BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4),
];
}
class CloudCard extends StatelessWidget {
const CloudCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.level = CloudShadowLevel.md,
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final CloudShadowLevel level;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isDark = scheme.brightness == Brightness.dark;
return Container(
margin: margin,
decoration: BoxDecoration(
color: scheme.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
// 深色下加暖色描边替代阴影
border: isDark ? Border.all(color: scheme.outlineVariant, width: 1) : null,
boxShadow: isDark ? AppShadows.dark : level.shadows,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(padding: padding, child: child),
),
),
),
);
}
}
参数设计依据
| 参数 | 取值逻辑 |
|---|---|
blurRadius 8→48 |
远大于 offset(2→20),模糊 : 偏移 ≈ 4:1 → 光是"漫射"的,像云影而非投影;M2/M3 默认约 2:1,偏硬 |
offset 只有 Y 轴 |
光源正上方,符合"云在上方漂浮" |
spreadRadius 负值 |
收缩阴影,避免卡片四周出现脏边 |
α 6%~14% |
实测:α=6% 在 #FCFAF8 上混色 #F2EEEA(对比 1.11,可辨但不刺眼);α=14% 混色 #E6DDD8(对比 1.29,用于弹窗层级) |
颜色 #5C2E12 而非黑 |
这是"暖"的关键:暖棕阴影在暖白底上是"暖灰",纯黑阴影会发灰发脏 |
| 深色模式换纯黑 + 描边 | 实测暖棕 α=6% 在 #16130F 上对比仅 1.05,完全不可见;必须换纯黑 α=40%(对比 1.06)+ outlineVariant 描边 |
M3 elevation 染色(surfaceTint)配合
M3 的 elevation→染色透明度曲线(Flutter 源码 elevation_overlay.dart 实测):elevation 1 → 5%、3 → 8%、6 → 11%、12 → 12%、16 → 14%。
建议:保留 surfaceTintColor = primary,让卡片在低 elevation 下获得 5%~8% 的暖橙染色 —— 这是一条零成本把品牌色渗进全站卡片的路径,且极其克制(5% 几乎只是"一点点暖意")。若嫌脏,设为 Colors.transparent 即可。
5. 图标风格
5.1 现状问题
lib/app/theme.dart未定义iconTheme,图标尺寸/颜色随组件默认值漂移;- 全项目混用填充态与线性态:
Icons.devices(填充)、Icons.dns_outlined(线性)、Icons.dns(填充)共存; - 尺寸无栅格:14、16、20、28、32、40、48、56 随意出现(实测
store_list_page.dart:365用 14、device_list_tile.dart:71用 32、home_page.dart:161用 28); - 无统一的端点/描边规范。
5.2 统一图标语言
| 维度 | 规范 | 说明 |
|---|---|---|
| 风格 | Outlined(线性)为默认,填充态仅用于"选中" | 线性更轻,呼应"云"的轻盈;底部导航现状已符合(home_outlined / home),予以保留并推广 |
| 描边粗细 | 1.8 ~ 2.0 dp(在 24dp 栅格下) | Material Symbols Outlined 默认 ≈ 2.0 |
| 端点/连接 | 圆角端点(round cap)+ 圆角连接(round join) | 这是呼应云朵弧线最直接的手法;比直角端点更柔和 |
| 栅格 | 24dp 网格 + 2dp 内边距(有效绘制区 20dp) | 与 Material 规范一致 |
| 光学尺寸 | opticalSize = 20(小尺寸场景) |
让小图标自动加粗描边,保持视觉重量一致 |
| 禁用 | 直角矩形类图标(Icons.square、Icons.stop、Icons.check_box 填充态) |
与圆润弧线冲突 |
5.3 尺寸栅格(AppIconSize)
class AppIconSize {
static const double inline = 16; // 正文行内图标
static const double dense = 20; // 密集信息行(门店信息行 _infoLine)
static const double regular = 24; // ★ 默认:列表 leading、按钮内、AppBar actions
static const double medium = 28; // 统计卡强调图标
static const double large = 32; // 列表主 leading(设备列表)
static const double display = 48; // 空状态 / 结果页
static const double hero = 64; // 关于页 logo、引导页
}
5.4 来源与落地(分两步,P0 零依赖)
P0(零依赖,立即可做):Flutter 自带的 MaterialIcons 字体中,Icons.xxx_outlined 变体本身就是线性风格,无需新增任何依赖。
// 全局强制线性 + 统一尺寸与颜色
ThemeData(
iconTheme: IconThemeData(
size: AppIconSize.regular, // 24
color: scheme.onSurfaceVariant,
// ↓ 可变字体轴:仅对支持 Variable Font 的字体生效
fill: 0.0, // 0 = 线性,1 = 填充
weight: 400,
grade: 0,
opticalSize: 24,
),
primaryIconTheme: IconThemeData(size: 24, color: scheme.primary),
)
⚠️ 重要说明:
IconThemeData.fill / weight / grade / opticalSize需要图标字体支持 OpenType 可变轴。Flutter 内置的MaterialIcons不支持,因此fill: 0目前是 no-op。 真正强制线性风格,P0 靠统一改用Icons.xxx_outlined变体(见 §7 清单);P2 再考虑引入 Material Symbols 可变字体让这四个轴生效。
P2(可选增强):引入 Material Symbols Outlined 可变字体(google_fonts 或自托管 woff2,约 300KB),在 pubspec.yaml 声明为 fontFamily,之后 fill: 0 等轴才能真正全局生效,并可做"线性→填充"的平滑过渡动画。
5.5 语义色图标映射(替换散落的 Colors.green/orange/red)
| 语义 | 图标 | 颜色来源 |
|---|---|---|
| 设备在线 | Icons.cloud_done_outlined(呼应云) |
AppSemanticColors.success |
| 设备离线 | Icons.cloud_off_outlined |
onSurfaceVariant |
| 设备告警 | Icons.warning_amber_outlined |
AppSemanticColors.warning |
| 门店正常 | Icons.storefront_outlined |
success |
| 门店停用 | Icons.block_outlined |
onSurfaceVariant |
| 删除 | Icons.delete_outline |
error |
| 编辑 | Icons.edit_outlined |
primary |
| 发送指令 | Icons.send_outlined |
primary |
把
Icons.dns/Icons.dns_outlined(机柜,与"云"无关)换成Icons.cloud_*系列 —— 这是最低成本、最高回报的图标层品牌化动作。
6. 移动端布局与触控适配
6.1 触控目标规范
| 项 | 规范 | 依据 |
|---|---|---|
| 最小触控区 | 48×48 dp | Material 3 / Android 无障碍建议(Apple 为 44) |
| 相邻可点元素间距 | ≥ 8 dp | 防误触 |
| 主操作按钮高度 | ≥ 52 dp | 单手拇指舒适区 |
| 输入框高度 | ≥ 56 dp(含 label 则为 60) | 保证点击 + 文字可读 |
| 列表项高度 | 单行 ≥ 56 dp,多行 ≥ 72 dp | ListTile 默认 56,符合 |
落地方式(全局一次性生效)
ThemeData(
// 让 IconButton 的点击区撑到 48(其默认 minimumSize 仅 40×40)
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.padded,
padding: EdgeInsets.zero,
),
),
materialTapTargetSize: MaterialTapTargetSize.padded,
// 手小/戴手套场景:把默认 compact 改为 standard,控件间距 +4dp
visualDensity: VisualDensity.standard,
// 按钮统一高度与圆角
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(64, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
)
重点修复:lib/widgets/device_list_tile.dart:81 的 IconButton 与 StatusChip 挤在同一 trailing 的 Row 内,两者间距为 0,且 StatusChip 高约 24dp —— 这是全项目误触风险最高的一处。建议:
StatusChip外层包Padding(padding: EdgeInsets.only(right: 8));- 或把"发送指令"下沉到左滑(Slidable)/ 长按菜单,释放
trailing空间。
6.2 单手操作友好
| 策略 | 具体做法 |
|---|---|
| 关键操作下沉 | 列表页 FAB 保留,但改为 FloatingActionButton.extended 置于 endFloat,并包 SafeArea;破坏性操作(删除)始终放在底部弹窗的吸底栏,不放 AppBar |
| 主操作吸底 | 长表单的"保存/创建"改为吸底按钮(见 6.4),不再埋在滚动区末尾 |
| AppBar 精简 | actions 最多 2 个图标;超过 2 个收进 PopupMenuButton(右上角是单手最难触及区) |
| 底部导航 | 5 个 tab 保持(现状 AppBottomNav 已符合 M3),但横屏切换为 NavigationRail(见 6.6) |
| 删除二次确认 | 保留现状 AlertDialog,但主按钮改 FilledButton(error),isDefaultAction 给"取消" |
6.3 列表 / 卡片 / 表单在窄屏下的排版规则
竖屏(< 600dp,手机主力场景)
| 元素 | 规则 |
|---|---|
| 列表项 | 卡片式,margin: EdgeInsets.symmetric(horizontal: 12, vertical: 6),圆角 lg(16),阴影 xs |
| 卡片内边距 | 16(现状 store_list_page 为 20,横屏才用 20) |
| 标题 | titleSmall + w600,最多 1 行 TextOverflow.ellipsis |
| 副信息行 | 图标 dense(20) + 文字 bodySmall(12/13),最多 4 行,超出折叠 |
| 状态 Chip | 右上角,radius.full,与标题同行;不与图标按钮相邻(见 6.1) |
| 统计卡 Grid | crossAxisCount: 2,childAspectRatio: 1.3(现状 1.4 在竖屏偏扁) |
| 表单 | 单列,labelText 常驻显示(不用 hintText 占位),字段间距 12 |
横屏(1600×900,实测主力场景)—— 见 6.6
6.4 输入框与底部弹窗适配(项目大量使用 bottom sheet)
输入框
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest, // 替换 surfaceContainerHighest(蓝灰) → 暖灰
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), // 12 → 18,高度 ≥56
prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48),
suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md), // 12
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.outline), // 常驻描边,弱视力用户可辨
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.primary, width: 2),// 聚焦加粗,不再依赖颜色单独表意
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.error),
),
floatingLabelStyle: MaterialStateTextStyle.resolveWith((s) =>
TextStyle(color: s.contains(MaterialState.error)
? scheme.error
: s.contains(MaterialState.focused) ? scheme.primary : scheme.onSurfaceVariant)),
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
hintStyle: TextStyle(color: scheme.onSurfaceVariant),
errorStyle: TextStyle(color: scheme.error, fontSize: 12),
errorMaxLines: 2,
)
其他要点:
- 每个
TextFormField显式声明textInputAction(next/done)并用onFieldSubmitted串联焦点 —— 现状门店表单 7 个字段均未设置,键盘上全是"换行"; - 数字字段用
keyboardType: TextInputType.number+inputFormatters: [FilteringTextInputFormatter.digitsOnly](现状门店终端数字段已设 keyboardType,但缺格式化器); - 手机号字段加
maxLength: 11。
底部弹窗(本项目最高优先级的结构性问题)
现状(lib/screens/store/store_list_page.dart:140-154 + 1237-1421):
showModalBottomSheet(
context: context,
isScrollControlled: true, // ← 全屏高度
builder: (ctx) => _StoreFormSheet(...), // ← 内部 SingleChildScrollView 无高度上限
);
// 表单内部:保存按钮在 SingleChildScrollView 的 children 末尾(第 1404 行)
四个问题:
isScrollControlled: true但未限制最大高度 → 内容多时弹窗顶到状态栏,用户失去"这是弹窗"的感知,且键盘弹起后内容被挤没;useSafeArea默认false(Flutter 源码bottom_sheet.dart:874)→ 全项目 30 处showModalBottomSheet全部存在内容被系统导航栏/手势条遮挡的风险;- 无
showDragHandle→ 没有可拖拽的视觉暗示,用户不知道能下滑关闭; - 保存按钮埋在滚动区末尾 → 门店新增表单有 7 个字段 + 主题模板网格 + 激活码区块,实际高度远超一屏,必须滚到底才能点"创建"。
改造方案
// ① 全局兜底:在 ThemeData 里一次性解决形状、宽度、把手、安全区
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
useSafeArea: true, // ★ 全局开启,30 处弹窗一次性受益
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
elevation: 0, // 交给自定义阴影
modalBackgroundColor: scheme.surface,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: AppRadius.sheetTop, // 顶部两角 28(云朵弧线)
),
// ★ 横屏不再铺满:最大宽度 640 并居中
constraints: const BoxConstraints(maxWidth: 640),
),
// ② 页面级:限制高度 + 吸底操作栏
Future<void> showAppBottomSheet({
required BuildContext context,
required WidgetBuilder bodyBuilder,
required Widget footer, // 吸底操作栏
String? title,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.92,
),
builder: (ctx) => _AppSheetScaffold(
title: title,
body: bodyBuilder(ctx),
footer: footer,
),
);
}
class _AppSheetScaffold extends StatelessWidget {
const _AppSheetScaffold({this.title, required this.body, required this.footer});
final String? title;
final Widget body;
final Widget footer;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text(title!,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold)),
),
),
// 可滚动内容区
Flexible(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20, right: 20, bottom: 20,
// 键盘弹起时抬起内容
top: 0,
),
child: body,
),
),
// ★ 吸底操作栏:始终可见,不受滚动影响
Container(
decoration: BoxDecoration(
color: scheme.surface,
border: Border(top: BorderSide(color: scheme.outlineVariant)),
boxShadow: AppShadows.lg,
),
padding: EdgeInsets.fromLTRB(
20, 12, 20, 12 + MediaQuery.viewInsetsOf(context).bottom * 0,
),
child: SafeArea(top: false, child: footer),
),
],
);
}
}
键盘适配:
SingleChildScrollView(padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom))仍需在body内保留,吸底栏用SafeArea(top: false)处理底部手势条。
6.5 横屏 1600×900 与竖屏适配策略(重点)
现状诊断(源码实证)
| 检查项 | 结果 |
|---|---|
OrientationBuilder |
0 处 |
响应式断点 / LayoutBuilder(业务布局用) |
0 处(仅 marquee_management_page.dart:1301/1344 的两处与布局无关) |
GridView 写死 crossAxisCount: 2 |
home_page.dart:67、device_detail_page.dart:312 |
| 内容最大宽度约束 | 仅登录页有(login_page.dart:158 maxWidth: 420),其余页面全无 |
| 底部导航 | 恒为 NavigationBar(高度 80),横屏下吃掉 8.9% 的垂直空间 |
后果:1600px 宽下,首页统计卡每张被拉到约 780px 宽、GridView 仍是 2 列 → 单屏只有 4 张巨卡;列表项文字行长超过 200 字符,眼动距离极长;底部弹窗铺满 1600px 宽,表单输入框宽到失真。而 900px 高度下 NavigationBar(80) + AppBar(56) 已占去 15%。
适配方案
① 建立断点工具 lib/utils/responsive.dart
enum AppWindowSize { compact, medium, expanded }
class AppResponsive {
AppResponsive(this.size, this.orientation);
final Size size;
final Orientation orientation;
factory AppResponsive.of(BuildContext context) => AppResponsive(
MediaQuery.sizeOf(context),
MediaQuery.orientationOf(context),
);
/// ★ 用 shortestSide 而非 width:横屏 1600×900 的 shortSide = 900
double get shortSide => size.shortestSide;
bool get isLandscape => orientation == Orientation.landscape;
AppWindowSize get windowSize {
final s = shortSide;
if (s < 600) return AppWindowSize.compact; // 手机竖屏 360-430
if (s < 840) return AppWindowSize.medium; // ★ 手机横屏 900 落这里
return AppWindowSize.expanded; // 平板 / 大屏
}
/// 列表页是否启用「左列表 + 右详情」双栏
bool get useMasterDetail => size.width >= 840 && isLandscape;
/// Grid 列数:按可用宽度自适应(1600px → 4 列;竖屏 400px → 2 列)
int gridCount({double minTileWidth = 320}) =>
(size.width / minTileWidth).floor().clamp(2, 6);
/// 内容最大宽度(表单/详情居中,避免行长失控)
double get contentMaxWidth => switch (windowSize) {
AppWindowSize.compact => double.infinity,
AppWindowSize.medium => 560,
AppWindowSize.expanded => 720,
};
/// 页面水平外边距
double get gutter => switch (windowSize) {
AppWindowSize.compact => 12,
AppWindowSize.medium => 24,
AppWindowSize.expanded => 32,
};
/// 卡片内边距
double get cardPadding => switch (windowSize) {
AppWindowSize.compact => 16,
_ => 20,
};
}
extension AppResponsiveX on BuildContext {
AppResponsive get responsive => AppResponsive.of(this);
}
② 底部导航:横屏切换为 NavigationRail
// lib/widgets/app_bottom_nav.dart
@override
Widget build(BuildContext context) {
final r = context.responsive;
if (r.isLandscape && r.size.width >= 840) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: (i) =>
shell.goBranch(i, initialLocation: i == shell.currentIndex),
labelType: NavigationRailLabelType.all,
// 品牌色指示:indicatorColor 走 secondaryContainer
destinations: _destinations(context),
),
const VerticalDivider(width: 1),
Expanded(child: ClipRect(child: shell)),
],
),
);
}
return Scaffold(
body: shell,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (i) =>
shell.goBranch(i, initialLocation: i == shell.currentIndex),
destinations: _destinations(context),
),
);
}
收益:横屏下不再有 80dp 的底部导航条,垂直空间 +80dp(900 高度的 8.9%),且 5 个 tab 移到左侧后拇指横向可达,比底部更易点。
③ 列表页双栏主从布局(门店 / 设备)
横屏 ≥840px 时,StoreListPage / DeviceListPage 改为:
┌──────────────┬───────────────────────────────┐
│ 搜索框 │ 门店详情(原 bottom sheet) │
│ 门店列表 340dp│ ← 点击列表项直接在右侧展开 │
│ │ │
│ [FAB 新增] │ [编辑] [指令] [删除] │
└──────────────┴───────────────────────────────┘
- 左栏固定
width: 340,右栏Expanded; - 点击列表项不再弹 bottom sheet,直接更新右栏内容(大幅减少横屏下的弹窗使用);
- 竖屏保持现状:点击 → 底部弹窗。
④ GridView 列数自适应
// home_page.dart:66 与 device_detail_page.dart:311
GridView.count(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300), // 竖屏2 / 横屏4
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: context.responsive.isLandscape ? 1.8 : 1.3,
children: [...],
)
⑤ 内容宽度约束
表单、详情页正文统一包一层:
Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: context.responsive.contentMaxWidth),
child: content,
),
)
⑥ 底部弹窗横屏改版
BottomSheetThemeData.constraints: BoxConstraints(maxWidth: 640) 已在主题层全局生效(见 6.4),横屏下弹窗自动居中、宽 640、两侧留白,表单不再横向失真。
此外横屏下把 maxHeight 收紧到 0.85(横屏 900 高度下 92% = 828 太高)。
⑦ AppBar 横屏压缩
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 3, // ★ 新增:滚动后浮起,解决"AppBar 与内容无分隔"
toolbarHeight: 52, // 横屏时收紧(用 responsive 覆盖为 48)
titleSpacing: 16,
actionsIconTheme: IconThemeData(size: 24, color: scheme.onSurfaceVariant),
),
⑧ 横屏专属检查清单
| 页面 | 横屏处理 |
|---|---|
login_page.dart |
已有 maxWidth: 420 ✅;补充:改为左右分栏(左品牌渐变区 40% + 右表单 60%),充分利用 1600px |
home_page.dart |
Grid 4 列;快捷入口 3 个 → 6 个(横排) |
device_list_page.dart |
双栏主从 |
store_list_page.dart |
双栏主从;FAB 移入左栏右下 |
device_detail_page.dart |
指标 Grid 4 列;基本信息改为两列 Wrap |
device_scan_page.dart |
扫码取景框需按 shortSide 而非 width 计算尺寸,否则横屏下被压扁 |
store_config_page.dart / branding_editor.dart |
预览区左、编辑区右(分栏),现状是上下堆叠,横屏需滚很远 |
7. 落地清单
7.1 P0 —— 品牌一致性 + 阻塞性问题(建议 1 个迭代内完成)
| # | 文件 | 改动点 |
|---|---|---|
| P0-1 | lib/app/theme.dart |
全量重写:primaryBlue → brandPrimary = #C2410C;ColorScheme.fromSeed → 显式 ColorScheme(...)(浅/深两套);新增 AppRadius、AppShadows、AppIconSize、AppBrand、AppSemanticColors |
| P0-2 | lib/app/theme.dart |
新增 iconTheme / primaryIconTheme;cardTheme elevation 1→0 + CloudCard 阴影;bottomSheetTheme(showDragHandle: true + useSafeArea: true + maxWidth: 640 + 顶部 28 圆角);appBarTheme 补 scrolledUnderElevation: 3 / surfaceTintColor;visualDensity / iconButtonTheme / 三个 ButtonTheme(48/52 触控 + 16 圆角);extensions: [AppBrand.light, AppSemanticColors.light] |
| P0-3 | lib/screens/login/login_page.dart |
整页硬编码深色改为跟随主题:L152 backgroundColor: #121212 → scheme.surface;L149 cardColor: #0D2137(偏蓝)→ scheme.surfaceContainerLow;L145-148 四个输入色 → 主题 token;L205/233/280 #2E7DFF → scheme.primary;L350 Checkbox → 主题;L370/372 按钮 #2E7DFF/#1A4480 → scheme.primary / onSurface.withOpacity(.12) |
| P0-4 | lib/screens/login/login_page.dart |
品牌化:Logo 从 80×80 → 112×112,下方加 AppBrand.cloudGradient 文字标题 / 或顶部加一条橙→金渐变的"云朵"装饰带;L158 maxWidth: 420 保留并推广 |
| P0-5 | lib/main.dart |
L60-61 statusBarIconBrightness: Brightness.light 硬编码 → 跟随 themeMode(当前浅色模式下状态栏图标不可见,是真 bug);方案:把 setSystemUIOverlayStyle 移到 OpsConsoleApp.build 内按 theme.brightness 动态设置 |
| P0-6 | lib/widgets/store_theme_grid.dart |
L77 选中边框 Color(0xFF2E7DFF) → Theme.of(context).colorScheme.primary |
| P0-7 | lib/providers/theme_provider.dart |
L7 默认 ThemeMode.dark → 建议改 ThemeMode.light(logo 明度均值 245/255,"明亮轻盈"才是品牌主场);或保留 dark 但确保深色方案已按 §3.4 落地 |
| P0-8 | lib/screens/store/store_list_page.dart |
三处 showModalBottomSheet(L142/158/188)改用 showAppBottomSheet(限高 92% + 吸底栏 + 安全区 + 把手);L1404-1416 保存按钮移出滚动区改为吸底 |
| P0-9 | lib/utils/responsive.dart |
新建:AppResponsive 断点工具 + BuildContext.responsive 扩展 |
7.2 P1 —— 布局与触控(第 2 个迭代)
| # | 文件 | 改动点 |
|---|---|---|
| P1-1 | lib/widgets/app_bottom_nav.dart |
横屏且宽 ≥840 时切换 NavigationRail;统一 destinations 提取为方法 |
| P1-2 | lib/widgets/cloud_card.dart |
新建 CloudCard 组件(§4.3 方案 B) |
| P1-3 | lib/widgets/app_bottom_sheet.dart |
新建 showAppBottomSheet + 吸底栏脚手架(§6.4) |
| P1-4 | lib/screens/home/home_page.dart |
L67 crossAxisCount: 2 → responsive.gridCount();L78-96 Colors.blue/purple/green/red → AppSemanticColors / 主题色;_StatCard 换 CloudCard;_QuickAction 圆角 + 48 触控 |
| P1-5 | lib/screens/home/device_detail_page.dart |
L312 crossAxisCount: 2 → responsive.gridCount();L325/334/343 Colors.red/orange → 语义色;L535 Colors.redAccent → scheme.error |
| P1-6 | lib/screens/store/store_list_page.dart |
L298/474/479/487/528/542/1221 Colors.grey/green/teal/red → 主题语义色;_StoreListTile 换 CloudCard(圆角 16 + 阴影 xs);L365 图标 14→20;列表项 margin 用 responsive.gutter |
| P1-7 | lib/widgets/device_list_tile.dart |
L71 Icons.dns/dns_outlined → Icons.cloud_done_outlined / Icons.cloud_off_outlined;L81 IconButton 与 StatusChip 间距补 8dp;换 CloudCard |
| P1-8 | lib/widgets/status_chip.dart |
L16-20 Colors.green/grey/orange → AppSemanticColors(成功/中性/警告) |
| P1-9 | lib/screens/store/org_tree_page.dart |
L280-290 _TypeInfo 六色(purple/blue/orange/green/grey)→ 主题色板内取色(避免与品牌橙混淆的 Colors.orange);L63/351 Colors.red → scheme.error |
| P1-10 | lib/screens/device/device_activation_page.dart |
L119/293 Colors.orange → scheme.primary(注意:这里用橙色表示"可点击",换色后必须改成 primary,否则与新品牌橙冲突);L158 语义色 |
| P1-11 | lib/widgets/region_picker.dart |
L189 Colors.blue → scheme.primary;L154 size.height * 0.6 改按断点(横屏改 0.8) |
| P1-12 | lib/screens/alerts/alerts_page.dart |
L15/17/19 Colors.red/orange/blue → 语义色 |
| P1-13 | lib/screens/admin/admin_hub_page.dart |
L19/25/31/37/43 五色 → 语义色 / 主题色板 |
| P1-14 | 其余 20+ 处 showModalBottomSheet |
统一加 useSafeArea: true + constraints(主题层已全局兜底 showDragHandle/shape/maxWidth,页面层补 maxHeight) |
| P1-15 | 全项目 Icons.xxx |
非选中态统一改 _outlined 变体;尺寸收敛到 AppIconSize 栅格 |
7.3 P2 —— 增强与品牌渗透
| # | 文件 | 改动点 |
|---|---|---|
| P2-1 | lib/screens/store/store_list_page.dartlib/screens/home/device_list_page.dart |
横屏双栏主从布局(§6.5-③) |
| P2-2 | lib/screens/login/login_page.dart |
横屏左右分栏(左品牌渐变 + 右表单) |
| P2-3 | pubspec.yaml |
引入 Material Symbols Outlined 可变字体,让 IconThemeData.fill/weight/grade/opticalSize 真正生效 |
| P2-4 | AppBar / 首页 | 增加"云朵"品牌元素:首页顶部加一条 AppBrand.cloudGradient 的渐隐云带;AppBar 标题旁加 20dp 小 logo(品牌渗透,现状 logo 仅出现于登录页与设置页) |
| P2-5 | lib/screens/device/device_scan_page.dart |
取景框按 shortestSide 计算,修复横屏压扁 |
| P2-6 | lib/screens/device/store_config_page.dartlib/widgets/branding_editor.dart |
横屏分栏(左预览 / 右编辑) |
| P2-7 | lib/main.dart |
L193/255 错误页 Colors.red.shade50 硬编码 → 跟随主题(深色模式下刺眼) |
| P2-8 | 全局 | 空状态 / 加载 / 错误三态组件统一(现状各页自行拼装,风格不一) |
7.4 lib/app/theme.dart 改造后关键代码片段
import 'package:flutter/material.dart';
/// 品牌原色(直接取自 assets/images/logo.png 实测像素,勿随意更改)。
class AppBrand {
const AppBrand._();
static const Color cloud = Color(0xFFFF914D); // logo 主色
static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄
static const Color cloudLight = Color(0xFFFFB487);
static const Color onCloud = Color(0xFF3D1A00); // 亮橙上唯一合法文字色
static const LinearGradient cloudGradient = LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [Color(0xFFFF914D), Color(0xFFFFBE58)],
);
static const Color shadowTint = Color(0xFF5C2E12); // 暖调阴影
}
/// 圆角梯度(4dp 栅格;源自 logo 圆角矩形 6/192≈3% 的"克制圆角"语义)。
class AppRadius {
const AppRadius._();
static const double xs = 4, sm = 8, md = 12, lg = 16, xl = 20, xxl = 28;
static const double full = 999;
static const BorderRadius sheetTop =
BorderRadius.vertical(top: Radius.circular(xxl));
}
/// 云感阴影:大模糊、小偏移、低透明、暖色 —— 替代扁平的 elevation:1。
class AppShadows {
const AppShadows._();
static const List<BoxShadow> xs = [
BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1),
];
static const List<BoxShadow> sm = [
BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2),
];
static const List<BoxShadow> md = [
BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4),
];
static const List<BoxShadow> lg = [
BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6),
];
static const List<BoxShadow> xl = [
BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8),
];
/// 深色模式:暖色阴影在暗底不可见(实测对比 1.05),改用纯黑 + 描边。
static const List<BoxShadow> dark = [
BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4),
];
}
/// 图标尺寸栅格。
class AppIconSize {
const AppIconSize._();
static const double inline = 16, dense = 20, regular = 24;
static const double medium = 28, large = 32, display = 48, hero = 64;
}
/// 应用主题:暖橙云感(取自 logo),浅色 / 深色两套。
class AppTheme {
AppTheme._();
/// 行动主色 = logo #FF914D 沿同色相压暗至可承载白字(vs 白 = 5.18,达 AA)。
static const Color brandPrimary = Color(0xFFC2410C);
static const ColorScheme _light = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFFC2410C), onPrimary: Color(0xFFFFFFFF),
primaryContainer: Color(0xFFFFD3B3), onPrimaryContainer: Color(0xFF4A1D00),
secondary: Color(0xFF8A5A3A), onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFF5DFCB), onSecondaryContainer: Color(0xFF5A3A22),
tertiary: Color(0xFF9A6000), onTertiary: Color(0xFFFFFFFF),
tertiaryContainer: Color(0xFFFFE0A3), onTertiaryContainer: Color(0xFF5C3D00),
error: Color(0xFFB02A20), onError: Color(0xFFFFFFFF),
errorContainer: Color(0xFFFFDDD8), onErrorContainer: Color(0xFF7A2A22),
surface: Color(0xFFFCFAF8), onSurface: Color(0xFF1A1614),
onSurfaceVariant: Color(0xFF6E655C),
surfaceContainerLowest: Color(0xFFFFFFFF),
surfaceContainerLow: Color(0xFFF7F3EF),
surfaceContainer: Color(0xFFF2EDE8),
surfaceContainerHigh: Color(0xFFECE5DF),
surfaceContainerHighest: Color(0xFFE5DED7),
outline: Color(0xFF8A8178), outlineVariant: Color(0xFFDCCFC2),
surfaceTint: Color(0xFFC2410C), shadow: AppBrand.shadowTint,
scrim: Color(0xFF1A1614),
inverseSurface: Color(0xFF2E2823), onInverseSurface: Color(0xFFF7EFE9),
inversePrimary: Color(0xFFFFB487),
);
static const ColorScheme _dark = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFFFB487), onPrimary: Color(0xFF542000),
primaryContainer: Color(0xFF63300D), onPrimaryContainer: Color(0xFFFFDCC2),
secondary: Color(0xFFE8C39B), onSecondary: Color(0xFF542000),
secondaryContainer: Color(0xFF5A3A22), onSecondaryContainer: Color(0xFFFFDCC2),
tertiary: Color(0xFFF2BE62), onTertiary: Color(0xFF542000),
tertiaryContainer: Color(0xFF5C3D00), onTertiaryContainer: Color(0xFFFFDCC2),
error: Color(0xFFFF8474), onError: Color(0xFF5A1610),
errorContainer: Color(0xFF7A2A22), onErrorContainer: Color(0xFFFFC9C2),
surface: Color(0xFF16130F), onSurface: Color(0xFFF3E9E2),
onSurfaceVariant: Color(0xFFC4B8AE),
surfaceContainerLowest: Color(0xFF0F0D0A),
surfaceContainerLow: Color(0xFF1E1A16),
surfaceContainer: Color(0xFF23201B),
surfaceContainerHigh: Color(0xFF2D2924),
surfaceContainerHighest: Color(0xFF38332D),
outline: Color(0xFF7A7068), outlineVariant: Color(0xFF453F39),
surfaceTint: Color(0xFFFFB487), shadow: Color(0xFF000000),
scrim: Color(0xFF0F0D0A),
inverseSurface: Color(0xFFF3E9E2), onInverseSurface: Color(0xFF2E2823),
inversePrimary: Color(0xFFC2410C),
);
static ThemeData light() => _base(_light);
static ThemeData dark() => _base(_dark);
static ThemeData _base(ColorScheme scheme) {
final isDark = scheme.brightness == Brightness.dark;
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// —— 暖调阴影(替代系统默认纯黑)——
shadowColor: isDark ? Colors.black : AppBrand.shadowTint,
// —— 触控:最小 48,主按钮 52 ——
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard,
// —— 图标:统一线性、24dp 栅格 ——
iconTheme: IconThemeData(
size: AppIconSize.regular,
color: scheme.onSurfaceVariant,
fill: 0.0, // 需可变字体支持;当前靠统一使用 _outlined 变体落地
weight: 400,
grade: 0,
opticalSize: 24,
),
primaryIconTheme: IconThemeData(size: AppIconSize.regular, color: scheme.primary),
// —— AppBar:滚动后浮起,解决与内容无分隔 ——
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 3,
toolbarHeight: 52,
actionsIconTheme:
IconThemeData(size: AppIconSize.regular, color: scheme.onSurfaceVariant),
),
// —— 卡片:elevation 0 + CloudCard 自定义阴影;圆角 16 ——
cardTheme: CardThemeData(
elevation: 0,
clipBehavior: Clip.antiAlias,
surfaceTintColor: scheme.surfaceTint,
shadowColor: isDark ? Colors.black : AppBrand.shadowTint,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg)),
),
// —— 底部弹窗:全局兜底 30 处调用 ——
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true, // 可下滑关闭的视觉暗示
useSafeArea: true, // ★ 默认 false,必须显式开启
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
modalBackgroundColor: scheme.surface,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.sheetTop),
constraints: const BoxConstraints(maxWidth: 640), // 横屏不铺满
),
// —— 输入框:高度 ≥56,常驻描边 ——
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48),
suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.outlineVariant)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.outline)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.primary, width: 2)),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.error)),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: scheme.error, width: 2)),
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
hintStyle: TextStyle(color: scheme.onSurfaceVariant),
errorStyle: TextStyle(color: scheme.error, fontSize: 12),
errorMaxLines: 2,
),
// —— 按钮:统一 52 高 / 48 触控 / 16 圆角 ——
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(64, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg)),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.padded,
padding: EdgeInsets.zero,
),
),
// —— Chip / SnackBar / Dialog ——
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurface),
selectedColor: scheme.secondaryContainer,
checkmarkColor: scheme.onSecondaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm)),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: scheme.inverseSurface,
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm)),
),
dialogTheme: DialogThemeData(
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xxl)),
),
// —— 品牌与语义色扩展(ThemeExtension,零依赖)——
extensions: <ThemeExtension<dynamic>>[
const AppBrandExtension(),
isDark ? AppSemanticColors.dark : AppSemanticColors.light,
],
);
}
}
8. 风险与权衡
8.1 换色影响面:所有硬编码蓝色(已全量搜索源码)
必须改(App 主题色误用)
| 文件:行 | 现值 | 改法 |
|---|---|---|
lib/app/theme.dart:8 |
Color(0xFF2E7DFF) |
→ #C2410C |
lib/screens/login/login_page.dart:205, 233, 280 |
Color(0xFF2E7DFF)(聚焦边框) |
→ scheme.primary |
lib/screens/login/login_page.dart:350 |
Color(0xFF2E7DFF)(Checkbox) |
→ scheme.primary |
lib/screens/login/login_page.dart:370 |
Color(0xFF2E7DFF)(登录按钮) |
→ scheme.primary |
lib/screens/login/login_page.dart:372 |
Color(0xFF1A4480)(禁用态) |
→ onSurface.withOpacity(0.12) |
lib/screens/login/login_page.dart:149 |
Color(0xFF0D2137)(卡片深蓝) |
→ scheme.surfaceContainerLow(暖黑) |
lib/screens/login/login_page.dart:152 |
Color(0xFF121212) |
→ scheme.surface |
lib/widgets/store_theme_grid.dart:77 |
Color(0xFF2E7DFF)(选中边框) |
→ scheme.primary |
lib/widgets/region_picker.dart:189 |
Colors.blue |
→ scheme.primary |
lib/screens/home/home_page.dart:78 |
Colors.blue |
→ scheme.primary |
lib/screens/admin/admin_hub_page.dart:19 |
Colors.blue |
→ 语义色 |
lib/screens/store/org_tree_page.dart:282 |
Colors.blue |
→ 语义色 |
lib/screens/alerts/alerts_page.dart:19 |
Colors.blue |
→ AppSemanticColors.info |
⚠️ 绝对不能动(业务数据色,不是 App 主题色)
| 文件:行 | 值 | 原因 |
|---|---|---|
lib/widgets/store_theme_preview.dart:110-115 |
#3B82F6 #8B5CF6 #EC4899 #F59E0B #10B981 #6366F1 |
门店主题模板的预览色板,是下发给 TV 端的业务配置,改了会导致门店界面配置错乱 |
lib/widgets/store_theme_grid.dart:129-134 |
同上 6 色 | 同上 |
lib/models/store_theme.dart:46-54 |
#1565C0 #00838F #2E7D32 #E65100 #455A64 |
门店主题色枚举,业务数据 |
这是本次换色最大的误伤风险:
#3B82F6与#2E7DFF都是蓝色,批量替换Color(0xFF2E7DFF)时若用正则误伤store_theme_grid.dart:129的#3B82F6,会破坏门店主题功能。建议只做精确字符串Color(0xFF2E7DFF)的整词替换,并逐处人工确认。
8.2 深色模式下橙色的处理(3 条硬约束)
-
主色必须提亮,文字必须改深色 深色下
primary = #FFB487(logo 渐变带原色),onPrimary = #542000(对比 7.66 ✅)。 绝对不能用白字:白字 on#FFB487仅 1.73 ❌。 若沿用浅色的#C2410C作深色主色,onPrimary白字虽可达标,但深橙在深底上会"沉下去",按钮看起来像禁用态 —— 必须提亮。 -
橙色只做小面积强调,容器级用深橙底 深色模式下大面积亮橙会产生光晕(halation),在 OLED 屏幕上尤其刺眼、且长时间注视疲劳。 规则:亮橙
#FFB487仅用于图标、进度条填充、选中指示、文字链接(面积 < 8%);容器级一律用primaryContainer = #63300D(深橙底)+onPrimaryContainer = #FFDCC2。 -
surface 必须暖化,不能是
#121212现状login_page.dart:152用#121212、:149用#0D2137(深蓝)—— 后者与暖橙主题直接冲突,登录是用户看到的第一屏,观感会分裂。 统一改为暖黑#16130F(R>G>B 的暖偏移),与#FFB487同源。 另外,深色下不要用纯黑#000000作 surface:OLED 上纯黑与滚动内容之间缺乏层次,且暖色阴影完全不可见(实测对比 1.05)→ 必须配合outlineVariant描边。
8.3 其他风险与权衡
| 风险 | 影响 | 缓解 |
|---|---|---|
ColorScheme.fromSeed → 显式声明 |
失去 M3 动态取色(Android 12+ 壁纸取色)能力 | 本项目是强品牌识别诉求,品牌一致性优先级 > 动态取色;且现状已用 .copyWith(primary:) 覆盖了 primary,动态取色本就未生效。可接受 |
| 亮橙不能作文字/图标色 | 设计上少了一个高亮手段,部分位置需改用 primary 深橙 |
用 primaryContainer(浅橙底)+ onPrimaryContainer(深棕字)组合替代;Banner/插画用大色块亮橙 + 深棕字(6.99 ✅) |
| App 图标 / 启动图 未同步 | 主题改了但桌面图标仍是旧色,品牌一致性打折 | 需同步更新 android/app/src/main/res/mipmap-*/ic_launcher.png 与 drawable*/launch_background.xml(本方案未覆盖,建议单独立项) |
M3 FilledButton 默认胶囊圆角 |
与卡片 16 圆角脱节 | 已在 filledButtonTheme 显式覆盖为 AppRadius.lg(16),必须检查所有未走主题的 style: FilledButton.styleFrom(...) 局部覆盖 |
| 横屏双栏改造工作量 | 涉及 store_list_page.dart(1423 行)、device_list_page.dart |
分两步:P1 先做断点工具 + Grid + NavigationRail(低风险);P2 再做双栏主从(需重构选中态管理,用 Riverpod 持有 selectedStoreId) |
visualDensity: standard |
控件间距 +4dp,单屏信息量略降 | 运维现场场景,误触代价值得;若反馈过松,可回退 VisualDensity.comfortable |
| 登录页去硬编码 | 该页此前是为规避"M3 动态取色导致文字/背景融为一体"而硬编码的(见 L144 注释) | 改成显式 ColorScheme 后色彩完全可控,该规避理由已不成立;但仍需保留完整的 try/catch 兜底逻辑,不要顺手删 |
| 深色→浅色默认切换(P0-7) | 老用户升级后界面突变 | 建议:StoreLocalCache 中无显式偏好时,跟随 ThemeMode.system,而非硬切 light |
9. 验收标准(可度量)
| 项 | 验收方式 | 目标 |
|---|---|---|
| 品牌一致性 | 取 5 个主页面截图,取样主色相 | 主色 H ∈ [20°, 26°](logo 为 22.9°) |
| 对比度 | 用 §3.8 清单逐项复核 | 全部达到 WCAG AA(正文 4.5 / 大字 3.0 / 非文本 3.0) |
| 触控目标 | Android 无障碍扫描 / flutter test 断言 |
所有可点元素 ≥48×48,间距 ≥8 |
| 横屏适配 | PGBM10 实机横屏 1600×900 走查 | 无横向拉伸失真;列表页双栏;底部导航为 NavigationRail |
| 弹窗 | 实机打开全部 30 处弹窗 | 全部有拖拽把手、限高 ≤92%、内容不被手势条遮挡、长表单有吸底操作栏 |
| 视觉回归 | 浅色 / 深色两套各 5 个页面截图对比 | 无文字与背景融为一体的情况;无硬编码蓝色残留(grep -rn "0xFF2E7DFF|Colors.blue" lib/ 除业务色外为 0) |
附录:本文所有对比度数值均由 WCAG 2.1 相对亮度公式脚本计算得出(源码见 §3.8 输出),非目测或估算。