Compare commits

..

No commits in common. "main" and "fix/auto-20260227-163852" have entirely different histories.

72 changed files with 503 additions and 8162 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

@ -20,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

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

@ -2,15 +2,12 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -26,46 +24,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>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要位置权限以扫描附近的蓝牙设备</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackgroundColor</string>
<key>UIImageName</key>
<string>LaunchImage</string>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@ -83,5 +41,22 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>需要位置权限以扫描附近的蓝牙设备</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackgroundColor</string>
<key>UIImageName</key>
<string>LaunchImage</string>
</dict>
</dict>
</plist>

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,39 +1,17 @@
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';
import '../../pages/device_control_page.dart';
import '../../pages/home_page.dart';
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 +27,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;
@ -124,10 +48,6 @@ GoRouter goRouter(Ref ref) {
extra: state.extra as Map<String, dynamic>?,
),
),
GoRoute(
path: '/product-selection',
builder: (context, state) => const ProductSelectionPage(),
),
GoRoute(
path: '/device-control',
builder: (context, state) => const DeviceControlPage(),
@ -136,61 +56,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(),
),
GoRoute(
path: '/badge/transfer',
builder: (context, state) {
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'8e620e452bb81f2c6ed87b136283a9e508dca2e9';

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,12 +0,0 @@
import 'dart:typed_data';
import 'package:http/http.dart' as http;
class BadgeRemoteDataSource {
Future<Uint8List> downloadImageBytes(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode == 200) {
return response.bodyBytes;
}
throw Exception('下载图片失败: ${response.statusCode}');
}
}

View File

@ -1,100 +0,0 @@
import 'dart:typed_data';
import 'package:fpdart/fpdart.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/badge_image.dart';
import '../../domain/repositories/badge_repository.dart';
import '../datasources/badge_remote_data_source.dart';
import '../services/badge_ai_generation_service.dart';
class BadgeRepositoryImpl implements BadgeRepository {
final BadgeAiGenerationService _aiService;
final BadgeRemoteDataSource _remoteDataSource;
BadgeRepositoryImpl({
BadgeAiGenerationService? aiService,
BadgeRemoteDataSource? remoteDataSource,
}) : _aiService = aiService ?? BadgeAiGenerationService.instance,
_remoteDataSource = remoteDataSource ?? BadgeRemoteDataSource();
@override
Future<Either<Failure, BadgeImage>> generateText2Image({
required String prompt,
String? style,
}) async {
try {
await _aiService.generateText2Image(
prompt: prompt,
style: style,
);
//
final result = await _waitForResult();
if (result == null) {
final error = _aiService.consumePendingError();
return Left(ServerFailure(error ?? '生成失败'));
}
return Right(BadgeImage(
imageUrl: result.imageUrl,
prompt: prompt,
style: style,
source: 't2i',
));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Future<Either<Failure, BadgeImage>> generateImage2Image({
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
}) async {
try {
await _aiService.generateImage2Image(
imageBytes: imageBytes,
prompt: prompt,
style: style,
strength: strength,
);
final result = await _waitForResult();
if (result == null) {
final error = _aiService.consumePendingError();
return Left(ServerFailure(error ?? '生成失败'));
}
return Right(BadgeImage(
imageUrl: result.imageUrl,
prompt: prompt ?? '',
style: style,
source: 'i2i',
strength: strength,
));
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
@override
Future<Either<Failure, Uint8List>> downloadImageBytes(
String imageUrl) async {
try {
final bytes = await _remoteDataSource.downloadImageBytes(imageUrl);
return Right(bytes);
} catch (e) {
return Left(ServerFailure('下载图片失败: $e'));
}
}
/// AI
Future<BadgeAiResult?> _waitForResult() async {
while (_aiService.isGenerating) {
await Future.delayed(const Duration(milliseconds: 200));
}
return _aiService.consumePendingResult();
}
}

View File

@ -1,255 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/network/api_config.dart';
/// AI SSE
/// MusicGenerationService
class BadgeAiGenerationService {
BadgeAiGenerationService._();
static final BadgeAiGenerationService instance =
BadgeAiGenerationService._();
Future<String?> _getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('access_token');
}
//
bool _isGenerating = false;
double _progress = 0.0;
String _statusMessage = '';
//
BadgeAiResult? _pendingResult;
String? _pendingError;
//
void Function(double progress, String message)? onProgress;
void Function(BadgeAiResult result)? onComplete;
void Function(String error)? onError;
bool get isGenerating => _isGenerating;
double get progress => _progress;
String get statusMessage => _statusMessage;
BadgeAiResult? consumePendingResult() {
final r = _pendingResult;
_pendingResult = null;
return r;
}
String? consumePendingError() {
final e = _pendingError;
_pendingError = null;
return e;
}
///
Future<void> generateText2Image({
required String prompt,
String? style,
}) async {
await _generate(
endpoint: '/badge/generate/t2i/',
body: {
'prompt': prompt,
if (style != null) 'style': style,
},
);
}
///
Future<void> generateImage2Image({
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
}) async {
await _generateMultipart(
endpoint: '/badge/generate/i2i/',
imageBytes: imageBytes,
fields: {
'prompt': prompt ?? '',
if (style != null) 'style': style,
'strength': strength.toString(),
},
);
}
/// SSE JSON
Future<void> _generate({
required String endpoint,
required Map<String, dynamic> body,
}) async {
if (_isGenerating) return;
_reset();
try {
final token = await _getToken();
final request = http.Request(
'POST',
Uri.parse('${ApiConfig.fullBaseUrl}$endpoint'),
);
request.headers['Content-Type'] = 'application/json';
if (token != null) request.headers['Authorization'] = 'Bearer $token';
request.body = jsonEncode(body);
final client = http.Client();
final response =
await client.send(request).timeout(const Duration(seconds: 120));
if (response.statusCode != 200) {
final errBody = await response.stream.bytesToString();
String errMsg = '服务器返回错误 (${response.statusCode})';
try {
final json = jsonDecode(errBody) as Map<String, dynamic>;
errMsg = json['message'] as String? ?? errMsg;
} catch (_) {}
throw Exception(errMsg);
}
await _parseSSE(response.stream, client);
} catch (e) {
_handleError(e);
}
}
/// Multipart
Future<void> _generateMultipart({
required String endpoint,
required Uint8List imageBytes,
required Map<String, String> fields,
}) async {
if (_isGenerating) return;
_reset();
try {
final token = await _getToken();
final request = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.fullBaseUrl}$endpoint'),
);
if (token != null) request.headers['Authorization'] = 'Bearer $token';
request.fields.addAll(fields);
request.files.add(http.MultipartFile.fromBytes(
'image',
imageBytes,
filename: 'reference.jpg',
));
final client = http.Client();
final streamedResponse =
await client.send(request).timeout(const Duration(seconds: 120));
if (streamedResponse.statusCode != 200) {
final errBody = await streamedResponse.stream.bytesToString();
String errMsg = '服务器返回错误 (${streamedResponse.statusCode})';
try {
final json = jsonDecode(errBody) as Map<String, dynamic>;
errMsg = json['message'] as String? ?? errMsg;
} catch (_) {}
throw Exception(errMsg);
}
await _parseSSE(streamedResponse.stream, client);
} catch (e) {
_handleError(e);
}
}
/// SSE
Future<void> _parseSSE(http.ByteStream stream, http.Client client) async {
String buffer = '';
String? imageUrl;
await for (final chunk in stream.transform(utf8.decoder)) {
buffer += chunk;
while (buffer.contains('\n\n')) {
final idx = buffer.indexOf('\n\n');
final line = buffer.substring(0, idx).trim();
buffer = buffer.substring(idx + 2);
if (!line.startsWith('data: ')) continue;
final jsonStr = line.substring(6);
try {
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
final stage = event['stage'] as String? ?? '';
final message = event['message'] as String? ?? '';
switch (stage) {
case 'generating':
_updateProgress(30, '正在生成图片...');
break;
case 'processing':
_updateProgress(60, '正在处理图片...');
break;
case 'done':
imageUrl = event['image_url'] as String?;
_updateProgress(100, '生成完成!');
break;
case 'error':
final errMsg = message.isNotEmpty ? message : '生成失败,请重试';
_isGenerating = false;
_progress = 0;
if (onError != null) {
onError!(errMsg);
} else {
_pendingError = errMsg;
}
client.close();
return;
}
} catch (e) {
debugPrint('Badge SSE parse error: $e');
}
}
}
client.close();
_isGenerating = false;
_progress = 0;
if (imageUrl != null) {
final result = BadgeAiResult(imageUrl: imageUrl);
_pendingResult = result;
onComplete?.call(result);
}
}
void _reset() {
_isGenerating = true;
_progress = 5;
_statusMessage = '正在连接 AI...';
_pendingResult = null;
_pendingError = null;
onProgress?.call(_progress, _statusMessage);
}
void _updateProgress(double p, String msg) {
_progress = p;
_statusMessage = msg;
onProgress?.call(p, msg);
}
void _handleError(Object e) {
debugPrint('Badge AI generation error: $e');
_isGenerating = false;
_progress = 0;
final errMsg = e.toString().replaceFirst('Exception: ', '');
_statusMessage = errMsg;
if (onError != null) {
onError!(errMsg);
} else {
_pendingError = errMsg;
}
}
}
class BadgeAiResult {
final String imageUrl;
const BadgeAiResult({required this.imageUrl});
}

View File

@ -1,361 +0,0 @@
import 'dart:async';
import 'dart:io';
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;
/// MTU = 512 ble_service_config.h
/// requestMtu iOS/Web
static const _defaultMtu = 512;
/// ATT 3 + GATT Handle 2
static const _attOverhead = 3;
/// : (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(' + ');
}
}

View File

@ -1,20 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'badge_image.freezed.dart';
part 'badge_image.g.dart';
@freezed
abstract class BadgeImage with _$BadgeImage {
const factory BadgeImage({
required String imageUrl,
@Default('') String prompt,
String? style,
@Default('t2i') String source, // t2i, i2i, upload
String? referenceImagePath,
@Default(0.7) double strength,
String? createdAt,
}) = _BadgeImage;
factory BadgeImage.fromJson(Map<String, dynamic> json) =>
_$BadgeImageFromJson(json);
}

View File

