Compare commits
No commits in common. "main" and "fix/auto-20260228-143427" have entirely different histories.
main
...
fix/auto-2
@ -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>`(fpdart),Controller 内 `fold` 处理
|
||||
4. **页面中优先使用 `ConsumerWidget`**(无状态);需要动画/本地状态时用 `ConsumerStatefulWidget`
|
||||
5. **`ref.watch()` 用于 UI 绑定**(触发重建),**`ref.read()` 用于事件处理**(不触发重建)
|
||||
6. **`ref.listen()` 用于副作用**(弹窗、导航、Toast),不触发 Widget 重建
|
||||
|
||||
### 示例
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
class MyController extends _$MyController {
|
||||
@override
|
||||
FutureOr<MyState> build() async {
|
||||
final repo = ref.watch(myRepositoryProvider);
|
||||
final result = await repo.fetchData();
|
||||
return result.fold(
|
||||
(failure) => throw failure,
|
||||
(data) => MyState(data: data),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、网络层
|
||||
|
||||
### 规则
|
||||
|
||||
1. 所有 API 调用通过 `ApiClient`(Dio 封装),不直接使用 `http` 包或 `Dio()`
|
||||
2. `ApiClient` 自动处理:Token 附加、401 自动刷新、错误上报
|
||||
3. 后端统一返回 `{code, message, data}`,`ApiClient._request()` 自动解析
|
||||
4. 异常类型:`ServerException`(code != 0)、`NetworkException`(连接失败)
|
||||
5. **不在 Widget/Controller 中直接调用 ApiClient**,应通过 Repository 层
|
||||
|
||||
### 错误处理链
|
||||
|
||||
```
|
||||
DataSource (throw Exception)
|
||||
→ Repository (catch → Either<Failure, T>)
|
||||
→ Controller (fold → AsyncValue)
|
||||
→ UI (AsyncValue.when: loading/data/error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、路由(go_router)
|
||||
|
||||
### 规则
|
||||
|
||||
1. 所有路由定义在 `core/router/app_router.dart`
|
||||
2. 使用 `context.go()` 进行声明式导航,避免 `Navigator.push()`(页面内二级跳转除外)
|
||||
3. 登录态检查在 `redirect` 中统一处理
|
||||
4. 产品类型 → 路由映射在 `_productRoutes` Map 中维护
|
||||
|
||||
---
|
||||
|
||||
## 五、性能规范
|
||||
|
||||
### 内存管理
|
||||
|
||||
1. **所有 Stream 订阅必须存入变量,dispose 时取消**
|
||||
```dart
|
||||
// ✅ 正确
|
||||
StreamSubscription<Duration>? _positionSub;
|
||||
_positionSub = stream.listen((data) { ... });
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ❌ 错误 — 无法取消,造成内存泄漏
|
||||
stream.listen((data) { ... });
|
||||
```
|
||||
|
||||
2. **AnimationController、VideoPlayerController、AudioPlayer 等必须在 dispose 中释放**
|
||||
|
||||
3. **页面离开时停止 BLE 扫描**
|
||||
```dart
|
||||
@override
|
||||
void dispose() {
|
||||
FlutterBluePlus.stopScan();
|
||||
_scanSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### Widget 重建优化
|
||||
|
||||
4. **能用 `const` 的地方必须加 `const`**
|
||||
```dart
|
||||
// ✅
|
||||
const SizedBox(height: 16)
|
||||
const EdgeInsets.all(16)
|
||||
const Text('Hello')
|
||||
|
||||
// ❌
|
||||
SizedBox(height: 16)
|
||||
EdgeInsets.all(16)
|
||||
```
|
||||
|
||||
5. **动画区域用 `RepaintBoundary` 包裹**,防止全页面重绘
|
||||
```dart
|
||||
RepaintBoundary(
|
||||
child: AnimatedGradientBackground(),
|
||||
)
|
||||
```
|
||||
|
||||
6. **`AnimatedBuilder` 必须使用 `child` 参数**,将不变的子树传入 child,不要在 builder 中重建
|
||||
```dart
|
||||
// ✅
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
child: const HeavyWidget(), // 只构建一次
|
||||
builder: (context, child) => Transform.rotate(
|
||||
angle: _controller.value,
|
||||
child: child, // 复用
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### API 调用
|
||||
|
||||
7. **多个独立 API 调用使用 `Future.wait` 并行**,不要顺序 await
|
||||
```dart
|
||||
// ✅ 并行
|
||||
final results = await Future.wait([
|
||||
api.get('/shelves/'),
|
||||
api.get('/stories/'),
|
||||
]);
|
||||
|
||||
// ❌ 顺序(慢 N 倍)
|
||||
final shelves = await api.get('/shelves/');
|
||||
final stories = await api.get('/stories/');
|
||||
```
|
||||
|
||||
8. **耗时计算(JSON 解析、图片处理)放到 Isolate**
|
||||
```dart
|
||||
final result = await Isolate.run(() => jsonDecode(bigJson));
|
||||
```
|
||||
|
||||
### 图片
|
||||
|
||||
9. **网络图片使用 `Image.network` + `fit: BoxFit.cover`**,不要手动指定 `cacheWidth/cacheHeight`(会导致显示异常)
|
||||
10. **大量图片列表使用 `ListView.builder`**,不要 `ListView(children: [...])`
|
||||
|
||||
---
|
||||
|
||||
## 六、构建配置
|
||||
|
||||
### Android Release
|
||||
|
||||
- `build.gradle.kts` 中 release 配置:
|
||||
- `isMinifyEnabled = true` — R8 代码压缩
|
||||
- `isShrinkResources = true` — 移除未使用资源
|
||||
- ProGuard 规则在 `proguard-rules.pro` 维护
|
||||
- 打包命令:`flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info`
|
||||
|
||||
### iOS Release
|
||||
|
||||
- 构建命令:`flutter build ipa --release --obfuscate --split-debug-info=./debug-info`
|
||||
|
||||
---
|
||||
|
||||
## 七、命名规范
|
||||
|
||||
### 文件命名
|
||||
|
||||
| 类型 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| 页面 | `xxx_page.dart` | `login_page.dart` |
|
||||
| 控制器 | `xxx_controller.dart` | `auth_controller.dart` |
|
||||
| 数据源 | `xxx_remote_data_source.dart` | `spirit_remote_data_source.dart` |
|
||||
| 仓库实现 | `xxx_repository_impl.dart` | `auth_repository_impl.dart` |
|
||||
| 仓库接口 | `xxx_repository.dart` | `auth_repository.dart` |
|
||||
| 模型 | `xxx_model.dart` | `user_model.dart` |
|
||||
| Widget | `xxx_widget.dart` 或直接描述 | `gradient_button.dart` |
|
||||
| 生成文件 | `*.g.dart` / `*.freezed.dart` | 自动生成,不手动编辑 |
|
||||
|
||||
### 类命名
|
||||
|
||||
- PascalCase:`AuthController`、`DeviceControlPage`、`ServerException`
|
||||
- 私有前缀:`_buildHeader()`、`_positionSub`
|
||||
|
||||
### 变量命名
|
||||
|
||||
- camelCase:`currentUser`、`isPlaying`、`_audioPlayer`
|
||||
- Provider:`xxxProvider`(由 `@riverpod` 自动生成)
|
||||
|
||||
---
|
||||
|
||||
## 八、主题系统
|
||||
|
||||
### 颜色
|
||||
|
||||
- 全局颜色定义在 `theme/app_colors.dart`
|
||||
- 产品主题颜色在 `theme/product_theme.dart`(4 种产品类型)
|
||||
- **不要在 Widget 中硬编码颜色值**,优先使用 `AppColors.xxx` 或 `Theme.of(context)`
|
||||
|
||||
### 字体
|
||||
|
||||
- 主字体:DM Sans(正文)、Outfit(标题)、Press Start 2P(品牌像素风)
|
||||
- 通过 `google_fonts` 包引用,**运行时自动下载并缓存**
|
||||
- 本地字体:Inter(在 `pubspec.yaml` 中声明)
|
||||
|
||||
### 间距
|
||||
|
||||
- 使用 `AppSpacing.xs/sm/md/lg/xl` 常量
|
||||
- 不要在 Widget 中硬编码间距数值
|
||||
|
||||
---
|
||||
|
||||
## 九、Git 规范
|
||||
|
||||
### 分支命名
|
||||
|
||||
- 功能分支:`fix/auto-YYYYMMDD-HHMMSS`
|
||||
- 合并目标:`main`
|
||||
|
||||
### Commit 规范
|
||||
|
||||
- `fix:` — Bug 修复
|
||||
- `feat:` — 新功能
|
||||
- `refactor:` — 重构(不改变行为)
|
||||
- `chore:` — 构建配置、依赖更新等
|
||||
|
||||
---
|
||||
|
||||
## 十、检查清单(PR 提交前)
|
||||
|
||||
- [ ] `dart analyze` 无 Error(Warning/Info 可接受)
|
||||
- [ ] 所有 Stream 订阅已在 dispose 中取消
|
||||
- [ ] 所有 Controller/Player 已在 dispose 中释放
|
||||
- [ ] 多个独立 API 调用已用 `Future.wait` 并行化
|
||||
- [ ] 动画 Widget 使用了 `RepaintBoundary` 或 `AnimatedBuilder(child:)`
|
||||
- [ ] 新增的 Widget 构造函数尽可能标记为 `const`
|
||||
- [ ] 列表使用 `ListView.builder`,而非 `ListView(children:)`
|
||||
- [ ] 未引入新的硬编码颜色值(使用 AppColors 或 theme)
|
||||
- [ ] Android release 构建正常(minify + shrinkResources 不会崩溃)
|
||||
@ -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 Bundle(Google Play 自动按设备裁剪)
|
||||
flutter build appbundle --release --obfuscate --split-debug-info=./debug-info
|
||||
```
|
||||
|
||||
| 参数 | 作用 | 预估收益 |
|
||||
|------|------|----------|
|
||||
| `--split-per-abi` | 按 CPU 架构分包 | 减少 30-40% |
|
||||
| `--obfuscate` | 代码混淆 | 减少 5-10% |
|
||||
| `--split-debug-info` | 分离调试符号 | 减少 5-10% |
|
||||
| `minifyEnabled=true` | R8 代码压缩 | 减少 15-25% |
|
||||
| `shrinkResources=true` | 移除未使用资源 | 减少 5-10% |
|
||||
|
||||
#### 2.2 iOS 构建优化
|
||||
|
||||
**Xcode Build Settings 调整:**
|
||||
- Strip Debug Symbols During Copy → YES
|
||||
- Strip Linked Product → YES
|
||||
- Dead Code Stripping → YES
|
||||
- Optimization Level → -Oz (Smallest)
|
||||
|
||||
**构建命令:**
|
||||
```bash
|
||||
flutter build ipa --release --obfuscate --split-debug-info=./debug-info
|
||||
```
|
||||
|
||||
### 阶段 3:依赖瘦身
|
||||
|
||||
#### 3.1 审计当前依赖
|
||||
|
||||
| 依赖 | 大小影响 | 建议 |
|
||||
|------|----------|------|
|
||||
| `google_fonts` | 较大,会下载字体 | 已内嵌 Inter 字体,考虑移除 |
|
||||
| `webview_flutter` | ~4MB 原生库 | 仅在需要时加载(deferred) |
|
||||
| `video_player` | ~3-4MB | 考虑 deferred import |
|
||||
| `image` (dart包) | 纯 Dart 图像处理 | 检查是否真的需要 |
|
||||
| `http` | 与 `dio` 重复 | 统一用 dio,移除 http |
|
||||
| `flutter_svg` | 中等 | 保留(SVG 比 PNG 小得多) |
|
||||
|
||||
#### 3.2 移除 `google_fonts`
|
||||
|
||||
你已经在 `pubspec.yaml` 中内嵌了 Inter 字体,`google_fonts` 是多余的:
|
||||
```yaml
|
||||
# 移除这个依赖
|
||||
# google_fonts: ^6.1.0
|
||||
|
||||
# 已有本地字体
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
- asset: assets/fonts/Inter-Regular.ttf
|
||||
# ...
|
||||
```
|
||||
|
||||
代码中改用 `TextStyle(fontFamily: 'Inter')` 替代 `GoogleFonts.inter()`。
|
||||
|
||||
#### 3.3 移除重复的 `http` 包
|
||||
```yaml
|
||||
# 移除 - 已有 dio 做网络请求
|
||||
# http: ^1.2.0
|
||||
```
|
||||
|
||||
#### 3.4 字体子集化
|
||||
|
||||
当前 4 个 Inter 字体文件共 1.6MB。如果只需要中英文:
|
||||
```bash
|
||||
# 安装 fonttools
|
||||
pip install fonttools brotli
|
||||
|
||||
# 裁剪只保留需要的字符集(中文 + 英文 + 数字 + 标点)
|
||||
pyftsubset Inter-Regular.ttf \
|
||||
--text-file=used_chars.txt \
|
||||
--output-file=Inter-Regular-subset.ttf
|
||||
|
||||
# 通常可从 400KB → 50-80KB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、性能优化:降低发热与 CPU 占用
|
||||
|
||||
### 3.1 Widget 重建优化(最重要)
|
||||
|
||||
#### 使用 `const` 构造函数
|
||||
```dart
|
||||
// ❌ 每次 build 都创建新实例
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16), // 每次创建新对象
|
||||
child: Text('Hello'),
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 编译期常量,不会触发重建
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text('Hello'),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**全项目检查:** 运行 `dart analyze` 查看 `prefer_const_constructors` 警告。
|
||||
|
||||
#### 使用 `RepaintBoundary` 隔离重绘区域
|
||||
```dart
|
||||
// 对频繁更新的局部 Widget 包裹 RepaintBoundary
|
||||
RepaintBoundary(
|
||||
child: AnimatedWidget(...), // 只重绘这部分,不影响外部
|
||||
)
|
||||
```
|
||||
|
||||
适用场景:
|
||||
- 动画区域
|
||||
- 频繁刷新的倒计时/进度条
|
||||
- BLE 数据实时显示
|
||||
|
||||
#### 拆分 Widget,避免大范围重建
|
||||
```dart
|
||||
// ❌ 整个页面一个大 build 方法
|
||||
class MyPage extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(someProvider); // 任何变化都重建整个页面
|
||||
return Scaffold(
|
||||
body: Column(children: [Header(), Content(state), Footer()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 拆成独立 Widget,只监听需要的部分
|
||||
class _Content extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final data = ref.watch(someProvider.select((s) => s.data)); // 精准监听
|
||||
return ...;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Riverpod 状态管理优化
|
||||
|
||||
#### 使用 `select` 精准订阅
|
||||
```dart
|
||||
// ❌ 监听整个 state,任何字段变化都重建
|
||||
final state = ref.watch(deviceProvider);
|
||||
|
||||
// ✅ 只监听需要的字段
|
||||
final deviceName = ref.watch(deviceProvider.select((s) => s.name));
|
||||
final isConnected = ref.watch(deviceProvider.select((s) => s.isConnected));
|
||||
```
|
||||
|
||||
#### 区分 `watch` 和 `listen`
|
||||
```dart
|
||||
// watch: 用于 UI 显示的数据(需要重建)
|
||||
final name = ref.watch(provider.select((s) => s.name));
|
||||
|
||||
// listen: 用于副作用(弹窗、导航、日志),不触发重建
|
||||
ref.listen(provider, (prev, next) {
|
||||
if (next.hasError) showErrorDialog(context, next.error);
|
||||
});
|
||||
```
|
||||
|
||||
### 3.3 BLE 通信优化(降低发热的关键)
|
||||
|
||||
你的 app 大量使用蓝牙(`flutter_blue_plus`),这是发热的主要来源之一。
|
||||
|
||||
```dart
|
||||
// ❌ 持续高频扫描
|
||||
FlutterBluePlus.startScan(timeout: Duration(seconds: 30));
|
||||
|
||||
// ✅ 扫到目标设备后立即停止
|
||||
FlutterBluePlus.startScan(timeout: Duration(seconds: 10));
|
||||
// 找到目标后
|
||||
FlutterBluePlus.stopScan();
|
||||
|
||||
// ❌ 不必要的频繁读取特征值
|
||||
Timer.periodic(Duration(milliseconds: 100), (_) => characteristic.read());
|
||||
|
||||
// ✅ 使用 notify 替代轮询
|
||||
await characteristic.setNotifyValue(true);
|
||||
characteristic.onValueReceived.listen((value) {
|
||||
// 只在有新数据时处理
|
||||
});
|
||||
```
|
||||
|
||||
**BLE 省电策略:**
|
||||
- 连接成功后立即停止扫描
|
||||
- 使用 notification 而非 polling
|
||||
- 非活跃页面暂停 BLE 数据监听
|
||||
- 合理设置连接间隔参数
|
||||
|
||||
### 3.4 图片与内存管理
|
||||
|
||||
#### 网络图片缓存
|
||||
```yaml
|
||||
# 添加依赖
|
||||
dependencies:
|
||||
cached_network_image: ^3.3.0
|
||||
```
|
||||
|
||||
```dart
|
||||
// 使用缓存图片组件
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
memCacheWidth: 300, // 限制内存中图片尺寸
|
||||
memCacheHeight: 300,
|
||||
placeholder: (_, __) => const CircularProgressIndicator(),
|
||||
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||
)
|
||||
```
|
||||
|
||||
#### 大图降采样
|
||||
```dart
|
||||
// ❌ 直接加载 4.7MB 的 PNG 到内存
|
||||
Image.asset('assets/www/首页背景2.png')
|
||||
|
||||
// ✅ 指定缓存尺寸,降低内存占用
|
||||
Image.asset(
|
||||
'assets/www/首页背景2.webp',
|
||||
cacheWidth: 750, // 按屏幕宽度限制
|
||||
cacheHeight: 1334,
|
||||
)
|
||||
```
|
||||
|
||||
#### 页面销毁时释放资源
|
||||
```dart
|
||||
class _MyPageState extends State<MyPage> {
|
||||
VideoPlayerController? _videoController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController?.dispose(); // 必须释放!
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Isolate 处理耗时任务
|
||||
|
||||
```dart
|
||||
// 图片处理、数据解析等放到 Isolate
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// 简单任务用 compute
|
||||
final result = await compute(processImage, imageData);
|
||||
|
||||
// 复杂任务用 Isolate
|
||||
Future<Uint8List> processImageInIsolate(Uint8List rawData) async {
|
||||
return await Isolate.run(() {
|
||||
// 在独立线程处理图片
|
||||
return heavyImageProcessing(rawData);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
- BLE 接收的大量数据解析
|
||||
- 图片编解码(badge 图片处理)
|
||||
- JSON 大数据解析
|
||||
|
||||
---
|
||||
|
||||
## 四、UI 流畅度优化(消除卡顿)
|
||||
|
||||
### 4.1 列表优化
|
||||
|
||||
#### 使用 `ListView.builder` 而非 `ListView`
|
||||
```dart
|
||||
// ❌ 一次性创建所有子 Widget
|
||||
ListView(children: items.map((e) => ItemWidget(e)).toList())
|
||||
|
||||
// ✅ 按需创建可见项
|
||||
ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemExtent: 80, // 固定高度可提升滚动性能
|
||||
addAutomaticKeepAlives: false, // 不需要保活的列表关闭此项
|
||||
addRepaintBoundaries: true,
|
||||
itemBuilder: (context, index) => ItemWidget(items[index]),
|
||||
)
|
||||
```
|
||||
|
||||
#### 图片列表优化
|
||||
```dart
|
||||
ListView.builder(
|
||||
cacheExtent: 500, // 预缓存区域
|
||||
itemBuilder: (context, index) {
|
||||
return RepaintBoundary(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: items[index].imageUrl,
|
||||
memCacheWidth: 200, // 限制内存图片大小
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 Impeller 渲染引擎(Flutter 3.41.2 默认启用)
|
||||
|
||||
你的 Flutter 版本已默认使用 Impeller 渲染引擎,优势:
|
||||
- **消除 Shader 编译卡顿**(Skia 的最大痛点)
|
||||
- 更稳定的帧率
|
||||
- 更低的 GPU 内存占用
|
||||
|
||||
验证 Impeller 是否启用:
|
||||
```bash
|
||||
# iOS 默认启用,Android 3.22+ 默认启用
|
||||
flutter run --profile # 然后查看 DevTools
|
||||
```
|
||||
|
||||
如果遇到兼容性问题可临时回退:
|
||||
```bash
|
||||
flutter run --no-enable-impeller
|
||||
```
|
||||
|
||||
### 4.3 Shader 预热(如仍用 Skia)
|
||||
|
||||
```dart
|
||||
// 在 main.dart 中
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 预编译 shader 缓存
|
||||
await ShaderWarmUp().execute();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
SkSL 收集命令:
|
||||
```bash
|
||||
# 1. 收集 shader
|
||||
flutter run --profile --cache-sksl --purge-persistent-cache
|
||||
|
||||
# 2. 操作 app 中所有页面和动画
|
||||
|
||||
# 3. 按 M 导出 flutter_01.sksl.json
|
||||
|
||||
# 4. 使用缓存构建
|
||||
flutter build apk --bundle-sksl-path flutter_01.sksl.json
|
||||
```
|
||||
|
||||
### 4.4 页面切换优化
|
||||
|
||||
```dart
|
||||
// go_router 中预加载关键页面
|
||||
GoRoute(
|
||||
path: '/device-control',
|
||||
pageBuilder: (context, state) => CustomTransitionPage(
|
||||
transitionDuration: const Duration(milliseconds: 200), // 缩短动画时间
|
||||
child: const DeviceControlPage(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(opacity: animation, child: child); // 用 Fade 替代 Slide
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### 4.5 WebView 优化
|
||||
|
||||
```dart
|
||||
// WebView 是重量级组件
|
||||
// 1. 延迟初始化
|
||||
late final WebViewController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 延迟到页面完全展示后再初始化 WebView
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initWebView();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 页面销毁时清理
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.clearCache();
|
||||
_controller.clearLocalStorage();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、工具与监控
|
||||
|
||||
### 5.1 包大小分析
|
||||
```bash
|
||||
# 分析 APK 大小组成
|
||||
flutter build apk --analyze-size
|
||||
|
||||
# 分析 iOS 包大小
|
||||
flutter build ipa --analyze-size
|
||||
```
|
||||
|
||||
### 5.2 性能分析
|
||||
```bash
|
||||
# Profile 模式运行(有性能数据但接近 release 性能)
|
||||
flutter run --profile
|
||||
|
||||
# 打开 DevTools
|
||||
flutter pub global activate devtools
|
||||
flutter pub global run devtools
|
||||
```
|
||||
|
||||
### 5.3 检测无用代码和依赖
|
||||
```bash
|
||||
# 检测未使用的依赖
|
||||
dart pub deps --no-dev
|
||||
|
||||
# 分析代码问题
|
||||
dart analyze
|
||||
```
|
||||
|
||||
### 5.4 Glance(APM 监控库)
|
||||
```yaml
|
||||
# 生产环境卡顿检测
|
||||
dependencies:
|
||||
glance_flutter: ^0.x.x # 实验性
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实施优先级与预期效果
|
||||
|
||||
### P0 紧急(1-2天,收益巨大)
|
||||
|
||||
| 任务 | 预期收益 |
|
||||
|------|----------|
|
||||
| 视频/音频迁移到 CDN | 包大小 -107MB (80%) |
|
||||
| 背景图转 WebP 压缩 | 包大小 -8MB |
|
||||
| 启用 `minifyEnabled` + `shrinkResources` | 包大小 -15-25% |
|
||||
| `--split-per-abi` 分架构打包 | APK -30-40% |
|
||||
|
||||
### P1 重要(3-5天)
|
||||
|
||||
| 任务 | 预期收益 |
|
||||
|------|----------|
|
||||
| 移除 `google_fonts` 和 `http` 包 | 包大小 -2-4MB |
|
||||
| 字体子集化 | 包大小 -1.2MB |
|
||||
| `--obfuscate --split-debug-info` | 包大小 -5-10% |
|
||||
| BLE 扫描/通信优化 | 降低发热 30-50% |
|
||||
| 图片内存管理 (cacheWidth/Height) | 降低内存 40% |
|
||||
|
||||
### P2 优化(1-2周)
|
||||
|
||||
| 任务 | 预期收益 |
|
||||
|------|----------|
|
||||
| 全项目 const 构造函数审查 | 减少 Widget 重建 |
|
||||
| Riverpod select 精准订阅 | 减少不必要重建 |
|
||||
| 列表 ListView.builder 优化 | 滚动更流畅 |
|
||||
| 耗时任务移入 Isolate | 消除主线程卡顿 |
|
||||
| RepaintBoundary 隔离动画区域 | 减少重绘范围 |
|
||||
| Deferred import (webview/video) | 减小初始加载 |
|
||||
|
||||
### P3 持续(长期)
|
||||
|
||||
| 任务 | 预期收益 |
|
||||
|------|----------|
|
||||
| 接入性能监控 (Glance/Sentry) | 线上问题感知 |
|
||||
| 定期 `flutter build --analyze-size` | 包大小回归检测 |
|
||||
| CI 集成包大小检查 | 防止包大小退化 |
|
||||
|
||||
---
|
||||
|
||||
## 七、预期总体效果
|
||||
|
||||
| 指标 | 优化前 | 优化后(预估) | 改善 |
|
||||
|------|--------|----------------|------|
|
||||
| APK 大小 | ~150-180MB | ~25-35MB | **80%+** |
|
||||
| IPA 大小 | ~200MB+ | ~40-60MB | **70%+** |
|
||||
| 冷启动时间 | 3-5s | 1-2s | **60%** |
|
||||
| 内存占用 | 较高 | 降低 40%+ | **40%+** |
|
||||
| 手机发热 | 明显 | 温热 | **显著改善** |
|
||||
| 滚动帧率 | 有掉帧 | 稳定 60fps | **流畅** |
|
||||
|
||||
---
|
||||
|
||||
## 八、Impeller 渲染引擎性能数据
|
||||
|
||||
Flutter 3.41.2 已默认启用 Impeller(替代 Skia),关键性能数据:
|
||||
|
||||
| 维度 | Skia | Impeller | 改善 |
|
||||
|------|------|---------|------|
|
||||
| Shader 编译 | 运行时 JIT(首次卡顿 20-200ms) | 构建时 AOT 预编译 | **消除 Shader 卡顿** |
|
||||
| GPU 光栅化耗时 | 4.05ms | 2.81ms | **-30%** |
|
||||
| 120Hz 帧率达标率 | 67.1% | 91.6% | **+36%** |
|
||||
| 复杂场景掉帧率 | ~12% | ~1.5% | **-87%** |
|
||||
|
||||
确认 Impeller 状态:
|
||||
```bash
|
||||
flutter run --profile # 然后在 DevTools 中查看渲染后端
|
||||
```
|
||||
|
||||
Android 持久化配置(`AndroidManifest.xml`):
|
||||
```xml
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="true" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、低端设备适配策略
|
||||
|
||||
针对不同档次手机动态降级:
|
||||
|
||||
```dart
|
||||
// 设备分级
|
||||
enum DeviceTier { low, mid, high }
|
||||
|
||||
DeviceTier getDeviceTier() {
|
||||
final memory = SysInfo.getTotalPhysicalMemory(); // 需原生获取
|
||||
if (memory < 3 * 1024 * 1024 * 1024) return DeviceTier.low;
|
||||
if (memory < 6 * 1024 * 1024 * 1024) return DeviceTier.mid;
|
||||
return DeviceTier.high;
|
||||
}
|
||||
```
|
||||
|
||||
| 策略 | 低端 | 中端 | 高端 |
|
||||
|------|------|------|------|
|
||||
| 动画 | 关闭粒子/复杂动画 | 简化动画 | 全特效 |
|
||||
| 图片 | WebP 降分辨率 30% | 标准分辨率 | 高清 |
|
||||
| BackdropFilter | 纯色替代 | 简化模糊 | 全高斯模糊 |
|
||||
| 并发网络请求 | 限 2 个 | 限 4 个 | 不限制 |
|
||||
|
||||
---
|
||||
|
||||
## 十、生产环境性能监控
|
||||
|
||||
| 工具 | 类型 | 用途 |
|
||||
|------|------|------|
|
||||
| [Glance](https://github.com/littleGnAl/glance) | 开源 APM | 采集卡顿时的堆栈,定位具体函数 |
|
||||
| [flutter_smooth](https://github.com/fzyzcjy/flutter_smooth) | 开源库 | 即使 Widget 树极重也能达到 ~60fps |
|
||||
| Firebase Performance | 云端 APM | 启动时间、网络延迟、自定义追踪 |
|
||||
| Sentry | 错误+性能 | 崩溃报告+性能事务 |
|
||||
|
||||
### 自动化性能测试(CI 集成)
|
||||
|
||||
```dart
|
||||
// integration_test/performance_test.dart
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('scrolling performance', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await binding.traceAction(() async {
|
||||
await tester.fling(find.byType(ListView), const Offset(0, -500), 10000);
|
||||
await tester.pumpAndSettle();
|
||||
}, reportKey: 'scrolling_timeline');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、参考资源
|
||||
|
||||
### 官方文档
|
||||
- [Flutter Performance Best Practices](https://docs.flutter.dev/perf/best-practices)
|
||||
- [App Size Optimization](https://docs.flutter.dev/perf/app-size)
|
||||
- [Impeller Rendering Engine](https://docs.flutter.dev/perf/impeller)
|
||||
- [Deferred Components](https://docs.flutter.dev/perf/deferred-components)
|
||||
- [Shader Compilation Jank](https://docs.flutter.dev/perf/shader)
|
||||
|
||||
### 优秀开源项目参考
|
||||
- [flutter_deer](https://github.com/simplezhli/flutter_deer) — 完善的 Flutter 实践项目,代码规范
|
||||
- [Best-Flutter-UI-Templates](https://github.com/mitesh77/Best-Flutter-UI-Templates) — UI 模板合集
|
||||
- [Solido/awesome-flutter](https://github.com/Solido/awesome-flutter) — Flutter 最全资源汇总
|
||||
|
||||
### 工具
|
||||
- [Glance](https://pub.dev/packages/glance_flutter) — APM 卡顿检测
|
||||
- [TinyPNG](https://tinypng.com/) — PNG/WebP 压缩
|
||||
- [pngquant](https://pngquant.org/) — 批量 PNG 压缩
|
||||
- [pyftsubset](https://fonttools.readthedocs.io/) — 字体子集化
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -26,10 +26,6 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要相机权限来拍照传图到徽章设备</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要相册权限来选择图片传图到徽章设备</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../features/auth/presentation/pages/login_page.dart';
|
||||
import '../../pages/bluetooth_page.dart';
|
||||
@ -13,27 +9,10 @@ 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 +28,6 @@ GoRouter goRouter(Ref ref) {
|
||||
return '/login';
|
||||
}
|
||||
if (hasToken && isLoginRoute) {
|
||||
// 登录成功 → 获取已绑定设备列表
|
||||
try {
|
||||
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
||||
final devices = await dataSource.getMyDevices();
|
||||
debugPrint('[Router] 已绑定设备数: ${devices.length}');
|
||||
|
||||
if (devices.isNotEmpty) {
|
||||
// 收集用户当前绑定的所有业务路由
|
||||
final boundRoutes = <String>{};
|
||||
for (final d in devices) {
|
||||
final dt = d.device.deviceType ?? d.device.deviceTypeInfo;
|
||||
final code = dt?.productCode ?? '';
|
||||
final m = _productCodeRoutes[code];
|
||||
if (m != null) boundRoutes.add(m.route);
|
||||
}
|
||||
|
||||
// 优先恢复上次退出时的业务页面(需确认该设备仍在绑定中)
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedRoute = prefs.getString(_lastRouteKey);
|
||||
final savedProductType = prefs.getString(_lastProductTypeKey);
|
||||
if (savedRoute != null && boundRoutes.contains(savedRoute)) {
|
||||
debugPrint('[Router] 恢复上次页面: $savedRoute');
|
||||
if (savedProductType != null) {
|
||||
final pt = ProductType.values.where((e) => e.name == savedProductType).firstOrNull;
|
||||
if (pt != null) {
|
||||
ref.read(currentProductTypeProvider.notifier).set(pt);
|
||||
}
|
||||
}
|
||||
return savedRoute;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Router] 读取上次页面失败: $e');
|
||||
}
|
||||
|
||||
// Fallback:按设备最近使用时间跳转
|
||||
devices.sort((a, b) {
|
||||
final ta = a.device.lastOnlineAt ?? '';
|
||||
final tb = b.device.lastOnlineAt ?? '';
|
||||
return tb.compareTo(ta);
|
||||
});
|
||||
final recent = devices.first;
|
||||
final resolvedDt = recent.device.deviceType ?? recent.device.deviceTypeInfo;
|
||||
final code = resolvedDt?.productCode ?? '';
|
||||
debugPrint('[Router] 最近设备 sn=${recent.device.sn}, productCode=$code');
|
||||
final mapping = _productCodeRoutes[code];
|
||||
if (mapping != null) {
|
||||
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
|
||||
return mapping.route;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Router] 获取设备失败: $e');
|
||||
}
|
||||
return '/home';
|
||||
}
|
||||
return null;
|
||||
@ -136,61 +61,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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,4 +48,4 @@ final class GoRouterProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c';
|
||||
String _$goRouterHash() => r'9f77a00bcbc90890c4b6594a9709288e5206c7d8';
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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}');
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// 编码为 JPEG(quality 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(' + ');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
};
|
||||
@ -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: '💥'),
|
||||
];
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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: ', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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_code,fallback 到 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?,
|
||||
|
||||
@ -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'] == null
|
||||
? null
|
||||
: DeviceType.fromJson(json['device_type'] as Map<String, dynamic>),
|
||||
deviceTypeInfo: json['device_type_info'] == null
|
||||
? null
|
||||
: DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>),
|
||||
macAddress: json['mac_address'] as String?,
|
||||
name: json['name'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'in_stock',
|
||||
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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
};
|
||||
@ -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});
|
||||
}
|
||||
|
||||
@ -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,15 @@ class DeviceController extends _$DeviceController {
|
||||
);
|
||||
}
|
||||
|
||||
/// 绑定设备,成功返回 null,失败返回错误信息
|
||||
Future<String?> bindDevice(String sn, {int? spiritId}) async {
|
||||
Future<bool> bindDevice(String sn, {int? spiritId}) async {
|
||||
final repository = ref.read(deviceRepositoryProvider);
|
||||
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
||||
if (!ref.mounted) return null; // 组件已卸载,绑定请求已发出,视为成功
|
||||
if (!ref.mounted) return false;
|
||||
return result.fold(
|
||||
(failure) => failure.message,
|
||||
(failure) => false,
|
||||
(bindingId) {
|
||||
if (ref.mounted) ref.invalidateSelf();
|
||||
return null;
|
||||
ref.invalidateSelf();
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -40,7 +38,6 @@ class DeviceController extends _$DeviceController {
|
||||
return result.fold(
|
||||
(failure) => false,
|
||||
(_) {
|
||||
if (!ref.mounted) return false;
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(
|
||||
current.where((d) => d.id != userDeviceId).toList(),
|
||||
@ -57,7 +54,6 @@ class DeviceController extends _$DeviceController {
|
||||
return result.fold(
|
||||
(failure) => false,
|
||||
(updated) {
|
||||
if (!ref.mounted) return false;
|
||||
ref.invalidateSelf();
|
||||
return true;
|
||||
},
|
||||
@ -65,7 +61,6 @@ class DeviceController extends _$DeviceController {
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
if (!ref.mounted) return;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
@ -90,7 +85,6 @@ class DeviceDetailController extends _$DeviceDetailController {
|
||||
return result.fold(
|
||||
(failure) => false,
|
||||
(_) {
|
||||
if (!ref.mounted) return false;
|
||||
ref.invalidateSelf();
|
||||
return true;
|
||||
},
|
||||
@ -104,7 +98,6 @@ class DeviceDetailController extends _$DeviceDetailController {
|
||||
return result.fold(
|
||||
(failure) => false,
|
||||
(_) {
|
||||
if (!ref.mounted) return false;
|
||||
ref.invalidateSelf();
|
||||
return true;
|
||||
},
|
||||
@ -112,26 +105,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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ final class DeviceControllerProvider
|
||||
DeviceController create() => DeviceController();
|
||||
}
|
||||
|
||||
String _$deviceControllerHash() => r'94e697fab82bfeb03a25eb12fc548fed925ef5cc';
|
||||
String _$deviceControllerHash() => r'3f73a13c7f93fecb9fe781efc4ee305b6186639e';
|
||||
|
||||
/// 管理用户设备列表
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../widgets/animated_gradient_background.dart';
|
||||
import '../features/device/presentation/controllers/device_controller.dart';
|
||||
import '../features/device/domain/entities/device.dart';
|
||||
import '../theme/product_theme.dart';
|
||||
|
||||
class ProductSelectionPage extends ConsumerStatefulWidget {
|
||||
const ProductSelectionPage({super.key});
|
||||
@ -38,13 +38,6 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
||||
'badge-basic': ['DZBJ-OFF'],
|
||||
};
|
||||
|
||||
/// 产品 ID 到 ProductType 的映射
|
||||
static const Map<String, ProductType> _productTypeMap = {
|
||||
'capybara': ProductType.capybara,
|
||||
'badge-ai': ProductType.badgeAi,
|
||||
'badge-basic': ProductType.badgeBasic,
|
||||
};
|
||||
|
||||
/// 查找用户是否已绑定该产品类型的设备
|
||||
UserDevice? _findBoundDevice(String productId, List<UserDevice> devices) {
|
||||
final codes = _productCodeMap[productId];
|
||||
@ -197,23 +190,12 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
||||
fadeStartY: headerHeight + 16,
|
||||
fadeEndY: safeTop,
|
||||
onTap: () {
|
||||
// 设置当前产品主题
|
||||
final productType = _productTypeMap[product['id']] ?? ProductType.common;
|
||||
ref.read(currentProductTypeProvider.notifier).set(productType);
|
||||
|
||||
if (boundDevice != null) {
|
||||
// 已绑定 → 根据产品类型进入对应控制页
|
||||
final pid = product['id'] as String;
|
||||
if (pid == 'badge-ai') {
|
||||
context.go('/badge-control');
|
||||
} else if (pid == 'badge-basic') {
|
||||
context.go('/badge-basic-control');
|
||||
} else {
|
||||
context.go('/device-control');
|
||||
}
|
||||
// 已绑定 → 直接进入设备控制页
|
||||
context.go('/device-control');
|
||||
} else {
|
||||
// 未绑定 → 跳转蓝牙搜索页(push 保留返回栈)
|
||||
context.push('/bluetooth');
|
||||
// 未绑定 → 跳转蓝牙搜索页
|
||||
context.go('/bluetooth');
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -232,7 +214,7 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
||||
children: [
|
||||
Text(
|
||||
'选择产品',
|
||||
style: TextStyle(fontFamily: 'Outfit',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
@ -364,7 +346,7 @@ class _ProductCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
product['name'],
|
||||
style: TextStyle(fontFamily: 'Outfit',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
|
||||
@ -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}' : '删除失败');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
'🔐',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../widgets/glass_dialog.dart';
|
||||
import '../widgets/animated_gradient_background.dart';
|
||||
import '../widgets/ios_toast.dart';
|
||||
@ -99,7 +100,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
),
|
||||
Text(
|
||||
'设置',
|
||||
style: TextStyle(fontFamily: 'Outfit',
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1F2937),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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';
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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:
|
||||
@ -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:
|
||||
@ -964,14 +940,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:
|
||||
@ -1297,46 +1265,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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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..."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user