1476 lines
72 KiB
Markdown
1476 lines
72 KiB
Markdown
|
|
# 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 输出),非目测或估算。**
|