@ -1,297 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'badge_image.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$BadgeImage {
String get imageUrl; String get prompt; String? get style; String get source;// t2i, i2i, upload
String? get referenceImagePath; double get strength; String? get createdAt;
/// Create a copy of BadgeImage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BadgeImageCopyWith<BadgeImage> get copyWith => _$BadgeImageCopyWithImpl<BadgeImage>(this as BadgeImage, _$identity);
/// Serializes this BadgeImage to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BadgeImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.style, style) || other.style == style)&&(identical(other.source, source) || other.source == source)&&(identical(other.referenceImagePath, referenceImagePath) || other.referenceImagePath == referenceImagePath)&&(identical(other.strength, strength) || other.strength == strength)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,imageUrl,prompt,style,source,referenceImagePath,strength,createdAt);
@override
String toString() {
return 'BadgeImage(imageUrl: $imageUrl, prompt: $prompt, style: $style, source: $source, referenceImagePath: $referenceImagePath, strength: $strength, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class $BadgeImageCopyWith<$Res> {
factory $BadgeImageCopyWith(BadgeImage value, $Res Function(BadgeImage) _then) = _$BadgeImageCopyWithImpl;
@useResult
$Res call({
String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt
});
}
/// @nodoc
class _$BadgeImageCopyWithImpl<$Res>
implements $BadgeImageCopyWith<$Res> {
_$BadgeImageCopyWithImpl(this._self, this._then);
final BadgeImage _self;
final $Res Function(BadgeImage) _then;
/// Create a copy of BadgeImage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? imageUrl = null,Object? prompt = null,Object? style = freezed,Object? source = null,Object? referenceImagePath = freezed,Object? strength = null,Object? createdAt = freezed,}) {
return _then(_self.copyWith(
imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,style: freezed == style ? _self.style : style // ignore: cast_nullable_to_non_nullable
as String?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
as String,referenceImagePath: freezed == referenceImagePath ? _self.referenceImagePath : referenceImagePath // ignore: cast_nullable_to_non_nullable
as String?,strength: null == strength ? _self.strength : strength // ignore: cast_nullable_to_non_nullable
as double,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [BadgeImage].
extension BadgeImagePatterns on BadgeImage {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BadgeImage value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _BadgeImage() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BadgeImage value) $default,){
final _that = this;
switch (_that) {
case _BadgeImage():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BadgeImage value)? $default,){
final _that = this;
switch (_that) {
case _BadgeImage() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _BadgeImage() when $default != null:
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt) $default,) {final _that = this;
switch (_that) {
case _BadgeImage():
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt)? $default,) {final _that = this;
switch (_that) {
case _BadgeImage() when $default != null:
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _BadgeImage implements BadgeImage {
const _BadgeImage({required this.imageUrl, this.prompt = '', this.style, this.source = 't2i', this.referenceImagePath, this.strength = 0.7, this.createdAt});
factory _BadgeImage.fromJson(Map<String, dynamic> json) => _$BadgeImageFromJson(json);
@override final String imageUrl;
@override@JsonKey() final String prompt;
@override final String? style;
@override@JsonKey() final String source;
// t2i, i2i, upload
@override final String? referenceImagePath;
@override@JsonKey() final double strength;
@override final String? createdAt;
/// Create a copy of BadgeImage
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$BadgeImageCopyWith<_BadgeImage> get copyWith => __$BadgeImageCopyWithImpl<_BadgeImage>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$BadgeImageToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BadgeImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.style, style) || other.style == style)&&(identical(other.source, source) || other.source == source)&&(identical(other.referenceImagePath, referenceImagePath) || other.referenceImagePath == referenceImagePath)&&(identical(other.strength, strength) || other.strength == strength)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,imageUrl,prompt,style,source,referenceImagePath,strength,createdAt);
@override
String toString() {
return 'BadgeImage(imageUrl: $imageUrl, prompt: $prompt, style: $style, source: $source, referenceImagePath: $referenceImagePath, strength: $strength, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class _$BadgeImageCopyWith<$Res> implements $BadgeImageCopyWith<$Res> {
factory _$BadgeImageCopyWith(_BadgeImage value, $Res Function(_BadgeImage) _then) = __$BadgeImageCopyWithImpl;
@override @useResult
$Res call({
String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt
});
}
/// @nodoc
class __$BadgeImageCopyWithImpl<$Res>
implements _$BadgeImageCopyWith<$Res> {
__$BadgeImageCopyWithImpl(this._self, this._then);
final _BadgeImage _self;
final $Res Function(_BadgeImage) _then;
/// Create a copy of BadgeImage
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? imageUrl = null,Object? prompt = null,Object? style = freezed,Object? source = null,Object? referenceImagePath = freezed,Object? strength = null,Object? createdAt = freezed,}) {
return _then(_BadgeImage(
imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,style: freezed == style ? _self.style : style // ignore: cast_nullable_to_non_nullable
as String?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
as String,referenceImagePath: freezed == referenceImagePath ? _self.referenceImagePath : referenceImagePath // ignore: cast_nullable_to_non_nullable
as String?,strength: null == strength ? _self.strength : strength // ignore: cast_nullable_to_non_nullable
as double,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@ -1,28 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'badge_image.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_BadgeImage _$BadgeImageFromJson(Map<String, dynamic> json) => _BadgeImage(
imageUrl: json['image_url'] as String,
prompt: json['prompt'] as String? ?? '',
style: json['style'] as String?,
source: json['source'] as String? ?? 't2i',
referenceImagePath: json['reference_image_path'] as String?,
strength: (json['strength'] as num?)?.toDouble() ?? 0.7,
createdAt: json['created_at'] as String?,
);
Map<String, dynamic> _$BadgeImageToJson(_BadgeImage instance) =>
<String, dynamic>{
'image_url': instance.imageUrl,
'prompt': instance.prompt,
'style': instance.style,
'source': instance.source,
'reference_image_path': instance.referenceImagePath,
'strength': instance.strength,
'created_at': instance.createdAt,
};

View File

@ -1,22 +0,0 @@
class BadgeStyle {
final String id;
final String name;
final String icon;
const BadgeStyle({
required this.id,
required this.name,
required this.icon,
});
}
const kBadgeStyles = [
BadgeStyle(id: 'anime', name: '动漫风', icon: '🎨'),
BadgeStyle(id: 'realistic', name: '写实风', icon: '📷'),
BadgeStyle(id: 'pixel', name: '像素风', icon: '👾'),
BadgeStyle(id: 'watercolor', name: '水彩风', icon: '🖌️'),
BadgeStyle(id: 'cyberpunk', name: '赛博朋克', icon: '🌆'),
BadgeStyle(id: 'cute', name: '可爱风', icon: '🧸'),
BadgeStyle(id: 'ink', name: '水墨风', icon: '🏔️'),
BadgeStyle(id: 'comic', name: '漫画风', icon: '💥'),
];

View File

@ -1,24 +0,0 @@
import 'dart:typed_data';
import 'package:fpdart/fpdart.dart';
import '../../../../core/errors/failures.dart';
import '../entities/badge_image.dart';
abstract class BadgeRepository {
/// AI
Future<Either<Failure, BadgeImage>> generateText2Image({
required String prompt,
String? style,
});
/// AI
Future<Either<Failure, BadgeImage>> generateImage2Image({
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
});
/// URL BLE
Future<Either<Failure, Uint8List>> downloadImageBytes(String imageUrl);
}

View File

@ -1,74 +0,0 @@
import 'dart:typed_data';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/repositories/badge_repository_impl.dart';
import '../../data/services/badge_ai_generation_service.dart';
import '../../domain/entities/badge_image.dart';
part 'badge_ai_controller.g.dart';
@riverpod
class BadgeAiController extends _$BadgeAiController {
@override
AsyncValue<BadgeImage?> build() => const AsyncData(null);
///
Future<bool> generateText2Image({
required String prompt,
String? style,
}) async {
state = const AsyncLoading();
final repo = BadgeRepositoryImpl();
final result = await repo.generateText2Image(
prompt: prompt,
style: style,
);
if (!ref.mounted) return false;
return result.fold(
(failure) {
state = AsyncError(failure.message, StackTrace.current);
return false;
},
(image) {
state = AsyncData(image);
return true;
},
);
}
///
Future<bool> generateImage2Image({
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
}) async {
state = const AsyncLoading();
final repo = BadgeRepositoryImpl();
final result = await repo.generateImage2Image(
imageBytes: imageBytes,
prompt: prompt,
style: style,
strength: strength,
);
if (!ref.mounted) return false;
return result.fold(
(failure) {
state = AsyncError(failure.message, StackTrace.current);
return false;
},
(image) {
state = AsyncData(image);
return true;
},
);
}
/// AI
double get progress => BadgeAiGenerationService.instance.progress;
String get statusMessage => BadgeAiGenerationService.instance.statusMessage;
bool get isGenerating => BadgeAiGenerationService.instance.isGenerating;
void clear() {
state = const AsyncData(null);
}
}

View File

@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'badge_ai_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BadgeAiController)
const badgeAiControllerProvider = BadgeAiControllerProvider._();
final class BadgeAiControllerProvider
extends $NotifierProvider<BadgeAiController, AsyncValue<BadgeImage?>> {
const BadgeAiControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'badgeAiControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$badgeAiControllerHash();
@$internal
@override
BadgeAiController create() => BadgeAiController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<BadgeImage?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<BadgeImage?>>(value),
);
}
}
String _$badgeAiControllerHash() => r'b270d32d4d80d40c3eddb5e610682aace3f709f2';
abstract class _$BadgeAiController extends $Notifier<AsyncValue<BadgeImage?>> {
AsyncValue<BadgeImage?> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<BadgeImage?>, AsyncValue<BadgeImage?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BadgeImage?>, AsyncValue<BadgeImage?>>,
AsyncValue<BadgeImage?>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@ -1,103 +0,0 @@
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 }
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,
});
BadgeTransferState copyWith({
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,
);
}
}
@riverpod
class BadgeTransferController extends _$BadgeTransferController {
final _transferService = BadgeBleTransferService();
@override
BadgeTransferState build() {
ref.onDispose(() {
FlutterBluePlus.stopScan();
});
return const BadgeTransferState();
}
///
Future<void> connectAndTransfer(
BluetoothDevice device,
String imageUrl, {
Uint8List? imageBytes,
}) 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,
);
},
);
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: ', ''),
);
}
}
}

View File

@ -1,64 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'badge_transfer_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BadgeTransferController)
const badgeTransferControllerProvider = BadgeTransferControllerProvider._();
final class BadgeTransferControllerProvider
extends $NotifierProvider<BadgeTransferController, BadgeTransferState> {
const BadgeTransferControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'badgeTransferControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$badgeTransferControllerHash();
@$internal
@override
BadgeTransferController create() => BadgeTransferController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(BadgeTransferState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<BadgeTransferState>(value),
);
}
}
String _$badgeTransferControllerHash() =>
r'5f06a9bcf5c9fcf0caf428497d2b9901b1b7f626';
abstract class _$BadgeTransferController extends $Notifier<BadgeTransferState> {
BadgeTransferState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<BadgeTransferState, BadgeTransferState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<BadgeTransferState, BadgeTransferState>,
BadgeTransferState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

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

@ -1,507 +0,0 @@
import 'dart:typed_data';
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';
import '../widgets/badge_ai_tab.dart';
import '../widgets/badge_upload_tab.dart';
class BadgeHomePage extends ConsumerStatefulWidget {
const BadgeHomePage({super.key});
@override
ConsumerState<BadgeHomePage> createState() => _BadgeHomePageState();
}
class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _aiTabKey = GlobalKey<BadgeAiTabState>();
//
String? _uploadedImagePath;
Uint8List? _uploadedImageBytes;
// AI
bool _isGenerating = false;
double _genProgress = 0;
String _genStatus = '';
String? _generatedImageUrl;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) setState(() {});
});
// AI
final pending = BadgeAiGenerationService.instance.consumePendingResult();
if (pending != null) {
_generatedImageUrl = pending.imageUrl;
}
// AI
final svc = BadgeAiGenerationService.instance;
svc.onProgress = (progress, message) {
if (mounted) {
setState(() {
_genProgress = progress;
_genStatus = message;
});
}
};
svc.onComplete = (result) {
if (mounted) {
setState(() {
_isGenerating = false;
_generatedImageUrl = result.imageUrl;
});
_showResultDialog(result.imageUrl);
}
};
svc.onError = (error) {
if (mounted) {
setState(() => _isGenerating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
}
};
}
@override
void dispose() {
_tabController.dispose();
final svc = BadgeAiGenerationService.instance;
svc.onProgress = null;
svc.onComplete = null;
svc.onError = null;
super.dispose();
}
void _showResultDialog(String imageUrl) {
final theme = ref.read(currentProductThemeProvider);
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'生成完成',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 1,
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFF3F4F6),
child: const Center(
child: Icon(Icons.broken_image,
size: 48, color: Color(0xFF9CA3AF)),
),
),
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
onPressed: () {
Navigator.of(ctx).pop();
_handleRetry();
},
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Colors.black.withOpacity(0.08),
width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
child: const Text(
'换一张',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7280),
),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: theme.buttonGradient,
borderRadius: BorderRadius.circular(14),
),
child: ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_handleUseImage();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: 0,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
child: const Text(
'使用此图',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
),
),
),
),
),
],
),
],
),
),
),
);
}
void _handleAiGenerate({
required String prompt,
String? style,
Uint8List? imageBytes,
double strength = 0.7,
}) {
setState(() {
_isGenerating = true;
_generatedImageUrl = null;
_genProgress = 0;
_genStatus = '正在连接 AI...';
});
if (imageBytes != null) {
ref.read(badgeAiControllerProvider.notifier).generateImage2Image(
imageBytes: imageBytes,
prompt: prompt,
style: style,
strength: strength,
);
} else {
ref.read(badgeAiControllerProvider.notifier).generateText2Image(
prompt: prompt,
style: style,
);
}
}
void _handleUploadSelected(String path, Uint8List? bytes) {
setState(() {
_uploadedImagePath = path;
_uploadedImageBytes = bytes;
});
}
void _handleRetry() {
setState(() {
_generatedImageUrl = null;
});
}
void _handleUseImage() {
final imageSource = _generatedImageUrl ?? _uploadedImagePath;
if (imageSource == null) return;
context.push('/badge/transfer', extra: {
'imageUrl': imageSource,
if (_uploadedImageBytes != null && _generatedImageUrl == null)
'imageBytes': _uploadedImageBytes,
});
}
@override
Widget build(BuildContext context) {
final productTheme = ref.watch(currentProductThemeProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
const AnimatedGradientBackground(),
SafeArea(
child: Column(
children: [
_buildHeader(),
_buildTabBar(productTheme),
Expanded(child: _buildTabContent(productTheme)),
],
),
),
if (_isGenerating) _buildGeneratingOverlay(productTheme),
_buildFixedBottomBar(productTheme),
],
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
GestureDetector(
onTap: () => context.pop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: const Icon(Icons.arrow_back_ios_new,
size: 18, color: Color(0xFF1F2937)),
),
),
const Expanded(
child: Center(
child: Text(
'电子吧唧传图',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
),
),
const SizedBox(width: 40),
],
),
);
}
Widget _buildTabBar(ProductThemeData productTheme) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.6)),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: productTheme.accentColor.withOpacity(0.15),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
labelColor: productTheme.accentColor,
unselectedLabelColor: const Color(0xFF6B7280),
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
tabs: const [
Tab(text: 'AI 生图'),
Tab(text: '上传图片'),
],
),
);
}
Widget _buildTabContent(ProductThemeData productTheme) {
return TabBarView(
controller: _tabController,
children: [
BadgeAiTab(
key: _aiTabKey,
onGenerate: _handleAiGenerate,
isGenerating: _isGenerating,
accentColor: productTheme.accentColor,
),
BadgeUploadTab(
onImageSelected: _handleUploadSelected,
accentColor: productTheme.accentColor,
),
],
);
}
Widget _buildGeneratingOverlay(ProductThemeData productTheme) {
return Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 40),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
color: productTheme.accentColor,
strokeWidth: 3,
),
),
const SizedBox(height: 16),
Text(
_genStatus,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: productTheme.accentColor,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: _genProgress / 100,
backgroundColor: productTheme.accentColor.withOpacity(0.15),
valueColor: AlwaysStoppedAnimation(productTheme.accentColor),
minHeight: 4,
),
),
],
),
),
),
),
);
}
///
Widget _buildFixedBottomBar(ProductThemeData productTheme) {
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,
);
},
);
} else if (isUploadTab) {
if (_uploadedImagePath != null) {
buttonContent = _buildGradientButton('使用此图', _handleUseImage);
} else {
buttonContent = _buildGradientButton('使用此图', () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先上传一张图片'),
backgroundColor: Colors.orange,
),
);
});
}
}
if (buttonContent == null) return const SizedBox.shrink();
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: EdgeInsets.fromLTRB(
20, 16, 20, MediaQuery.of(context).padding.bottom + 16),
child: buttonContent,
),
);
}
Widget _buildGradientButton(String label, VoidCallback? onPressed) {
final theme = ref.read(currentProductThemeProvider);
return SizedBox(
height: 52,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: theme.buttonGradient,
borderRadius: BorderRadius.circular(16),
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: 0,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text(
label,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
);
}
}

