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

1476 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ops-console(爱视云管理)手机端 UI 优化方案
> # ⛔ 本文档已作废,请勿据此实施
>
> **唯一有效版本:[`手机端UI优化方案-v2.md`](./手机端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)
```dart
// 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)—— 完整角色映射
```dart
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 渐变带的原色,直接复用,品牌一致性最高。
```dart
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` 暴露,避免散落硬编码:
```dart
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%"**,正好落在 12~28dp 区间。这样既保住 logo 的克制基因,又满足移动端拇指触控的视觉引导需求。
### 4.2 圆角梯度(`AppRadius`)
```dart
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`
```dart
// 暖调阴影色替代默认纯黑
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` 精确控制,**这是"云朵漂浮"的真正实现**:
```dart
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`)
```dart
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` 变体本身就是线性风格,**无需新增任何依赖**。
```dart
// 全局强制线性 + 统一尺寸与颜色
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,符合 |
**落地方式(全局一次性生效)**
```dart
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)
#### 输入框
```dart
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`):
```dart
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 个字段 + 主题模板网格 + 激活码区块,实际高度远超一屏,**必须滚到底才能点"创建"**。
**改造方案**
```dart
// ① 全局兜底:在 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`**
```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`**
```dart
// 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 列数自适应**
```dart
// 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: [...],
)
```
**⑤ 内容宽度约束**
表单、详情页正文统一包一层:
```dart
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 横屏压缩**
```dart
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`<br>`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`<br>`lib/widgets/branding_editor.dart` | 横屏分栏(左预览 / 右编辑) |
| P2-7 | `lib/main.dart` | L193/255 错误页 `Colors.red.shade50` 硬编码 → 跟随主题(深色模式下刺眼) |
| P2-8 | 全局 | 空状态 / 加载 / 错误三态组件统一(现状各页自行拼装,风格不一) |
### 7.4 `lib/app/theme.dart` 改造后关键代码片段
```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 输出),非目测或估算。**