Files
CloudHome-Ops/docs/手机端UI优化方案.md

72 KiB
Raw Blame History

ops-console(爱视云管理)手机端 UI 优化方案

⛔ 本文档已作废,请勿据此实施

唯一有效版本:手机端UI优化方案-v2.md

本文件仅作历史存档保留。v2 相对本文的变更:

  1. 基调从「重新定义品牌视觉语言」改为「回归旧版橙白 + 合理化改进」;
  2. 新增「新旧版延续性对照」章节;
  3. 图标章节按冻结区重写 —— 用户要求「首页应用图标及与应用显示相关的图标维持原有设计不做任何修改」,本文 §5「图标风格」已作废,请勿执行(尤其不要把 Icons.dns 改成 Icons.cloud_*);
  4. 新增「导航栏 / 设置页 / 详情页 / 弹窗」四类代码级落地指引。

本文的配色体系(§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 为唯一取色来源,重建一套暖橙 + 近白云感的品牌设计语言,并同步解决三个被源码证实的硬伤:

  1. 层次缺失:Card elevation 仅 1、圆角无梯度 → 整屏扁平,与"云朵漂浮"意象无关;
  2. 触控与横屏未适配:全项目 0 处 OrientationBuilder、0 处响应式断点,GridView 写死 crossAxisCount: 2 → 1600px 横屏下卡片被拉伸到 780px,信息密度崩溃;
  3. 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 条可执行的判据:

  1. 中性色一律带暖色偏移(R>G>B),禁止使用纯灰 #9E9E9E / 纯黑 #000000;
  2. 阴影用暖棕低透明而非纯黑,模糊半径大、偏移量小 → "托起"而非"压住";
  3. 圆角中等偏大但不成胶囊,且顶部圆角 ≥ 底部圆角(呼应云朵上缘多弧、下缘平缓);
  4. 品牌亮橙 #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。 反过来,亮橙在浅色底上作为图标/文字色也不可用(#FFB487 on #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 步)

  1. lib/app/theme.dart:8 primaryBlue → 改名 brandPrimary,值 #2E7DFF → #C2410C; 同时废弃 ColorScheme.fromSeed 改为显式 ColorScheme(...) 全量声明。

    原因:fromSeed 会按 HCT 算法生成容器色与辅助色,得到的是带紫/蓝偏移的派生色(蓝色种子尤其明显),无法精确控制品牌色;显式声明才能保证 #FFD3B3 这类 logo 同源的容器色落地。

  2. 全局 primaryBlue 引用点逐一替换(清单见 §8.1);
  3. 亮橙 #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 中真正可继承的是三条定性特征:

  1. 非胶囊 —— logo 是圆角矩形,不是圆形/胶囊,所以 UI 主体不采用 999 全圆;
  2. 四角一致 —— 不做"左上大、右下小"这类不规则圆角(弹窗除外,见 4.2);
  3. 克制感 —— 圆角明显但不夸张,落在移动端舒适区间的中下段。

定量上把落点定为**"容器短边的 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 行)

四个问题:

  1. isScrollControlled: true 但未限制最大高度 → 内容多时弹窗顶到状态栏,用户失去"这是弹窗"的感知,且键盘弹起后内容被挤没;
  2. useSafeArea 默认 false(Flutter 源码 bottom_sheet.dart:874)→ 全项目 30 处 showModalBottomSheet 全部存在内容被系统导航栏/手势条遮挡的风险;
  3. 无 showDragHandle → 没有可拖拽的视觉暗示,用户不知道能下滑关闭;
  4. 保存按钮埋在滚动区末尾 → 门店新增表单有 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.dart
lib/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.dart
lib/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 条硬约束)

  1. 主色必须提亮,文字必须改深色 深色下 primary = #FFB487(logo 渐变带原色),onPrimary = #542000(对比 7.66 ✅)。 绝对不能用白字:白字 on #FFB487 仅 1.73 ❌。 若沿用浅色的 #C2410C 作深色主色,onPrimary 白字虽可达标,但深橙在深底上会"沉下去",按钮看起来像禁用态 —— 必须提亮。

  2. 橙色只做小面积强调,容器级用深橙底 深色模式下大面积亮橙会产生光晕(halation),在 OLED 屏幕上尤其刺眼、且长时间注视疲劳。 规则:亮橙 #FFB487 仅用于图标、进度条填充、选中指示、文字链接(面积 < 8%);容器级一律用 primaryContainer = #63300D(深橙底)+ onPrimaryContainer = #FFDCC2。

  3. 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 输出),非目测或估算。