View File

@ -1,973 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
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 '../../../../features/device/data/datasources/device_remote_data_source.dart';
import '../../../../theme/product_theme.dart';
import '../../../../widgets/animated_gradient_background.dart';
import '../controllers/badge_transfer_controller.dart';
import '../widgets/badge_ble_device_card.dart';
/// Airhub
class _ResolvedDevice {
final String mac;
final String displayName;
final BluetoothDevice bleDevice;
final int rssi;
final bool isBound; //
const _ResolvedDevice({
required this.mac,
required this.displayName,
required this.bleDevice,
required this.rssi,
this.isBound = false,
});
_ResolvedDevice copyWith({bool? isBound}) => _ResolvedDevice(
mac: mac,
displayName: displayName,
bleDevice: bleDevice,
rssi: rssi,
isBound: isBound ?? this.isBound,
);
}
class BadgeTransferPage extends ConsumerStatefulWidget {
final String imageUrl;
final Uint8List? imageBytes;
const BadgeTransferPage({super.key, required this.imageUrl, this.imageBytes});
@override
ConsumerState<BadgeTransferPage> createState() => _BadgeTransferPageState();
}
class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
static const _airhubPrefix = 'Airhub_';
/// MAC "D0CF1303BBF2"
static String _normalizeMac(String mac) =>
mac.replaceAll(RegExp(r'[:\-\.]'), '').toUpperCase();
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothAdapterState>? _adapterSubscription;
///
final List<_ResolvedDevice> _devices = [];
/// MAC API
final Map<String, Map<String, dynamic>> _macInfoCache = {};
/// BLE
final Map<String, ScanResult> _pendingBleResults = {};
/// MAC
Set<String> _boundMacs = {};
/// MAC product_code
Map<String, String> _boundMacProductCodes = {};
_ResolvedDevice? _selectedDevice;
bool _isScanning = false;
bool _isAutoConnecting = false;
@override
void initState() {
super.initState();
_adapterSubscription =
FlutterBluePlus.adapterState.listen((adapterState) {
if (!mounted) return;
});
_loadBoundDevicesAndScan();
}
@override
void dispose() {
_scanSubscription?.cancel();
_adapterSubscription?.cancel();
FlutterBluePlus.stopScan();
super.dispose();
}
/// product_code
String get _currentProductCode {
final productType = ref.read(currentProductTypeProvider);
switch (productType) {
case ProductType.badgeAi:
return 'DZBJ-ON';
case ProductType.badgeBasic:
return 'DZBJ-OFF';
default:
return '';
}
}
///
Future<void> _loadBoundDevicesAndScan() async {
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
final devices = await dataSource.getMyDevices();
_boundMacs = {};
_boundMacProductCodes = {};
for (final d in devices) {
final mac = _normalizeMac(d.device.macAddress ?? '');
if (mac.isEmpty) continue;
_boundMacs.add(mac);
// deviceType deviceTypeInfo product_code
final dt = d.device.deviceType ?? d.device.deviceTypeInfo;
if (dt != null) {
_boundMacProductCodes[mac] = dt.productCode;
}
}
debugPrint('[Badge BLE] 已绑定设备 MAC: $_boundMacs');
debugPrint('[Badge BLE] MAC→产品码: $_boundMacProductCodes');
} catch (e) {
debugPrint('[Badge BLE] 获取绑定设备失败: $e');
}
if (mounted) _startScan();
}
/// MAC
String? _extractMacFromName(String bleName) {
if (!bleName.startsWith(_airhubPrefix)) return null;
final rawMac = bleName.substring(_airhubPrefix.length).trim();
if (rawMac.isEmpty) return null;
final hex = rawMac.replaceAll(RegExp(r'[:\-]'), '').toUpperCase();
if (hex.length != 12 || !RegExp(r'^[0-9A-F]{12}$').hasMatch(hex)) {
return null;
}
return '${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}:'
'${hex.substring(6, 8)}:${hex.substring(8, 10)}:${hex.substring(10, 12)}';
}
void _startScan() {
setState(() {
_isScanning = true;
_devices.clear();
_selectedDevice = null;
_isAutoConnecting = false;
});
_macInfoCache.clear();
_pendingBleResults.clear();
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.onScanResults.listen((results) {
if (!mounted) return;
for (final r in results) {
final name = r.device.platformName;
final mac = _extractMacFromName(name);
if (mac != null) {
_pendingBleResults[mac] = r;
if (!_macInfoCache.containsKey(mac)) {
_macInfoCache[mac] = {};
_queryAndAddDevice(mac);
}
} else if (name.isNotEmpty || kIsWeb) {
// Web fallback
final key = r.device.remoteId.str;
if (!_pendingBleResults.containsKey(key)) {
_pendingBleResults[key] = r;
_addDeviceIfBound(
mac: key,
displayName: name.isNotEmpty ? name : 'Airhub 设备',
scanResult: r,
isBound: true, // Web MAC
);
}
}
}
});
final serviceGuid = Guid('00000b00-0000-1000-8000-00805f9b34fb');
FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidUsesFineLocation: true,
withServices: [serviceGuid],
webOptionalServices: [serviceGuid],
).catchError((e) {
debugPrint('[Badge BLE] startScan 异常: $e');
});
Future.delayed(const Duration(seconds: 30), () {
if (mounted && _isScanning) {
setState(() => _isScanning = false);
}
});
}
/// MAC
Future<void> _queryAndAddDevice(String mac) async {
String displayName = 'Airhub 设备';
String productCode = '';
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
final data = await dataSource.queryByMac(mac);
_macInfoCache[mac] = data;
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
productCode = data['device_type']?['product_code'] as String? ?? '';
if (deviceTypeName.isNotEmpty) {
displayName = deviceTypeName;
}
} catch (e) {
debugPrint('[Badge BLE] queryByMac 失败($mac): $e');
}
if (!mounted) return;
final scanResult = _pendingBleResults[mac];
if (scanResult == null) return;
final normalizedMac = _normalizeMac(mac);
//
final isBound = _boundMacs.contains(normalizedMac);
if (!isBound) {
debugPrint('[Badge BLE] 设备 $mac 未绑定,不显示');
return;
}
//
// queryByMac product_codefallback getMyDevices
final code = productCode.isNotEmpty
? productCode
: (_boundMacProductCodes[normalizedMac] ?? '');
final requiredCode = _currentProductCode;
if (requiredCode.isNotEmpty && code.isNotEmpty && code != requiredCode) {
debugPrint('[Badge BLE] 设备 $mac ($code) 不匹配当前产品 ($requiredCode),不显示');
return;
}
_addDeviceIfBound(
mac: mac,
displayName: displayName,
scanResult: scanResult,
isBound: true,
);
}
///
void _addDeviceIfBound({
required String mac,
required String displayName,
required ScanResult scanResult,
required bool isBound,
}) {
if (!mounted) return;
setState(() {
if (!_devices.any((d) => d.mac == mac)) {
final device = _ResolvedDevice(
mac: mac,
displayName: displayName,
bleDevice: scanResult.device,
rssi: scanResult.rssi,
isBound: isBound,
);
_devices.add(device);
//
if (isBound && _selectedDevice == null && !_isAutoConnecting) {
_selectedDevice = device;
_isAutoConnecting = true;
_isScanning = false;
//
FlutterBluePlus.stopScan().catchError((_) {});
}
}
});
}
/// //
bool _isActiveTransfer(TransferStatus status) =>
status == TransferStatus.connecting ||
status == TransferStatus.transferring ||
status == TransferStatus.done ||
status == TransferStatus.error;
@override
Widget build(BuildContext context) {
final transferState = ref.watch(badgeTransferControllerProvider);
final productTheme = ref.watch(currentProductThemeProvider);
final isActive = _isActiveTransfer(transferState.status);
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
const AnimatedGradientBackground(),
SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(
child: isActive
// // +
? _buildHeroLayout(transferState, productTheme)
// +
: _buildSelectionLayout(transferState, productTheme),
),
],
),
),
_buildBottomBar(transferState, productTheme),
],
),
);
}
///
Widget _buildHeroLayout(BadgeTransferState transferState, ProductThemeData productTheme) {
return Column(
children: [
const Spacer(flex: 2),
// +
_buildHeroPreview(transferState, productTheme),
const SizedBox(height: 24),
//
_buildStatusInfo(transferState, productTheme),
const Spacer(flex: 3),
],
);
}
/// +
Widget _buildSelectionLayout(BadgeTransferState transferState, ProductThemeData productTheme) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 100),
child: Column(
children: [
_buildSmallPreview(productTheme),
const SizedBox(height: 20),
_buildDeviceList(transferState, productTheme),
],
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_PressableButton(
onTap: () => context.pop(),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: const Icon(Icons.arrow_back_ios_new,
size: 18, color: Color(0xFF1F2937)),
),
),
const Expanded(
child: Center(
child: Text(
'传输徽章',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
),
),
const SizedBox(width: 44),
],
),
);
}
/// + /
Widget _buildHeroPreview(BadgeTransferState transferState, ProductThemeData productTheme) {
const double size = 220;
final isDone = transferState.status == TransferStatus.done;
final isError = transferState.status == TransferStatus.error;
final isTransferring = transferState.status == TransferStatus.connecting ||
transferState.status == TransferStatus.transferring;
return SizedBox(
width: size + 28,
height: size + 28,
child: Stack(
alignment: Alignment.center,
children: [
// /
if (isTransferring)
SizedBox(
width: size + 20,
height: size + 20,
child: CircularProgressIndicator(
value: transferState.progress > 0 ? transferState.progress : null,
strokeWidth: 4,
color: productTheme.accentColor,
backgroundColor: productTheme.accentColor.withOpacity(0.1),
),
)
else
// //
Container(
width: size + 20,
height: size + 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isDone
? const Color(0xFF10B981).withOpacity(0.4)
: isError
? const Color(0xFFEF4444).withOpacity(0.3)
: productTheme.accentColor.withOpacity(0.2),
width: 3,
),
),
),
//
Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF1F2937),
boxShadow: [
BoxShadow(
color: isDone
? const Color(0xFF10B981).withOpacity(0.2)
: isError
? const Color(0xFFEF4444).withOpacity(0.15)
: productTheme.accentColor.withOpacity(0.2),
blurRadius: 30,
spreadRadius: 4,
),
],
),
padding: const EdgeInsets.all(6),
child: ClipOval(child: _buildPreviewImage()),
),
// 绿
if (isDone)
Positioned(
right: 12,
bottom: 12,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
curve: Curves.elasticOut,
builder: (context, value, child) =>
Transform.scale(scale: value, child: child),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF10B981),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: const Color(0xFF10B981).withOpacity(0.3),
blurRadius: 12,
),
],
),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 24),
),
),
),
//
if (isError)
Positioned(
right: 12,
bottom: 12,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
builder: (context, value, child) =>
Transform.scale(scale: value, child: child),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withOpacity(0.3),
blurRadius: 12,
),
],
),
child: const Icon(Icons.close_rounded, color: Colors.white, size: 24),
),
),
),
],
),
);
}
///
Widget _buildStatusInfo(BadgeTransferState transferState, ProductThemeData productTheme) {
switch (transferState.status) {
case TransferStatus.connecting:
case TransferStatus.transferring:
final pct = (transferState.progress * 100).toInt();
return Column(
children: [
Text(
transferState.statusMessage ?? '正在传输...',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
if (pct > 0) ...[
const SizedBox(height: 4),
Text(
'$pct%',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: productTheme.accentColor,
),
),
],
const SizedBox(height: 6),
const Text(
'请保持设备靠近,不要关闭蓝牙',
style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
),
],
);
case TransferStatus.done:
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
builder: (context, value, child) =>
Opacity(opacity: value, child: child),
child: const Column(
children: [
Text(
'传输完成!',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Color(0xFF10B981),
),
),
SizedBox(height: 4),
Text(
'图片已成功传输到徽章',
style: TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)),
),
],
),
);
case TransferStatus.error:
return Column(
children: [
const Text(
'传输失败',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFFEF4444),
),
),
const SizedBox(height: 4),
Text(
transferState.errorMessage ?? '未知错误',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
),
],
);
default:
return const SizedBox.shrink();
}
}
///
Widget _buildSmallPreview(ProductThemeData productTheme) {
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF1F2937),
boxShadow: [
BoxShadow(
color: productTheme.accentColor.withOpacity(0.12),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(5),
child: ClipOval(child: _buildPreviewImage()),
),
const SizedBox(height: 8),
const Text(
'实际显示效果',
style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
),
],
);
}
/// imageUrl
Widget _buildPreviewImage() {
// 使
if (widget.imageBytes != null) {
return Image.memory(
widget.imageBytes!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildImagePlaceholder(),
);
}
final url = widget.imageUrl;
if (url.startsWith('http')) {
return Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildImagePlaceholder(),
);
}
//
if (!kIsWeb) {
final file = File(url);
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildImagePlaceholder(),
);
}
return _buildImagePlaceholder();
}
Widget _buildImagePlaceholder() {
return const Icon(
Icons.image,
color: Colors.white54,
size: 36,
);
}
///
Widget _buildDeviceList(BadgeTransferState transferState, ProductThemeData productTheme) {
final accent = productTheme.accentColor;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bluetooth_searching,
size: 20, color: accent),
const SizedBox(width: 8),
const Expanded(
child: Text(
'选择设备',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF374151),
),
),
),
GestureDetector(
onTap: _isScanning ? null : _loadBoundDevicesAndScan,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: accent.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_isScanning ? '扫描中...' : '重新扫描',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _isScanning
? const Color(0xFF9CA3AF)
: accent,
),
),
),
),
],
),
const SizedBox(height: 16),
if (_devices.isEmpty)
Container(
padding: const EdgeInsets.all(32),
alignment: Alignment.center,
child: Column(
children: [
if (_isScanning) ...[
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
color: accent,
strokeWidth: 2.5,
),
),
const SizedBox(height: 12),
const Text(
'正在搜索已绑定的设备...',
style: TextStyle(
fontSize: 13,
color: Color(0xFF9CA3AF),
),
),
] else ...[
const Icon(Icons.bluetooth_disabled,
size: 32, color: Color(0xFF9CA3AF)),
const SizedBox(height: 12),
const Text(
'未找到已绑定的设备\n请确认设备已开机并绑定到当前账号',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Color(0xFF9CA3AF),
),
),
],
],
),
)
else
...List.generate(_devices.length, (index) {
final device = _devices[index];
final isSelected = _selectedDevice?.mac == device.mac;
return BadgeBleDeviceCard(
displayName: device.displayName,
rssi: device.rssi,
selected: isSelected,
accentColor: productTheme.accentColorLight,
onTap: () {
setState(() {
_selectedDevice = device;
});
},
);
}),
],
),
);
}
///
Widget _buildBottomBar(BadgeTransferState transferState, ProductThemeData productTheme) {
final Widget buttonContent;
switch (transferState.status) {
case TransferStatus.idle:
case TransferStatus.scanning:
if (_selectedDevice != null) {
buttonContent = _buildGradientButton('开始传输', () {
ref
.read(badgeTransferControllerProvider.notifier)
.connectAndTransfer(
_selectedDevice!.bleDevice,
widget.imageUrl,
imageBytes: widget.imageBytes,
);
});
} else {
buttonContent = _buildDisabledButton(
_isScanning ? '正在搜索设备...' : '请先选择设备',
);
}
case TransferStatus.connecting:
buttonContent = _buildDisabledButton('连接中...');
case TransferStatus.transferring:
buttonContent = _buildDisabledButton('传输中...');
case TransferStatus.done:
buttonContent = _buildGradientButton('完成', () => context.pop());
case TransferStatus.error:
buttonContent = Row(
children: [
Expanded(
child: SizedBox(
height: 52,
child: OutlinedButton(
onPressed: () => context.pop(),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Colors.black.withOpacity(0.08), width: 1.5),
backgroundColor: Colors.white.withOpacity(0.8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
child: const Text(
'返回',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7280),
),
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: _buildGradientButton('重试', () {
if (_selectedDevice != null) {
ref
.read(badgeTransferControllerProvider.notifier)
.connectAndTransfer(
_selectedDevice!.bleDevice,
widget.imageUrl,
);
}
}),
),
],
);
}
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: EdgeInsets.fromLTRB(
20, 20, 20, MediaQuery.of(context).padding.bottom + 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withOpacity(0.0),
Colors.white.withOpacity(0.6),
Colors.white.withOpacity(0.9),
],
stops: const [0.0, 0.3, 1.0],
),
),
child: buttonContent,
),
);
}
Widget _buildGradientButton(String label, VoidCallback onPressed) {
final theme = ref.read(currentProductThemeProvider);
return SizedBox(
height: 52,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: theme.buttonGradient,
borderRadius: BorderRadius.circular(16),
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: 0,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
),
child: Text(
label,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
);
}
Widget _buildDisabledButton(String label) {
return SizedBox(
height: 52,
width: double.infinity,
child: ElevatedButton(
onPressed: null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE5E7EB),
disabledBackgroundColor: const Color(0xFFE5E7EB),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
),
child: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF9CA3AF),
),
),
),
);
}
}
/// scale 0.95 + haptic
class _PressableButton extends StatefulWidget {
final VoidCallback onTap;
final Widget child;
const _PressableButton({required this.onTap, required this.child});
@override
State<_PressableButton> createState() => _PressableButtonState();
}
class _PressableButtonState extends State<_PressableButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: widget.onTap,
child: AnimatedScale(
scale: _pressed ? 0.93 : 1.0,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
child: widget.child,
),
);
}
}

