Compare commits

..

1 Commits

Author SHA1 Message Date
repair-agent
54fcf294bc fix: auto repair bugs #68 2026-03-18 17:03:18 +08:00
46 changed files with 645 additions and 4592 deletions

View File

@ -1,294 +0,0 @@
# 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>`fpdartController 内 `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` 无 ErrorWarning/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 不会崩溃)

View File

@ -1,703 +0,0 @@
# 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 BundleGoogle 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 GlanceAPM 监控库)
```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/) — 字体子集化

File diff suppressed because one or more lines are too long

View File

@ -23,9 +23,6 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
@ -39,7 +36,6 @@ DEPENDENCIES:
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
@ -64,8 +60,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
@ -80,7 +74,6 @@ SPEC CHECKSUMS:
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@ -26,10 +26,6 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要相机权限来拍照传图到徽章设备</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要相册权限来选择图片传图到徽章设备</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>

View File

@ -143,6 +143,7 @@ class _AuthInterceptor extends Interceptor {
'/auth/phone-login/',
'/auth/refresh/',
'/version/check/',
'/devices/query-by-mac/',
];
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));

View File

@ -1,14 +1,6 @@
import 'package:flutter/foundation.dart' show kIsWeb;
class ApiConfig {
/// Web
static const String _localUrl = 'http://192.168.124.8:8000';
/// 线APP
static const String _prodUrl = 'https://qiyuan-rtc-api.airlabs.art';
/// Web APP 线
static String get baseUrl => kIsWeb ? _localUrl : _prodUrl;
///
static const String baseUrl = 'http://192.168.124.8:8000';
/// HTTPS
static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art';

View File

@ -1,9 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/auth/presentation/pages/login_page.dart';
import '../../pages/bluetooth_page.dart';
@ -13,27 +9,12 @@ import '../../pages/product_selection_page.dart';
import '../../pages/profile/profile_page.dart';
import '../../pages/webview_page.dart';
import '../../pages/wifi_config_page.dart';
import '../../features/badge/presentation/pages/badge_basic_control_page.dart';
import '../../features/badge/presentation/pages/badge_control_page.dart';
import '../../features/badge/presentation/pages/badge_home_page.dart';
import '../../features/badge/presentation/pages/badge_transfer_page.dart';
import '../../features/device/data/datasources/device_remote_data_source.dart';
import '../../theme/product_theme.dart';
import '../network/token_manager.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
const _productCodeRoutes = {
'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
'DZBJ-ON': (route: '/badge-control', type: ProductType.badgeAi),
'DZBJ-OFF': (route: '/badge-basic-control', type: ProductType.badgeBasic),
};
@riverpod
GoRouter goRouter(Ref ref) {
final tokenManager = ref.watch(tokenManagerProvider);
@ -49,60 +30,6 @@ GoRouter goRouter(Ref ref) {
return '/login';
}
if (hasToken && isLoginRoute) {
//
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
final devices = await dataSource.getMyDevices();
debugPrint('[Router] 已绑定设备数: ${devices.length}');
if (devices.isNotEmpty) {
//
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) {
final ta = a.device.lastOnlineAt ?? '';
final tb = b.device.lastOnlineAt ?? '';
return tb.compareTo(ta);
});
final recent = devices.first;
final resolvedDt = recent.device.deviceType ?? recent.device.deviceTypeInfo;
final code = resolvedDt?.productCode ?? '';
debugPrint('[Router] 最近设备 sn=${recent.device.sn}, productCode=$code');
final mapping = _productCodeRoutes[code];
if (mapping != null) {
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
return mapping.route;
}
}
} catch (e) {
debugPrint('[Router] 获取设备失败: $e');
}
return '/home';
}
return null;
@ -136,14 +63,6 @@ GoRouter goRouter(Ref ref) {
path: '/webview_fallback',
builder: (context, state) => const WebViewPage(),
),
GoRoute(
path: '/badge-control',
builder: (context, state) => const BadgeControlPage(),
),
GoRoute(
path: '/badge-basic-control',
builder: (context, state) => const BadgeBasicControlPage(),
),
GoRoute(
path: '/badge',
builder: (context, state) => const BadgeHomePage(),
@ -154,43 +73,9 @@ GoRouter goRouter(Ref ref) {
final extra = state.extra as Map<String, dynamic>? ?? {};
return BadgeTransferPage(
imageUrl: extra['imageUrl'] as String? ?? '',
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);
}
}

View File

@ -48,4 +48,4 @@ final class GoRouterProvider
}
}
String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c';
String _$goRouterHash() => r'4b725bca8dba655c6abb2f19069fca97c8bebbb6';

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/phone_auth_service.dart';
import '../../../../theme/app_colors.dart';
@ -94,7 +95,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
// Title
Text(
'服务协议',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: const Color(0xFF374151),
@ -218,7 +219,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
debugPrint('[Login] tokenLogin 结果: $success');
if (success && mounted) {
context.go('/login');
await _navigateAfterLogin();
}
}
@ -273,8 +274,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
.read(authControllerProvider.notifier)
.codeLogin(_phoneController.text, _codeController.text);
if (success && mounted) {
// /login router redirect
context.go('/login');
await _navigateAfterLogin();
}
}
Future<void> _navigateAfterLogin() async {
if (!mounted) return;
try {
final devices = await ref.read(deviceControllerProvider.future);
if (!mounted) return;
if (devices.isNotEmpty) {
debugPrint('[Login] User has ${devices.length} device(s), navigating to device control');
context.go('/device-control');
} else {
debugPrint('[Login] No devices, navigating to home');
context.go('/home');
}
} catch (e) {
debugPrint('[Login] Device check failed: $e');
if (mounted) context.go('/home');
}
}
@ -306,7 +324,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
// Logo HTML .login-logo
Text(
'Airhub',
style: TextStyle(fontFamily: 'Press Start 2P',
style: GoogleFonts.pressStart2p(
fontSize: 28,
color: const Color(0xFF6366F1), //
letterSpacing: 2,
@ -462,7 +480,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
children: [
Text(
'欢迎使用 Airhub',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.w700,
color: const Color(0xFF6B5B95),
@ -727,7 +745,7 @@ class _AgreementContentPage extends StatelessWidget {
),
title: Text(
title,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: const Color(0xFF374151),

View File

@ -1,361 +1,38 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
/// BLE
/// APP蓝牙传图接口说明.md
///
/// BLE UUID (16-bit 128-bit):
/// : 0x0B00 00000b00-0000-1000-8000-00805f9b34fb
/// : 0x0B01 00000b01-0000-1000-8000-00805f9b34fb
/// : 0x0B02 00000b02-0000-1000-8000-00805f9b34fb
class BadgeBleTransferService {
/// 360×360 LCD
static const _targetSize = 360;
enum TransferState { idle, transferring, complete, error }
/// MTU = 512 ble_service_config.h
/// requestMtu iOS/Web
static const _defaultMtu = 512;
class BadgeTransferState {
final TransferState transferState;
final List<ScanResult> scannedDevices;
final BluetoothDevice? selectedDevice;
final double progress;
final String? errorMessage;
/// ATT 3 + GATT Handle 2
static const _attOverhead = 3;
const BadgeTransferState({
this.transferState = TransferState.idle,
this.scannedDevices = const [],
this.selectedDevice,
this.progress = 0,
this.errorMessage,
});
/// : (1) + (1)
static const _frameHeaderSize = 2;
/// malloc / fopen
static const _preambleDelayMs = 50;
/// (ms) ESP32 BLE
static const _packetDelayMs = 5;
/// N write-with-response
static int get _syncInterval => kIsWeb ? 5 : 10;
///
static const _maxTransferRetries = 2;
///
Future<void> connectAndTransfer({
required BluetoothDevice device,
required String imageUrl,
Uint8List? imageBytes,
void Function(double progress, String message)? onProgress,
}) async {
//
onProgress?.call(0.05, '处理图片...');
final jpegBytes = await _prepareImage(imageUrl, imageBytes);
debugPrint('[BLE Transfer] JPEG 大小: ${jpegBytes.length} 字节');
final filename =
'face_${DateTime.now().millisecondsSinceEpoch ~/ 1000}.jpg';
//
for (int attempt = 0; attempt <= _maxTransferRetries; attempt++) {
try {
await _doTransfer(
device: device,
filename: filename,
imageData: jpegBytes,
onProgress: onProgress,
);
return;
} catch (e) {
try {
await device.disconnect();
} catch (_) {}
if (attempt < _maxTransferRetries) {
final wait = 2 * (attempt + 1);
onProgress?.call(
0.0, '传输中断,${wait}秒后重试 (${attempt + 1}/$_maxTransferRetries)...');
debugPrint('[BLE Transfer] 第 ${attempt + 1} 次失败: $e${wait}s 后重试');
await Future.delayed(Duration(seconds: wait));
} else {
rethrow;
}
}
}
}
///
Future<void> _doTransfer({
required BluetoothDevice device,
required String filename,
required Uint8List imageData,
void Function(double progress, String message)? onProgress,
}) async {
StreamSubscription<BluetoothConnectionState>? connSub;
bool disconnected = false;
try {
// 1.
onProgress?.call(0.0, '正在连接设备...');
await device.connect(timeout: const Duration(seconds: 10));
connSub = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected) {
disconnected = true;
}
});
// 2. MTU
onProgress?.call(0.02, '协商传输参数...');
final mtu = await _negotiateMtu(device);
final chunkSize = mtu - _attOverhead - _frameHeaderSize;
debugPrint('[BLE Transfer] 最终 MTU=$mtu, chunkSize=$chunkSize');
// 3.
onProgress?.call(0.05, '发现服务...');
final services = await device.discoverServices();
for (final s in services) {
debugPrint('[BLE Transfer] 服务: ${s.uuid}');
for (final c in s.characteristics) {
debugPrint(
'[BLE Transfer] 特征: ${c.uuid} (${_propsStr(c.properties)})');
}
}
final imageService = services.firstWhere(
(s) => _matchUuid16(s.uuid.toString(), '0b00'),
orElse: () => throw Exception('未找到图片传输服务 (0x0B00)'),
);
final writeChar = imageService.characteristics.firstWhere(
(c) => _matchUuid16(c.uuid.toString(), '0b01'),
orElse: () => throw Exception('未找到写入特征 (0x0B01)'),
);
// 4.
onProgress?.call(0.10, '开始传输...');
await _sendImage(
writeChar: writeChar,
filename: filename,
imageData: imageData,
chunkSize: chunkSize,
isDisconnected: () => disconnected,
onProgress: (p) {
final overall = 0.10 + p * 0.90;
onProgress?.call(overall, '正在传输...');
},
);
onProgress?.call(1.0, '传输完成');
} finally {
connSub?.cancel();
try {
await device.disconnect();
} catch (_) {}
}
}
/// MTU 512
Future<int> _negotiateMtu(BluetoothDevice device) async {
// 1. Android
try {
final result = await device.requestMtu(_defaultMtu);
debugPrint('[BLE Transfer] requestMtu 返回: $result');
} catch (e) {
debugPrint('[BLE Transfer] requestMtu 不可用iOS/Web 正常): $e');
}
// 2.
int mtu = device.mtuNow;
debugPrint('[BLE Transfer] mtuNow 初始值: $mtu');
if (mtu >= 64) return mtu;
// 3. mtuNow 23
// mtu iOS/Web ms
try {
mtu = await device.mtu
.where((v) => v >= 64) // 23
.first
.timeout(const Duration(seconds: 3));
debugPrint('[BLE Transfer] MTU 流更新: $mtu');
return mtu;
} catch (_) {
debugPrint('[BLE Transfer] 等待 MTU 协商超时,使用设备端约定值 $_defaultMtu');
}
// 4. ble_service_config.h MTU=512
return _defaultMtu;
}
/// 360×360 JPEG
Future<Uint8List> _prepareImage(
String imageUrl, Uint8List? localBytes) async {
// 1.
Uint8List rawBytes;
if (localBytes != null) {
rawBytes = localBytes;
} else if (imageUrl.startsWith('http')) {
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode != 200) {
throw Exception('下载图片失败 (${response.statusCode})');
}
rawBytes = response.bodyBytes;
} else if (!kIsWeb) {
final file = File(imageUrl);
if (!await file.exists()) {
throw Exception('本地图片不存在: $imageUrl');
}
rawBytes = await file.readAsBytes();
} else {
throw Exception('无法加载图片: $imageUrl');
}
// 2. isolate JPEG
final jpegBytes = await compute(_processImageToJpeg, rawBytes);
debugPrint(
'[BLE Transfer] 图片处理完成: ${rawBytes.length}${jpegBytes.length} 字节');
return jpegBytes;
}
/// 360×360 JPEG
/// isolate UI
static Uint8List _processImageToJpeg(Uint8List rawBytes) {
final decoded = img.decodeImage(rawBytes);
if (decoded == null) {
throw Exception('图片解码失败,不支持的格式');
}
//
final cropSide =
decoded.width < decoded.height ? decoded.width : decoded.height;
final cropX = (decoded.width - cropSide) ~/ 2;
final cropY = (decoded.height - cropSide) ~/ 2;
img.Image cropped = img.copyCrop(decoded,
x: cropX, y: cropY, width: cropSide, height: cropSide);
//
if (cropped.width != _targetSize || cropped.height != _targetSize) {
cropped = img.copyResize(cropped,
width: _targetSize,
height: _targetSize,
interpolation: img.Interpolation.linear);
}
// JPEGquality 85
return Uint8List.fromList(img.encodeJpg(cropped, quality: 85));
}
/// +
Future<void> _sendImage({
required BluetoothCharacteristic writeChar,
required String filename,
required Uint8List imageData,
required int chunkSize,
required bool Function() isDisconnected,
void Function(double)? onProgress,
}) async {
final len = imageData.length;
final totalPackets = (len / chunkSize).ceil();
debugPrint('[BLE Transfer] 总包数: $totalPackets, chunkSize: $chunkSize');
// 26 write-with-response
final header = Uint8List(26);
header[0] = 0xFD;
final nameBytes = Uint8List.fromList(filename.codeUnits);
for (int i = 0; i < nameBytes.length && i < 22; i++) {
header[i + 1] = nameBytes[i];
}
header[23] = (len >> 16) & 0xFF;
header[24] = (len >> 8) & 0xFF;
header[25] = len & 0xFF;
await _bleWriteWithRetry(writeChar, header, withoutResponse: false);
await Future.delayed(const Duration(milliseconds: _preambleDelayMs));
//
int offset = 0;
int packetNo = 0;
final syncEvery = _syncInterval;
while (offset < len) {
if (isDisconnected()) {
throw Exception(
'BLE 连接断开,传输中止于 $offset/$len 字节 (${(offset * 100 / len).toInt()}%)');
}
final remaining = len - offset;
final chunkLen = remaining < chunkSize ? remaining : chunkSize;
final isEnd = (offset + chunkLen >= len) ? 0x01 : 0x00;
final packet = Uint8List(2 + chunkLen);
packet[0] = packetNo & 0xFF;
packet[1] = isEnd;
packet.setRange(2, 2 + chunkLen, imageData, offset);
// N write-with-response
final useSync = (packetNo % syncEvery == 0) || isEnd == 1;
await _bleWriteWithRetry(writeChar, packet,
withoutResponse: !useSync);
if (!useSync) {
await Future.delayed(const Duration(milliseconds: _packetDelayMs));
}
offset += chunkLen;
packetNo++;
onProgress?.call(offset / len);
}
debugPrint('[BLE Transfer] 发送完成: $offset/$len 字节, $packetNo');
}
/// 16-bit UUID
bool _matchUuid16(String uuid, String short16) {
final u = uuid.toLowerCase();
final s = short16.toLowerCase();
if (u == '0000$s-0000-1000-8000-00805f9b34fb') return true;
if (u == s || u == '0000$s') return true;
if (u.startsWith('0000$s-')) return true;
return false;
}
/// BLE 3 + 退 +
Future<void> _bleWriteWithRetry(
BluetoothCharacteristic characteristic,
Uint8List data, {
bool withoutResponse = true,
}) async {
const maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
await characteristic.write(data, withoutResponse: withoutResponse);
return;
} catch (e) {
if (i < maxRetry - 1) {
await Future.delayed(Duration(milliseconds: 20 * (i + 1)));
} else if (withoutResponse) {
try {
await characteristic.write(data, withoutResponse: false);
return;
} catch (_) {
rethrow;
}
} else {
rethrow;
}
}
}
}
String _propsStr(CharacteristicProperties p) {
final parts = <String>[];
if (p.write) parts.add('write');
if (p.writeWithoutResponse) parts.add('writeNoResp');
if (p.read) parts.add('read');
if (p.notify) parts.add('notify');
if (p.indicate) parts.add('indicate');
return parts.join(' + ');
BadgeTransferState copyWith({
TransferState? transferState,
List<ScanResult>? scannedDevices,
BluetoothDevice? selectedDevice,
double? progress,
String? errorMessage,
}) {
return BadgeTransferState(
transferState: transferState ?? this.transferState,
scannedDevices: scannedDevices ?? this.scannedDevices,
selectedDevice: selectedDevice ?? this.selectedDevice,
progress: progress ?? this.progress,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}

View File

@ -1,10 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/services/badge_transfer_service.dart';
part 'badge_transfer_controller.g.dart';
enum TransferStatus { idle, scanning, connecting, transferring, done, error }
@ -13,14 +9,12 @@ class BadgeTransferState {
final TransferStatus status;
final BluetoothDevice? device;
final double progress;
final String? statusMessage;
final String? errorMessage;
const BadgeTransferState({
this.status = TransferStatus.idle,
this.device,
this.progress = 0.0,
this.statusMessage,
this.errorMessage,
});
@ -28,14 +22,12 @@ class BadgeTransferState {
TransferStatus? status,
BluetoothDevice? device,
double? progress,
String? statusMessage,
String? errorMessage,
}) {
return BadgeTransferState(
status: status ?? this.status,
device: device ?? this.device,
progress: progress ?? this.progress,
statusMessage: statusMessage ?? this.statusMessage,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@ -43,8 +35,6 @@ class BadgeTransferState {
@riverpod
class BadgeTransferController extends _$BadgeTransferController {
final _transferService = BadgeBleTransferService();
@override
BadgeTransferState build() {
ref.onDispose(() {
@ -53,50 +43,53 @@ class BadgeTransferController extends _$BadgeTransferController {
return const BadgeTransferState();
}
///
void startScan() {
if (!ref.mounted) return;
state = state.copyWith(status: TransferStatus.scanning);
FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
FlutterBluePlus.scanResults.listen((results) {
if (!ref.mounted) return;
// Process scan results
});
FlutterBluePlus.adapterState.listen((adapterState) {
if (!ref.mounted) return;
// Handle adapter state changes
});
}
void stopScan() {
FlutterBluePlus.stopScan();
if (!ref.mounted) return;
state = state.copyWith(status: TransferStatus.idle);
}
Future<void> connectAndTransfer(
BluetoothDevice device,
String imageUrl, {
Uint8List? imageBytes,
}) async {
String imageUrl,
) async {
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.connecting,
device: device,
progress: 0.0,
statusMessage: '正在连接设备...',
);
try {
await _transferService.connectAndTransfer(
device: device,
imageUrl: imageUrl,
imageBytes: imageBytes,
onProgress: (progress, message) {
if (!ref.mounted) return;
final newStatus = progress < 0.15
? TransferStatus.connecting
: TransferStatus.transferring;
state = state.copyWith(
status: newStatus,
progress: progress,
statusMessage: message,
);
},
);
await device.connect();
if (!ref.mounted) return;
state = state.copyWith(status: TransferStatus.transferring);
// Transfer logic here
await Future.delayed(const Duration(seconds: 2));
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.done,
progress: 1.0,
statusMessage: '传输完成',
);
} catch (e) {
debugPrint('[BadgeTransfer] 传输失败: $e');
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.error,
errorMessage: e.toString().replaceFirst('Exception: ', ''),
errorMessage: e.toString(),
);
}
}

View File

@ -42,7 +42,7 @@ final class BadgeTransferControllerProvider
}
String _$badgeTransferControllerHash() =>
r'5f06a9bcf5c9fcf0caf428497d2b9901b1b7f626';
r'6ed7ce6b7aa6eae541366245e297b1c1ed1413f2';
abstract class _$BadgeTransferController extends $Notifier<BadgeTransferState> {
BadgeTransferState build();

View File

@ -1,412 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/network/api_client.dart';
import '../../../../pages/profile/profile_page.dart';
import '../../../../theme/product_theme.dart';
import '../../../../widgets/animated_gradient_background.dart';
/// (DZBJ-OFF)
class BadgeBasicControlPage extends ConsumerStatefulWidget {
const BadgeBasicControlPage({super.key});
@override
ConsumerState<BadgeBasicControlPage> createState() =>
_BadgeBasicControlPageState();
}
class _BadgeBasicControlPageState extends ConsumerState<BadgeBasicControlPage>
with SingleTickerProviderStateMixin {
int _currentTab = 0;
late AnimationController _floatController;
late Animation<double> _floatAnimation;
String? _lastImageUrl;
bool _loading = true;
@override
void initState() {
super.initState();
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic));
_floatController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3000),
)..repeat(reverse: true);
_floatAnimation = Tween<double>(begin: -8, end: 8).animate(
CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
);
_loadLastImage();
}
@override
void dispose() {
_floatController.dispose();
super.dispose();
}
Future<void> _loadLastImage() async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get('/badge/history/');
final images = ((data as Map<String, dynamic>)['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>()
.where((img) =>
img['generation_status'] == 'completed' &&
(img['image_url'] as String?)?.isNotEmpty == true)
.toList();
if (images.isNotEmpty && mounted) {
setState(() => _lastImageUrl = images.first['image_url'] as String);
}
} catch (_) {}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final productTheme = ref.watch(currentProductThemeProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
const AnimatedGradientBackground(),
IndexedStack(
index: _currentTab,
children: [
_buildHomePage(productTheme),
const ProfilePage(),
],
),
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 12,
child: _buildBottomNavBar(productTheme),
),
],
),
);
}
Widget _buildHomePage(ProductThemeData productTheme) {
return SafeArea(
bottom: false,
child: Stack(
children: [
Center(
child: AnimatedBuilder(
animation: _floatAnimation,
builder: (context, child) => Transform.translate(
offset: Offset(0, _floatAnimation.value),
child: child,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_BadgePreviewCircle(
imageUrl: _lastImageUrl,
loading: _loading,
accentColor: productTheme.accentColor,
size: 240,
),
const SizedBox(height: 16),
if (!_loading && _lastImageUrl == null)
Text(
'点击右上角「传图」上传你的第一张图',
style: TextStyle(
fontSize: 13,
color: productTheme.accentColor.withOpacity(0.5),
),
),
],
),
),
),
Positioned(
top: 8,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_GlassIconButton(
onTap: () => context.push('/product-selection'),
child: SvgPicture.asset(
'assets/www/icons/icon-switch.svg',
width: 20,
height: 20,
colorFilter: const ColorFilter.mode(
Color(0xFF4B5563),
BlendMode.srcIn,
),
),
),
_GlassPillButton(
onTap: () => context.push('/badge'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'传图',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: productTheme.accentColor,
),
),
const SizedBox(width: 4),
Icon(Icons.send_rounded,
size: 16, color: productTheme.accentColor),
],
),
),
],
),
),
],
),
);
}
Widget _buildBottomNavBar(ProductThemeData productTheme) {
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
width: 180,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.6),
borderRadius: BorderRadius.circular(32),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: const Color(0xFF4B5563).withOpacity(0.08),
offset: const Offset(0, 10),
blurRadius: 30,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg',
Icons.home, productTheme),
_buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg',
Icons.person, productTheme),
],
),
),
),
),
);
}
Widget _buildNavItem(
int index, String iconPath, IconData fallback, ProductThemeData theme) {
final isActive = _currentTab == index;
return GestureDetector(
onTap: () => setState(() => _currentTab = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: isActive ? theme.buttonGradient : null,
borderRadius: BorderRadius.circular(28),
boxShadow: isActive
? [
BoxShadow(
color: theme.accentColor.withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 15,
),
]
: null,
),
alignment: Alignment.center,
child: SvgPicture.asset(
iconPath,
width: isActive ? 28 : 26,
height: isActive ? 28 : 26,
colorFilter: ColorFilter.mode(
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
fallback,
color: isActive ? Colors.white : const Color(0xFF6B7280),
size: 24,
),
),
),
);
}
}
class _BadgePreviewCircle extends StatelessWidget {
final String? imageUrl;
final bool loading;
final Color accentColor;
final double size;
const _BadgePreviewCircle({
required this.imageUrl,
required this.loading,
required this.accentColor,
required this.size,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF2D3748),
border: Border.all(color: const Color(0xFF4A5568), width: 4),
boxShadow: [
BoxShadow(
color: accentColor.withOpacity(0.2),
blurRadius: 40,
spreadRadius: 8,
),
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(8),
child: ClipOval(
child: loading
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: accentColor.withOpacity(0.5),
),
)
: imageUrl != null
? Image.network(
imageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholder(),
)
: _buildPlaceholder(),
),
);
}
Widget _buildPlaceholder() {
return Container(
color: const Color(0xFF1A202C),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 48, color: accentColor.withOpacity(0.4)),
const SizedBox(height: 8),
Text(
'暂无图片',
style: TextStyle(
fontSize: 13,
color: accentColor.withOpacity(0.3),
),
),
],
),
),
);
}
}
class _GlassIconButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;
const _GlassIconButton({required this.onTap, required this.child});
@override
State<_GlassIconButton> createState() => _GlassIconButtonState();
}
class _GlassIconButtonState extends State<_GlassIconButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedScale(
scale: _pressed ? 0.92 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
alignment: Alignment.center,
child: widget.child,
),
),
);
}
}
class _GlassPillButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;
const _GlassPillButton({required this.onTap, required this.child});
@override
State<_GlassPillButton> createState() => _GlassPillButtonState();
}
class _GlassPillButtonState extends State<_GlassPillButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedScale(
scale: _pressed ? 0.94 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: widget.child,
),
),
);
}
}

View File

@ -1,320 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import '../../../../pages/profile/profile_page.dart';
import '../../../../theme/product_theme.dart';
import '../../../../widgets/animated_gradient_background.dart';
/// AI (DZBJ-ON)
class BadgeControlPage extends ConsumerStatefulWidget {
const BadgeControlPage({super.key});
@override
ConsumerState<BadgeControlPage> createState() => _BadgeControlPageState();
}
class _BadgeControlPageState extends ConsumerState<BadgeControlPage>
with SingleTickerProviderStateMixin {
int _currentTab = 0; // 0: , 1:
late AnimationController _floatController;
late Animation<double> _floatAnimation;
@override
void initState() {
super.initState();
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi));
_floatController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3000),
)..repeat(reverse: true);
_floatAnimation = Tween<double>(begin: -8, end: 8).animate(
CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_floatController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final productTheme = ref.watch(currentProductThemeProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
const AnimatedGradientBackground(),
//
IndexedStack(
index: _currentTab,
children: [
_buildHomePage(productTheme),
const ProfilePage(),
],
),
//
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 12,
child: _buildBottomNavBar(productTheme),
),
],
),
);
}
Widget _buildHomePage(ProductThemeData productTheme) {
return SafeArea(
bottom: false,
child: Stack(
children: [
//
Center(
child: AnimatedBuilder(
animation: _floatAnimation,
builder: (context, child) => Transform.translate(
offset: Offset(0, _floatAnimation.value),
child: child,
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: productTheme.accentColor.withOpacity(0.2),
blurRadius: 60,
spreadRadius: 15,
),
BoxShadow(
color: productTheme.accentColorLight.withOpacity(0.1),
blurRadius: 100,
spreadRadius: 30,
),
],
),
child: Image.asset(
'assets/www/Capybara.png',
width: 260,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.smart_toy,
size: 150,
color: productTheme.accentColor),
),
),
),
),
//
Positioned(
top: 8,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_GlassIconButton(
onTap: () => context.push('/product-selection'),
child: SvgPicture.asset(
'assets/www/icons/icon-switch.svg',
width: 20,
height: 20,
colorFilter: const ColorFilter.mode(
Color(0xFF4B5563),
BlendMode.srcIn,
),
),
),
_GlassPillButton(
onTap: () => context.push('/badge'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'传图',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: productTheme.accentColor,
),
),
const SizedBox(width: 4),
Icon(Icons.send_rounded,
size: 16, color: productTheme.accentColor),
],
),
),
],
),
),
],
),
);
}
Widget _buildBottomNavBar(ProductThemeData productTheme) {
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
width: 180,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.6),
borderRadius: BorderRadius.circular(32),
border: Border.all(color: Colors.white.withOpacity(0.8)),
boxShadow: [
BoxShadow(
color: const Color(0xFF4B5563).withOpacity(0.08),
offset: const Offset(0, 10),
blurRadius: 30,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(0, 'assets/www/icons/icon-home-pixel.svg',
Icons.home, productTheme),
_buildNavItem(1, 'assets/www/icons/icon-user-pixel.svg',
Icons.person, productTheme),
],
),
),
),
),
);
}
Widget _buildNavItem(
int index, String iconPath, IconData fallback, ProductThemeData theme) {
final isActive = _currentTab == index;
return GestureDetector(
onTap: () => setState(() => _currentTab = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: isActive ? theme.buttonGradient : null,
borderRadius: BorderRadius.circular(28),
boxShadow: isActive
? [
BoxShadow(
color: theme.accentColor.withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 15,
),
]
: null,
),
alignment: Alignment.center,
child: SvgPicture.asset(
iconPath,
width: isActive ? 28 : 26,
height: isActive ? 28 : 26,
colorFilter: ColorFilter.mode(
isActive ? Colors.white : const Color(0xFF6B7280).withOpacity(0.6),
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
fallback,
color: isActive ? Colors.white : const Color(0xFF6B7280),
size: 24,
),
),
),
);
}
}
class _GlassIconButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;
const _GlassIconButton({required this.onTap, required this.child});
@override
State<_GlassIconButton> createState() => _GlassIconButtonState();
}
class _GlassIconButtonState extends State<_GlassIconButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedScale(
scale: _pressed ? 0.92 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
alignment: Alignment.center,
child: widget.child,
),
),
);
}
}
class _GlassPillButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;
const _GlassPillButton({required this.onTap, required this.child});
@override
State<_GlassPillButton> createState() => _GlassPillButtonState();
}
class _GlassPillButtonState extends State<_GlassPillButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedScale(
scale: _pressed ? 0.94 : 1.0,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(_pressed ? 0.45 : 0.25),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: widget.child,
),
),
);
}
}

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../theme/product_theme.dart';
import '../../../../widgets/animated_gradient_background.dart';
import '../../data/services/badge_ai_generation_service.dart';
import '../controllers/badge_ai_controller.dart';
@ -24,13 +23,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
//
String? _uploadedImagePath;
Uint8List? _uploadedImageBytes;
// AI
bool _isGenerating = false;
double _genProgress = 0;
String _genStatus = '';
String? _generatedImageUrl;
bool _hasAiResult = false;
@override
void initState() {
@ -44,6 +43,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
final pending = BadgeAiGenerationService.instance.consumePendingResult();
if (pending != null) {
_generatedImageUrl = pending.imageUrl;
_hasAiResult = true;
}
// AI
@ -61,6 +61,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
setState(() {
_isGenerating = false;
_generatedImageUrl = result.imageUrl;
_hasAiResult = true;
});
_showResultDialog(result.imageUrl);
}
@ -86,7 +87,6 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
}
void _showResultDialog(String imageUrl) {
final theme = ref.read(currentProductThemeProvider);
showDialog(
context: context,
barrierDismissible: false,
@ -163,7 +163,14 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: theme.buttonGradient,
gradient: const LinearGradient(
colors: [
Color(0xFF22D3EE),
Color(0xFF3B82F6),
Color(0xFF6366F1),
Color(0xFF8B5CF6),
],
),
borderRadius: BorderRadius.circular(14),
),
child: ElevatedButton(
@ -206,6 +213,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
setState(() {
_isGenerating = true;
_generatedImageUrl = null;
_hasAiResult = false;
_genProgress = 0;
_genStatus = '正在连接 AI...';
});
@ -228,30 +236,24 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
void _handleUploadSelected(String path, Uint8List? bytes) {
setState(() {
_uploadedImagePath = path;
_uploadedImageBytes = bytes;
});
}
void _handleRetry() {
setState(() {
_generatedImageUrl = null;
_hasAiResult = false;
});
}
void _handleUseImage() {
final imageSource = _generatedImageUrl ?? _uploadedImagePath;
if (imageSource == null) return;
context.push('/badge/transfer', extra: {
'imageUrl': imageSource,
if (_uploadedImageBytes != null && _generatedImageUrl == null)
'imageBytes': _uploadedImageBytes,
});
context.push('/badge/transfer', extra: {'imageUrl': imageSource});
}
@override
Widget build(BuildContext context) {
final productTheme = ref.watch(currentProductThemeProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
@ -261,13 +263,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
child: Column(
children: [
_buildHeader(),
_buildTabBar(productTheme),
Expanded(child: _buildTabContent(productTheme)),
_buildTabBar(),
Expanded(child: _buildTabContent()),
],
),
),
if (_isGenerating) _buildGeneratingOverlay(productTheme),
_buildFixedBottomBar(productTheme),
if (_isGenerating) _buildGeneratingOverlay(),
_buildFixedBottomBar(),
],
),
);
@ -279,7 +281,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
child: Row(
children: [
GestureDetector(
onTap: () => context.pop(),
onTap: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
},
child: Container(
width: 40,
height: 40,
@ -310,7 +318,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
);
}
Widget _buildTabBar(ProductThemeData productTheme) {
Widget _buildTabBar() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
padding: const EdgeInsets.all(4),
@ -326,7 +334,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: productTheme.accentColor.withOpacity(0.15),
color: const Color(0xFF6366F1).withOpacity(0.15),
blurRadius: 12,
offset: const Offset(0, 2),
),
@ -334,7 +342,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
labelColor: productTheme.accentColor,
labelColor: const Color(0xFF6366F1),
unselectedLabelColor: const Color(0xFF6B7280),
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
@ -346,7 +354,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
);
}
Widget _buildTabContent(ProductThemeData productTheme) {
Widget _buildTabContent() {
return TabBarView(
controller: _tabController,
children: [
@ -354,17 +362,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
key: _aiTabKey,
onGenerate: _handleAiGenerate,
isGenerating: _isGenerating,
accentColor: productTheme.accentColor,
),
BadgeUploadTab(
onImageSelected: _handleUploadSelected,
accentColor: productTheme.accentColor,
),
BadgeUploadTab(onImageSelected: _handleUploadSelected),
],
);
}
Widget _buildGeneratingOverlay(ProductThemeData productTheme) {
Widget _buildGeneratingOverlay() {
return Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
@ -379,21 +383,21 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
const SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
color: productTheme.accentColor,
color: Color(0xFF6366F1),
strokeWidth: 3,
),
),
const SizedBox(height: 16),
Text(
_genStatus,
style: TextStyle(
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: productTheme.accentColor,
color: Color(0xFF6366F1),
),
),
const SizedBox(height: 12),
@ -401,8 +405,8 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: _genProgress / 100,
backgroundColor: productTheme.accentColor.withOpacity(0.15),
valueColor: AlwaysStoppedAnimation(productTheme.accentColor),
backgroundColor: const Color(0xFF6366F1).withOpacity(0.15),
valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)),
minHeight: 4,
),
),
@ -415,40 +419,38 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
}
///
Widget _buildFixedBottomBar(ProductThemeData productTheme) {
Widget _buildFixedBottomBar() {
final isAiTab = _tabController.index == 0;
final isUploadTab = _tabController.index == 1;
Widget? buttonContent;
if (isAiTab) {
buttonContent = _buildGradientButton(
'开始生成',
_isGenerating
? null
: () {
final aiState = _aiTabKey.currentState;
if (aiState == null) return;
final prompt = aiState.currentPrompt;
final isI2I = aiState.referenceImageBytes != null;
//
if (!isI2I && prompt.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请输入图片描述'),
backgroundColor: Colors.orange,
),
);
return;
}
_handleAiGenerate(
prompt: prompt,
style: aiState.selectedStyle,
imageBytes: aiState.referenceImageBytes,
strength: aiState.strength,
);
},
);
// "开始生成"
if (!_isGenerating && !_hasAiResult) {
buttonContent = _buildGradientButton('开始生成', () {
final aiState = _aiTabKey.currentState;
if (aiState == null) return;
final prompt = aiState.currentPrompt;
final isI2I = aiState.referenceImageBytes != null;
//
if (!isI2I && prompt.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请输入图片描述'),
backgroundColor: Colors.orange,
),
);
return;
}
_handleAiGenerate(
prompt: prompt,
style: aiState.selectedStyle,
imageBytes: aiState.referenceImageBytes,
strength: aiState.strength,
);
});
}
} else if (isUploadTab) {
if (_uploadedImagePath != null) {
buttonContent = _buildGradientButton('使用此图', _handleUseImage);
@ -478,13 +480,14 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
);
}
Widget _buildGradientButton(String label, VoidCallback? onPressed) {
final theme = ref.read(currentProductThemeProvider);
Widget _buildGradientButton(String label, VoidCallback onPressed) {
return SizedBox(
height: 52,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: theme.buttonGradient,
gradient: const LinearGradient(
colors: [Color(0xFF22D3EE), Color(0xFF3B82F6), Color(0xFF6366F1), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16),
),
child: ElevatedButton(

View File

@ -12,13 +12,11 @@ class BadgeAiTab extends StatefulWidget {
double strength,
}) onGenerate;
final bool isGenerating;
final Color accentColor;
const BadgeAiTab({
super.key,
required this.onGenerate,
this.isGenerating = false,
this.accentColor = const Color(0xFF6366F1),
});
@override
@ -66,26 +64,25 @@ class BadgeAiTabState extends State<BadgeAiTab> {
@override
Widget build(BuildContext context) {
final accent = widget.accentColor;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
_buildModeToggle(accent),
_buildModeToggle(),
const SizedBox(height: 16),
//
if (_isI2I) ...[
_buildReferenceImageSection(accent),
_buildReferenceImageSection(),
const SizedBox(height: 16),
_buildStrengthSlider(accent),
_buildStrengthSlider(),
const SizedBox(height: 16),
],
//
_buildPromptInput(accent),
_buildPromptInput(),
const SizedBox(height: 20),
//
@ -104,35 +101,35 @@ class BadgeAiTabState extends State<BadgeAiTab> {
],
),
const SizedBox(height: 12),
_buildStyleGrid(accent),
_buildStyleGrid(),
],
),
);
}
Widget _buildModeToggle(Color accent) {
Widget _buildModeToggle() {
return Row(
children: [
_buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false), accent),
_buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false)),
const SizedBox(width: 8),
_buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true), accent),
_buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true)),
],
);
}
Widget _buildModeBtn(String label, bool active, VoidCallback onTap, Color accent) {
Widget _buildModeBtn(String label, bool active, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: active ? accent : Colors.transparent,
color: active ? const Color(0xFF6366F1) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: active
? accent
: accent.withOpacity(0.2),
? const Color(0xFF6366F1)
: const Color(0xFF6366F1).withOpacity(0.2),
width: 1.5,
),
),
@ -148,7 +145,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
);
}
Widget _buildReferenceImageSection(Color accent) {
Widget _buildReferenceImageSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -207,7 +204,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
height: 180,
decoration: BoxDecoration(
border: Border.all(
color: accent.withOpacity(0.25),
color: const Color(0xFF6366F1).withOpacity(0.25),
width: 2,
),
borderRadius: BorderRadius.circular(16),
@ -216,7 +213,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, size: 36, color: accent.withOpacity(0.6)),
Icon(Icons.add, size: 36, color: const Color(0xFFA78BFA)),
const SizedBox(height: 8),
const Text('点击上传参考图', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
const SizedBox(height: 4),
@ -230,7 +227,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
);
}
Widget _buildStrengthSlider(Color accent) {
Widget _buildStrengthSlider() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -249,18 +246,18 @@ class BadgeAiTabState extends State<BadgeAiTab> {
),
Text(
(_strength).toStringAsFixed(1),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: accent),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF6366F1)),
),
],
),
const SizedBox(height: 8),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: accent,
inactiveTrackColor: accent.withOpacity(0.15),
activeTrackColor: const Color(0xFF6366F1),
inactiveTrackColor: const Color(0xFF6366F1).withOpacity(0.15),
thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11),
overlayColor: accent.withOpacity(0.1),
overlayColor: const Color(0xFF6366F1).withOpacity(0.1),
trackHeight: 6,
),
child: Slider(
@ -283,7 +280,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
);
}
Widget _buildPromptInput(Color accent) {
Widget _buildPromptInput() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -318,7 +315,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: accent),
borderSide: const BorderSide(color: Color(0xFFA78BFA)),
),
contentPadding: const EdgeInsets.all(14),
),
@ -328,7 +325,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
);
}
Widget _buildStyleGrid(Color accent) {
Widget _buildStyleGrid() {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -344,7 +341,6 @@ class BadgeAiTabState extends State<BadgeAiTab> {
return BadgeStyleChip(
style: style,
selected: _selectedStyle == style.id,
accentColor: accent,
onTap: () {
setState(() {
_selectedStyle = _selectedStyle == style.id ? null : style.id;

View File

@ -1,23 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class BadgeBleDeviceCard extends StatelessWidget {
final String displayName;
final int rssi;
final ScanResult scanResult;
final bool selected;
final VoidCallback onTap;
final Color accentColor;
const BadgeBleDeviceCard({
super.key,
required this.displayName,
required this.rssi,
required this.scanResult,
required this.selected,
required this.onTap,
this.accentColor = const Color(0xFF8B5CF6),
});
@override
Widget build(BuildContext context) {
final name = scanResult.device.platformName.isNotEmpty
? scanResult.device.platformName
: '未知设备';
final rssi = scanResult.rssi;
return GestureDetector(
onTap: onTap,
child: Container(
@ -25,12 +27,12 @@ class BadgeBleDeviceCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: selected
? accentColor.withOpacity(0.08)
? const Color(0xFF8B5CF6).withOpacity(0.08)
: Colors.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? accentColor
? const Color(0xFF8B5CF6)
: Colors.black.withOpacity(0.06),
width: selected ? 2 : 1,
),
@ -40,7 +42,7 @@ class BadgeBleDeviceCard extends StatelessWidget {
Icon(
Icons.bluetooth,
color: selected
? accentColor
? const Color(0xFF8B5CF6)
: const Color(0xFF9CA3AF),
size: 22,
),
@ -50,12 +52,12 @@ class BadgeBleDeviceCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: selected
? accentColor
? const Color(0xFF8B5CF6)
: const Color(0xFF1F2937),
),
),
@ -70,7 +72,7 @@ class BadgeBleDeviceCard extends StatelessWidget {
),
),
if (selected)
Icon(Icons.check_circle, color: accentColor, size: 22),
const Icon(Icons.check_circle, color: Color(0xFF8B5CF6), size: 22),
],
),
),

View File

@ -5,14 +5,12 @@ class BadgeStyleChip extends StatelessWidget {
final BadgeStyle style;
final bool selected;
final VoidCallback onTap;
final Color accentColor;
const BadgeStyleChip({
super.key,
required this.style,
required this.selected,
required this.onTap,
this.accentColor = const Color(0xFF6366F1),
});
@override
@ -23,12 +21,12 @@ class BadgeStyleChip extends StatelessWidget {
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: selected
? accentColor.withOpacity(0.1)
? const Color(0xFF6366F1).withOpacity(0.1)
: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? accentColor
? const Color(0xFF6366F1)
: Colors.white.withOpacity(0.4),
width: selected ? 2 : 1,
),
@ -44,7 +42,7 @@ class BadgeStyleChip extends StatelessWidget {
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? accentColor
? const Color(0xFF6366F1)
: const Color(0xFF6B7280),
),
),

View File

@ -8,12 +8,10 @@ import '../../../../core/network/api_config.dart';
class BadgeUploadTab extends StatefulWidget {
final void Function(String imagePath, Uint8List? bytes) onImageSelected;
final Color accentColor;
const BadgeUploadTab({
super.key,
required this.onImageSelected,
this.accentColor = const Color(0xFF6366F1),
});
@override
@ -68,7 +66,6 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
Navigator.of(context).pop();
_selectAiImage(url);
},
accentColor: widget.accentColor,
),
);
}
@ -183,7 +180,7 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: widget.accentColor, size: 48),
Icon(icon, color: const Color(0xFF6366F1), size: 48),
const SizedBox(height: 12),
Text(
label,
@ -203,12 +200,8 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
class _AiHistoryBottomSheet extends StatefulWidget {
final void Function(String imageUrl) onSelect;
final Color accentColor;
const _AiHistoryBottomSheet({
required this.onSelect,
this.accentColor = const Color(0xFF6366F1),
});
const _AiHistoryBottomSheet({required this.onSelect});
@override
State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState();
@ -293,8 +286,8 @@ class _AiHistoryBottomSheetState extends State<_AiHistoryBottomSheet> {
Widget _buildContent() {
if (_loading) {
return Center(
child: CircularProgressIndicator(color: widget.accentColor),
return const Center(
child: CircularProgressIndicator(color: Color(0xFF6366F1)),
);
}
if (_error != null) {

View File

@ -1,178 +1,79 @@
import 'dart:math';
import 'package:flutter/material.dart';
///
class TransferProgressRing extends StatefulWidget {
class TransferProgressRing extends StatelessWidget {
final double progress;
final bool isComplete;
final Color accentColor;
const TransferProgressRing({
super.key,
required this.progress,
this.isComplete = false,
this.accentColor = const Color(0xFF8B5CF6),
});
@override
State<TransferProgressRing> createState() => _TransferProgressRingState();
}
class _TransferProgressRingState extends State<TransferProgressRing>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _progressAnim;
double _oldProgress = 0.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_progressAnim = Tween<double>(begin: 0, end: widget.progress)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
}
@override
void didUpdateWidget(TransferProgressRing oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_oldProgress = _progressAnim.value;
_progressAnim = Tween<double>(begin: _oldProgress, end: widget.progress)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller
..reset()
..forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _progressAnim,
builder: (context, child) {
final p = _progressAnim.value.clamp(0.0, 1.0);
return SizedBox(
width: 120,
height: 120,
child: CustomPaint(
painter: _GradientRingPainter(
progress: p,
isComplete: widget.isComplete,
accentColor: widget.accentColor,
),
child: Center(
child: widget.isComplete
? TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 400),
curve: Curves.elasticOut,
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: child,
);
},
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Color(0xFF10B981),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded,
color: Colors.white, size: 32),
),
)
: Text(
'${(p * 100).toInt()}%',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: widget.accentColor,
),
),
),
),
);
},
return SizedBox(
width: 120,
height: 120,
child: CustomPaint(
painter: _ProgressRingPainter(
progress: progress,
isComplete: isComplete,
),
child: Center(
child: isComplete
? const Icon(Icons.check, color: Color(0xFF10B981), size: 48)
: Text(
'${(progress * 100).toInt()}%',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Color(0xFF8B5CF6),
),
),
),
),
);
}
}
class _GradientRingPainter extends CustomPainter {
class _ProgressRingPainter extends CustomPainter {
final double progress;
final bool isComplete;
final Color accentColor;
_GradientRingPainter({
required this.progress,
required this.isComplete,
required this.accentColor,
});
_ProgressRingPainter({required this.progress, required this.isComplete});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 6;
final rect = Rect.fromCircle(center: center, radius: radius);
//
// Background ring
final bgPaint = Paint()
..color = accentColor.withOpacity(0.1)
..color = const Color(0xFFE5E7EB)
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
if (progress <= 0) return;
//
final sweepAngle = 2 * pi * progress;
if (isComplete) {
final completePaint = Paint()
..color = const Color(0xFF10B981)
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, -pi / 2, sweepAngle, false, completePaint);
return;
}
//
final gradient = SweepGradient(
startAngle: -pi / 2,
endAngle: -pi / 2 + sweepAngle,
colors: [
accentColor.withOpacity(0.4),
accentColor,
],
stops: const [0.0, 1.0],
transform: const GradientRotation(-pi / 2),
);
final gradientPaint = Paint()
..shader = gradient.createShader(rect)
// Progress arc
final color = isComplete ? const Color(0xFF10B981) : const Color(0xFF8B5CF6);
final fgPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, -pi / 2, sweepAngle, false, gradientPaint);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
2 * pi * progress,
false,
fgPaint,
);
}
@override
bool shouldRepaint(covariant _GradientRingPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.isComplete != isComplete ||
oldDelegate.accentColor != accentColor;
bool shouldRepaint(covariant _ProgressRingPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.isComplete != isComplete;
}

View File

@ -3,23 +3,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'device.freezed.dart';
part 'device.g.dart';
/// API ID
class _SafeDeviceTypeConverter
implements JsonConverter<DeviceType?, Object?> {
const _SafeDeviceTypeConverter();
@override
DeviceType? fromJson(Object? json) {
if (json is Map<String, dynamic>) {
return DeviceType.fromJson(json);
}
return null; // ID null
}
@override
Object? toJson(DeviceType? object) => object?.toJson();
}
@freezed
abstract class DeviceType with _$DeviceType {
const factory DeviceType({
@ -41,8 +24,8 @@ abstract class DeviceInfo with _$DeviceInfo {
const factory DeviceInfo({
required int id,
required String sn,
@_SafeDeviceTypeConverter() DeviceType? deviceType,
@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo,
DeviceType? deviceType,
DeviceType? deviceTypeInfo,
String? macAddress,
@Default('') String name,
@Default('in_stock') String status,

View File

@ -296,7 +296,7 @@ as String?,
/// @nodoc
mixin _$DeviceInfo {
int get id; String get sn;@_SafeDeviceTypeConverter() DeviceType? get deviceType;@_SafeDeviceTypeConverter() DeviceType? get deviceTypeInfo; String? get macAddress; String get name; String get status; bool get isOnline; String get firmwareVersion; String? get lastOnlineAt; String? get createdAt;
int get id; String get sn; DeviceType? get deviceType; DeviceType? get deviceTypeInfo; String? get macAddress; String get name; String get status; bool get isOnline; String get firmwareVersion; String? get lastOnlineAt; String? get createdAt;
/// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -329,7 +329,7 @@ abstract mixin class $DeviceInfoCopyWith<$Res> {
factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl;
@useResult
$Res call({
int id, String sn,@_SafeDeviceTypeConverter() DeviceType? deviceType,@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
});
@ -468,7 +468,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
@ -489,7 +489,7 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this;
switch (_that) {
case _DeviceInfo():
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
@ -509,7 +509,7 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this;
switch (_that) {
case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
@ -524,13 +524,13 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
@JsonSerializable()
class _DeviceInfo implements DeviceInfo {
const _DeviceInfo({required this.id, required this.sn, @_SafeDeviceTypeConverter() this.deviceType, @_SafeDeviceTypeConverter() this.deviceTypeInfo, this.macAddress, this.name = '', this.status = 'in_stock', this.isOnline = false, this.firmwareVersion = '', this.lastOnlineAt, this.createdAt});
const _DeviceInfo({required this.id, required this.sn, this.deviceType, this.deviceTypeInfo, this.macAddress, this.name = '', this.status = 'in_stock', this.isOnline = false, this.firmwareVersion = '', this.lastOnlineAt, this.createdAt});
factory _DeviceInfo.fromJson(Map<String, dynamic> json) => _$DeviceInfoFromJson(json);
@override final int id;
@override final String sn;
@override@_SafeDeviceTypeConverter() final DeviceType? deviceType;
@override@_SafeDeviceTypeConverter() final DeviceType? deviceTypeInfo;
@override final DeviceType? deviceType;
@override final DeviceType? deviceTypeInfo;
@override final String? macAddress;
@override@JsonKey() final String name;
@override@JsonKey() final String status;
@ -572,7 +572,7 @@ abstract mixin class _$DeviceInfoCopyWith<$Res> implements $DeviceInfoCopyWith<$
factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl;
@override @useResult
$Res call({
int id, String sn,@_SafeDeviceTypeConverter() DeviceType? deviceType,@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt
});

View File

@ -30,10 +30,12 @@ Map<String, dynamic> _$DeviceTypeToJson(_DeviceType instance) =>
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
id: (json['id'] as num).toInt(),
sn: json['sn'] as String,
deviceType: const _SafeDeviceTypeConverter().fromJson(json['device_type']),
deviceTypeInfo: const _SafeDeviceTypeConverter().fromJson(
json['device_type_info'],
),
deviceType: json['device_type'] == null
? null
: DeviceType.fromJson(json['device_type'] as Map<String, dynamic>),
deviceTypeInfo: json['device_type_info'] == null
? null
: DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>),
macAddress: json['mac_address'] as String?,
name: json['name'] as String? ?? '',
status: json['status'] as String? ?? 'in_stock',
@ -43,23 +45,20 @@ _DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
createdAt: json['created_at'] as String?,
);
Map<String, dynamic> _$DeviceInfoToJson(
_DeviceInfo instance,
) => <String, dynamic>{
'id': instance.id,
'sn': instance.sn,
'device_type': const _SafeDeviceTypeConverter().toJson(instance.deviceType),
'device_type_info': const _SafeDeviceTypeConverter().toJson(
instance.deviceTypeInfo,
),
'mac_address': instance.macAddress,
'name': instance.name,
'status': instance.status,
'is_online': instance.isOnline,
'firmware_version': instance.firmwareVersion,
'last_online_at': instance.lastOnlineAt,
'created_at': instance.createdAt,
};
Map<String, dynamic> _$DeviceInfoToJson(_DeviceInfo instance) =>
<String, dynamic>{
'id': instance.id,
'sn': instance.sn,
'device_type': instance.deviceType,
'device_type_info': instance.deviceTypeInfo,
'mac_address': instance.macAddress,
'name': instance.name,
'status': instance.status,
'is_online': instance.isOnline,
'firmware_version': instance.firmwareVersion,
'last_online_at': instance.lastOnlineAt,
'created_at': instance.createdAt,
};
_UserDevice _$UserDeviceFromJson(Map<String, dynamic> json) => _UserDevice(
id: (json['id'] as num).toInt(),

View File

@ -19,16 +19,16 @@ class DeviceController extends _$DeviceController {
);
}
/// null
Future<String?> bindDevice(String sn, {int? spiritId}) async {
Future<bool> bindDevice(String sn, {int? spiritId}) async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.bindDevice(sn, spiritId: spiritId);
if (!ref.mounted) return null; //
if (!ref.mounted) return false;
return result.fold(
(failure) => failure.message,
(failure) => false,
(bindingId) {
if (ref.mounted) ref.invalidateSelf();
return null;
if (!ref.mounted) return false;
ref.invalidateSelf();
return true;
},
);
}

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/services/ble_provisioning_service.dart';
@ -13,7 +14,6 @@ import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart';
import '../theme/product_theme.dart';
///
enum DeviceType { plush, badgeAi, badge }
@ -26,7 +26,6 @@ class MockDevice {
final DeviceType type;
final bool hasAI;
final bool isNetworkRequired;
final String bindStatus; // unbound / bound_by_me / bound_by_other
final BluetoothDevice? bleDevice;
const MockDevice({
@ -36,12 +35,9 @@ class MockDevice {
required this.type,
required this.hasAI,
this.isNetworkRequired = true,
this.bindStatus = 'unbound',
this.bleDevice,
});
bool get isBoundByOther => bindStatus == 'bound_by_other';
String get iconPath {
switch (type) {
case DeviceType.plush:
@ -222,19 +218,10 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
});
// _queryDeviceByMac
try {
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
} catch (e) {
// Web : requestDevice() FlutterBluePlusException
debugPrint('[BLE Scan] startScan 异常(用户可能取消了选择器): $e');
if (mounted) {
setState(() => _isSearching = false);
}
return;
}
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
);
// 30
if (mounted && _isSearching) {
@ -256,10 +243,8 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
_macInfoCache[mac] = data;
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
final productCode = data['device_type']?['product_code'] as String? ?? '';
final sn = data['sn'] as String? ?? '';
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
final bindStatus = data['bind_status'] as String? ?? 'unbound';
final bleDevice = _pendingBleDevices[mac];
// API
@ -272,10 +257,9 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
sn: sn,
name: displayName,
macAddress: mac,
type: _inferDeviceTypeByCode(productCode, displayName),
type: _inferDeviceType(displayName),
hasAI: _inferHasAI(displayName),
isNetworkRequired: isNetworkRequired,
bindStatus: bindStatus,
bleDevice: bleDevice,
));
}
@ -289,45 +273,36 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
debugPrint('[Bluetooth] 设备已就绪: $mac$displayName');
} catch (e) {
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
// API BLE fallback
if (!mounted) return;
//
setState(() => _isSearching = false);
final bleDevice = _pendingBleDevices[mac];
setState(() {
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 (_) {}
_macInfoCache.remove(mac); //
showGlassDialog(
context: context,
title: '设备查询失败',
description: '无法验证设备信息,请检查网络后重试。',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
}
}
///
/// product_code fallback
DeviceType _inferDeviceTypeByCode(String productCode, String name) {
// product_code
switch (productCode) {
case 'KPBL-ON':
return DeviceType.plush;
case 'DZBJ-ON':
return DeviceType.badgeAi;
case 'DZBJ-OFF':
return DeviceType.badge;
}
// fallback:
DeviceType _inferDeviceType(String name) {
final lower = name.toLowerCase();
if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯')) {
if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) {
return DeviceType.plush;
}
if (lower.contains('ai') || lower.contains('智能')) {
return DeviceType.badgeAi;
}
if (lower.contains('吧唧') || lower.contains('badge')) {
return DeviceType.badge;
}
return DeviceType.plush;
return DeviceType.badge;
}
/// AI
@ -379,21 +354,6 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
bool _isConnecting = false;
///
void _setThemeAndNavigate(DeviceType type) {
switch (type) {
case DeviceType.badgeAi:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi);
context.go('/badge-control');
case DeviceType.badge:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
context.go('/badge-basic-control');
case DeviceType.plush:
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
context.go('/device-control');
}
}
///
Future<void> _handleConnect() async {
if (_devices.isEmpty || _isConnecting) return;
@ -405,55 +365,11 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
}
final device = _devices[_currentIndex];
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, bindStatus=${device.bindStatus}');
//
if (device.isBoundByOther) {
showGlassDialog(
context: context,
title: '无法连接',
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}');
if (!device.isNetworkRequired) {
// ->
if (device.sn.isNotEmpty) {
setState(() => _isConnecting = true);
try {
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
} catch (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;
setState(() => _isConnecting = false);
}
_setThemeAndNavigate(device.type);
// ->
context.go('/device-control');
return;
}
@ -462,23 +378,11 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
setState(() => _isConnecting = true);
if (device.sn.isNotEmpty) {
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
}
if (!mounted) return;
setState(() => _isConnecting = false);
_setThemeAndNavigate(device.type);
context.go('/device-control');
return;
}
@ -569,13 +473,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
children: [
// - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
GestureDetector(
onTap: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
},
onTap: () => context.go('/home'),
child: Container(
width: 40,
height: 40,
@ -596,7 +494,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
child: Text(
'搜索设备',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -668,7 +566,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
height: 120,
placeholderBuilder: (_) => Text(
'?',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 48,
fontWeight: FontWeight.w700,
color: const Color(0xFFF59E0B), // Amber color per HTML
@ -837,25 +735,12 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
//
Text(
device.name,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: device.isBoundByOther
? const Color(0xFF9CA3AF)
: const Color(0xFF1F2937),
color: const Color(0xFF1F2937),
),
),
//
if (device.isBoundByOther) ...[
const SizedBox(height: 4),
Text(
'已被其他用户绑定',
style: TextStyle(
fontSize: 12,
color: const Color(0xFFEF4444),
),
),
],
],
);
}
@ -913,13 +798,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
children: [
// - HTML: frosted glass with border
GestureDetector(
onTap: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
},
onTap: () => context.go('/home'),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Container(

View File

@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../core/network/api_client.dart';
import 'story_detail_page.dart';
@ -18,7 +19,6 @@ import '../widgets/ios_toast.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart';
import '../features/device/presentation/controllers/device_controller.dart';
import '../theme/product_theme.dart';
class DeviceControlPage extends ConsumerStatefulWidget {
const DeviceControlPage({super.key});
@ -48,7 +48,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
@override
void initState() {
super.initState();
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara));
_mascotAnimController = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
@ -302,7 +301,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: const Color(0xFF4B5563),
@ -326,7 +325,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
const SizedBox(width: 4),
Text(
batteryText,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: const Color(0xFF4B5563),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../widgets/animated_gradient_background.dart';
@ -87,7 +88,7 @@ class _HomePageState extends State<HomePage>
child: Text(
'Airhub',
// Use Press Start 2P pixel font per HTML CSS
style: TextStyle(fontFamily: 'Press Start 2P',
style: GoogleFonts.pressStart2p(
fontSize: 28,
color: const Color(0xFF6366F1), //
letterSpacing: 2,
@ -238,7 +239,7 @@ class _HomePageState extends State<HomePage>
child: Center(
child: Text(
'立即连接',
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../widgets/gradient_button.dart';
@ -57,7 +58,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// Title
Text(
'服务协议',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -325,7 +326,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
padding: const EdgeInsets.only(top: 32),
child: Text(
'Airhub',
style: TextStyle(fontFamily: 'Press Start 2P',
style: GoogleFonts.pressStart2p(
fontSize: 28,
color: const Color(0xFF6366F1),
letterSpacing: 2,
@ -501,7 +502,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
// Heading - font-size: 32px, font-weight: 700
Text(
'欢迎使用 Airhub',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.w700,
color: const Color(0xFF6B5B95),

View File

@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:google_fonts/google_fonts.dart';
import 'package:just_audio/just_audio.dart';
import '../services/music_generation_service.dart';
import '../widgets/animated_gradient_background.dart';
@ -800,7 +801,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
child: Text(
'灵感电台',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -1107,7 +1108,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
track.lyrics.isNotEmpty
? _cleanLyrics(track.lyrics)
: '生成音乐后\n点我看歌词',
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12,
height: 1.6,
color: track.lyrics.isNotEmpty
@ -1169,7 +1170,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
),
child: Text(
bubbleText,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: const Color(0xFF6B4423),
@ -1226,7 +1227,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
child: Text(
_currentTime,
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12,
color: const Color(0xFF6B7280),
fontFeatures: const [FontFeature.tabularFigures()],
@ -1276,7 +1277,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
child: Text(
_totalTime,
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12,
color: const Color(0xFF6B7280),
fontFeatures: const [FontFeature.tabularFigures()],
@ -1433,7 +1434,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
children: [
Text(
mood['title'] as String,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive
@ -1446,7 +1447,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
const SizedBox(height: 2),
Text(
mood['desc'] as String,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 11,
color: isActive
? const Color(0xFF6B7280)
@ -1907,7 +1908,7 @@ class _InputModalContent extends StatelessWidget {
children: [
Text(
'自由创作',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF374151),
@ -1934,7 +1935,7 @@ class _InputModalContent extends StatelessWidget {
alignment: Alignment.centerLeft,
child: Text(
'描述你想要的音乐氛围、场景或情绪',
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12,
color: const Color(0xFF9CA3AF),
),
@ -1949,11 +1950,11 @@ class _InputModalContent extends StatelessWidget {
controller: controller,
minLines: 4,
maxLines: 6,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 14, color: const Color(0xFF374151)),
decoration: InputDecoration(
hintText: '例如:水豚在雨中等公交,心情却很平静...',
hintStyle: TextStyle(fontFamily: 'DM Sans',
hintStyle: GoogleFonts.dmSans(
fontSize: 14, color: const Color(0xFF9CA3AF)),
filled: true,
fillColor: Colors.black.withOpacity(0.03),
@ -2098,7 +2099,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
children: [
Text(
'我的唱片架',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w600,
color: const Color(0xFF374151),
@ -2265,7 +2266,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
Flexible(
child: Text(
track.title,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 12,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
color: isCurrent

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../widgets/animated_gradient_background.dart';
import '../features/device/presentation/controllers/device_controller.dart';
import '../features/device/domain/entities/device.dart';
import '../theme/product_theme.dart';
class ProductSelectionPage extends ConsumerStatefulWidget {
const ProductSelectionPage({super.key});
@ -38,13 +38,6 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
'badge-basic': ['DZBJ-OFF'],
};
/// ID ProductType
static const Map<String, ProductType> _productTypeMap = {
'capybara': ProductType.capybara,
'badge-ai': ProductType.badgeAi,
'badge-basic': ProductType.badgeBasic,
};
///
UserDevice? _findBoundDevice(String productId, List<UserDevice> devices) {
final codes = _productCodeMap[productId];
@ -197,23 +190,12 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
fadeStartY: headerHeight + 16,
fadeEndY: safeTop,
onTap: () {
//
final productType = _productTypeMap[product['id']] ?? ProductType.common;
ref.read(currentProductTypeProvider.notifier).set(productType);
if (boundDevice != null) {
//
final pid = product['id'] as String;
if (pid == 'badge-ai') {
context.go('/badge-control');
} else if (pid == 'badge-basic') {
context.go('/badge-basic-control');
} else {
context.go('/device-control');
}
//
context.go('/device-control');
} else {
// push
context.push('/bluetooth');
//
context.go('/bluetooth');
}
},
);
@ -232,7 +214,7 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
children: [
Text(
'选择产品',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -364,7 +346,7 @@ class _ProductCard extends StatelessWidget {
children: [
Text(
product['name'],
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 19,
fontWeight: FontWeight.bold,
color: Colors.white,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../widgets/glass_dialog.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/ios_toast.dart';
@ -99,7 +100,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
Text(
'设置',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),

View File

@ -2,14 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_svg/flutter_svg.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 '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart';
import '../theme/product_theme.dart';
class WifiConfigPage extends ConsumerStatefulWidget {
final Map<String, dynamic>? extra;
@ -134,74 +132,18 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_isBinding) return;
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? ?? '';
if (sn.isNotEmpty) {
try {
debugPrint('[WiFi Config] Binding device sn=$sn');
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
if (!mounted) return;
if (error != null) {
setState(() => _isBinding = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
} catch (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;
setState(() => _isBinding = false);
final deviceType = _deviceInfo['type'] as String? ?? '';
if (deviceType == 'badgeAi') {
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi);
context.go('/badge-control');
} else if (deviceType == 'badge') {
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
context.go('/badge-basic-control');
} else {
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
context.go('/device-control');
}
context.go('/device-control');
return;
}
@ -356,7 +298,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
child: Text(
'WiFi配网',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -420,7 +362,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
Text(
'选择WiFi网络',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.bold,
color: const Color(0xFF1F2937),
@ -562,7 +504,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
Text(
_selectedWifiSsid,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.bold,
color: const Color(0xFF1F2937),
@ -630,7 +572,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
),
Text(
'正在配网...',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.bold,
color: const Color(0xFF1F2937),
@ -746,7 +688,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
const SizedBox(height: 24),
Text(
'配网成功!',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 24,
fontWeight: FontWeight.bold,
color: const Color(0xFF1F2937),

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData get lightTheme {
// Base text theme with DM Sans (PRD: /UI )
final baseTextTheme = const TextTheme(
final baseTextTheme = GoogleFonts.dmSansTextTheme(const TextTheme(
// h1 / Large Headings
displayLarge: TextStyle(
color: AppColors.textPrimary,
@ -39,19 +40,19 @@ class AppTheme {
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
).apply(fontFamily: 'DM Sans');
));
// Apply Outfit to heading styles (PRD: /Display )
final textTheme = baseTextTheme.copyWith(
displayLarge: baseTextTheme.displayLarge?.copyWith(fontFamily: 'Outfit'),
displayMedium: baseTextTheme.displayMedium?.copyWith(fontFamily: 'Outfit'),
displaySmall: baseTextTheme.displaySmall?.copyWith(fontFamily: 'Outfit'),
headlineLarge: baseTextTheme.headlineLarge?.copyWith(fontFamily: 'Outfit'),
headlineMedium: baseTextTheme.headlineMedium?.copyWith(fontFamily: 'Outfit'),
headlineSmall: baseTextTheme.headlineSmall?.copyWith(fontFamily: 'Outfit'),
titleLarge: baseTextTheme.titleLarge?.copyWith(fontFamily: 'Outfit'),
titleMedium: baseTextTheme.titleMedium?.copyWith(fontFamily: 'Outfit'),
titleSmall: baseTextTheme.titleSmall?.copyWith(fontFamily: 'Outfit'),
displayLarge: GoogleFonts.outfit(textStyle: baseTextTheme.displayLarge),
displayMedium: GoogleFonts.outfit(textStyle: baseTextTheme.displayMedium),
displaySmall: GoogleFonts.outfit(textStyle: baseTextTheme.displaySmall),
headlineLarge: GoogleFonts.outfit(textStyle: baseTextTheme.headlineLarge),
headlineMedium: GoogleFonts.outfit(textStyle: baseTextTheme.headlineMedium),
headlineSmall: GoogleFonts.outfit(textStyle: baseTextTheme.headlineSmall),
titleLarge: GoogleFonts.outfit(textStyle: baseTextTheme.titleLarge),
titleMedium: GoogleFonts.outfit(textStyle: baseTextTheme.titleMedium),
titleSmall: GoogleFonts.outfit(textStyle: baseTextTheme.titleSmall),
);
return ThemeData(
@ -66,7 +67,7 @@ class AppTheme {
background: AppColors.bgBase,
),
// PRD: DM Sans 退
fontFamily: 'DM Sans',
fontFamily: GoogleFonts.dmSans().fontFamily,
fontFamilyFallback: const [
'Roboto',
'PingFang SC',

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// - Profile PRD
class AppColors {
@ -110,77 +111,77 @@ class AppColors {
/// - PRD规范: Outfit() + DM Sans() + Press Start 2P(Logo)
class AppTextStyles {
// : 17px w600 #1F2937
static final TextStyle title = TextStyle(fontFamily: 'Outfit',
static final TextStyle title = GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
);
// User Name Outfit (heading/display)
static final TextStyle userName = TextStyle(fontFamily: 'Outfit',
static final TextStyle userName = GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
// User ID DM Sans (body)
static final TextStyle userId = TextStyle(fontFamily: 'DM Sans',
static final TextStyle userId = GoogleFonts.dmSans(
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
);
// Menu Text DM Sans (body/UI)
static final TextStyle menuText = TextStyle(fontFamily: 'DM Sans',
static final TextStyle menuText = GoogleFonts.dmSans(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
);
// Badge Text DM Sans (small UI)
static final TextStyle badge = TextStyle(fontFamily: 'DM Sans',
static final TextStyle badge = GoogleFonts.dmSans(
fontSize: 10,
fontWeight: FontWeight.w400,
color: Colors.white,
);
// Modal Title Outfit (heading)
static final TextStyle modalTitle = TextStyle(fontFamily: 'Outfit',
static final TextStyle modalTitle = GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
// Book specific styles Outfit (heading)
static final TextStyle bookTitle = TextStyle(fontFamily: 'Outfit',
static final TextStyle bookTitle = GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
);
// Book count DM Sans (body)
static final TextStyle bookCount = TextStyle(fontFamily: 'DM Sans',
static final TextStyle bookCount = GoogleFonts.dmSans(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
);
// Slot title DM Sans (small UI)
static final TextStyle slotTitle = TextStyle(fontFamily: 'DM Sans',
static final TextStyle slotTitle = GoogleFonts.dmSans(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w400,
);
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
static final TextStyle emptyPlus = TextStyle(fontFamily: 'DM Sans',
static final TextStyle emptyPlus = GoogleFonts.dmSans(
fontSize: 24,
fontWeight: FontWeight.w300,
color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
);
// Button text DM Sans (UI)
static final TextStyle createStoryBtn = TextStyle(fontFamily: 'DM Sans',
static final TextStyle createStoryBtn = GoogleFonts.dmSans(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,

View File

@ -1,190 +0,0 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'product_theme.g.dart';
///
enum ProductType {
///
common,
///
capybara,
/// AI
badgeAi,
///
badgeBasic,
}
///
class ProductThemeData {
///
final LinearGradient buttonGradient;
///
final Color accentColor;
///
final Color accentColorLight;
///
final List<BoxShadow> buttonShadows;
const ProductThemeData({
required this.buttonGradient,
required this.accentColor,
required this.accentColorLight,
required this.buttonShadows,
});
}
///
class ProductThemes {
ProductThemes._();
/// / (cyan blue indigo purple)
static final common = ProductThemeData(
buttonGradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF22D3EE),
Color(0xFF3B82F6),
Color(0xFF6366F1),
Color(0xFF8B5CF6),
],
stops: [0.0, 0.35, 0.65, 1.0],
),
accentColor: const Color(0xFF6366F1),
accentColorLight: const Color(0xFF8B5CF6),
buttonShadows: [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
],
);
/// /
static final capybara = ProductThemeData(
buttonGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFECCFA8), Color(0xFFC99672)],
),
accentColor: const Color(0xFFC99672),
accentColorLight: const Color(0xFFECCFA8),
buttonShadows: [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
],
);
/// AI
static final badgeAi = ProductThemeData(
buttonGradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF22D3EE),
Color(0xFF60A5FA),
Color(0xFF818CF8),
Color(0xFFA78BFA),
],
stops: [0.0, 0.35, 0.70, 1.0],
),
accentColor: const Color(0xFF818CF8),
accentColorLight: const Color(0xFFA78BFA),
buttonShadows: [
BoxShadow(
color: const Color(0xFF818CF8).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFFA78BFA).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
],
);
///
static final badgeBasic = ProductThemeData(
buttonGradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFFC084FC),
Color(0xFFD8B4FE),
Color(0xFFC4B5FD),
Color(0xFFA78BFA),
],
stops: [0.0, 0.35, 0.70, 1.0],
),
accentColor: const Color(0xFFC084FC),
accentColorLight: const Color(0xFFD8B4FE),
buttonShadows: [
BoxShadow(
color: const Color(0xFFC084FC).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFFA78BFA).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
],
);
///
static ProductThemeData of(ProductType type) {
switch (type) {
case ProductType.common:
return common;
case ProductType.capybara:
return capybara;
case ProductType.badgeAi:
return badgeAi;
case ProductType.badgeBasic:
return badgeBasic;
}
}
}
/// Notifier
@Riverpod(keepAlive: true)
class CurrentProductType extends _$CurrentProductType {
@override
ProductType build() => ProductType.common;
void set(ProductType type) => state = type;
}
///
@riverpod
ProductThemeData currentProductTheme(Ref ref) {
return ProductThemes.of(ref.watch(currentProductTypeProvider));
}

View File

@ -1,121 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product_theme.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Notifier
@ProviderFor(CurrentProductType)
const currentProductTypeProvider = CurrentProductTypeProvider._();
/// Notifier
final class CurrentProductTypeProvider
extends $NotifierProvider<CurrentProductType, ProductType> {
/// Notifier
const CurrentProductTypeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentProductTypeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentProductTypeHash();
@$internal
@override
CurrentProductType create() => CurrentProductType();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProductType value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProductType>(value),
);
}
}
String _$currentProductTypeHash() =>
r'53603ab5884787f0a4bb1aed5de18ff33089b5e7';
/// Notifier
abstract class _$CurrentProductType extends $Notifier<ProductType> {
ProductType build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ProductType, ProductType>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ProductType, ProductType>,
ProductType,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
///
@ProviderFor(currentProductTheme)
const currentProductThemeProvider = CurrentProductThemeProvider._();
///
final class CurrentProductThemeProvider
extends
$FunctionalProvider<
ProductThemeData,
ProductThemeData,
ProductThemeData
>
with $Provider<ProductThemeData> {
///
const CurrentProductThemeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentProductThemeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentProductThemeHash();
@$internal
@override
$ProviderElement<ProductThemeData> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ProductThemeData create(Ref ref) {
return currentProductTheme(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProductThemeData value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProductThemeData>(value),
);
}
}
String _$currentProductThemeHash() =>
r'a4e7be1ce8791e6e3323950346ef72e4f5d07fa9';

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'gradient_button.dart';
import '../theme/app_colors.dart' as appclr;
@ -10,8 +11,8 @@ class GlassDialog extends StatelessWidget {
final String confirmText;
final VoidCallback onCancel;
final VoidCallback onConfirm;
final bool isDanger;
final Gradient? confirmGradient;
final bool
isDanger; // If we need a specific danger style, though CSS shows Pink Gradient default
const GlassDialog({
super.key,
@ -23,7 +24,6 @@ class GlassDialog extends StatelessWidget {
required this.onCancel,
required this.onConfirm,
this.isDanger = false,
this.confirmGradient,
});
@override
@ -54,7 +54,7 @@ class GlassDialog extends StatelessWidget {
// Title
Text(
title,
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: const Color(0xFF4B2404),
@ -98,7 +98,7 @@ class GlassDialog extends StatelessWidget {
GradientButton(
text: confirmText,
height: 48,
gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
gradient: appclr.AppColors.btnPlushGradient,
onPressed: onConfirm,
),
] else ...[
@ -131,7 +131,7 @@ class GlassDialog extends StatelessWidget {
child: GradientButton(
text: confirmText,
height: 44,
gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
gradient: appclr.AppColors.btnPlushGradient,
onPressed: onConfirm,
),
),
@ -155,7 +155,6 @@ Future<T?> showGlassDialog<T>({
String confirmText = '确定',
required VoidCallback onConfirm,
bool isDanger = false,
Gradient? confirmGradient,
}) {
return showGeneralDialog<T>(
context: context,
@ -177,7 +176,6 @@ Future<T?> showGlassDialog<T>({
onCancel: () => Navigator.of(context).pop(),
onConfirm: onConfirm,
isDanger: isDanger,
confirmGradient: confirmGradient,
);
},
transitionBuilder: (context, anim1, anim2, child) {

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/product_theme.dart';
class GradientButton extends StatelessWidget {
final String text;
@ -9,7 +9,6 @@ class GradientButton extends StatelessWidget {
final double height;
final bool isLoading;
final Gradient? gradient;
final List<BoxShadow>? shadows;
const GradientButton({
super.key,
@ -19,53 +18,57 @@ class GradientButton extends StatelessWidget {
this.height = 48.0, //
this.isLoading = false,
this.gradient,
this.shadows,
});
/// ProductThemeData
factory GradientButton.fromTheme({
Key? key,
required String text,
required ProductThemeData theme,
VoidCallback? onPressed,
double width = double.infinity,
double height = 48.0,
bool isLoading = false,
}) {
return GradientButton(
key: key,
text: text,
onPressed: onPressed,
width: width,
height: height,
isLoading: isLoading,
gradient: theme.buttonGradient,
shadows: theme.buttonShadows,
);
// Check if using plush/capybara gradient
bool get _isPlushGradient {
if (gradient == null) return false;
if (gradient is LinearGradient) {
final lg = gradient as LinearGradient;
// Check if colors match plush gradient colors
if (lg.colors.length >= 2) {
return lg.colors.first.value == 0xFFECCFA8 ||
lg.colors.last.value == 0xFFC99672;
}
}
return false;
}
List<BoxShadow> get _boxShadows {
if (shadows != null) return shadows!;
//
if (gradient is LinearGradient) {
final lg = gradient as LinearGradient;
if (lg.colors.length >= 2) {
final shadowColor = lg.colors[lg.colors.length ~/ 2];
return [
BoxShadow(
color: shadowColor.withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: lg.colors.last.withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
}
if (_isPlushGradient) {
// Warm brown glow for Capybara plush gradient
return [
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.35),
offset: Offset.zero,
blurRadius: 15,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.25),
offset: Offset.zero,
blurRadius: 30,
),
BoxShadow(
color: const Color(0xFFC99672).withOpacity(0.4),
offset: const Offset(0, 6),
blurRadius: 20,
),
];
} else {
// Purple/indigo glow for primary gradient
return [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
}
return AppColors.shadowPrimaryButton;
}
@override
@ -124,7 +127,7 @@ class GradientButton extends StatelessWidget {
)
: Text(
text,
style: TextStyle(fontFamily: 'DM Sans',
style: GoogleFonts.dmSans(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,

View File

@ -40,14 +40,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.10"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
@ -524,6 +516,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -580,14 +580,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
@ -964,14 +956,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver:
dependency: transitive
description:

View File

@ -61,11 +61,10 @@ dependencies:
# Existing dependencies
webview_flutter: ^4.4.2
permission_handler: ^11.0.0
# google_fonts removed — local fonts used instead
google_fonts: ^6.1.0
flutter_blue_plus: ^1.31.0
flutter_svg: ^2.0.9
image_picker: ^1.2.1
image: ^4.3.0
just_audio: ^0.9.42
http: ^1.2.0
video_player: ^2.9.2
@ -90,17 +89,6 @@ flutter:
weight: 600
- asset: assets/fonts/Inter-Bold.ttf
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
# https://flutter.dev/to/resolution-aware-images