Merge fix/auto-20260326-103901: 设备绑定状态检查、路由记忆恢复、错误拦截优化
This commit is contained in:
commit
c6a6983e4b
294
airhub_app/CLAUDE.md
Normal file
294
airhub_app/CLAUDE.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# Airhub App 开发规范
|
||||||
|
|
||||||
|
> 所有新功能开发、代码修改、Bug 修复均需遵循本规范
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目架构
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── core/ # 基础设施层(全局共享)
|
||||||
|
│ ├── errors/ # 异常类 & Failure 类
|
||||||
|
│ ├── network/ # ApiClient, TokenManager, ApiConfig
|
||||||
|
│ ├── router/ # go_router 路由配置
|
||||||
|
│ └── services/ # 全局服务(LogCenter 等)
|
||||||
|
├── features/ # 功能模块(按业务领域划分)
|
||||||
|
│ ├── auth/ # 认证
|
||||||
|
│ ├── badge/ # 电子吧唧
|
||||||
|
│ ├── device/ # 设备管理
|
||||||
|
│ ├── notification/ # 通知
|
||||||
|
│ ├── spirit/ # 精灵/角色
|
||||||
|
│ ├── user/ # 用户
|
||||||
|
│ └── system/ # 系统(版本检查等)
|
||||||
|
├── pages/ # 独立页面(未归入 feature 的页面)
|
||||||
|
├── widgets/ # 全局可复用 Widget
|
||||||
|
├── theme/ # 主题、颜色、Design Tokens
|
||||||
|
├── services/ # 应用级服务(TTS、音乐生成等)
|
||||||
|
└── main.dart # 入口
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature 模块内部结构
|
||||||
|
|
||||||
|
```
|
||||||
|
features/xxx/
|
||||||
|
├── data/
|
||||||
|
│ ├── datasources/ # 远程/本地数据源
|
||||||
|
│ ├── models/ # 数据模型(Freezed)
|
||||||
|
│ └── repositories/ # Repository 实现
|
||||||
|
├── domain/
|
||||||
|
│ └── repositories/ # Repository 接口
|
||||||
|
└── presentation/
|
||||||
|
├── controllers/ # Riverpod 控制器
|
||||||
|
├── pages/ # 页面
|
||||||
|
└── widgets/ # 功能专属 Widget
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、状态管理(Riverpod)
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
1. **使用 `@riverpod` 注解**声明 Provider,不手写 `StateNotifierProvider` 等
|
||||||
|
2. Controller 使用 `AsyncValue<T>` 管理异步状态
|
||||||
|
3. Repository 返回 `Either<Failure, T>`(fpdart),Controller 内 `fold` 处理
|
||||||
|
4. **页面中优先使用 `ConsumerWidget`**(无状态);需要动画/本地状态时用 `ConsumerStatefulWidget`
|
||||||
|
5. **`ref.watch()` 用于 UI 绑定**(触发重建),**`ref.read()` 用于事件处理**(不触发重建)
|
||||||
|
6. **`ref.listen()` 用于副作用**(弹窗、导航、Toast),不触发 Widget 重建
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class MyController extends _$MyController {
|
||||||
|
@override
|
||||||
|
FutureOr<MyState> build() async {
|
||||||
|
final repo = ref.watch(myRepositoryProvider);
|
||||||
|
final result = await repo.fetchData();
|
||||||
|
return result.fold(
|
||||||
|
(failure) => throw failure,
|
||||||
|
(data) => MyState(data: data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、网络层
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
1. 所有 API 调用通过 `ApiClient`(Dio 封装),不直接使用 `http` 包或 `Dio()`
|
||||||
|
2. `ApiClient` 自动处理:Token 附加、401 自动刷新、错误上报
|
||||||
|
3. 后端统一返回 `{code, message, data}`,`ApiClient._request()` 自动解析
|
||||||
|
4. 异常类型:`ServerException`(code != 0)、`NetworkException`(连接失败)
|
||||||
|
5. **不在 Widget/Controller 中直接调用 ApiClient**,应通过 Repository 层
|
||||||
|
|
||||||
|
### 错误处理链
|
||||||
|
|
||||||
|
```
|
||||||
|
DataSource (throw Exception)
|
||||||
|
→ Repository (catch → Either<Failure, T>)
|
||||||
|
→ Controller (fold → AsyncValue)
|
||||||
|
→ UI (AsyncValue.when: loading/data/error)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、路由(go_router)
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
1. 所有路由定义在 `core/router/app_router.dart`
|
||||||
|
2. 使用 `context.go()` 进行声明式导航,避免 `Navigator.push()`(页面内二级跳转除外)
|
||||||
|
3. 登录态检查在 `redirect` 中统一处理
|
||||||
|
4. 产品类型 → 路由映射在 `_productRoutes` Map 中维护
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、性能规范
|
||||||
|
|
||||||
|
### 内存管理
|
||||||
|
|
||||||
|
1. **所有 Stream 订阅必须存入变量,dispose 时取消**
|
||||||
|
```dart
|
||||||
|
// ✅ 正确
|
||||||
|
StreamSubscription<Duration>? _positionSub;
|
||||||
|
_positionSub = stream.listen((data) { ... });
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_positionSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 错误 — 无法取消,造成内存泄漏
|
||||||
|
stream.listen((data) { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **AnimationController、VideoPlayerController、AudioPlayer 等必须在 dispose 中释放**
|
||||||
|
|
||||||
|
3. **页面离开时停止 BLE 扫描**
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
|
_scanSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Widget 重建优化
|
||||||
|
|
||||||
|
4. **能用 `const` 的地方必须加 `const`**
|
||||||
|
```dart
|
||||||
|
// ✅
|
||||||
|
const SizedBox(height: 16)
|
||||||
|
const EdgeInsets.all(16)
|
||||||
|
const Text('Hello')
|
||||||
|
|
||||||
|
// ❌
|
||||||
|
SizedBox(height: 16)
|
||||||
|
EdgeInsets.all(16)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **动画区域用 `RepaintBoundary` 包裹**,防止全页面重绘
|
||||||
|
```dart
|
||||||
|
RepaintBoundary(
|
||||||
|
child: AnimatedGradientBackground(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **`AnimatedBuilder` 必须使用 `child` 参数**,将不变的子树传入 child,不要在 builder 中重建
|
||||||
|
```dart
|
||||||
|
// ✅
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
child: const HeavyWidget(), // 只构建一次
|
||||||
|
builder: (context, child) => Transform.rotate(
|
||||||
|
angle: _controller.value,
|
||||||
|
child: child, // 复用
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 调用
|
||||||
|
|
||||||
|
7. **多个独立 API 调用使用 `Future.wait` 并行**,不要顺序 await
|
||||||
|
```dart
|
||||||
|
// ✅ 并行
|
||||||
|
final results = await Future.wait([
|
||||||
|
api.get('/shelves/'),
|
||||||
|
api.get('/stories/'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ❌ 顺序(慢 N 倍)
|
||||||
|
final shelves = await api.get('/shelves/');
|
||||||
|
final stories = await api.get('/stories/');
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **耗时计算(JSON 解析、图片处理)放到 Isolate**
|
||||||
|
```dart
|
||||||
|
final result = await Isolate.run(() => jsonDecode(bigJson));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图片
|
||||||
|
|
||||||
|
9. **网络图片使用 `Image.network` + `fit: BoxFit.cover`**,不要手动指定 `cacheWidth/cacheHeight`(会导致显示异常)
|
||||||
|
10. **大量图片列表使用 `ListView.builder`**,不要 `ListView(children: [...])`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、构建配置
|
||||||
|
|
||||||
|
### Android Release
|
||||||
|
|
||||||
|
- `build.gradle.kts` 中 release 配置:
|
||||||
|
- `isMinifyEnabled = true` — R8 代码压缩
|
||||||
|
- `isShrinkResources = true` — 移除未使用资源
|
||||||
|
- ProGuard 规则在 `proguard-rules.pro` 维护
|
||||||
|
- 打包命令:`flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info`
|
||||||
|
|
||||||
|
### iOS Release
|
||||||
|
|
||||||
|
- 构建命令:`flutter build ipa --release --obfuscate --split-debug-info=./debug-info`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、命名规范
|
||||||
|
|
||||||
|
### 文件命名
|
||||||
|
|
||||||
|
| 类型 | 格式 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 页面 | `xxx_page.dart` | `login_page.dart` |
|
||||||
|
| 控制器 | `xxx_controller.dart` | `auth_controller.dart` |
|
||||||
|
| 数据源 | `xxx_remote_data_source.dart` | `spirit_remote_data_source.dart` |
|
||||||
|
| 仓库实现 | `xxx_repository_impl.dart` | `auth_repository_impl.dart` |
|
||||||
|
| 仓库接口 | `xxx_repository.dart` | `auth_repository.dart` |
|
||||||
|
| 模型 | `xxx_model.dart` | `user_model.dart` |
|
||||||
|
| Widget | `xxx_widget.dart` 或直接描述 | `gradient_button.dart` |
|
||||||
|
| 生成文件 | `*.g.dart` / `*.freezed.dart` | 自动生成,不手动编辑 |
|
||||||
|
|
||||||
|
### 类命名
|
||||||
|
|
||||||
|
- PascalCase:`AuthController`、`DeviceControlPage`、`ServerException`
|
||||||
|
- 私有前缀:`_buildHeader()`、`_positionSub`
|
||||||
|
|
||||||
|
### 变量命名
|
||||||
|
|
||||||
|
- camelCase:`currentUser`、`isPlaying`、`_audioPlayer`
|
||||||
|
- Provider:`xxxProvider`(由 `@riverpod` 自动生成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、主题系统
|
||||||
|
|
||||||
|
### 颜色
|
||||||
|
|
||||||
|
- 全局颜色定义在 `theme/app_colors.dart`
|
||||||
|
- 产品主题颜色在 `theme/product_theme.dart`(4 种产品类型)
|
||||||
|
- **不要在 Widget 中硬编码颜色值**,优先使用 `AppColors.xxx` 或 `Theme.of(context)`
|
||||||
|
|
||||||
|
### 字体
|
||||||
|
|
||||||
|
- 主字体:DM Sans(正文)、Outfit(标题)、Press Start 2P(品牌像素风)
|
||||||
|
- 通过 `google_fonts` 包引用,**运行时自动下载并缓存**
|
||||||
|
- 本地字体:Inter(在 `pubspec.yaml` 中声明)
|
||||||
|
|
||||||
|
### 间距
|
||||||
|
|
||||||
|
- 使用 `AppSpacing.xs/sm/md/lg/xl` 常量
|
||||||
|
- 不要在 Widget 中硬编码间距数值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、Git 规范
|
||||||
|
|
||||||
|
### 分支命名
|
||||||
|
|
||||||
|
- 功能分支:`fix/auto-YYYYMMDD-HHMMSS`
|
||||||
|
- 合并目标:`main`
|
||||||
|
|
||||||
|
### Commit 规范
|
||||||
|
|
||||||
|
- `fix:` — Bug 修复
|
||||||
|
- `feat:` — 新功能
|
||||||
|
- `refactor:` — 重构(不改变行为)
|
||||||
|
- `chore:` — 构建配置、依赖更新等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、检查清单(PR 提交前)
|
||||||
|
|
||||||
|
- [ ] `dart analyze` 无 Error(Warning/Info 可接受)
|
||||||
|
- [ ] 所有 Stream 订阅已在 dispose 中取消
|
||||||
|
- [ ] 所有 Controller/Player 已在 dispose 中释放
|
||||||
|
- [ ] 多个独立 API 调用已用 `Future.wait` 并行化
|
||||||
|
- [ ] 动画 Widget 使用了 `RepaintBoundary` 或 `AnimatedBuilder(child:)`
|
||||||
|
- [ ] 新增的 Widget 构造函数尽可能标记为 `const`
|
||||||
|
- [ ] 列表使用 `ListView.builder`,而非 `ListView(children:)`
|
||||||
|
- [ ] 未引入新的硬编码颜色值(使用 AppColors 或 theme)
|
||||||
|
- [ ] Android release 构建正常(minify + shrinkResources 不会崩溃)
|
||||||
703
airhub_app/FLUTTER_OPTIMIZATION_GUIDE.md
Normal file
703
airhub_app/FLUTTER_OPTIMIZATION_GUIDE.md
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
# Flutter App 全面优化方案
|
||||||
|
|
||||||
|
> 针对 airhub_app 项目的从头到尾优化整改方案
|
||||||
|
> 目标:减小包大小、降低发热、消除卡顿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目现状分析
|
||||||
|
|
||||||
|
| 指标 | 当前值 | 问题 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Flutter 版本 | 3.41.2 (Dart 3.11.0) | 较新,支持 Impeller |
|
||||||
|
| 代码规模 | 132 个 Dart 文件 / 28k 行 | 中等规模 |
|
||||||
|
| assets 总大小 | **~135MB** | **严重超标** |
|
||||||
|
| storybook_videos/ | **92MB** (11个mp4,最大48MB) | **主要元凶** |
|
||||||
|
| music/ | **15MB** (10个mp3) | 应该CDN下载 |
|
||||||
|
| story_covers/ | **7.4MB** (PNG图片) | 需压缩或远程加载 |
|
||||||
|
| 背景图 PNG | 单张 4.5-4.7MB | **未压缩的巨型PNG** |
|
||||||
|
| 字体文件 | 1.6MB (4个Inter字重) | 可裁剪子集 |
|
||||||
|
| google_fonts 依赖 | 存在 | 会下载额外字体 |
|
||||||
|
| Android release 配置 | 未启用 minifyEnabled | 缺少代码压缩 |
|
||||||
|
| ProGuard | 已引用但未启用 shrinkResources | 资源未裁剪 |
|
||||||
|
| iOS 构建 | 默认配置 | 未做符号裁剪优化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、包大小优化(预估可减少 60-70%)
|
||||||
|
|
||||||
|
### 阶段 1:资源外迁(预估减少 110MB+ → 约 80% 的包大小)
|
||||||
|
|
||||||
|
这是**最关键也是收益最大**的一步。
|
||||||
|
|
||||||
|
#### 1.1 视频资源迁移到 CDN / 服务器
|
||||||
|
```
|
||||||
|
当前: assets/www/storybook_videos/ → 92MB 全部打进包
|
||||||
|
目标: 视频从服务端按需下载,本地缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
**实施方案:**
|
||||||
|
- 将所有 mp4 文件上传到后端服务器或 OSS/CDN
|
||||||
|
- App 端改为流式播放或首次使用时下载到本地缓存
|
||||||
|
- 使用 `video_player` 的网络播放功能直接播放远程URL
|
||||||
|
- 实现下载进度显示和本地缓存管理
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 改为网络播放
|
||||||
|
VideoPlayerController.networkUrl(Uri.parse('https://cdn.example.com/videos/magic_broom.mp4'))
|
||||||
|
|
||||||
|
// 或按需下载到缓存
|
||||||
|
final cacheDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${cacheDir.path}/magic_broom.mp4');
|
||||||
|
if (!file.existsSync()) {
|
||||||
|
await dio.download(url, file.path, onReceiveProgress: (received, total) {
|
||||||
|
// 显示下载进度
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 音频资源迁移
|
||||||
|
```
|
||||||
|
当前: assets/www/music/ → 15MB
|
||||||
|
目标: 音频按需下载或流式播放
|
||||||
|
```
|
||||||
|
|
||||||
|
- `just_audio` 本身支持网络URL播放
|
||||||
|
- 改为 `AudioSource.uri(Uri.parse('https://cdn.example.com/music/xxx.mp3'))`
|
||||||
|
|
||||||
|
#### 1.3 故事封面图迁移
|
||||||
|
```
|
||||||
|
当前: assets/www/story_covers/ → 7.4MB
|
||||||
|
目标: 网络图片 + 本地缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
- 添加 `cached_network_image` 依赖
|
||||||
|
- 封面图改为网络加载,自动缓存
|
||||||
|
|
||||||
|
#### 1.4 背景图压缩
|
||||||
|
```
|
||||||
|
当前: 首页背景2.png (4.7MB) + 首页底图.png (4.5MB)
|
||||||
|
目标: 压缩到 200-500KB
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案A:转 WebP 格式**
|
||||||
|
```bash
|
||||||
|
# 安装 cwebp
|
||||||
|
brew install webp
|
||||||
|
|
||||||
|
# 转换 (质量80,通常可减少 85%+)
|
||||||
|
cwebp -q 80 "assets/www/首页背景2.png" -o "assets/www/首页背景2.webp"
|
||||||
|
cwebp -q 80 "assets/www/首页底图.png" -o "assets/www/首页底图.webp"
|
||||||
|
```
|
||||||
|
|
||||||
|
**方案B:用 pngquant 压缩**
|
||||||
|
```bash
|
||||||
|
pngquant --quality=65-80 "assets/www/首页背景2.png" --output "assets/www/首页背景2.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期效果:4.7MB → 300-500KB(减少 **90%**)
|
||||||
|
|
||||||
|
### 阶段 2:构建配置优化(预估再减 30-40%)
|
||||||
|
|
||||||
|
#### 2.1 Android 构建优化
|
||||||
|
|
||||||
|
**修改 `android/app/build.gradle.kts`:**
|
||||||
|
```kotlin
|
||||||
|
android {
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release") // 换正式签名
|
||||||
|
|
||||||
|
// 启用代码压缩和资源裁剪
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建命令使用 `--split-per-abi`:**
|
||||||
|
```bash
|
||||||
|
# 分架构打包,每个 APK 减少 30-40%
|
||||||
|
flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info
|
||||||
|
|
||||||
|
# 或 App Bundle(Google Play 自动按设备裁剪)
|
||||||
|
flutter build appbundle --release --obfuscate --split-debug-info=./debug-info
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 作用 | 预估收益 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `--split-per-abi` | 按 CPU 架构分包 | 减少 30-40% |
|
||||||
|
| `--obfuscate` | 代码混淆 | 减少 5-10% |
|
||||||
|
| `--split-debug-info` | 分离调试符号 | 减少 5-10% |
|
||||||
|
| `minifyEnabled=true` | R8 代码压缩 | 减少 15-25% |
|
||||||
|
| `shrinkResources=true` | 移除未使用资源 | 减少 5-10% |
|
||||||
|
|
||||||
|
#### 2.2 iOS 构建优化
|
||||||
|
|
||||||
|
**Xcode Build Settings 调整:**
|
||||||
|
- Strip Debug Symbols During Copy → YES
|
||||||
|
- Strip Linked Product → YES
|
||||||
|
- Dead Code Stripping → YES
|
||||||
|
- Optimization Level → -Oz (Smallest)
|
||||||
|
|
||||||
|
**构建命令:**
|
||||||
|
```bash
|
||||||
|
flutter build ipa --release --obfuscate --split-debug-info=./debug-info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 3:依赖瘦身
|
||||||
|
|
||||||
|
#### 3.1 审计当前依赖
|
||||||
|
|
||||||
|
| 依赖 | 大小影响 | 建议 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `google_fonts` | 较大,会下载字体 | 已内嵌 Inter 字体,考虑移除 |
|
||||||
|
| `webview_flutter` | ~4MB 原生库 | 仅在需要时加载(deferred) |
|
||||||
|
| `video_player` | ~3-4MB | 考虑 deferred import |
|
||||||
|
| `image` (dart包) | 纯 Dart 图像处理 | 检查是否真的需要 |
|
||||||
|
| `http` | 与 `dio` 重复 | 统一用 dio,移除 http |
|
||||||
|
| `flutter_svg` | 中等 | 保留(SVG 比 PNG 小得多) |
|
||||||
|
|
||||||
|
#### 3.2 移除 `google_fonts`
|
||||||
|
|
||||||
|
你已经在 `pubspec.yaml` 中内嵌了 Inter 字体,`google_fonts` 是多余的:
|
||||||
|
```yaml
|
||||||
|
# 移除这个依赖
|
||||||
|
# google_fonts: ^6.1.0
|
||||||
|
|
||||||
|
# 已有本地字体
|
||||||
|
fonts:
|
||||||
|
- family: Inter
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Inter-Regular.ttf
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
代码中改用 `TextStyle(fontFamily: 'Inter')` 替代 `GoogleFonts.inter()`。
|
||||||
|
|
||||||
|
#### 3.3 移除重复的 `http` 包
|
||||||
|
```yaml
|
||||||
|
# 移除 - 已有 dio 做网络请求
|
||||||
|
# http: ^1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4 字体子集化
|
||||||
|
|
||||||
|
当前 4 个 Inter 字体文件共 1.6MB。如果只需要中英文:
|
||||||
|
```bash
|
||||||
|
# 安装 fonttools
|
||||||
|
pip install fonttools brotli
|
||||||
|
|
||||||
|
# 裁剪只保留需要的字符集(中文 + 英文 + 数字 + 标点)
|
||||||
|
pyftsubset Inter-Regular.ttf \
|
||||||
|
--text-file=used_chars.txt \
|
||||||
|
--output-file=Inter-Regular-subset.ttf
|
||||||
|
|
||||||
|
# 通常可从 400KB → 50-80KB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、性能优化:降低发热与 CPU 占用
|
||||||
|
|
||||||
|
### 3.1 Widget 重建优化(最重要)
|
||||||
|
|
||||||
|
#### 使用 `const` 构造函数
|
||||||
|
```dart
|
||||||
|
// ❌ 每次 build 都创建新实例
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(16), // 每次创建新对象
|
||||||
|
child: Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 编译期常量,不会触发重建
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**全项目检查:** 运行 `dart analyze` 查看 `prefer_const_constructors` 警告。
|
||||||
|
|
||||||
|
#### 使用 `RepaintBoundary` 隔离重绘区域
|
||||||
|
```dart
|
||||||
|
// 对频繁更新的局部 Widget 包裹 RepaintBoundary
|
||||||
|
RepaintBoundary(
|
||||||
|
child: AnimatedWidget(...), // 只重绘这部分,不影响外部
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- 动画区域
|
||||||
|
- 频繁刷新的倒计时/进度条
|
||||||
|
- BLE 数据实时显示
|
||||||
|
|
||||||
|
#### 拆分 Widget,避免大范围重建
|
||||||
|
```dart
|
||||||
|
// ❌ 整个页面一个大 build 方法
|
||||||
|
class MyPage extends ConsumerWidget {
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(someProvider); // 任何变化都重建整个页面
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(children: [Header(), Content(state), Footer()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 拆成独立 Widget,只监听需要的部分
|
||||||
|
class _Content extends ConsumerWidget {
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final data = ref.watch(someProvider.select((s) => s.data)); // 精准监听
|
||||||
|
return ...;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Riverpod 状态管理优化
|
||||||
|
|
||||||
|
#### 使用 `select` 精准订阅
|
||||||
|
```dart
|
||||||
|
// ❌ 监听整个 state,任何字段变化都重建
|
||||||
|
final state = ref.watch(deviceProvider);
|
||||||
|
|
||||||
|
// ✅ 只监听需要的字段
|
||||||
|
final deviceName = ref.watch(deviceProvider.select((s) => s.name));
|
||||||
|
final isConnected = ref.watch(deviceProvider.select((s) => s.isConnected));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 区分 `watch` 和 `listen`
|
||||||
|
```dart
|
||||||
|
// watch: 用于 UI 显示的数据(需要重建)
|
||||||
|
final name = ref.watch(provider.select((s) => s.name));
|
||||||
|
|
||||||
|
// listen: 用于副作用(弹窗、导航、日志),不触发重建
|
||||||
|
ref.listen(provider, (prev, next) {
|
||||||
|
if (next.hasError) showErrorDialog(context, next.error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 BLE 通信优化(降低发热的关键)
|
||||||
|
|
||||||
|
你的 app 大量使用蓝牙(`flutter_blue_plus`),这是发热的主要来源之一。
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// ❌ 持续高频扫描
|
||||||
|
FlutterBluePlus.startScan(timeout: Duration(seconds: 30));
|
||||||
|
|
||||||
|
// ✅ 扫到目标设备后立即停止
|
||||||
|
FlutterBluePlus.startScan(timeout: Duration(seconds: 10));
|
||||||
|
// 找到目标后
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
|
|
||||||
|
// ❌ 不必要的频繁读取特征值
|
||||||
|
Timer.periodic(Duration(milliseconds: 100), (_) => characteristic.read());
|
||||||
|
|
||||||
|
// ✅ 使用 notify 替代轮询
|
||||||
|
await characteristic.setNotifyValue(true);
|
||||||
|
characteristic.onValueReceived.listen((value) {
|
||||||
|
// 只在有新数据时处理
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**BLE 省电策略:**
|
||||||
|
- 连接成功后立即停止扫描
|
||||||
|
- 使用 notification 而非 polling
|
||||||
|
- 非活跃页面暂停 BLE 数据监听
|
||||||
|
- 合理设置连接间隔参数
|
||||||
|
|
||||||
|
### 3.4 图片与内存管理
|
||||||
|
|
||||||
|
#### 网络图片缓存
|
||||||
|
```yaml
|
||||||
|
# 添加依赖
|
||||||
|
dependencies:
|
||||||
|
cached_network_image: ^3.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 使用缓存图片组件
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
memCacheWidth: 300, // 限制内存中图片尺寸
|
||||||
|
memCacheHeight: 300,
|
||||||
|
placeholder: (_, __) => const CircularProgressIndicator(),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 大图降采样
|
||||||
|
```dart
|
||||||
|
// ❌ 直接加载 4.7MB 的 PNG 到内存
|
||||||
|
Image.asset('assets/www/首页背景2.png')
|
||||||
|
|
||||||
|
// ✅ 指定缓存尺寸,降低内存占用
|
||||||
|
Image.asset(
|
||||||
|
'assets/www/首页背景2.webp',
|
||||||
|
cacheWidth: 750, // 按屏幕宽度限制
|
||||||
|
cacheHeight: 1334,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 页面销毁时释放资源
|
||||||
|
```dart
|
||||||
|
class _MyPageState extends State<MyPage> {
|
||||||
|
VideoPlayerController? _videoController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_videoController?.dispose(); // 必须释放!
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Isolate 处理耗时任务
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 图片处理、数据解析等放到 Isolate
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
// 简单任务用 compute
|
||||||
|
final result = await compute(processImage, imageData);
|
||||||
|
|
||||||
|
// 复杂任务用 Isolate
|
||||||
|
Future<Uint8List> processImageInIsolate(Uint8List rawData) async {
|
||||||
|
return await Isolate.run(() {
|
||||||
|
// 在独立线程处理图片
|
||||||
|
return heavyImageProcessing(rawData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- BLE 接收的大量数据解析
|
||||||
|
- 图片编解码(badge 图片处理)
|
||||||
|
- JSON 大数据解析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、UI 流畅度优化(消除卡顿)
|
||||||
|
|
||||||
|
### 4.1 列表优化
|
||||||
|
|
||||||
|
#### 使用 `ListView.builder` 而非 `ListView`
|
||||||
|
```dart
|
||||||
|
// ❌ 一次性创建所有子 Widget
|
||||||
|
ListView(children: items.map((e) => ItemWidget(e)).toList())
|
||||||
|
|
||||||
|
// ✅ 按需创建可见项
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemExtent: 80, // 固定高度可提升滚动性能
|
||||||
|
addAutomaticKeepAlives: false, // 不需要保活的列表关闭此项
|
||||||
|
addRepaintBoundaries: true,
|
||||||
|
itemBuilder: (context, index) => ItemWidget(items[index]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 图片列表优化
|
||||||
|
```dart
|
||||||
|
ListView.builder(
|
||||||
|
cacheExtent: 500, // 预缓存区域
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: items[index].imageUrl,
|
||||||
|
memCacheWidth: 200, // 限制内存图片大小
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Impeller 渲染引擎(Flutter 3.41.2 默认启用)
|
||||||
|
|
||||||
|
你的 Flutter 版本已默认使用 Impeller 渲染引擎,优势:
|
||||||
|
- **消除 Shader 编译卡顿**(Skia 的最大痛点)
|
||||||
|
- 更稳定的帧率
|
||||||
|
- 更低的 GPU 内存占用
|
||||||
|
|
||||||
|
验证 Impeller 是否启用:
|
||||||
|
```bash
|
||||||
|
# iOS 默认启用,Android 3.22+ 默认启用
|
||||||
|
flutter run --profile # 然后查看 DevTools
|
||||||
|
```
|
||||||
|
|
||||||
|
如果遇到兼容性问题可临时回退:
|
||||||
|
```bash
|
||||||
|
flutter run --no-enable-impeller
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Shader 预热(如仍用 Skia)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 在 main.dart 中
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// 预编译 shader 缓存
|
||||||
|
await ShaderWarmUp().execute();
|
||||||
|
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SkSL 收集命令:
|
||||||
|
```bash
|
||||||
|
# 1. 收集 shader
|
||||||
|
flutter run --profile --cache-sksl --purge-persistent-cache
|
||||||
|
|
||||||
|
# 2. 操作 app 中所有页面和动画
|
||||||
|
|
||||||
|
# 3. 按 M 导出 flutter_01.sksl.json
|
||||||
|
|
||||||
|
# 4. 使用缓存构建
|
||||||
|
flutter build apk --bundle-sksl-path flutter_01.sksl.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 页面切换优化
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// go_router 中预加载关键页面
|
||||||
|
GoRoute(
|
||||||
|
path: '/device-control',
|
||||||
|
pageBuilder: (context, state) => CustomTransitionPage(
|
||||||
|
transitionDuration: const Duration(milliseconds: 200), // 缩短动画时间
|
||||||
|
child: const DeviceControlPage(),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child); // 用 Fade 替代 Slide
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 WebView 优化
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// WebView 是重量级组件
|
||||||
|
// 1. 延迟初始化
|
||||||
|
late final WebViewController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// 延迟到页面完全展示后再初始化 WebView
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_initWebView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 页面销毁时清理
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.clearCache();
|
||||||
|
_controller.clearLocalStorage();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、工具与监控
|
||||||
|
|
||||||
|
### 5.1 包大小分析
|
||||||
|
```bash
|
||||||
|
# 分析 APK 大小组成
|
||||||
|
flutter build apk --analyze-size
|
||||||
|
|
||||||
|
# 分析 iOS 包大小
|
||||||
|
flutter build ipa --analyze-size
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 性能分析
|
||||||
|
```bash
|
||||||
|
# Profile 模式运行(有性能数据但接近 release 性能)
|
||||||
|
flutter run --profile
|
||||||
|
|
||||||
|
# 打开 DevTools
|
||||||
|
flutter pub global activate devtools
|
||||||
|
flutter pub global run devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 检测无用代码和依赖
|
||||||
|
```bash
|
||||||
|
# 检测未使用的依赖
|
||||||
|
dart pub deps --no-dev
|
||||||
|
|
||||||
|
# 分析代码问题
|
||||||
|
dart analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Glance(APM 监控库)
|
||||||
|
```yaml
|
||||||
|
# 生产环境卡顿检测
|
||||||
|
dependencies:
|
||||||
|
glance_flutter: ^0.x.x # 实验性
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实施优先级与预期效果
|
||||||
|
|
||||||
|
### P0 紧急(1-2天,收益巨大)
|
||||||
|
|
||||||
|
| 任务 | 预期收益 |
|
||||||
|
|------|----------|
|
||||||
|
| 视频/音频迁移到 CDN | 包大小 -107MB (80%) |
|
||||||
|
| 背景图转 WebP 压缩 | 包大小 -8MB |
|
||||||
|
| 启用 `minifyEnabled` + `shrinkResources` | 包大小 -15-25% |
|
||||||
|
| `--split-per-abi` 分架构打包 | APK -30-40% |
|
||||||
|
|
||||||
|
### P1 重要(3-5天)
|
||||||
|
|
||||||
|
| 任务 | 预期收益 |
|
||||||
|
|------|----------|
|
||||||
|
| 移除 `google_fonts` 和 `http` 包 | 包大小 -2-4MB |
|
||||||
|
| 字体子集化 | 包大小 -1.2MB |
|
||||||
|
| `--obfuscate --split-debug-info` | 包大小 -5-10% |
|
||||||
|
| BLE 扫描/通信优化 | 降低发热 30-50% |
|
||||||
|
| 图片内存管理 (cacheWidth/Height) | 降低内存 40% |
|
||||||
|
|
||||||
|
### P2 优化(1-2周)
|
||||||
|
|
||||||
|
| 任务 | 预期收益 |
|
||||||
|
|------|----------|
|
||||||
|
| 全项目 const 构造函数审查 | 减少 Widget 重建 |
|
||||||
|
| Riverpod select 精准订阅 | 减少不必要重建 |
|
||||||
|
| 列表 ListView.builder 优化 | 滚动更流畅 |
|
||||||
|
| 耗时任务移入 Isolate | 消除主线程卡顿 |
|
||||||
|
| RepaintBoundary 隔离动画区域 | 减少重绘范围 |
|
||||||
|
| Deferred import (webview/video) | 减小初始加载 |
|
||||||
|
|
||||||
|
### P3 持续(长期)
|
||||||
|
|
||||||
|
| 任务 | 预期收益 |
|
||||||
|
|------|----------|
|
||||||
|
| 接入性能监控 (Glance/Sentry) | 线上问题感知 |
|
||||||
|
| 定期 `flutter build --analyze-size` | 包大小回归检测 |
|
||||||
|
| CI 集成包大小检查 | 防止包大小退化 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、预期总体效果
|
||||||
|
|
||||||
|
| 指标 | 优化前 | 优化后(预估) | 改善 |
|
||||||
|
|------|--------|----------------|------|
|
||||||
|
| APK 大小 | ~150-180MB | ~25-35MB | **80%+** |
|
||||||
|
| IPA 大小 | ~200MB+ | ~40-60MB | **70%+** |
|
||||||
|
| 冷启动时间 | 3-5s | 1-2s | **60%** |
|
||||||
|
| 内存占用 | 较高 | 降低 40%+ | **40%+** |
|
||||||
|
| 手机发热 | 明显 | 温热 | **显著改善** |
|
||||||
|
| 滚动帧率 | 有掉帧 | 稳定 60fps | **流畅** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Impeller 渲染引擎性能数据
|
||||||
|
|
||||||
|
Flutter 3.41.2 已默认启用 Impeller(替代 Skia),关键性能数据:
|
||||||
|
|
||||||
|
| 维度 | Skia | Impeller | 改善 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| Shader 编译 | 运行时 JIT(首次卡顿 20-200ms) | 构建时 AOT 预编译 | **消除 Shader 卡顿** |
|
||||||
|
| GPU 光栅化耗时 | 4.05ms | 2.81ms | **-30%** |
|
||||||
|
| 120Hz 帧率达标率 | 67.1% | 91.6% | **+36%** |
|
||||||
|
| 复杂场景掉帧率 | ~12% | ~1.5% | **-87%** |
|
||||||
|
|
||||||
|
确认 Impeller 状态:
|
||||||
|
```bash
|
||||||
|
flutter run --profile # 然后在 DevTools 中查看渲染后端
|
||||||
|
```
|
||||||
|
|
||||||
|
Android 持久化配置(`AndroidManifest.xml`):
|
||||||
|
```xml
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
|
android:value="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、低端设备适配策略
|
||||||
|
|
||||||
|
针对不同档次手机动态降级:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 设备分级
|
||||||
|
enum DeviceTier { low, mid, high }
|
||||||
|
|
||||||
|
DeviceTier getDeviceTier() {
|
||||||
|
final memory = SysInfo.getTotalPhysicalMemory(); // 需原生获取
|
||||||
|
if (memory < 3 * 1024 * 1024 * 1024) return DeviceTier.low;
|
||||||
|
if (memory < 6 * 1024 * 1024 * 1024) return DeviceTier.mid;
|
||||||
|
return DeviceTier.high;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 策略 | 低端 | 中端 | 高端 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 动画 | 关闭粒子/复杂动画 | 简化动画 | 全特效 |
|
||||||
|
| 图片 | WebP 降分辨率 30% | 标准分辨率 | 高清 |
|
||||||
|
| BackdropFilter | 纯色替代 | 简化模糊 | 全高斯模糊 |
|
||||||
|
| 并发网络请求 | 限 2 个 | 限 4 个 | 不限制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、生产环境性能监控
|
||||||
|
|
||||||
|
| 工具 | 类型 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Glance](https://github.com/littleGnAl/glance) | 开源 APM | 采集卡顿时的堆栈,定位具体函数 |
|
||||||
|
| [flutter_smooth](https://github.com/fzyzcjy/flutter_smooth) | 开源库 | 即使 Widget 树极重也能达到 ~60fps |
|
||||||
|
| Firebase Performance | 云端 APM | 启动时间、网络延迟、自定义追踪 |
|
||||||
|
| Sentry | 错误+性能 | 崩溃报告+性能事务 |
|
||||||
|
|
||||||
|
### 自动化性能测试(CI 集成)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// integration_test/performance_test.dart
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('scrolling performance', (tester) async {
|
||||||
|
await tester.pumpWidget(const MyApp());
|
||||||
|
await binding.traceAction(() async {
|
||||||
|
await tester.fling(find.byType(ListView), const Offset(0, -500), 10000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}, reportKey: 'scrolling_timeline');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、参考资源
|
||||||
|
|
||||||
|
### 官方文档
|
||||||
|
- [Flutter Performance Best Practices](https://docs.flutter.dev/perf/best-practices)
|
||||||
|
- [App Size Optimization](https://docs.flutter.dev/perf/app-size)
|
||||||
|
- [Impeller Rendering Engine](https://docs.flutter.dev/perf/impeller)
|
||||||
|
- [Deferred Components](https://docs.flutter.dev/perf/deferred-components)
|
||||||
|
- [Shader Compilation Jank](https://docs.flutter.dev/perf/shader)
|
||||||
|
|
||||||
|
### 优秀开源项目参考
|
||||||
|
- [flutter_deer](https://github.com/simplezhli/flutter_deer) — 完善的 Flutter 实践项目,代码规范
|
||||||
|
- [Best-Flutter-UI-Templates](https://github.com/mitesh77/Best-Flutter-UI-Templates) — UI 模板合集
|
||||||
|
- [Solido/awesome-flutter](https://github.com/Solido/awesome-flutter) — Flutter 最全资源汇总
|
||||||
|
|
||||||
|
### 工具
|
||||||
|
- [Glance](https://pub.dev/packages/glance_flutter) — APM 卡顿检测
|
||||||
|
- [TinyPNG](https://tinypng.com/) — PNG/WebP 压缩
|
||||||
|
- [pngquant](https://pngquant.org/) — 批量 PNG 压缩
|
||||||
|
- [pyftsubset](https://fonttools.readthedocs.io/) — 字体子集化
|
||||||
BIN
airhub_app/assets/fonts/DMSans-Italic-Variable.ttf
Normal file
BIN
airhub_app/assets/fonts/DMSans-Italic-Variable.ttf
Normal file
Binary file not shown.
BIN
airhub_app/assets/fonts/DMSans-Variable.ttf
Normal file
BIN
airhub_app/assets/fonts/DMSans-Variable.ttf
Normal file
Binary file not shown.
BIN
airhub_app/assets/fonts/Outfit-Variable.ttf
Normal file
BIN
airhub_app/assets/fonts/Outfit-Variable.ttf
Normal file
Binary file not shown.
BIN
airhub_app/assets/fonts/PressStart2P-Regular.ttf
Normal file
BIN
airhub_app/assets/fonts/PressStart2P-Regular.ttf
Normal file
Binary file not shown.
@ -1,7 +1,9 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../features/auth/presentation/pages/login_page.dart';
|
import '../../features/auth/presentation/pages/login_page.dart';
|
||||||
import '../../pages/bluetooth_page.dart';
|
import '../../pages/bluetooth_page.dart';
|
||||||
@ -21,6 +23,10 @@ import '../network/token_manager.dart';
|
|||||||
|
|
||||||
part 'app_router.g.dart';
|
part 'app_router.g.dart';
|
||||||
|
|
||||||
|
const _lastRouteKey = 'last_business_route';
|
||||||
|
const _lastProductTypeKey = 'last_product_type';
|
||||||
|
const _validBusinessRoutes = {'/device-control', '/badge-control', '/badge-basic-control'};
|
||||||
|
|
||||||
/// 产品代码 → 路由 + ProductType 的映射
|
/// 产品代码 → 路由 + ProductType 的映射
|
||||||
const _productCodeRoutes = {
|
const _productCodeRoutes = {
|
||||||
'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
|
'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
|
||||||
@ -43,29 +49,52 @@ GoRouter goRouter(Ref ref) {
|
|||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
if (hasToken && isLoginRoute) {
|
if (hasToken && isLoginRoute) {
|
||||||
// 登录成功 → 跳到最近使用的设备业务页
|
// 登录成功 → 获取已绑定设备列表
|
||||||
try {
|
try {
|
||||||
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
||||||
final devices = await dataSource.getMyDevices();
|
final devices = await dataSource.getMyDevices();
|
||||||
debugPrint('[Router] 已绑定设备数: ${devices.length}');
|
debugPrint('[Router] 已绑定设备数: ${devices.length}');
|
||||||
|
|
||||||
if (devices.isNotEmpty) {
|
if (devices.isNotEmpty) {
|
||||||
// 按 last_online_at 降序,取最近使用的设备
|
// 收集用户当前绑定的所有业务路由
|
||||||
|
final boundRoutes = <String>{};
|
||||||
|
for (final d in devices) {
|
||||||
|
final dt = d.device.deviceType ?? d.device.deviceTypeInfo;
|
||||||
|
final code = dt?.productCode ?? '';
|
||||||
|
final m = _productCodeRoutes[code];
|
||||||
|
if (m != null) boundRoutes.add(m.route);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先恢复上次退出时的业务页面(需确认该设备仍在绑定中)
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final savedRoute = prefs.getString(_lastRouteKey);
|
||||||
|
final savedProductType = prefs.getString(_lastProductTypeKey);
|
||||||
|
if (savedRoute != null && boundRoutes.contains(savedRoute)) {
|
||||||
|
debugPrint('[Router] 恢复上次页面: $savedRoute');
|
||||||
|
if (savedProductType != null) {
|
||||||
|
final pt = ProductType.values.where((e) => e.name == savedProductType).firstOrNull;
|
||||||
|
if (pt != null) {
|
||||||
|
ref.read(currentProductTypeProvider.notifier).set(pt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedRoute;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Router] 读取上次页面失败: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback:按设备最近使用时间跳转
|
||||||
devices.sort((a, b) {
|
devices.sort((a, b) {
|
||||||
final ta = a.device.lastOnlineAt ?? '';
|
final ta = a.device.lastOnlineAt ?? '';
|
||||||
final tb = b.device.lastOnlineAt ?? '';
|
final tb = b.device.lastOnlineAt ?? '';
|
||||||
return tb.compareTo(ta);
|
return tb.compareTo(ta);
|
||||||
});
|
});
|
||||||
final recent = devices.first;
|
final recent = devices.first;
|
||||||
final dt = recent.device.deviceType;
|
final resolvedDt = recent.device.deviceType ?? recent.device.deviceTypeInfo;
|
||||||
final dti = recent.device.deviceTypeInfo;
|
|
||||||
debugPrint('[Router] 最近设备 sn=${recent.device.sn}');
|
|
||||||
debugPrint('[Router] deviceType=$dt');
|
|
||||||
debugPrint('[Router] deviceTypeInfo=$dti');
|
|
||||||
final resolvedDt = dt ?? dti;
|
|
||||||
final code = resolvedDt?.productCode ?? '';
|
final code = resolvedDt?.productCode ?? '';
|
||||||
debugPrint('[Router] productCode=$code');
|
debugPrint('[Router] 最近设备 sn=${recent.device.sn}, productCode=$code');
|
||||||
final mapping = _productCodeRoutes[code];
|
final mapping = _productCodeRoutes[code];
|
||||||
debugPrint('[Router] mapping=$mapping → route=${mapping?.route}');
|
|
||||||
if (mapping != null) {
|
if (mapping != null) {
|
||||||
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
|
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
|
||||||
return mapping.route;
|
return mapping.route;
|
||||||
@ -125,10 +154,43 @@ GoRouter goRouter(Ref ref) {
|
|||||||
final extra = state.extra as Map<String, dynamic>? ?? {};
|
final extra = state.extra as Map<String, dynamic>? ?? {};
|
||||||
return BadgeTransferPage(
|
return BadgeTransferPage(
|
||||||
imageUrl: extra['imageUrl'] as String? ?? '',
|
imageUrl: extra['imageUrl'] as String? ?? '',
|
||||||
imageBytes: extra['imageBytes'] as Uint8List?,
|
imageBytes: extra['imageBytes'] is Uint8List
|
||||||
|
? extra['imageBytes'] as Uint8List
|
||||||
|
: extra['imageBytes'] is List
|
||||||
|
? Uint8List.fromList(List<int>.from(extra['imageBytes'] as List))
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
observers: [_BusinessRouteObserver(ref)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 监听路由变化,进入业务页时自动保存到 SharedPreferences
|
||||||
|
class _BusinessRouteObserver extends NavigatorObserver {
|
||||||
|
final Ref _ref;
|
||||||
|
_BusinessRouteObserver(this._ref);
|
||||||
|
|
||||||
|
void _saveIfBusiness(Route<dynamic>? route) {
|
||||||
|
final name = route?.settings.name;
|
||||||
|
if (name != null && _validBusinessRoutes.contains(name)) {
|
||||||
|
final productType = _ref.read(currentProductTypeProvider);
|
||||||
|
debugPrint('[Router] 保存业务页: $name, productType=${productType.name}');
|
||||||
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
|
prefs.setString(_lastRouteKey, name);
|
||||||
|
prefs.setString(_lastProductTypeKey, productType.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
_saveIfBusiness(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||||
|
_saveIfBusiness(newRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
import '../../../../core/services/phone_auth_service.dart';
|
import '../../../../core/services/phone_auth_service.dart';
|
||||||
import '../../../../theme/app_colors.dart';
|
import '../../../../theme/app_colors.dart';
|
||||||
@ -95,7 +94,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'服务协议',
|
'服务协议',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -307,7 +306,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Logo — 匹配 HTML .login-logo
|
// Logo — 匹配 HTML .login-logo
|
||||||
Text(
|
Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
style: GoogleFonts.pressStart2p(
|
style: TextStyle(fontFamily: 'Press Start 2P',
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1), // 靛蓝
|
color: const Color(0xFF6366F1), // 靛蓝
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -463,7 +462,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'欢迎使用 Airhub',
|
'欢迎使用 Airhub',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF6B5B95),
|
color: const Color(0xFF6B5B95),
|
||||||
@ -728,7 +727,7 @@ class _AgreementContentPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../../../../core/network/api_config.dart';
|
import '../../../../core/network/api_client.dart';
|
||||||
import '../../../../pages/profile/profile_page.dart';
|
import '../../../../pages/profile/profile_page.dart';
|
||||||
import '../../../../theme/product_theme.dart';
|
import '../../../../theme/product_theme.dart';
|
||||||
import '../../../../widgets/animated_gradient_background.dart';
|
import '../../../../widgets/animated_gradient_background.dart';
|
||||||
@ -53,17 +50,9 @@ class _BadgeBasicControlPageState extends ConsumerState<BadgeBasicControlPage>
|
|||||||
|
|
||||||
Future<void> _loadLastImage() async {
|
Future<void> _loadLastImage() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final apiClient = ref.read(apiClientProvider);
|
||||||
final token = prefs.getString('access_token');
|
final data = await apiClient.get('/badge/history/');
|
||||||
final resp = await http.get(
|
final images = ((data as Map<String, dynamic>)['images'] as List<dynamic>? ?? [])
|
||||||
Uri.parse('${ApiConfig.fullBaseUrl}/badge/history/'),
|
|
||||||
headers: {if (token != null) 'Authorization': 'Bearer $token'},
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (resp.statusCode == 200) {
|
|
||||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
||||||
final data = body['data'] as Map<String, dynamic>? ?? {};
|
|
||||||
final images = (data['images'] as List<dynamic>? ?? [])
|
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.where((img) =>
|
.where((img) =>
|
||||||
img['generation_status'] == 'completed' &&
|
img['generation_status'] == 'completed' &&
|
||||||
@ -72,7 +61,6 @@ class _BadgeBasicControlPageState extends ConsumerState<BadgeBasicControlPage>
|
|||||||
if (images.isNotEmpty && mounted) {
|
if (images.isNotEmpty && mounted) {
|
||||||
setState(() => _lastImageUrl = images.first['image_url'] as String);
|
setState(() => _lastImageUrl = images.first['image_url'] as String);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,11 @@ class DeviceController extends _$DeviceController {
|
|||||||
Future<String?> bindDevice(String sn, {int? spiritId}) async {
|
Future<String?> bindDevice(String sn, {int? spiritId}) async {
|
||||||
final repository = ref.read(deviceRepositoryProvider);
|
final repository = ref.read(deviceRepositoryProvider);
|
||||||
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
||||||
if (!ref.mounted) return '组件已卸载';
|
if (!ref.mounted) return null; // 组件已卸载,绑定请求已发出,视为成功
|
||||||
return result.fold(
|
return result.fold(
|
||||||
(failure) => failure.message,
|
(failure) => failure.message,
|
||||||
(bindingId) {
|
(bindingId) {
|
||||||
if (!ref.mounted) return '组件已卸载';
|
if (ref.mounted) ref.invalidateSelf();
|
||||||
ref.invalidateSelf();
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/services/ble_provisioning_service.dart';
|
import '../core/services/ble_provisioning_service.dart';
|
||||||
@ -290,23 +289,18 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName');
|
debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
|
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
|
||||||
// API 查询失败时,用 BLE 名作为 fallback 也显示出来
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final bleDevice = _pendingBleDevices[mac];
|
// 查询失败 → 停止扫描,提示用户
|
||||||
setState(() {
|
setState(() => _isSearching = false);
|
||||||
if (!_devices.any((d) => d.macAddress == mac)) {
|
|
||||||
_devices.add(MockDevice(
|
|
||||||
sn: '',
|
|
||||||
name: '${_airhubPrefix}设备',
|
|
||||||
macAddress: mac,
|
|
||||||
type: DeviceType.plush,
|
|
||||||
hasAI: true,
|
|
||||||
bleDevice: bleDevice,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_isSearching = false;
|
|
||||||
});
|
|
||||||
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
||||||
|
_macInfoCache.remove(mac); // 移除占位,允许重新扫描时再查
|
||||||
|
showGlassDialog(
|
||||||
|
context: context,
|
||||||
|
title: '设备查询失败',
|
||||||
|
description: '无法验证设备信息,请检查网络后重试。',
|
||||||
|
confirmText: '确定',
|
||||||
|
onConfirm: () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,6 +439,16 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Bluetooth] bindDevice 异常: $e');
|
debugPrint('[Bluetooth] bindDevice 异常: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isConnecting = false);
|
||||||
|
showGlassDialog(
|
||||||
|
context: context,
|
||||||
|
title: '绑定失败',
|
||||||
|
description: '$e',
|
||||||
|
confirmText: '确定',
|
||||||
|
onConfirm: () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isConnecting = false);
|
setState(() => _isConnecting = false);
|
||||||
@ -565,7 +569,13 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
children: [
|
children: [
|
||||||
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => context.pop(),
|
onTap: () {
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@ -586,7 +596,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'搜索设备',
|
'搜索设备',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -658,7 +668,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
height: 120,
|
height: 120,
|
||||||
placeholderBuilder: (_) => Text(
|
placeholderBuilder: (_) => Text(
|
||||||
'?',
|
'?',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFFF59E0B), // Amber color per HTML
|
color: const Color(0xFFF59E0B), // Amber color per HTML
|
||||||
@ -827,7 +837,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
// 设备名称
|
// 设备名称
|
||||||
Text(
|
Text(
|
||||||
device.name,
|
device.name,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: device.isBoundByOther
|
color: device.isBoundByOther
|
||||||
@ -903,7 +913,13 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
children: [
|
children: [
|
||||||
// 取消按钮 - HTML: frosted glass with border
|
// 取消按钮 - HTML: frosted glass with border
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => context.pop(),
|
onTap: () {
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
context.go('/');
|
||||||
|
}
|
||||||
|
},
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(25),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import 'dart:math';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/network/api_client.dart';
|
import '../core/network/api_client.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
@ -303,7 +302,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
statusText,
|
statusText,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B5563),
|
color: const Color(0xFF4B5563),
|
||||||
@ -327,7 +326,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
batteryText,
|
batteryText,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B5563),
|
color: const Color(0xFF4B5563),
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
@ -88,7 +87,7 @@ class _HomePageState extends State<HomePage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
// Use Press Start 2P pixel font per HTML CSS
|
// Use Press Start 2P pixel font per HTML CSS
|
||||||
style: GoogleFonts.pressStart2p(
|
style: TextStyle(fontFamily: 'Press Start 2P',
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1), // 靛蓝
|
color: const Color(0xFF6366F1), // 靛蓝
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -239,7 +238,7 @@ class _HomePageState extends State<HomePage>
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'立即连接',
|
'立即连接',
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
@ -58,7 +57,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'服务协议',
|
'服务协议',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -326,7 +325,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
padding: const EdgeInsets.only(top: 32),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
style: GoogleFonts.pressStart2p(
|
style: TextStyle(fontFamily: 'Press Start 2P',
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1),
|
color: const Color(0xFF6366F1),
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -502,7 +501,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Heading - font-size: 32px, font-weight: 700
|
// Heading - font-size: 32px, font-weight: 700
|
||||||
Text(
|
Text(
|
||||||
'欢迎使用 Airhub',
|
'欢迎使用 Airhub',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF6B5B95),
|
color: const Color(0xFF6B5B95),
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import 'dart:math';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show PlatformException;
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../services/music_generation_service.dart';
|
import '../services/music_generation_service.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
@ -801,7 +800,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'灵感电台',
|
'灵感电台',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -1108,7 +1107,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
track.lyrics.isNotEmpty
|
track.lyrics.isNotEmpty
|
||||||
? _cleanLyrics(track.lyrics)
|
? _cleanLyrics(track.lyrics)
|
||||||
: '生成音乐后\n点我看歌词',
|
: '生成音乐后\n点我看歌词',
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
color: track.lyrics.isNotEmpty
|
color: track.lyrics.isNotEmpty
|
||||||
@ -1170,7 +1169,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
bubbleText,
|
bubbleText,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: const Color(0xFF6B4423),
|
color: const Color(0xFF6B4423),
|
||||||
@ -1227,7 +1226,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
_currentTime,
|
_currentTime,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
@ -1277,7 +1276,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
_totalTime,
|
_totalTime,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
@ -1434,7 +1433,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
mood['title'] as String,
|
mood['title'] as String,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||||||
color: isActive
|
color: isActive
|
||||||
@ -1447,7 +1446,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
mood['desc'] as String,
|
mood['desc'] as String,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: isActive
|
color: isActive
|
||||||
? const Color(0xFF6B7280)
|
? const Color(0xFF6B7280)
|
||||||
@ -1908,7 +1907,7 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'自由创作',
|
'自由创作',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -1935,7 +1934,7 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'描述你想要的音乐氛围、场景或情绪',
|
'描述你想要的音乐氛围、场景或情绪',
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF9CA3AF),
|
color: const Color(0xFF9CA3AF),
|
||||||
),
|
),
|
||||||
@ -1950,11 +1949,11 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 14, color: const Color(0xFF374151)),
|
fontSize: 14, color: const Color(0xFF374151)),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '例如:水豚在雨中等公交,心情却很平静...',
|
hintText: '例如:水豚在雨中等公交,心情却很平静...',
|
||||||
hintStyle: GoogleFonts.dmSans(
|
hintStyle: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 14, color: const Color(0xFF9CA3AF)),
|
fontSize: 14, color: const Color(0xFF9CA3AF)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.black.withOpacity(0.03),
|
fillColor: Colors.black.withOpacity(0.03),
|
||||||
@ -2099,7 +2098,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'我的唱片架',
|
'我的唱片架',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -2266,7 +2265,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
track.title,
|
track.title,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
|
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
|
||||||
color: isCurrent
|
color: isCurrent
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
import '../features/device/presentation/controllers/device_controller.dart';
|
||||||
@ -233,7 +232,7 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'选择产品',
|
'选择产品',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -365,7 +364,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
product['name'],
|
product['name'],
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import '../widgets/glass_dialog.dart';
|
import '../widgets/glass_dialog.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/ios_toast.dart';
|
import '../widgets/ios_toast.dart';
|
||||||
@ -100,7 +99,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'设置',
|
'设置',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/services/ble_provisioning_service.dart';
|
import '../core/services/ble_provisioning_service.dart';
|
||||||
|
import '../features/device/data/datasources/device_remote_data_source.dart';
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
import '../features/device/presentation/controllers/device_controller.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
@ -134,6 +134,30 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
if (_isBinding) return;
|
if (_isBinding) return;
|
||||||
setState(() => _isBinding = true);
|
setState(() => _isBinding = true);
|
||||||
|
|
||||||
|
// 绑定前再次检查设备归属(防止 BLE 扫描时检查遗漏)
|
||||||
|
final mac = _deviceInfo['mac'] as String? ?? '';
|
||||||
|
if (mac.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
||||||
|
final macData = await dataSource.queryByMac(mac);
|
||||||
|
final bindStatus = macData['bind_status'] as String? ?? 'unbound';
|
||||||
|
if (bindStatus == 'bound_by_other') {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBinding = false);
|
||||||
|
showGlassDialog(
|
||||||
|
context: context,
|
||||||
|
title: '无法绑定',
|
||||||
|
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
|
||||||
|
confirmText: '确定',
|
||||||
|
onConfirm: () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[WiFi Config] 检查设备归属失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final sn = _deviceInfo['sn'] as String? ?? '';
|
final sn = _deviceInfo['sn'] as String? ?? '';
|
||||||
if (sn.isNotEmpty) {
|
if (sn.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
@ -153,6 +177,16 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WiFi Config] bindDevice 异常: $e');
|
debugPrint('[WiFi Config] bindDevice 异常: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBinding = false);
|
||||||
|
showGlassDialog(
|
||||||
|
context: context,
|
||||||
|
title: '绑定失败',
|
||||||
|
description: '$e',
|
||||||
|
confirmText: '确定',
|
||||||
|
onConfirm: () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -322,7 +356,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'WiFi配网',
|
'WiFi配网',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -386,7 +420,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'选择WiFi网络',
|
'选择WiFi网络',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -528,7 +562,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_selectedWifiSsid,
|
_selectedWifiSsid,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -596,7 +630,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'正在配网...',
|
'正在配网...',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -712,7 +746,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'配网成功!',
|
'配网成功!',
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
// Base text theme with DM Sans (PRD: 正文/UI 字体)
|
// Base text theme with DM Sans (PRD: 正文/UI 字体)
|
||||||
final baseTextTheme = GoogleFonts.dmSansTextTheme(const TextTheme(
|
final baseTextTheme = const TextTheme(
|
||||||
// h1 / Large Headings
|
// h1 / Large Headings
|
||||||
displayLarge: TextStyle(
|
displayLarge: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
@ -40,19 +39,19 @@ class AppTheme {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
));
|
).apply(fontFamily: 'DM Sans');
|
||||||
|
|
||||||
// Apply Outfit to heading styles (PRD: 标题/Display 字体)
|
// Apply Outfit to heading styles (PRD: 标题/Display 字体)
|
||||||
final textTheme = baseTextTheme.copyWith(
|
final textTheme = baseTextTheme.copyWith(
|
||||||
displayLarge: GoogleFonts.outfit(textStyle: baseTextTheme.displayLarge),
|
displayLarge: baseTextTheme.displayLarge?.copyWith(fontFamily: 'Outfit'),
|
||||||
displayMedium: GoogleFonts.outfit(textStyle: baseTextTheme.displayMedium),
|
displayMedium: baseTextTheme.displayMedium?.copyWith(fontFamily: 'Outfit'),
|
||||||
displaySmall: GoogleFonts.outfit(textStyle: baseTextTheme.displaySmall),
|
displaySmall: baseTextTheme.displaySmall?.copyWith(fontFamily: 'Outfit'),
|
||||||
headlineLarge: GoogleFonts.outfit(textStyle: baseTextTheme.headlineLarge),
|
headlineLarge: baseTextTheme.headlineLarge?.copyWith(fontFamily: 'Outfit'),
|
||||||
headlineMedium: GoogleFonts.outfit(textStyle: baseTextTheme.headlineMedium),
|
headlineMedium: baseTextTheme.headlineMedium?.copyWith(fontFamily: 'Outfit'),
|
||||||
headlineSmall: GoogleFonts.outfit(textStyle: baseTextTheme.headlineSmall),
|
headlineSmall: baseTextTheme.headlineSmall?.copyWith(fontFamily: 'Outfit'),
|
||||||
titleLarge: GoogleFonts.outfit(textStyle: baseTextTheme.titleLarge),
|
titleLarge: baseTextTheme.titleLarge?.copyWith(fontFamily: 'Outfit'),
|
||||||
titleMedium: GoogleFonts.outfit(textStyle: baseTextTheme.titleMedium),
|
titleMedium: baseTextTheme.titleMedium?.copyWith(fontFamily: 'Outfit'),
|
||||||
titleSmall: GoogleFonts.outfit(textStyle: baseTextTheme.titleSmall),
|
titleSmall: baseTextTheme.titleSmall?.copyWith(fontFamily: 'Outfit'),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@ -67,7 +66,7 @@ class AppTheme {
|
|||||||
background: AppColors.bgBase,
|
background: AppColors.bgBase,
|
||||||
),
|
),
|
||||||
// PRD: DM Sans 为默认正文字体,回退到系统字体
|
// PRD: DM Sans 为默认正文字体,回退到系统字体
|
||||||
fontFamily: GoogleFonts.dmSans().fontFamily,
|
fontFamily: 'DM Sans',
|
||||||
fontFamilyFallback: const [
|
fontFamilyFallback: const [
|
||||||
'Roboto',
|
'Roboto',
|
||||||
'PingFang SC',
|
'PingFang SC',
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
/// 颜色定义 - 精确还原 Profile PRD
|
/// 颜色定义 - 精确还原 Profile PRD
|
||||||
class AppColors {
|
class AppColors {
|
||||||
@ -111,77 +110,77 @@ class AppColors {
|
|||||||
/// 字体样式 - PRD规范: Outfit(标题) + DM Sans(正文) + Press Start 2P(Logo)
|
/// 字体样式 - PRD规范: Outfit(标题) + DM Sans(正文) + Press Start 2P(Logo)
|
||||||
class AppTextStyles {
|
class AppTextStyles {
|
||||||
// 页面标题 — 统一规范: 17px w600 #1F2937
|
// 页面标题 — 统一规范: 17px w600 #1F2937
|
||||||
static final TextStyle title = GoogleFonts.outfit(
|
static final TextStyle title = TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
);
|
);
|
||||||
|
|
||||||
// User Name → Outfit (heading/display)
|
// User Name → Outfit (heading/display)
|
||||||
static final TextStyle userName = GoogleFonts.outfit(
|
static final TextStyle userName = TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// User ID → DM Sans (body)
|
// User ID → DM Sans (body)
|
||||||
static final TextStyle userId = GoogleFonts.dmSans(
|
static final TextStyle userId = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Menu Text → DM Sans (body/UI)
|
// Menu Text → DM Sans (body/UI)
|
||||||
static final TextStyle menuText = GoogleFonts.dmSans(
|
static final TextStyle menuText = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Badge Text → DM Sans (small UI)
|
// Badge Text → DM Sans (small UI)
|
||||||
static final TextStyle badge = GoogleFonts.dmSans(
|
static final TextStyle badge = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Modal Title → Outfit (heading)
|
// Modal Title → Outfit (heading)
|
||||||
static final TextStyle modalTitle = GoogleFonts.outfit(
|
static final TextStyle modalTitle = TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Book specific styles → Outfit (heading)
|
// Book specific styles → Outfit (heading)
|
||||||
static final TextStyle bookTitle = GoogleFonts.outfit(
|
static final TextStyle bookTitle = TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Book count → DM Sans (body)
|
// Book count → DM Sans (body)
|
||||||
static final TextStyle bookCount = GoogleFonts.dmSans(
|
static final TextStyle bookCount = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slot title → DM Sans (small UI)
|
// Slot title → DM Sans (small UI)
|
||||||
static final TextStyle slotTitle = GoogleFonts.dmSans(
|
static final TextStyle slotTitle = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
);
|
);
|
||||||
|
|
||||||
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
|
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
|
||||||
static final TextStyle emptyPlus = GoogleFonts.dmSans(
|
static final TextStyle emptyPlus = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
|
color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
|
||||||
);
|
);
|
||||||
|
|
||||||
// Button text → DM Sans (UI)
|
// Button text → DM Sans (UI)
|
||||||
static final TextStyle createStoryBtn = GoogleFonts.dmSans(
|
static final TextStyle createStoryBtn = TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'gradient_button.dart';
|
import 'gradient_button.dart';
|
||||||
import '../theme/app_colors.dart' as appclr;
|
import '../theme/app_colors.dart' as appclr;
|
||||||
|
|
||||||
@ -55,7 +54,7 @@ class GlassDialog extends StatelessWidget {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: GoogleFonts.outfit(
|
style: TextStyle(fontFamily: 'Outfit',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B2404),
|
color: const Color(0xFF4B2404),
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../theme/product_theme.dart';
|
import '../theme/product_theme.dart';
|
||||||
|
|
||||||
@ -125,7 +124,7 @@ class GradientButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
text,
|
text,
|
||||||
style: GoogleFonts.dmSans(
|
style: TextStyle(fontFamily: 'DM Sans',
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -524,14 +524,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
version: "14.8.1"
|
||||||
google_fonts:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_fonts
|
|
||||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.3.3"
|
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -61,7 +61,7 @@ dependencies:
|
|||||||
# Existing dependencies
|
# Existing dependencies
|
||||||
webview_flutter: ^4.4.2
|
webview_flutter: ^4.4.2
|
||||||
permission_handler: ^11.0.0
|
permission_handler: ^11.0.0
|
||||||
google_fonts: ^6.1.0
|
# google_fonts removed — local fonts used instead
|
||||||
flutter_blue_plus: ^1.31.0
|
flutter_blue_plus: ^1.31.0
|
||||||
flutter_svg: ^2.0.9
|
flutter_svg: ^2.0.9
|
||||||
image_picker: ^1.2.1
|
image_picker: ^1.2.1
|
||||||
@ -90,6 +90,17 @@ flutter:
|
|||||||
weight: 600
|
weight: 600
|
||||||
- asset: assets/fonts/Inter-Bold.ttf
|
- asset: assets/fonts/Inter-Bold.ttf
|
||||||
weight: 700
|
weight: 700
|
||||||
|
- family: DM Sans
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/DMSans-Variable.ttf
|
||||||
|
- asset: assets/fonts/DMSans-Italic-Variable.ttf
|
||||||
|
style: italic
|
||||||
|
- family: Outfit
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Outfit-Variable.ttf
|
||||||
|
- family: Press Start 2P
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/PressStart2P-Regular.ttf
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user