View File

@ -1,357 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../domain/entities/badge_style.dart';
import 'badge_style_chip.dart';
class BadgeAiTab extends StatefulWidget {
final void Function({
required String prompt,
String? style,
Uint8List? imageBytes,
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
State<BadgeAiTab> createState() => BadgeAiTabState();
}
class BadgeAiTabState extends State<BadgeAiTab> {
bool _isI2I = false;
String? _selectedStyle;
String? _referenceImagePath;
Uint8List? _referenceImageBytes;
double _strength = 0.7;
final _promptController = TextEditingController();
String get currentPrompt => _promptController.text.trim();
String? get selectedStyle => _selectedStyle;
String? get referenceImagePath => _isI2I ? _referenceImagePath : null;
Uint8List? get referenceImageBytes => _isI2I ? _referenceImageBytes : null;
double get strength => _strength;
@override
void dispose() {
_promptController.dispose();
super.dispose();
}
Future<void> _pickReferenceImage() async {
final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
final bytes = await file.readAsBytes();
setState(() {
_referenceImagePath = file.path;
_referenceImageBytes = bytes;
});
}
}
void _removeReferenceImage() {
setState(() {
_referenceImagePath = null;
_referenceImageBytes = null;
});
}
@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),
const SizedBox(height: 16),
//
if (_isI2I) ...[
_buildReferenceImageSection(accent),
const SizedBox(height: 16),
_buildStrengthSlider(accent),
const SizedBox(height: 16),
],
//
_buildPromptInput(accent),
const SizedBox(height: 20),
//
Row(
children: [
Icon(Icons.layers_outlined, size: 16, color: const Color(0xFF6B7280)),
const SizedBox(width: 6),
const Text(
'选择风格',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7280),
),
),
],
),
const SizedBox(height: 12),
_buildStyleGrid(accent),
],
),
);
}
Widget _buildModeToggle(Color accent) {
return Row(
children: [
_buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false), accent),
const SizedBox(width: 8),
_buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true), accent),
],
);
}
Widget _buildModeBtn(String label, bool active, VoidCallback onTap, Color accent) {
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,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: active
? accent
: accent.withOpacity(0.2),
width: 1.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: active ? Colors.white : const Color(0xFF6B7280),
),
),
),
);
}
Widget _buildReferenceImageSection(Color accent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.image_outlined, size: 16, color: const Color(0xFF6B7280)),
const SizedBox(width: 4),
const Text(
'参考图片',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
),
],
),
const SizedBox(height: 10),
if (_referenceImageBytes != null)
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: double.infinity,
height: 200,
child: Image.memory(_referenceImageBytes!, fit: BoxFit.cover),
),
),
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: _removeReferenceImage,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 16),
),
),
),
],
)
else
GestureDetector(
onTap: _pickReferenceImage,
child: Container(
width: double.infinity,
height: 180,
decoration: BoxDecoration(
border: Border.all(
color: accent.withOpacity(0.25),
width: 2,
),
borderRadius: BorderRadius.circular(16),
color: Colors.white.withOpacity(0.4),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, size: 36, color: accent.withOpacity(0.6)),
const SizedBox(height: 8),
const Text('点击上传参考图', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
const SizedBox(height: 4),
const Text('支持 JPG / PNG', style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF))),
],
),
),
),
],
),
);
}
Widget _buildStrengthSlider(Color accent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'参考强度',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
),
Text(
(_strength).toStringAsFixed(1),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: accent),
),
],
),
const SizedBox(height: 8),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: accent,
inactiveTrackColor: accent.withOpacity(0.15),
thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11),
overlayColor: accent.withOpacity(0.1),
trackHeight: 6,
),
child: Slider(
value: _strength,
min: 0.1,
max: 1.0,
divisions: 9,
onChanged: (v) => setState(() => _strength = v),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text('更自由', style: TextStyle(fontSize: 11, color: Color(0xFF9CA3AF))),
Text('更相似', style: TextStyle(fontSize: 11, color: Color(0xFF9CA3AF))),
],
),
],
),
);
}
Widget _buildPromptInput(Color accent) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isI2I ? '描述你希望的变化(可选)' : '描述你想要的图片',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
),
const SizedBox(height: 10),
TextField(
controller: _promptController,
maxLines: 3,
maxLength: 200,
decoration: InputDecoration(
hintText: '例如:一只穿着太空服的卡皮巴拉,星空背景,可爱动漫风格',
hintStyle: const TextStyle(color: Color(0xFF9CA3AF), fontSize: 15),
filled: true,
fillColor: Colors.white.withOpacity(0.8),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.black.withOpacity(0.06)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.black.withOpacity(0.06), width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: accent),
),
contentPadding: const EdgeInsets.all(14),
),
),
],
),
);
}
Widget _buildStyleGrid(Color accent) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.85,
),
itemCount: kBadgeStyles.length,
itemBuilder: (context, index) {
final style = kBadgeStyles[index];
return BadgeStyleChip(
style: style,
selected: _selectedStyle == style.id,
accentColor: accent,
onTap: () {
setState(() {
_selectedStyle = _selectedStyle == style.id ? null : style.id;
});
},
);
},
);
}
}

View File

@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
class BadgeBleDeviceCard extends StatelessWidget {
final String displayName;
final int rssi;
final bool selected;
final VoidCallback onTap;
final Color accentColor;
const BadgeBleDeviceCard({
super.key,
required this.displayName,
required this.rssi,
required this.selected,
required this.onTap,
this.accentColor = const Color(0xFF8B5CF6),
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: selected
? accentColor.withOpacity(0.08)
: Colors.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? accentColor
: Colors.black.withOpacity(0.06),
width: selected ? 2 : 1,
),
),
child: Row(
children: [
Icon(
Icons.bluetooth,
color: selected
? accentColor
: const Color(0xFF9CA3AF),
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: selected
? accentColor
: const Color(0xFF1F2937),
),
),
Text(
'信号: $rssi dBm',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF9CA3AF),
),
),
],
),
),
if (selected)
Icon(Icons.check_circle, color: accentColor, size: 22),
],
),
),
);
}
}

View File

@ -1,56 +0,0 @@
import 'package:flutter/material.dart';
import '../../domain/entities/badge_style.dart';
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
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: selected
? accentColor.withOpacity(0.1)
: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? accentColor
: Colors.white.withOpacity(0.4),
width: selected ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(style.icon, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 4),
Text(
style.name,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? accentColor
: const Color(0xFF6B7280),
),
),
],
),
),
);
}
}

View File

@ -1,359 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
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
State<BadgeUploadTab> createState() => _BadgeUploadTabState();
}
class _BadgeUploadTabState extends State<BadgeUploadTab> {
String? _selectedImagePath;
Uint8List? _selectedImageBytes;
String? _selectedNetworkUrl;
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final file = await picker.pickImage(source: source);
if (file != null) {
final bytes = await file.readAsBytes();
setState(() {
_selectedImagePath = file.path;
_selectedImageBytes = bytes;
_selectedNetworkUrl = null;
});
widget.onImageSelected(file.path, bytes);
}
}
void _selectAiImage(String imageUrl) {
setState(() {
_selectedNetworkUrl = imageUrl;
_selectedImagePath = imageUrl;
_selectedImageBytes = null;
});
widget.onImageSelected(imageUrl, null);
}
void _removeImage() {
setState(() {
_selectedImagePath = null;
_selectedImageBytes = null;
_selectedNetworkUrl = null;
});
}
bool get _hasImage => _selectedImageBytes != null || _selectedNetworkUrl != null;
void _showAiHistoryPicker() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _AiHistoryBottomSheet(
onSelect: (url) {
Navigator.of(context).pop();
_selectAiImage(url);
},
accentColor: widget.accentColor,
),
);
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 100),
child: Column(
children: [
if (_hasImage) ...[
Stack(
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: AspectRatio(
aspectRatio: 1,
child: _selectedImageBytes != null
? Image.memory(_selectedImageBytes!, fit: BoxFit.cover)
: Image.network(
_selectedNetworkUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(
child: Icon(Icons.broken_image, size: 48, color: Color(0xFF9CA3AF)),
),
),
),
),
),
Positioned(
top: 12,
right: 12,
child: GestureDetector(
onTap: _removeImage,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 18),
),
),
),
],
),
const SizedBox(height: 16),
],
if (!_hasImage) ...[
Row(
children: [
Expanded(
child: _buildPickerCard(
icon: Icons.photo_library_outlined,
label: '从相册选择',
onTap: () => _pickImage(ImageSource.gallery),
),
),
const SizedBox(width: 14),
Expanded(
child: _buildPickerCard(
icon: Icons.camera_alt_outlined,
label: '拍照',
onTap: () => _pickImage(ImageSource.camera),
),
),
],
),
const SizedBox(height: 14),
_buildPickerCard(
icon: Icons.auto_awesome_outlined,
label: 'AI 生成的图片',
onTap: _showAiHistoryPicker,
aspectRatio: 2.5,
),
],
],
),
);
}
Widget _buildPickerCard({
required IconData icon,
required String label,
required VoidCallback onTap,
double aspectRatio = 1,
}) {
return GestureDetector(
onTap: onTap,
child: AspectRatio(
aspectRatio: aspectRatio,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.4)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: widget.accentColor, size: 48),
const SizedBox(height: 12),
Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
],
),
),
),
);
}
}
class _AiHistoryBottomSheet extends StatefulWidget {
final void Function(String imageUrl) onSelect;
final Color accentColor;
const _AiHistoryBottomSheet({
required this.onSelect,
this.accentColor = const Color(0xFF6366F1),
});
@override
State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState();
}
class _AiHistoryBottomSheetState extends State<_AiHistoryBottomSheet> {
List<Map<String, dynamic>> _images = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token');
final resp = await http.get(
Uri.parse('${ApiConfig.fullBaseUrl}/badge/history/'),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
).timeout(const Duration(seconds: 15));
if (resp.statusCode == 200) {
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? {};
final items = (data['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>()
.where((img) =>
img['generation_status'] == 'completed' &&
(img['image_url'] as String?)?.isNotEmpty == true)
.toList();
if (mounted) setState(() { _images = items; _loading = false; });
} else {
if (mounted) setState(() { _error = '加载失败'; _loading = false; });
}
} catch (e) {
if (mounted) setState(() { _error = '网络错误'; _loading = false; });
}
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.6,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE5E7EB),
borderRadius: BorderRadius.circular(2),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Text(
'选择 AI 生成的图片',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
),
const Divider(height: 1),
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
if (_loading) {
return Center(
child: CircularProgressIndicator(color: widget.accentColor),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Color(0xFF9CA3AF)),
const SizedBox(height: 12),
Text(_error!, style: const TextStyle(color: Color(0xFF9CA3AF))),
],
),
);
}
if (_images.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image_not_supported_outlined, size: 48, color: Color(0xFF9CA3AF)),
SizedBox(height: 12),
Text('还没有 AI 生成的图片', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 14)),
SizedBox(height: 4),
Text('去「AI 生图」tab 试试吧', style: TextStyle(color: Color(0xFFD1D5DB), fontSize: 13)),
],
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: _images.length,
itemBuilder: (context, index) {
final img = _images[index];
final url = img['image_url'] as String;
return GestureDetector(
onTap: () => widget.onSelect(url),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black.withOpacity(0.06)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(
child: Icon(Icons.broken_image, color: Color(0xFF9CA3AF)),
),
),
),
),
);
},
);
}
}

View File

@ -1,178 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
///
class TransferProgressRing extends StatefulWidget {
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,
),
),
),
),
);
},
);
}
}
class _GradientRingPainter extends CustomPainter {
final double progress;
final bool isComplete;
final Color accentColor;
_GradientRingPainter({
required this.progress,
required this.isComplete,
required this.accentColor,
});
@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);
//
final bgPaint = Paint()
..color = accentColor.withOpacity(0.1)
..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)
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(rect, -pi / 2, sweepAngle, false, gradientPaint);
}
@override
bool shouldRepaint(covariant _GradientRingPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.isComplete != isComplete ||
oldDelegate.accentColor != accentColor;
}

View File

@ -2,7 +2,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../../core/network/api_client.dart';
import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
part 'device_remote_data_source.g.dart';
@ -33,9 +32,6 @@ abstract class DeviceRemoteDataSource {
/// POST /devices/{id}/wifi/
Future<void> configWifi(int userDeviceId, String ssid);
/// GET /devices/role-memories/
Future<List<RoleMemory>> listRoleMemories({int? deviceTypeId});
}
@riverpod
@ -129,18 +125,4 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
data: {'ssid': ssid},
);
}
@override
Future<List<RoleMemory>> listRoleMemories({int? deviceTypeId}) async {
final queryParams = <String, dynamic>{};
if (deviceTypeId != null) queryParams['device_type_id'] = deviceTypeId;
final data = await _apiClient.get(
'/devices/role-memories/',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
);
final list = data as List<dynamic>;
return list
.map((e) => RoleMemory.fromJson(e as Map<String, dynamic>))
.toList();
}
}

View File

@ -4,7 +4,6 @@ import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
import '../../domain/repositories/device_repository.dart';
import '../datasources/device_remote_data_source.dart';
@ -145,20 +144,4 @@ class DeviceRepositoryImpl implements DeviceRepository {
return left(NetworkFailure(e.message));
}
}
@override
Future<Either<Failure, List<RoleMemory>>> listRoleMemories({
int? deviceTypeId,
}) async {
try {
final result = await _remoteDataSource.listRoleMemories(
deviceTypeId: deviceTypeId,
);
return right(result);
} on ServerException catch (e) {
return left(ServerFailure(e.message));
} on NetworkException catch (e) {
return left(NetworkFailure(e.message));
}
}
}

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,12 +24,11 @@ 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,
@Default(false) bool isOnline,
@Default('') String firmwareVersion,
String? lastOnlineAt,
String? createdAt,

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; 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)
@ -309,16 +309,16 @@ $DeviceInfoCopyWith<DeviceInfo> get copyWith => _$DeviceInfoCopyWithImpl<DeviceI
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,isOnline,firmwareVersion,lastOnlineAt,createdAt);
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,firmwareVersion,lastOnlineAt,createdAt);
@override
String toString() {
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, isOnline: $isOnline, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
}
@ -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, String firmwareVersion, String? lastOnlineAt, String? createdAt
});
@ -346,7 +346,7 @@ class _$DeviceInfoCopyWithImpl<$Res>
/// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? isOnline = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable
@ -355,8 +355,7 @@ as DeviceType?,deviceTypeInfo: freezed == deviceTypeInfo ? _self.deviceTypeInfo
as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable
as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable
as bool,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,
@ -468,10 +467,10 @@ 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, 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 _:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
return orElse();
}
@ -489,10 +488,10 @@ 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, 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 _:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
throw StateError('Unexpected subclass');
}
@ -509,10 +508,10 @@ 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, 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 _:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _:
return null;
}
@ -524,17 +523,16 @@ 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.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;
@override@JsonKey() final bool isOnline;
@override@JsonKey() final String firmwareVersion;
@override final String? lastOnlineAt;
@override final String? createdAt;
@ -552,16 +550,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DeviceInfo&&(identical(other.id, id) || other.id == id)&&(identical(other.sn, sn) || other.sn == sn)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeInfo, deviceTypeInfo) || other.deviceTypeInfo == deviceTypeInfo)&&(identical(other.macAddress, macAddress) || other.macAddress == macAddress)&&(identical(other.name, name) || other.name == name)&&(identical(other.status, status) || other.status == status)&&(identical(other.firmwareVersion, firmwareVersion) || other.firmwareVersion == firmwareVersion)&&(identical(other.lastOnlineAt, lastOnlineAt) || other.lastOnlineAt == lastOnlineAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,isOnline,firmwareVersion,lastOnlineAt,createdAt);
int get hashCode => Object.hash(runtimeType,id,sn,deviceType,deviceTypeInfo,macAddress,name,status,firmwareVersion,lastOnlineAt,createdAt);
@override
String toString() {
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, isOnline: $isOnline, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
return 'DeviceInfo(id: $id, sn: $sn, deviceType: $deviceType, deviceTypeInfo: $deviceTypeInfo, macAddress: $macAddress, name: $name, status: $status, firmwareVersion: $firmwareVersion, lastOnlineAt: $lastOnlineAt, createdAt: $createdAt)';
}
@ -572,7 +570,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, String firmwareVersion, String? lastOnlineAt, String? createdAt
});
@ -589,7 +587,7 @@ class __$DeviceInfoCopyWithImpl<$Res>
/// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? isOnline = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? sn = null,Object? deviceType = freezed,Object? deviceTypeInfo = freezed,Object? macAddress = freezed,Object? name = null,Object? status = null,Object? firmwareVersion = null,Object? lastOnlineAt = freezed,Object? createdAt = freezed,}) {
return _then(_DeviceInfo(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,sn: null == sn ? _self.sn : sn // ignore: cast_nullable_to_non_nullable
@ -598,8 +596,7 @@ as DeviceType?,deviceTypeInfo: freezed == deviceTypeInfo ? _self.deviceTypeInfo
as DeviceType?,macAddress: freezed == macAddress ? _self.macAddress : macAddress // ignore: cast_nullable_to_non_nullable
as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable
as bool,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
as String,lastOnlineAt: freezed == lastOnlineAt ? _self.lastOnlineAt : lastOnlineAt // ignore: cast_nullable_to_non_nullable
as String?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,

View File

@ -30,36 +30,33 @@ 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'] is Map<String, dynamic>)
? DeviceType.fromJson(json['device_type'] as Map<String, dynamic>)
: null,
deviceTypeInfo: (json['device_type_info'] is Map<String, dynamic>)
? DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>)
: null,
macAddress: json['mac_address'] as String?,
name: json['name'] as String? ?? '',
status: json['status'] as String? ?? 'in_stock',
isOnline: json['is_online'] as bool? ?? false,
firmwareVersion: json['firmware_version'] as String? ?? '',
lastOnlineAt: json['last_online_at'] as String?,
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,
'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

@ -1,28 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'role_memory.freezed.dart';
part 'role_memory.g.dart';
@freezed
abstract class RoleMemory with _$RoleMemory {
const factory RoleMemory({
required int id,
required int deviceType,
@Default('') String deviceTypeName,
@Default(true) bool isBound,
@Default('') String nickname,
@Default('') String userName,
@Default(50) int volume,
@Default(50) int brightness,
@Default(true) bool allowInterrupt,
@Default(false) bool privacyMode,
@Default('') String prompt,
@Default('') String voiceId,
@Default('') String memorySummary,
String? createdAt,
String? updatedAt,
}) = _RoleMemory;
factory RoleMemory.fromJson(Map<String, dynamic> json) =>
_$RoleMemoryFromJson(json);
}

View File

@ -1,319 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'role_memory.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RoleMemory {
int get id; int get deviceType; String get deviceTypeName; bool get isBound; String get nickname; String get userName; int get volume; int get brightness; bool get allowInterrupt; bool get privacyMode; String get prompt; String get voiceId; String get memorySummary; String? get createdAt; String? get updatedAt;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RoleMemoryCopyWith<RoleMemory> get copyWith => _$RoleMemoryCopyWithImpl<RoleMemory>(this as RoleMemory, _$identity);
/// Serializes this RoleMemory to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RoleMemory&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeName, deviceTypeName) || other.deviceTypeName == deviceTypeName)&&(identical(other.isBound, isBound) || other.isBound == isBound)&&(identical(other.nickname, nickname) || other.nickname == nickname)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.allowInterrupt, allowInterrupt) || other.allowInterrupt == allowInterrupt)&&(identical(other.privacyMode, privacyMode) || other.privacyMode == privacyMode)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.voiceId, voiceId) || other.voiceId == voiceId)&&(identical(other.memorySummary, memorySummary) || other.memorySummary == memorySummary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,deviceType,deviceTypeName,isBound,nickname,userName,volume,brightness,allowInterrupt,privacyMode,prompt,voiceId,memorySummary,createdAt,updatedAt);
@override
String toString() {
return 'RoleMemory(id: $id, deviceType: $deviceType, deviceTypeName: $deviceTypeName, isBound: $isBound, nickname: $nickname, userName: $userName, volume: $volume, brightness: $brightness, allowInterrupt: $allowInterrupt, privacyMode: $privacyMode, prompt: $prompt, voiceId: $voiceId, memorySummary: $memorySummary, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class $RoleMemoryCopyWith<$Res> {
factory $RoleMemoryCopyWith(RoleMemory value, $Res Function(RoleMemory) _then) = _$RoleMemoryCopyWithImpl;
@useResult
$Res call({
int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt
});
}
/// @nodoc
class _$RoleMemoryCopyWithImpl<$Res>
implements $RoleMemoryCopyWith<$Res> {
_$RoleMemoryCopyWithImpl(this._self, this._then);
final RoleMemory _self;
final $Res Function(RoleMemory) _then;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? deviceType = null,Object? deviceTypeName = null,Object? isBound = null,Object? nickname = null,Object? userName = null,Object? volume = null,Object? brightness = null,Object? allowInterrupt = null,Object? privacyMode = null,Object? prompt = null,Object? voiceId = null,Object? memorySummary = null,Object? createdAt = freezed,Object? updatedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,deviceType: null == deviceType ? _self.deviceType : deviceType // ignore: cast_nullable_to_non_nullable
as int,deviceTypeName: null == deviceTypeName ? _self.deviceTypeName : deviceTypeName // ignore: cast_nullable_to_non_nullable
as String,isBound: null == isBound ? _self.isBound : isBound // ignore: cast_nullable_to_non_nullable
as bool,nickname: null == nickname ? _self.nickname : nickname // ignore: cast_nullable_to_non_nullable
as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable
as int,brightness: null == brightness ? _self.brightness : brightness // ignore: cast_nullable_to_non_nullable
as int,allowInterrupt: null == allowInterrupt ? _self.allowInterrupt : allowInterrupt // ignore: cast_nullable_to_non_nullable
as bool,privacyMode: null == privacyMode ? _self.privacyMode : privacyMode // ignore: cast_nullable_to_non_nullable
as bool,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,voiceId: null == voiceId ? _self.voiceId : voiceId // ignore: cast_nullable_to_non_nullable
as String,memorySummary: null == memorySummary ? _self.memorySummary : memorySummary // ignore: cast_nullable_to_non_nullable
as String,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [RoleMemory].
extension RoleMemoryPatterns on RoleMemory {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _RoleMemory value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _RoleMemory value) $default,){
final _that = this;
switch (_that) {
case _RoleMemory():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _RoleMemory value)? $default,){
final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt) $default,) {final _that = this;
switch (_that) {
case _RoleMemory():
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt)? $default,) {final _that = this;
switch (_that) {
case _RoleMemory() when $default != null:
return $default(_that.id,_that.deviceType,_that.deviceTypeName,_that.isBound,_that.nickname,_that.userName,_that.volume,_that.brightness,_that.allowInterrupt,_that.privacyMode,_that.prompt,_that.voiceId,_that.memorySummary,_that.createdAt,_that.updatedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _RoleMemory implements RoleMemory {
const _RoleMemory({required this.id, required this.deviceType, this.deviceTypeName = '', this.isBound = true, this.nickname = '', this.userName = '', this.volume = 50, this.brightness = 50, this.allowInterrupt = true, this.privacyMode = false, this.prompt = '', this.voiceId = '', this.memorySummary = '', this.createdAt, this.updatedAt});
factory _RoleMemory.fromJson(Map<String, dynamic> json) => _$RoleMemoryFromJson(json);
@override final int id;
@override final int deviceType;
@override@JsonKey() final String deviceTypeName;
@override@JsonKey() final bool isBound;
@override@JsonKey() final String nickname;
@override@JsonKey() final String userName;
@override@JsonKey() final int volume;
@override@JsonKey() final int brightness;
@override@JsonKey() final bool allowInterrupt;
@override@JsonKey() final bool privacyMode;
@override@JsonKey() final String prompt;
@override@JsonKey() final String voiceId;
@override@JsonKey() final String memorySummary;
@override final String? createdAt;
@override final String? updatedAt;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RoleMemoryCopyWith<_RoleMemory> get copyWith => __$RoleMemoryCopyWithImpl<_RoleMemory>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$RoleMemoryToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RoleMemory&&(identical(other.id, id) || other.id == id)&&(identical(other.deviceType, deviceType) || other.deviceType == deviceType)&&(identical(other.deviceTypeName, deviceTypeName) || other.deviceTypeName == deviceTypeName)&&(identical(other.isBound, isBound) || other.isBound == isBound)&&(identical(other.nickname, nickname) || other.nickname == nickname)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.brightness, brightness) || other.brightness == brightness)&&(identical(other.allowInterrupt, allowInterrupt) || other.allowInterrupt == allowInterrupt)&&(identical(other.privacyMode, privacyMode) || other.privacyMode == privacyMode)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.voiceId, voiceId) || other.voiceId == voiceId)&&(identical(other.memorySummary, memorySummary) || other.memorySummary == memorySummary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,deviceType,deviceTypeName,isBound,nickname,userName,volume,brightness,allowInterrupt,privacyMode,prompt,voiceId,memorySummary,createdAt,updatedAt);
@override
String toString() {
return 'RoleMemory(id: $id, deviceType: $deviceType, deviceTypeName: $deviceTypeName, isBound: $isBound, nickname: $nickname, userName: $userName, volume: $volume, brightness: $brightness, allowInterrupt: $allowInterrupt, privacyMode: $privacyMode, prompt: $prompt, voiceId: $voiceId, memorySummary: $memorySummary, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class _$RoleMemoryCopyWith<$Res> implements $RoleMemoryCopyWith<$Res> {
factory _$RoleMemoryCopyWith(_RoleMemory value, $Res Function(_RoleMemory) _then) = __$RoleMemoryCopyWithImpl;
@override @useResult
$Res call({
int id, int deviceType, String deviceTypeName, bool isBound, String nickname, String userName, int volume, int brightness, bool allowInterrupt, bool privacyMode, String prompt, String voiceId, String memorySummary, String? createdAt, String? updatedAt
});
}
/// @nodoc
class __$RoleMemoryCopyWithImpl<$Res>
implements _$RoleMemoryCopyWith<$Res> {
__$RoleMemoryCopyWithImpl(this._self, this._then);
final _RoleMemory _self;
final $Res Function(_RoleMemory) _then;
/// Create a copy of RoleMemory
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? deviceType = null,Object? deviceTypeName = null,Object? isBound = null,Object? nickname = null,Object? userName = null,Object? volume = null,Object? brightness = null,Object? allowInterrupt = null,Object? privacyMode = null,Object? prompt = null,Object? voiceId = null,Object? memorySummary = null,Object? createdAt = freezed,Object? updatedAt = freezed,}) {
return _then(_RoleMemory(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as int,deviceType: null == deviceType ? _self.deviceType : deviceType // ignore: cast_nullable_to_non_nullable
as int,deviceTypeName: null == deviceTypeName ? _self.deviceTypeName : deviceTypeName // ignore: cast_nullable_to_non_nullable
as String,isBound: null == isBound ? _self.isBound : isBound // ignore: cast_nullable_to_non_nullable
as bool,nickname: null == nickname ? _self.nickname : nickname // ignore: cast_nullable_to_non_nullable
as String,userName: null == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable
as int,brightness: null == brightness ? _self.brightness : brightness // ignore: cast_nullable_to_non_nullable
as int,allowInterrupt: null == allowInterrupt ? _self.allowInterrupt : allowInterrupt // ignore: cast_nullable_to_non_nullable
as bool,privacyMode: null == privacyMode ? _self.privacyMode : privacyMode // ignore: cast_nullable_to_non_nullable
as bool,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
as String,voiceId: null == voiceId ? _self.voiceId : voiceId // ignore: cast_nullable_to_non_nullable
as String,memorySummary: null == memorySummary ? _self.memorySummary : memorySummary // ignore: cast_nullable_to_non_nullable
as String,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@ -1,44 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'role_memory.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_RoleMemory _$RoleMemoryFromJson(Map<String, dynamic> json) => _RoleMemory(
id: (json['id'] as num).toInt(),
deviceType: (json['device_type'] as num).toInt(),
deviceTypeName: json['device_type_name'] as String? ?? '',
isBound: json['is_bound'] as bool? ?? true,
nickname: json['nickname'] as String? ?? '',
userName: json['user_name'] as String? ?? '',
volume: (json['volume'] as num?)?.toInt() ?? 50,
brightness: (json['brightness'] as num?)?.toInt() ?? 50,
allowInterrupt: json['allow_interrupt'] as bool? ?? true,
privacyMode: json['privacy_mode'] as bool? ?? false,
prompt: json['prompt'] as String? ?? '',
voiceId: json['voice_id'] as String? ?? '',
memorySummary: json['memory_summary'] as String? ?? '',
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$RoleMemoryToJson(_RoleMemory instance) =>
<String, dynamic>{
'id': instance.id,
'device_type': instance.deviceType,
'device_type_name': instance.deviceTypeName,
'is_bound': instance.isBound,
'nickname': instance.nickname,
'user_name': instance.userName,
'volume': instance.volume,
'brightness': instance.brightness,
'allow_interrupt': instance.allowInterrupt,
'privacy_mode': instance.privacyMode,
'prompt': instance.prompt,
'voice_id': instance.voiceId,
'memory_summary': instance.memorySummary,
'created_at': instance.createdAt,
'updated_at': instance.updatedAt,
};

View File

@ -2,7 +2,6 @@ import 'package:fpdart/fpdart.dart';
import '../../../../core/errors/failures.dart';
import '../entities/device.dart';
import '../entities/device_detail.dart';
import '../entities/role_memory.dart';
abstract class DeviceRepository {
Future<Either<Failure, Map<String, dynamic>>> queryByMac(String mac);
@ -14,5 +13,4 @@ abstract class DeviceRepository {
Future<Either<Failure, UserDevice>> updateSpirit(int userDeviceId, int spiritId);
Future<Either<Failure, void>> updateSettings(int userDeviceId, Map<String, dynamic> settings);
Future<Either<Failure, void>> configWifi(int userDeviceId, String ssid);
Future<Either<Failure, List<RoleMemory>>> listRoleMemories({int? deviceTypeId});
}

View File

@ -1,7 +1,6 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/device.dart';
import '../../domain/entities/device_detail.dart';
import '../../domain/entities/role_memory.dart';
import '../../data/repositories/device_repository_impl.dart';
part 'device_controller.g.dart';
@ -19,16 +18,14 @@ 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; //
return result.fold(
(failure) => failure.message,
(failure) => false,
(bindingId) {
if (ref.mounted) ref.invalidateSelf();
return null;
ref.invalidateSelf();
return true;
},
);
}
@ -36,11 +33,9 @@ class DeviceController extends _$DeviceController {
Future<bool> unbindDevice(int userDeviceId) async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.unbindDevice(userDeviceId);
if (!ref.mounted) return false;
return result.fold(
(failure) => false,
(_) {
if (!ref.mounted) return false;
final current = state.value ?? [];
state = AsyncData(
current.where((d) => d.id != userDeviceId).toList(),
@ -53,11 +48,9 @@ class DeviceController extends _$DeviceController {
Future<bool> updateSpirit(int userDeviceId, int spiritId) async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.updateSpirit(userDeviceId, spiritId);
if (!ref.mounted) return false;
return result.fold(
(failure) => false,
(updated) {
if (!ref.mounted) return false;
ref.invalidateSelf();
return true;
},
@ -65,7 +58,6 @@ class DeviceController extends _$DeviceController {
}
void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf();
}
}
@ -86,11 +78,9 @@ class DeviceDetailController extends _$DeviceDetailController {
Future<bool> updateSettings(Map<String, dynamic> settings) async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.updateSettings(userDeviceId, settings);
if (!ref.mounted) return false;
return result.fold(
(failure) => false,
(_) {
if (!ref.mounted) return false;
ref.invalidateSelf();
return true;
},
@ -100,11 +90,9 @@ class DeviceDetailController extends _$DeviceDetailController {
Future<bool> configWifi(String ssid) async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.configWifi(userDeviceId, ssid);
if (!ref.mounted) return false;
return result.fold(
(failure) => false,
(_) {
if (!ref.mounted) return false;
ref.invalidateSelf();
return true;
},
@ -112,26 +100,6 @@ class DeviceDetailController extends _$DeviceDetailController {
}
void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf();
}
}
///
@riverpod
class RoleMemoryController extends _$RoleMemoryController {
@override
FutureOr<List<RoleMemory>> build() async {
final repository = ref.read(deviceRepositoryProvider);
final result = await repository.listRoleMemories();
return result.fold(
(failure) => <RoleMemory>[],
(memories) => memories,
);
}
void refresh() {
if (!ref.mounted) return;
ref.invalidateSelf();
}
}

View File

@ -36,7 +36,7 @@ final class DeviceControllerProvider
DeviceController create() => DeviceController();
}
String _$deviceControllerHash() => r'94e697fab82bfeb03a25eb12fc548fed925ef5cc';
String _$deviceControllerHash() => r'9b39117bd54964ba0035aad0eca10250454efaa7';
///
@ -107,7 +107,7 @@ final class DeviceDetailControllerProvider
}
String _$deviceDetailControllerHash() =>
r'd4e78c0f2298de55e7df31b4a34778b8169387a5';
r'1d9049597e39a0af3a70331378559aca0e1da54d';
///
@ -161,56 +161,3 @@ abstract class _$DeviceDetailController extends $AsyncNotifier<DeviceDetail?> {
element.handleValue(ref, created);
}
}
///
@ProviderFor(RoleMemoryController)
const roleMemoryControllerProvider = RoleMemoryControllerProvider._();
///
final class RoleMemoryControllerProvider
extends $AsyncNotifierProvider<RoleMemoryController, List<RoleMemory>> {
///
const RoleMemoryControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'roleMemoryControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$roleMemoryControllerHash();
@$internal
@override
RoleMemoryController create() => RoleMemoryController();
}
String _$roleMemoryControllerHash() =>
r'e02cd6952277bf766c0b657979b28f4bf8e98c1b';
///
abstract class _$RoleMemoryController extends $AsyncNotifier<List<RoleMemory>> {
FutureOr<List<RoleMemory>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref as $Ref<AsyncValue<List<RoleMemory>>, List<RoleMemory>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<RoleMemory>>, List<RoleMemory>>,
AsyncValue<List<RoleMemory>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

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:
@ -178,18 +174,12 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
/// BLE API
Future<void> _startSearch() async {
// Web : Web Bluetooth API
if (!kIsWeb && !_isBluetoothOn) {
if (!_isBluetoothOn) {
_showBluetoothOffDialog();
return;
}
// Web : startScan await
// Web Bluetooth API
// requestDevice
if (!kIsWeb) {
await _requestPermissions();
}
await _requestPermissions();
if (!mounted) return;
setState(() {
@ -222,19 +212,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 +237,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 +251,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 +267,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 +348,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 +359,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 +372,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 +467,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 +488,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 +560,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 +729,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 +792,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),
@ -475,7 +474,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
await _addNewBookWithAnimation(
title: saveResult['title'] as String? ?? '新故事',
content: saveResult['content'] as String? ?? '',
coverUrl: saveResult['cover_url'] as String? ?? '',
);
}
}
@ -603,7 +601,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
_addNewBookWithAnimation(
title: saveResult['title'] as String? ?? '新故事',
content: saveResult['content'] as String? ?? '',
coverUrl: saveResult['cover_url'] as String? ?? '',
);
}
}
@ -903,7 +900,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
);
}
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = '', String coverUrl = ''}) async {
Future<void> _addNewBookWithAnimation({String title = '新故事', String content = ''}) async {
// Find the first shelf that has space
int? targetShelfId;
for (final shelf in _shelves) {
@ -926,7 +923,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
'title': title,
'content': content,
'shelf_id': targetShelfId,
if (coverUrl.isNotEmpty) 'cover_url': coverUrl,
});
// Reload to get the new story
await _loadShelves();

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,20 +1,17 @@
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';
import '../widgets/ios_toast.dart';
class ProductSelectionPage extends ConsumerStatefulWidget {
class ProductSelectionPage extends StatefulWidget {
const ProductSelectionPage({super.key});
@override
ConsumerState<ProductSelectionPage> createState() => _ProductSelectionPageState();
State<ProductSelectionPage> createState() => _ProductSelectionPageState();
}
class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
class _ProductSelectionPageState extends State<ProductSelectionPage> {
final ScrollController _scrollController = ScrollController();
@override
@ -31,37 +28,12 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
super.dispose();
}
/// ID product_code
static const Map<String, List<String>> _productCodeMap = {
'capybara': ['KPBL-ON'],
'badge-ai': ['DZBJ-ON'],
'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];
if (codes == null || codes.isEmpty) return null;
for (final device in devices) {
final dt = device.device.deviceType ?? device.device.deviceTypeInfo;
if (dt != null && codes.contains(dt.productCode)) {
return device;
}
}
return null;
}
static final List<Map<String, dynamic>> _products = [
{
'id': 'capybara',
'name': '毛绒机芯',
'status': '已连接',
'statusColor': const Color(0xFF10B981),
'icon': 'assets/www/Capybara.png',
'isPng': true,
'hasTag': true,
@ -73,10 +45,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFFC9A07A),
'selected': true,
},
{
'id': 'badge-ai',
'name': '电子吧唧 AI',
'status': '离线',
'statusColor': const Color(0xFFE5E7EB),
'icon': 'assets/www/icons/icon-product-badge.svg',
'isPng': false,
'hasTag': true,
@ -88,10 +63,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFF6366F1),
'selected': false,
},
{
'id': 'badge-basic',
'name': '普通吧唧',
'status': '未配对',
'statusColor': const Color(0xFFE5E7EB),
'icon': 'assets/www/icons/icon-product-badge.svg',
'isPng': false,
'hasTag': false,
@ -102,10 +80,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFFA78BFA),
'selected': false,
},
{
'id': 'bracelet',
'name': 'AI 手链',
'status': '点击扫描',
'statusColor': const Color(0xFFE5E7EB),
'icon': 'assets/www/icons/icon-product-badge.svg',
'isPng': false,
'hasTag': true,
@ -117,10 +98,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFFE07B54),
'selected': false,
},
{
'id': 'vsinger',
'name': '洛天依',
'status': '去下载专属 APP →',
'statusColor': Colors.transparent,
'icon': 'assets/www/icons/icon-product-luo.svg',
'isPng': false,
'hasTag': false,
@ -131,10 +115,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFF2DD4BF),
'selected': false,
},
{
'id': 'nightlight',
'name': 'AI 星空夜灯',
'status': '未配对',
'statusColor': const Color(0xFFE5E7EB),
'icon': 'assets/www/icons/icon-product-badge.svg',
'isPng': false,
'hasTag': true,
@ -146,10 +133,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFF7C3AED),
'selected': false,
},
{
'id': 'feeder',
'name': '智能喂食器',
'status': '点击扫描',
'statusColor': const Color(0xFFE5E7EB),
'icon': 'assets/www/icons/icon-product-badge.svg',
'isPng': false,
'hasTag': false,
@ -160,24 +150,24 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
end: Alignment.centerRight,
),
'shadowColor': const Color(0xFFE11D48),
'selected': false,
},
];
@override
Widget build(BuildContext context) {
final safeTop = MediaQuery.of(context).padding.top;
// = safeArea + padding + + padding
final headerHeight = safeTop + 12 + 40 + 12;
//
final devicesAsync = ref.watch(deviceControllerProvider);
final devices = devicesAsync.value ?? [];
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
// 1.
const AnimatedGradientBackground(),
// 2.
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
@ -188,39 +178,17 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
itemCount: _products.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final product = _products[index];
final boundDevice = _findBoundDevice(product['id'] as String, devices);
return _FadeOnScrollCard(
key: ValueKey(product['id']),
product: product,
isBound: boundDevice != null,
key: ValueKey(_products[index]['id']),
product: _products[index],
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');
}
} else {
// push
context.push('/bluetooth');
}
},
);
},
),
),
// 3. +
Positioned(
top: 0,
left: 0,
@ -230,14 +198,16 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
child: Stack(
alignment: Alignment.center,
children: [
//
Text(
'选择产品',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
),
),
//
Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
@ -268,21 +238,17 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
}
}
///
/// Opacity ShaderMask widget
class _FadeOnScrollCard extends StatefulWidget {
final Map<String, dynamic> product;
final bool isBound;
final double fadeStartY;
final double fadeEndY;
final VoidCallback onTap;
final double fadeStartY; //
final double fadeEndY; //
const _FadeOnScrollCard({
super.key,
required this.product,
required this.isBound,
required this.fadeStartY,
required this.fadeEndY,
required this.onTap,
});
@override
@ -313,11 +279,7 @@ class _FadeOnScrollCardState extends State<_FadeOnScrollCard> {
return Opacity(
key: _posKey,
opacity: opacity,
child: _ProductCard(
product: widget.product,
isBound: widget.isBound,
onTap: widget.onTap,
),
child: _ProductCard(product: widget.product),
);
}
}
@ -325,20 +287,22 @@ class _FadeOnScrollCardState extends State<_FadeOnScrollCard> {
///
class _ProductCard extends StatelessWidget {
final Map<String, dynamic> product;
final bool isBound;
final VoidCallback onTap;
const _ProductCard({
super.key,
required this.product,
required this.isBound,
required this.onTap,
});
const _ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
bool isSelected = product['selected'] == true;
return GestureDetector(
onTap: onTap,
onTap: () {
if (isSelected) {
Navigator.of(context).pop();
} else {
AppToast.show(
context, '${product['name']} 离线或未配对', isError: true);
}
},
child: Container(
height: 120,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
@ -364,7 +328,7 @@ class _ProductCard extends StatelessWidget {
children: [
Text(
product['name'],
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 19,
fontWeight: FontWeight.bold,
color: Colors.white,
@ -379,27 +343,29 @@ class _ProductCard extends StatelessWidget {
const SizedBox(height: 6),
Row(
children: [
Container(
width: 7,
height: 7,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: isBound
? const Color(0xFF34D399)
: Colors.white.withOpacity(0.5),
shape: BoxShape.circle,
boxShadow: isBound
? [
BoxShadow(
color: const Color(0xFF34D399)
.withOpacity(0.4),
spreadRadius: 2)
]
: [],
if ((product['statusColor'] as Color) !=
Colors.transparent)
Container(
width: 7,
height: 7,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: product['id'] == 'capybara'
? const Color(0xFF34D399)
: Colors.white.withOpacity(0.5),
shape: BoxShape.circle,
boxShadow: product['id'] == 'capybara'
? [
BoxShadow(
color: const Color(0xFF34D399)
.withOpacity(0.4),
spreadRadius: 2)
]
: [],
),
),
),
Text(
isBound ? '已连接' : '点击配对',
product['status'],
style: TextStyle(
fontSize: 13,
color: Colors.white.withOpacity(0.85)),

View File

@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:airhub_app/theme/design_tokens.dart';
import 'package:airhub_app/widgets/animated_gradient_background.dart';
import 'package:airhub_app/widgets/glass_dialog.dart';
import 'package:airhub_app/features/device/domain/entities/role_memory.dart';
import 'package:airhub_app/features/device/presentation/controllers/device_controller.dart';
import 'package:airhub_app/widgets/ios_toast.dart';
import 'package:airhub_app/features/spirit/domain/entities/spirit.dart';
import 'package:airhub_app/features/spirit/presentation/controllers/spirit_controller.dart';
class AgentManagePage extends ConsumerStatefulWidget {
const AgentManagePage({super.key});
@ -16,7 +17,7 @@ class AgentManagePage extends ConsumerStatefulWidget {
class _AgentManagePageState extends ConsumerState<AgentManagePage> {
@override
Widget build(BuildContext context) {
final memoriesAsync = ref.watch(roleMemoryControllerProvider);
final spiritsAsync = ref.watch(spiritControllerProvider);
return Scaffold(
backgroundColor: Colors.transparent,
@ -27,7 +28,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
children: [
_buildHeader(context),
Expanded(
child: memoriesAsync.when(
child: spiritsAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
@ -44,7 +45,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => ref.read(roleMemoryControllerProvider.notifier).refresh(),
onTap: () => ref.read(spiritControllerProvider.notifier).refresh(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
@ -60,8 +61,8 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
],
),
),
data: (memories) {
if (memories.isEmpty) {
data: (spirits) {
if (spirits.isEmpty) {
return Center(
child: Text(
'暂无角色记忆',
@ -79,9 +80,9 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
right: 20,
bottom: 40 + MediaQuery.of(context).padding.bottom,
),
itemCount: memories.length,
itemCount: spirits.length,
itemBuilder: (context, index) {
return _buildMemoryCard(memories[index]);
return _buildAgentCard(spirits[index]);
},
);
},
@ -158,16 +159,16 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
);
}
Widget _buildMemoryCard(RoleMemory memory) {
final dateStr = memory.createdAt != null
? memory.createdAt!.substring(0, 10).replaceAll('-', '/')
Widget _buildAgentCard(Spirit spirit) {
final dateStr = spirit.createdAt != null
? spirit.createdAt!.substring(0, 10).replaceAll('-', '/')
: '';
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFD4A373),
color: const Color(0xFFD4A373), // Fallback
gradient: const LinearGradient(colors: AppColors.gradientCapybara),
borderRadius: BorderRadius.circular(20),
boxShadow: [
@ -180,6 +181,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
),
child: Stack(
children: [
// Top highlight layer
Positioned(
left: 0,
right: 0,
@ -224,7 +226,19 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: const Text('🧠', style: TextStyle(fontSize: 24)),
child: spirit.avatar != null && spirit.avatar!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
spirit.avatar!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Text('🧠', style: TextStyle(fontSize: 24)),
),
)
: const Text('🧠', style: TextStyle(fontSize: 24)),
),
const SizedBox(width: 12),
Expanded(
@ -232,9 +246,7 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
memory.deviceTypeName.isNotEmpty
? memory.deviceTypeName
: '角色记忆 #${memory.id}',
spirit.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@ -248,36 +260,32 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
],
),
),
if (memory.nickname.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
memory.nickname,
style: TextStyle(
fontSize: 13,
color: Colors.white.withOpacity(0.8),
),
),
],
],
),
),
],
),
const SizedBox(height: 12),
_buildDetailRow('状态:', memory.isBound ? '已绑定' : '空闲'),
if (memory.memorySummary.isNotEmpty) ...[
const SizedBox(height: 6),
_buildDetailRow('记忆:', memory.memorySummary.length > 30
? '${memory.memorySummary.substring(0, 30)}...'
: memory.memorySummary),
],
_buildDetailRow('状态:', spirit.isActive ? '活跃' : '未激活'),
const SizedBox(height: 12),
Container(height: 1, color: Colors.white.withOpacity(0.2)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildStatusTag(memory.isBound),
_buildActionBtn(
'解绑',
isDanger: true,
onTap: () => _showUnbindDialog(spirit),
),
const SizedBox(width: 8),
_buildActionBtn(
'删除',
isDanger: true,
onTap: () => _showDeleteDialog(spirit),
),
],
),
],
@ -305,33 +313,84 @@ class _AgentManagePageState extends ConsumerState<AgentManagePage> {
);
}
Widget _buildStatusTag(bool isBound) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isBound ? Icons.link : Icons.link_off,
size: 14,
color: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 4),
Text(
isBound ? '使用中' : '空闲',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
Widget _buildActionBtn(
String text, {
bool isDanger = false,
bool isInject = false,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isDanger) ...[
Icon(
Icons.link_off,
size: 14,
color: AppColors.danger.withOpacity(0.9),
),
const SizedBox(width: 4),
] else if (isInject) ...[
Icon(Icons.download, size: 14, color: const Color(0xFFB07D5A)),
const SizedBox(width: 4),
],
Text(
text,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDanger
? AppColors.danger
: (isInject ? const Color(0xFFB07D5A) : Colors.white),
),
),
),
],
],
),
),
);
}
void _showUnbindDialog(Spirit spirit) {
showGlassDialog(
context: context,
title: '确认解绑角色记忆?',
description: '解绑后,该角色记忆将与当前设备断开连接,但数据会保留在云端。',
cancelText: '取消',
confirmText: '确认解绑',
isDanger: true,
onConfirm: () async {
Navigator.pop(context); // Close dialog
final success = await ref.read(spiritControllerProvider.notifier).unbind(spirit.id);
if (mounted) {
AppToast.show(context, success ? '已解绑: ${spirit.name}' : '解绑失败');
}
},
);
}
void _showDeleteDialog(Spirit spirit) {
showGlassDialog(
context: context,
title: '确认删除角色记忆?',
description: '删除后,该角色记忆数据将无法恢复。',
cancelText: '取消',
confirmText: '确认删除',
isDanger: true,
onConfirm: () async {
Navigator.pop(context);
final success = await ref.read(spiritControllerProvider.notifier).delete(spirit.id);
if (mounted) {
AppToast.show(context, success ? '已删除: ${spirit.name}' : '删除失败');
}
},
);
}
}

View File

@ -11,7 +11,6 @@ import 'package:airhub_app/widgets/glass_dialog.dart';
import 'package:airhub_app/features/auth/presentation/controllers/auth_controller.dart';
import 'package:airhub_app/features/system/data/datasources/system_remote_data_source.dart';
import 'package:airhub_app/features/device/presentation/controllers/device_controller.dart';
import 'package:airhub_app/features/user/presentation/controllers/user_controller.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@ -27,11 +26,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
Widget build(BuildContext context) {
// watch provider
ref.watch(deviceControllerProvider);
final user = ref.watch(userControllerProvider).value;
final phone = user?.phone ?? '';
final maskedPhone = phone.length >= 7
? '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'
: phone;
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
@ -54,8 +48,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_buildItem(
'📱',
'绑定手机',
value: maskedPhone.isNotEmpty ? maskedPhone : '未绑定',
onTap: () => _showMessage('绑定手机', maskedPhone.isNotEmpty ? maskedPhone : '未绑定'),
value: '138****3069',
onTap: () => _showMessage('绑定手机', '138****3069'),
),
_buildItem(
'🔐',

View File

@ -1,6 +1,8 @@
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 'product_selection_page.dart';
import '../widgets/glass_dialog.dart';
import '../widgets/animated_gradient_background.dart';
import '../widgets/ios_toast.dart';
@ -99,7 +101,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
Text(
'设置',
style: TextStyle(fontFamily: 'Outfit',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937),
@ -492,7 +494,10 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
.unbindDevice(_userDeviceId!);
if (mounted) {
if (success) {
context.go('/product-selection');
Navigator.pop(context); // close settings
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const ProductSelectionPage()),
);
} else {
AppToast.show(context, '解绑失败', isError: true);
}

View File

@ -4,7 +4,6 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:just_audio/just_audio.dart';
import 'package:video_player/video_player.dart';
import '../theme/design_tokens.dart';
import '../widgets/gradient_button.dart';
import '../widgets/pill_progress_button.dart';
@ -35,10 +34,6 @@ class _StoryDetailPageState extends State<StoryDetailPage>
bool _hasGeneratedVideo = false;
bool _isLoadingVideo = false;
// Video Player
VideoPlayerController? _videoController;
bool _videoInitialized = false;
// TTS uses global TTSService singleton
final TTSService _ttsService = TTSService.instance;
final AudioPlayer _audioPlayer = AudioPlayer();
@ -113,15 +108,6 @@ class _StoryDetailPageState extends State<StoryDetailPage>
debugPrint('durationStream error (ignored): $e');
});
// Auto-show video tab if story already has a video
final hasVideo = _currentStory['has_video'] == true;
final videoUrl = _currentStory['video_url'] as String? ?? '';
if (hasVideo && videoUrl.isNotEmpty) {
_hasGeneratedVideo = true;
_activeTab = 'video';
_initVideoPlayer(videoUrl);
}
// Check if audio already exists
debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}');
debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"');
@ -165,33 +151,12 @@ class _StoryDetailPageState extends State<StoryDetailPage>
setState(() {});
}
Future<void> _initVideoPlayer(String url) async {
try {
final controller = VideoPlayerController.networkUrl(Uri.parse(url));
_videoController = controller;
controller.addListener(_onVideoChanged);
await controller.initialize();
if (mounted) {
setState(() => _videoInitialized = true);
}
} catch (e) {
debugPrint('Video init error: $e');
}
}
void _onVideoChanged() {
if (!mounted) return;
setState(() {});
}
@override
void dispose() {
_ttsService.removeListener(_onTTSChanged);
_positionSub?.cancel();
_playerStateSub?.cancel();
_audioPlayer.dispose();
_videoController?.removeListener(_onVideoChanged);
_videoController?.dispose();
_genieController?.dispose();
super.dispose();
}
@ -206,8 +171,7 @@ class _StoryDetailPageState extends State<StoryDetailPage>
if (_ttsService.error != null &&
!_ttsService.isGenerating &&
_ttsService.audioUrl == null &&
_ttsService.errorTitle == title) {
_ttsService.audioUrl == null) {
return TTSButtonState.error;
}
if (_ttsService.isGeneratingFor(title)) {
@ -554,58 +518,28 @@ class _StoryDetailPageState extends State<StoryDetailPage>
);
}
// Not yet initialized black + spinner while video loads
if (!_videoInitialized || _videoController == null) {
return const AspectRatio(
aspectRatio: 16 / 9,
child: ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 3),
return Stack(
alignment: Alignment.center,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.black,
child: const Center(
child: Icon(Icons.videocam, color: Colors.white54, size: 48),
),
),
),
);
}
// Initialized: VideoPlayer shows frame 0 naturally as thumbnail when paused
final isPlaying = _videoController!.value.isPlaying;
return AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: Stack(
alignment: Alignment.center,
children: [
// Video fills the area (Positioned.fill avoids StackFit.expand distortion)
Positioned.fill(child: VideoPlayer(_videoController!)),
// Full-area tap handler
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (_videoController!.value.isPlaying) {
await _videoController!.pause();
} else {
await _videoController!.play();
}
if (mounted) setState(() {});
},
),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
shape: BoxShape.circle,
),
// Play button IgnorePointer lets taps pass through to GestureDetector below
if (!isPlaying)
IgnorePointer(
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow, color: Colors.black),
),
),
],
),
child: const Icon(Icons.play_arrow, color: Colors.black),
),
],
);
}

View File

@ -72,7 +72,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
String buffer = '';
String? storyTitle;
String? storyContent;
String storyCoverUrl = '';
await for (final chunk in response.stream.transform(utf8.decoder)) {
buffer += chunk;
@ -110,13 +109,9 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
case 'parsing':
_updateProgress(progress / 100, '正在编制最后的魔法...');
break;
case 'cover':
_updateProgress(progress / 100, '正在绘制故事封面...');
break;
case 'done':
storyTitle = event['title'] as String? ?? '卡皮巴拉的故事';
storyContent = event['content'] as String? ?? '';
storyCoverUrl = event['cover_url'] as String? ?? '';
_updateProgress(1.0, '大功告成!');
break;
case 'error':
@ -147,7 +142,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
story: {
'title': storyTitle,
'content': storyContent,
'cover_url': storyCoverUrl,
},
),
),
@ -160,7 +154,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
'action': 'saved',
'title': storyTitle,
'content': storyContent,
'cover_url': storyCoverUrl,
});
} else {
Navigator.of(context).pop(result);

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;
@ -31,7 +29,6 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
double _progress = 0.0;
String _progressText = '正在连接WiFi...';
bool _connectFailed = false;
bool _isBinding = false;
// Device Info
Map<String, dynamic> _deviceInfo = {};
@ -131,77 +128,13 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_currentStep == 2 && _passwordController.text.isEmpty) return;
if (_currentStep == 4) {
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;
}
} 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;
}
debugPrint('[WiFi Config] Binding device sn=$sn');
await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
}
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 +289,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 +353,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 +495,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 +563,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 +679,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),
@ -772,7 +705,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
}
if (_currentStep == 4) {
showNext = true;
nextText = _isBinding ? '绑定中...' : '进入设备';
nextText = '进入设备';
}
if (!showNext && _currentStep != 3) {
@ -831,7 +764,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (_currentStep < 4) const SizedBox(width: 16),
GradientButton(
text: nextText,
onPressed: _isBinding ? null : _handleNext,
onPressed: _handleNext,
height: 56,
width: _currentStep == 4 ? 200 : 160,
),

View File

@ -29,7 +29,6 @@ class TTSService extends ChangeNotifier {
// Error
String? _error;
String? _errorTitle; // Which story the error belongs to
// Getters
bool get isGenerating => _isGenerating;
@ -40,7 +39,6 @@ class TTSService extends ChangeNotifier {
String? get completedStoryTitle => _completedStoryTitle;
bool get justCompleted => _justCompleted;
String? get error => _error;
String? get errorTitle => _errorTitle;
/// Check if audio is ready for a specific story.
bool hasAudioFor(String title) {
@ -184,7 +182,6 @@ class TTSService extends ChangeNotifier {
_isGenerating = false;
if (_audioUrl == null) {
_error = '未获取到音频';
_errorTitle = title;
_statusMessage = '生成失败';
}
notifyListeners();
@ -193,7 +190,6 @@ class TTSService extends ChangeNotifier {
_isGenerating = false;
_progress = 0.0;
_error = e.toString();
_errorTitle = title;
_statusMessage = '生成失败';
_justCompleted = false;
notifyListeners();
@ -216,7 +212,6 @@ class TTSService extends ChangeNotifier {
_completedStoryTitle = null;
_justCompleted = false;
_error = null;
_errorTitle = null;
notifyListeners();
}
}

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

@ -10,7 +10,6 @@ import file_selector_macos
import flutter_blue_plus_darwin
import just_audio
import shared_preferences_foundation
import video_player_avfoundation
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@ -19,6 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
}

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:
@ -140,10 +132,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -240,14 +232,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
custom_lint:
dependency: transitive
description:
@ -524,6 +508,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:
@ -548,14 +540,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.3.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
@ -580,14 +564,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:
@ -668,6 +644,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@ -752,18 +736,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@ -964,14 +948,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:
@ -1229,26 +1205,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.29.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.15"
version: "0.6.12"
typed_data:
dependency: transitive
description:
@ -1297,46 +1273,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
url: "https://pub.dev"
source: hosted
version: "2.9.3"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:

View File

@ -61,14 +61,12 @@ 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
flutter:
uses-material-design: true
@ -90,17 +88,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

View File

@ -1,16 +1,12 @@
#!/bin/bash
# 重新编译并启动 Flutter Web (localhost:8080)
# 杀掉占用 8080 端口的进程(强制)
# 杀掉占用 8080 端口的进程
PID=$(lsof -ti:8080 2>/dev/null)
if [ -n "$PID" ]; then
echo "正在强制停止旧进程 (PID: $PID)..."
kill -9 $PID 2>/dev/null
# 等待端口真正释放(最多 5 秒)
for i in $(seq 1 10); do
lsof -ti:8080 > /dev/null 2>&1 || break
sleep 0.5
done
echo "正在停止旧进程 (PID: $PID)..."
kill $PID 2>/dev/null
sleep 1
fi
echo "正在编译并启动 Flutter Web..."