Compare commits
No commits in common. "main" and "fix/auto-20260228-145348" 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):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- video_player_avfoundation (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@ -39,7 +36,6 @@ DEPENDENCIES:
|
|||||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- 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`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@ -64,8 +60,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
video_player_avfoundation:
|
|
||||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
@ -80,7 +74,6 @@ SPEC CHECKSUMS:
|
|||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
|
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|||||||
@ -26,10 +26,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>需要相机权限来拍照传图到徽章设备</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>需要相册权限来选择图片传图到徽章设备</string>
|
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
<string>需要蓝牙权限来搜索和连接您的设备</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
|||||||
@ -143,6 +143,7 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
'/auth/phone-login/',
|
'/auth/phone-login/',
|
||||||
'/auth/refresh/',
|
'/auth/refresh/',
|
||||||
'/version/check/',
|
'/version/check/',
|
||||||
|
'/devices/query-by-mac/',
|
||||||
];
|
];
|
||||||
|
|
||||||
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
|
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
|
|
||||||
class ApiConfig {
|
class ApiConfig {
|
||||||
/// 本地开发地址(Web 调试用)
|
/// 后端服务器地址(本地开发环境)
|
||||||
static const String _localUrl = 'http://192.168.124.8:8000';
|
static const String baseUrl = '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;
|
|
||||||
|
|
||||||
/// 一键授权登录专用域名(HTTPS,用于阿里云号码认证)
|
/// 一键授权登录专用域名(HTTPS,用于阿里云号码认证)
|
||||||
static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art';
|
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:go_router/go_router.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../../features/auth/presentation/pages/login_page.dart';
|
import '../../features/auth/presentation/pages/login_page.dart';
|
||||||
import '../../pages/bluetooth_page.dart';
|
import '../../pages/bluetooth_page.dart';
|
||||||
@ -13,27 +9,10 @@ import '../../pages/product_selection_page.dart';
|
|||||||
import '../../pages/profile/profile_page.dart';
|
import '../../pages/profile/profile_page.dart';
|
||||||
import '../../pages/webview_page.dart';
|
import '../../pages/webview_page.dart';
|
||||||
import '../../pages/wifi_config_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';
|
import '../network/token_manager.dart';
|
||||||
|
|
||||||
part 'app_router.g.dart';
|
part 'app_router.g.dart';
|
||||||
|
|
||||||
const _lastRouteKey = 'last_business_route';
|
|
||||||
const _lastProductTypeKey = 'last_product_type';
|
|
||||||
const _validBusinessRoutes = {'/device-control', '/badge-control', '/badge-basic-control'};
|
|
||||||
|
|
||||||
/// 产品代码 → 路由 + ProductType 的映射
|
|
||||||
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
|
@riverpod
|
||||||
GoRouter goRouter(Ref ref) {
|
GoRouter goRouter(Ref ref) {
|
||||||
final tokenManager = ref.watch(tokenManagerProvider);
|
final tokenManager = ref.watch(tokenManagerProvider);
|
||||||
@ -49,60 +28,6 @@ GoRouter goRouter(Ref ref) {
|
|||||||
return '/login';
|
return '/login';
|
||||||
}
|
}
|
||||||
if (hasToken && isLoginRoute) {
|
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 '/home';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -136,61 +61,6 @@ GoRouter goRouter(Ref ref) {
|
|||||||
path: '/webview_fallback',
|
path: '/webview_fallback',
|
||||||
builder: (context, state) => const WebViewPage(),
|
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/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../../../core/services/phone_auth_service.dart';
|
import '../../../../core/services/phone_auth_service.dart';
|
||||||
import '../../../../theme/app_colors.dart';
|
import '../../../../theme/app_colors.dart';
|
||||||
@ -94,7 +95,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'服务协议',
|
'服务协议',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -218,7 +219,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
|
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
|
||||||
debugPrint('[Login] tokenLogin 结果: $success');
|
debugPrint('[Login] tokenLogin 结果: $success');
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
context.go('/login');
|
await _navigateAfterLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,8 +274,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
.read(authControllerProvider.notifier)
|
.read(authControllerProvider.notifier)
|
||||||
.codeLogin(_phoneController.text, _codeController.text);
|
.codeLogin(_phoneController.text, _codeController.text);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
// 登录成功后跳到 /login,触发 router redirect 统一处理跳转
|
await _navigateAfterLogin();
|
||||||
context.go('/login');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Logo — 匹配 HTML .login-logo
|
||||||
Text(
|
Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
style: TextStyle(fontFamily: 'Press Start 2P',
|
style: GoogleFonts.pressStart2p(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1), // 靛蓝
|
color: const Color(0xFF6366F1), // 靛蓝
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -462,7 +480,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'欢迎使用 Airhub',
|
'欢迎使用 Airhub',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF6B5B95),
|
color: const Color(0xFF6B5B95),
|
||||||
@ -727,7 +745,7 @@ class _AgreementContentPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
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;
|
|
||||||
}
|
|
||||||
@ -3,23 +3,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'device.freezed.dart';
|
part 'device.freezed.dart';
|
||||||
part 'device.g.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
|
@freezed
|
||||||
abstract class DeviceType with _$DeviceType {
|
abstract class DeviceType with _$DeviceType {
|
||||||
const factory DeviceType({
|
const factory DeviceType({
|
||||||
@ -41,12 +24,11 @@ abstract class DeviceInfo with _$DeviceInfo {
|
|||||||
const factory DeviceInfo({
|
const factory DeviceInfo({
|
||||||
required int id,
|
required int id,
|
||||||
required String sn,
|
required String sn,
|
||||||
@_SafeDeviceTypeConverter() DeviceType? deviceType,
|
DeviceType? deviceType,
|
||||||
@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo,
|
DeviceType? deviceTypeInfo,
|
||||||
String? macAddress,
|
String? macAddress,
|
||||||
@Default('') String name,
|
@Default('') String name,
|
||||||
@Default('in_stock') String status,
|
@Default('in_stock') String status,
|
||||||
@Default(false) bool isOnline,
|
|
||||||
@Default('') String firmwareVersion,
|
@Default('') String firmwareVersion,
|
||||||
String? lastOnlineAt,
|
String? lastOnlineAt,
|
||||||
String? createdAt,
|
String? createdAt,
|
||||||
|
|||||||
@ -296,7 +296,7 @@ as String?,
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$DeviceInfo {
|
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
|
/// Create a copy of DeviceInfo
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@ -309,16 +309,16 @@ $DeviceInfoCopyWith<DeviceInfo> get copyWith => _$DeviceInfoCopyWithImpl<DeviceI
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of DeviceInfo
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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
|
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 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?,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,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 String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,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,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?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
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) {
|
switch (_that) {
|
||||||
case _DeviceInfo() when $default != null:
|
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();
|
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) {
|
switch (_that) {
|
||||||
case _DeviceInfo():
|
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');
|
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) {
|
switch (_that) {
|
||||||
case _DeviceInfo() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -524,17 +523,16 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _DeviceInfo implements DeviceInfo {
|
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);
|
factory _DeviceInfo.fromJson(Map<String, dynamic> json) => _$DeviceInfoFromJson(json);
|
||||||
|
|
||||||
@override final int id;
|
@override final int id;
|
||||||
@override final String sn;
|
@override final String sn;
|
||||||
@override@_SafeDeviceTypeConverter() final DeviceType? deviceType;
|
@override final DeviceType? deviceType;
|
||||||
@override@_SafeDeviceTypeConverter() final DeviceType? deviceTypeInfo;
|
@override final DeviceType? deviceTypeInfo;
|
||||||
@override final String? macAddress;
|
@override final String? macAddress;
|
||||||
@override@JsonKey() final String name;
|
@override@JsonKey() final String name;
|
||||||
@override@JsonKey() final String status;
|
@override@JsonKey() final String status;
|
||||||
@override@JsonKey() final bool isOnline;
|
|
||||||
@override@JsonKey() final String firmwareVersion;
|
@override@JsonKey() final String firmwareVersion;
|
||||||
@override final String? lastOnlineAt;
|
@override final String? lastOnlineAt;
|
||||||
@override final String? createdAt;
|
@override final String? createdAt;
|
||||||
@ -552,16 +550,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of DeviceInfo
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_DeviceInfo(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
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
|
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 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?,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,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 String,firmwareVersion: null == firmwareVersion ? _self.firmwareVersion : firmwareVersion // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,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,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?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
|||||||
@ -30,36 +30,33 @@ Map<String, dynamic> _$DeviceTypeToJson(_DeviceType instance) =>
|
|||||||
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
|
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
|
||||||
id: (json['id'] as num).toInt(),
|
id: (json['id'] as num).toInt(),
|
||||||
sn: json['sn'] as String,
|
sn: json['sn'] as String,
|
||||||
deviceType: const _SafeDeviceTypeConverter().fromJson(json['device_type']),
|
deviceType: json['device_type'] == null
|
||||||
deviceTypeInfo: const _SafeDeviceTypeConverter().fromJson(
|
? null
|
||||||
json['device_type_info'],
|
: 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?,
|
macAddress: json['mac_address'] as String?,
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
status: json['status'] as String? ?? 'in_stock',
|
status: json['status'] as String? ?? 'in_stock',
|
||||||
isOnline: json['is_online'] as bool? ?? false,
|
|
||||||
firmwareVersion: json['firmware_version'] as String? ?? '',
|
firmwareVersion: json['firmware_version'] as String? ?? '',
|
||||||
lastOnlineAt: json['last_online_at'] as String?,
|
lastOnlineAt: json['last_online_at'] as String?,
|
||||||
createdAt: json['created_at'] as String?,
|
createdAt: json['created_at'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DeviceInfoToJson(
|
Map<String, dynamic> _$DeviceInfoToJson(_DeviceInfo instance) =>
|
||||||
_DeviceInfo instance,
|
<String, dynamic>{
|
||||||
) => <String, dynamic>{
|
'id': instance.id,
|
||||||
'id': instance.id,
|
'sn': instance.sn,
|
||||||
'sn': instance.sn,
|
'device_type': instance.deviceType,
|
||||||
'device_type': const _SafeDeviceTypeConverter().toJson(instance.deviceType),
|
'device_type_info': instance.deviceTypeInfo,
|
||||||
'device_type_info': const _SafeDeviceTypeConverter().toJson(
|
'mac_address': instance.macAddress,
|
||||||
instance.deviceTypeInfo,
|
'name': instance.name,
|
||||||
),
|
'status': instance.status,
|
||||||
'mac_address': instance.macAddress,
|
'firmware_version': instance.firmwareVersion,
|
||||||
'name': instance.name,
|
'last_online_at': instance.lastOnlineAt,
|
||||||
'status': instance.status,
|
'created_at': instance.createdAt,
|
||||||
'is_online': instance.isOnline,
|
};
|
||||||
'firmware_version': instance.firmwareVersion,
|
|
||||||
'last_online_at': instance.lastOnlineAt,
|
|
||||||
'created_at': instance.createdAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
_UserDevice _$UserDeviceFromJson(Map<String, dynamic> json) => _UserDevice(
|
_UserDevice _$UserDeviceFromJson(Map<String, dynamic> json) => _UserDevice(
|
||||||
id: (json['id'] as num).toInt(),
|
id: (json['id'] as num).toInt(),
|
||||||
|
|||||||
@ -19,16 +19,16 @@ class DeviceController extends _$DeviceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 绑定设备,成功返回 null,失败返回错误信息
|
Future<bool> bindDevice(String sn, {int? spiritId}) async {
|
||||||
Future<String?> bindDevice(String sn, {int? spiritId}) async {
|
|
||||||
final repository = ref.read(deviceRepositoryProvider);
|
final repository = ref.read(deviceRepositoryProvider);
|
||||||
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
final result = await repository.bindDevice(sn, spiritId: spiritId);
|
||||||
if (!ref.mounted) return null; // 组件已卸载,绑定请求已发出,视为成功
|
if (!ref.mounted) return false;
|
||||||
return result.fold(
|
return result.fold(
|
||||||
(failure) => failure.message,
|
(failure) => false,
|
||||||
(bindingId) {
|
(bindingId) {
|
||||||
if (ref.mounted) ref.invalidateSelf();
|
if (!ref.mounted) return false;
|
||||||
return null;
|
ref.invalidateSelf();
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/services/ble_provisioning_service.dart';
|
import '../core/services/ble_provisioning_service.dart';
|
||||||
@ -13,7 +14,6 @@ import '../features/device/presentation/controllers/device_controller.dart';
|
|||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
import '../widgets/glass_dialog.dart';
|
import '../widgets/glass_dialog.dart';
|
||||||
import '../theme/product_theme.dart';
|
|
||||||
|
|
||||||
/// 设备类型
|
/// 设备类型
|
||||||
enum DeviceType { plush, badgeAi, badge }
|
enum DeviceType { plush, badgeAi, badge }
|
||||||
@ -26,7 +26,6 @@ class MockDevice {
|
|||||||
final DeviceType type;
|
final DeviceType type;
|
||||||
final bool hasAI;
|
final bool hasAI;
|
||||||
final bool isNetworkRequired;
|
final bool isNetworkRequired;
|
||||||
final String bindStatus; // unbound / bound_by_me / bound_by_other
|
|
||||||
final BluetoothDevice? bleDevice;
|
final BluetoothDevice? bleDevice;
|
||||||
|
|
||||||
const MockDevice({
|
const MockDevice({
|
||||||
@ -36,12 +35,9 @@ class MockDevice {
|
|||||||
required this.type,
|
required this.type,
|
||||||
required this.hasAI,
|
required this.hasAI,
|
||||||
this.isNetworkRequired = true,
|
this.isNetworkRequired = true,
|
||||||
this.bindStatus = 'unbound',
|
|
||||||
this.bleDevice,
|
this.bleDevice,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isBoundByOther => bindStatus == 'bound_by_other';
|
|
||||||
|
|
||||||
String get iconPath {
|
String get iconPath {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DeviceType.plush:
|
case DeviceType.plush:
|
||||||
@ -222,19 +218,10 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 持续扫描(不设超时),由 _queryDeviceByMac 成功后停止
|
// 持续扫描(不设超时),由 _queryDeviceByMac 成功后停止
|
||||||
try {
|
await FlutterBluePlus.startScan(
|
||||||
await FlutterBluePlus.startScan(
|
timeout: const Duration(seconds: 30),
|
||||||
timeout: const Duration(seconds: 30),
|
androidUsesFineLocation: true,
|
||||||
androidUsesFineLocation: true,
|
);
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Web 平台: 用户取消 requestDevice() 选择器会抛出 FlutterBluePlusException
|
|
||||||
debugPrint('[BLE Scan] startScan 异常(用户可能取消了选择器): $e');
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isSearching = false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 30 秒兜底超时:如果始终没找到设备
|
// 30 秒兜底超时:如果始终没找到设备
|
||||||
if (mounted && _isSearching) {
|
if (mounted && _isSearching) {
|
||||||
@ -256,10 +243,8 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
_macInfoCache[mac] = data;
|
_macInfoCache[mac] = data;
|
||||||
|
|
||||||
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
|
final deviceTypeName = data['device_type']?['name'] as String? ?? '';
|
||||||
final productCode = data['device_type']?['product_code'] as String? ?? '';
|
|
||||||
final sn = data['sn'] as String? ?? '';
|
final sn = data['sn'] as String? ?? '';
|
||||||
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
|
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
|
||||||
final bindStatus = data['bind_status'] as String? ?? 'unbound';
|
|
||||||
final bleDevice = _pendingBleDevices[mac];
|
final bleDevice = _pendingBleDevices[mac];
|
||||||
|
|
||||||
// API 返回了有效设备名 → 添加到列表
|
// API 返回了有效设备名 → 添加到列表
|
||||||
@ -272,10 +257,9 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
sn: sn,
|
sn: sn,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
macAddress: mac,
|
macAddress: mac,
|
||||||
type: _inferDeviceTypeByCode(productCode, displayName),
|
type: _inferDeviceType(displayName),
|
||||||
hasAI: _inferHasAI(displayName),
|
hasAI: _inferHasAI(displayName),
|
||||||
isNetworkRequired: isNetworkRequired,
|
isNetworkRequired: isNetworkRequired,
|
||||||
bindStatus: bindStatus,
|
|
||||||
bleDevice: bleDevice,
|
bleDevice: bleDevice,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -289,45 +273,36 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName');
|
debugPrint('[Bluetooth] 设备已就绪: $mac → $displayName');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
|
debugPrint('[Bluetooth] queryByMac 失败($mac): $e');
|
||||||
|
// API 查询失败时,用 BLE 名作为 fallback 也显示出来
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// 查询失败 → 停止扫描,提示用户
|
final bleDevice = _pendingBleDevices[mac];
|
||||||
setState(() => _isSearching = false);
|
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 (_) {}
|
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
||||||
_macInfoCache.remove(mac); // 移除占位,允许重新扫描时再查
|
|
||||||
showGlassDialog(
|
|
||||||
context: context,
|
|
||||||
title: '设备查询失败',
|
|
||||||
description: '无法验证设备信息,请检查网络后重试。',
|
|
||||||
confirmText: '确定',
|
|
||||||
onConfirm: () => Navigator.of(context).pop(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据设备名称推断设备类型
|
/// 根据设备名称推断设备类型
|
||||||
/// 优先用 product_code 精确匹配,fallback 到名称推断
|
DeviceType _inferDeviceType(String name) {
|
||||||
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: 名称推断
|
|
||||||
final lower = name.toLowerCase();
|
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;
|
return DeviceType.plush;
|
||||||
}
|
}
|
||||||
if (lower.contains('ai') || lower.contains('智能')) {
|
if (lower.contains('ai') || lower.contains('智能')) {
|
||||||
return DeviceType.badgeAi;
|
return DeviceType.badgeAi;
|
||||||
}
|
}
|
||||||
if (lower.contains('吧唧') || lower.contains('badge')) {
|
return DeviceType.badge;
|
||||||
return DeviceType.badge;
|
|
||||||
}
|
|
||||||
return DeviceType.plush;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据设备名称推断是否支持 AI
|
/// 根据设备名称推断是否支持 AI
|
||||||
@ -379,21 +354,6 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
|
|
||||||
bool _isConnecting = false;
|
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 {
|
Future<void> _handleConnect() async {
|
||||||
if (_devices.isEmpty || _isConnecting) return;
|
if (_devices.isEmpty || _isConnecting) return;
|
||||||
@ -405,55 +365,11 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final device = _devices[_currentIndex];
|
final device = _devices[_currentIndex];
|
||||||
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, bindStatus=${device.bindStatus}');
|
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}');
|
||||||
|
|
||||||
// 设备已被其他用户绑定 → 拦截
|
|
||||||
if (device.isBoundByOther) {
|
|
||||||
showGlassDialog(
|
|
||||||
context: context,
|
|
||||||
title: '无法连接',
|
|
||||||
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
|
|
||||||
confirmText: '确定',
|
|
||||||
onConfirm: () => Navigator.of(context).pop(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!device.isNetworkRequired) {
|
if (!device.isNetworkRequired) {
|
||||||
// 不需要联网 -> 跳过配网,绑定设备后进入业务页
|
// 不需要联网 -> 直接去设备控制页
|
||||||
if (device.sn.isNotEmpty) {
|
context.go('/device-control');
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,23 +378,11 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
|
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
|
||||||
setState(() => _isConnecting = true);
|
setState(() => _isConnecting = true);
|
||||||
if (device.sn.isNotEmpty) {
|
if (device.sn.isNotEmpty) {
|
||||||
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isConnecting = false);
|
setState(() => _isConnecting = false);
|
||||||
_setThemeAndNavigate(device.type);
|
context.go('/device-control');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -569,13 +473,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
children: [
|
children: [
|
||||||
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
// 返回按钮 - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () => context.go('/home'),
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
} else {
|
|
||||||
context.go('/');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@ -596,7 +494,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'搜索设备',
|
'搜索设备',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -668,7 +566,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
height: 120,
|
height: 120,
|
||||||
placeholderBuilder: (_) => Text(
|
placeholderBuilder: (_) => Text(
|
||||||
'?',
|
'?',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFFF59E0B), // Amber color per HTML
|
color: const Color(0xFFF59E0B), // Amber color per HTML
|
||||||
@ -837,25 +735,12 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
// 设备名称
|
// 设备名称
|
||||||
Text(
|
Text(
|
||||||
device.name,
|
device.name,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: device.isBoundByOther
|
color: const Color(0xFF1F2937),
|
||||||
? const Color(0xFF9CA3AF)
|
|
||||||
: const Color(0xFF1F2937),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 已被其他用户绑定提示
|
|
||||||
if (device.isBoundByOther) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'已被其他用户绑定',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: const Color(0xFFEF4444),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -913,13 +798,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
|
|||||||
children: [
|
children: [
|
||||||
// 取消按钮 - HTML: frosted glass with border
|
// 取消按钮 - HTML: frosted glass with border
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () => context.go('/home'),
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
} else {
|
|
||||||
context.go('/');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(25),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:math';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/network/api_client.dart';
|
import '../core/network/api_client.dart';
|
||||||
import 'story_detail_page.dart';
|
import 'story_detail_page.dart';
|
||||||
@ -18,7 +19,6 @@ import '../widgets/ios_toast.dart';
|
|||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
import '../features/device/presentation/controllers/device_controller.dart';
|
||||||
import '../theme/product_theme.dart';
|
|
||||||
|
|
||||||
class DeviceControlPage extends ConsumerStatefulWidget {
|
class DeviceControlPage extends ConsumerStatefulWidget {
|
||||||
const DeviceControlPage({super.key});
|
const DeviceControlPage({super.key});
|
||||||
@ -48,7 +48,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara));
|
|
||||||
_mascotAnimController = AnimationController(
|
_mascotAnimController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
@ -302,7 +301,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
statusText,
|
statusText,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B5563),
|
color: const Color(0xFF4B5563),
|
||||||
@ -326,7 +325,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
batteryText,
|
batteryText,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B5563),
|
color: const Color(0xFF4B5563),
|
||||||
@ -475,7 +474,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
await _addNewBookWithAnimation(
|
await _addNewBookWithAnimation(
|
||||||
title: saveResult['title'] as String? ?? '新故事',
|
title: saveResult['title'] as String? ?? '新故事',
|
||||||
content: saveResult['content'] as String? ?? '',
|
content: saveResult['content'] as String? ?? '',
|
||||||
coverUrl: saveResult['cover_url'] as String? ?? '',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -603,7 +601,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
_addNewBookWithAnimation(
|
_addNewBookWithAnimation(
|
||||||
title: saveResult['title'] as String? ?? '新故事',
|
title: saveResult['title'] as String? ?? '新故事',
|
||||||
content: saveResult['content'] 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
|
// Find the first shelf that has space
|
||||||
int? targetShelfId;
|
int? targetShelfId;
|
||||||
for (final shelf in _shelves) {
|
for (final shelf in _shelves) {
|
||||||
@ -926,7 +923,6 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
|
|||||||
'title': title,
|
'title': title,
|
||||||
'content': content,
|
'content': content,
|
||||||
'shelf_id': targetShelfId,
|
'shelf_id': targetShelfId,
|
||||||
if (coverUrl.isNotEmpty) 'cover_url': coverUrl,
|
|
||||||
});
|
});
|
||||||
// Reload to get the new story
|
// Reload to get the new story
|
||||||
await _loadShelves();
|
await _loadShelves();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
@ -87,7 +88,7 @@ class _HomePageState extends State<HomePage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
// Use Press Start 2P pixel font per HTML CSS
|
// Use Press Start 2P pixel font per HTML CSS
|
||||||
style: TextStyle(fontFamily: 'Press Start 2P',
|
style: GoogleFonts.pressStart2p(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1), // 靛蓝
|
color: const Color(0xFF6366F1), // 靛蓝
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -238,7 +239,7 @@ class _HomePageState extends State<HomePage>
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'立即连接',
|
'立即连接',
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
@ -57,7 +58,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
'服务协议',
|
'服务协议',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -325,7 +326,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
padding: const EdgeInsets.only(top: 32),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Airhub',
|
'Airhub',
|
||||||
style: TextStyle(fontFamily: 'Press Start 2P',
|
style: GoogleFonts.pressStart2p(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: const Color(0xFF6366F1),
|
color: const Color(0xFF6366F1),
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -501,7 +502,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
|||||||
// Heading - font-size: 32px, font-weight: 700
|
// Heading - font-size: 32px, font-weight: 700
|
||||||
Text(
|
Text(
|
||||||
'欢迎使用 Airhub',
|
'欢迎使用 Airhub',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF6B5B95),
|
color: const Color(0xFF6B5B95),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:math';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show PlatformException;
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../services/music_generation_service.dart';
|
import '../services/music_generation_service.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
@ -800,7 +801,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'灵感电台',
|
'灵感电台',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -1107,7 +1108,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
track.lyrics.isNotEmpty
|
track.lyrics.isNotEmpty
|
||||||
? _cleanLyrics(track.lyrics)
|
? _cleanLyrics(track.lyrics)
|
||||||
: '生成音乐后\n点我看歌词',
|
: '生成音乐后\n点我看歌词',
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
color: track.lyrics.isNotEmpty
|
color: track.lyrics.isNotEmpty
|
||||||
@ -1169,7 +1170,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
bubbleText,
|
bubbleText,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: const Color(0xFF6B4423),
|
color: const Color(0xFF6B4423),
|
||||||
@ -1226,7 +1227,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
_currentTime,
|
_currentTime,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
@ -1276,7 +1277,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
_totalTime,
|
_totalTime,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF6B7280),
|
color: const Color(0xFF6B7280),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
@ -1433,7 +1434,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
mood['title'] as String,
|
mood['title'] as String,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||||||
color: isActive
|
color: isActive
|
||||||
@ -1446,7 +1447,7 @@ class _MusicCreationPageState extends State<MusicCreationPage>
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
mood['desc'] as String,
|
mood['desc'] as String,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: isActive
|
color: isActive
|
||||||
? const Color(0xFF6B7280)
|
? const Color(0xFF6B7280)
|
||||||
@ -1907,7 +1908,7 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'自由创作',
|
'自由创作',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -1934,7 +1935,7 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'描述你想要的音乐氛围、场景或情绪',
|
'描述你想要的音乐氛围、场景或情绪',
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: const Color(0xFF9CA3AF),
|
color: const Color(0xFF9CA3AF),
|
||||||
),
|
),
|
||||||
@ -1949,11 +1950,11 @@ class _InputModalContent extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 14, color: const Color(0xFF374151)),
|
fontSize: 14, color: const Color(0xFF374151)),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '例如:水豚在雨中等公交,心情却很平静...',
|
hintText: '例如:水豚在雨中等公交,心情却很平静...',
|
||||||
hintStyle: TextStyle(fontFamily: 'DM Sans',
|
hintStyle: GoogleFonts.dmSans(
|
||||||
fontSize: 14, color: const Color(0xFF9CA3AF)),
|
fontSize: 14, color: const Color(0xFF9CA3AF)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.black.withOpacity(0.03),
|
fillColor: Colors.black.withOpacity(0.03),
|
||||||
@ -2098,7 +2099,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'我的唱片架',
|
'我的唱片架',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
@ -2265,7 +2266,7 @@ class _PlaylistModalContentState extends State<_PlaylistModalContent>
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
track.title,
|
track.title,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
|
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
|
||||||
color: isCurrent
|
color: isCurrent
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
import '../features/device/presentation/controllers/device_controller.dart';
|
||||||
import '../features/device/domain/entities/device.dart';
|
import '../features/device/domain/entities/device.dart';
|
||||||
import '../theme/product_theme.dart';
|
|
||||||
|
|
||||||
class ProductSelectionPage extends ConsumerStatefulWidget {
|
class ProductSelectionPage extends ConsumerStatefulWidget {
|
||||||
const ProductSelectionPage({super.key});
|
const ProductSelectionPage({super.key});
|
||||||
@ -38,13 +38,6 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
|||||||
'badge-basic': ['DZBJ-OFF'],
|
'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) {
|
UserDevice? _findBoundDevice(String productId, List<UserDevice> devices) {
|
||||||
final codes = _productCodeMap[productId];
|
final codes = _productCodeMap[productId];
|
||||||
@ -197,23 +190,12 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
|||||||
fadeStartY: headerHeight + 16,
|
fadeStartY: headerHeight + 16,
|
||||||
fadeEndY: safeTop,
|
fadeEndY: safeTop,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// 设置当前产品主题
|
|
||||||
final productType = _productTypeMap[product['id']] ?? ProductType.common;
|
|
||||||
ref.read(currentProductTypeProvider.notifier).set(productType);
|
|
||||||
|
|
||||||
if (boundDevice != null) {
|
if (boundDevice != null) {
|
||||||
// 已绑定 → 根据产品类型进入对应控制页
|
// 已绑定 → 直接进入设备控制页
|
||||||
final pid = product['id'] as String;
|
context.go('/device-control');
|
||||||
if (pid == 'badge-ai') {
|
|
||||||
context.go('/badge-control');
|
|
||||||
} else if (pid == 'badge-basic') {
|
|
||||||
context.go('/badge-basic-control');
|
|
||||||
} else {
|
|
||||||
context.go('/device-control');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 未绑定 → 跳转蓝牙搜索页(push 保留返回栈)
|
// 未绑定 → 跳转蓝牙搜索页
|
||||||
context.push('/bluetooth');
|
context.go('/bluetooth');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -232,7 +214,7 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'选择产品',
|
'选择产品',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -364,7 +346,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
product['name'],
|
product['name'],
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../widgets/glass_dialog.dart';
|
import '../widgets/glass_dialog.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/ios_toast.dart';
|
import '../widgets/ios_toast.dart';
|
||||||
@ -99,7 +100,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'设置',
|
'设置',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import 'dart:ui' as ui;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart' show PlatformException;
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
import '../widgets/pill_progress_button.dart';
|
import '../widgets/pill_progress_button.dart';
|
||||||
@ -35,10 +34,6 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
bool _hasGeneratedVideo = false;
|
bool _hasGeneratedVideo = false;
|
||||||
bool _isLoadingVideo = false;
|
bool _isLoadingVideo = false;
|
||||||
|
|
||||||
// Video Player
|
|
||||||
VideoPlayerController? _videoController;
|
|
||||||
bool _videoInitialized = false;
|
|
||||||
|
|
||||||
// TTS — uses global TTSService singleton
|
// TTS — uses global TTSService singleton
|
||||||
final TTSService _ttsService = TTSService.instance;
|
final TTSService _ttsService = TTSService.instance;
|
||||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
@ -113,15 +108,6 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
debugPrint('durationStream error (ignored): $e');
|
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
|
// Check if audio already exists
|
||||||
debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}');
|
debugPrint('[StoryDetail] story keys: ${_currentStory.keys.toList()}');
|
||||||
debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"');
|
debugPrint('[StoryDetail] audio_url value: "${_currentStory['audio_url']}"');
|
||||||
@ -165,33 +151,12 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
setState(() {});
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ttsService.removeListener(_onTTSChanged);
|
_ttsService.removeListener(_onTTSChanged);
|
||||||
_positionSub?.cancel();
|
_positionSub?.cancel();
|
||||||
_playerStateSub?.cancel();
|
_playerStateSub?.cancel();
|
||||||
_audioPlayer.dispose();
|
_audioPlayer.dispose();
|
||||||
_videoController?.removeListener(_onVideoChanged);
|
|
||||||
_videoController?.dispose();
|
|
||||||
_genieController?.dispose();
|
_genieController?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -206,8 +171,7 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
|
|
||||||
if (_ttsService.error != null &&
|
if (_ttsService.error != null &&
|
||||||
!_ttsService.isGenerating &&
|
!_ttsService.isGenerating &&
|
||||||
_ttsService.audioUrl == null &&
|
_ttsService.audioUrl == null) {
|
||||||
_ttsService.errorTitle == title) {
|
|
||||||
return TTSButtonState.error;
|
return TTSButtonState.error;
|
||||||
}
|
}
|
||||||
if (_ttsService.isGeneratingFor(title)) {
|
if (_ttsService.isGeneratingFor(title)) {
|
||||||
@ -554,58 +518,28 @@ class _StoryDetailPageState extends State<StoryDetailPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not yet initialized — black + spinner while video loads
|
return Stack(
|
||||||
if (!_videoInitialized || _videoController == null) {
|
alignment: Alignment.center,
|
||||||
return const AspectRatio(
|
children: [
|
||||||
aspectRatio: 16 / 9,
|
AspectRatio(
|
||||||
child: ColoredBox(
|
aspectRatio: 16 / 9,
|
||||||
color: Colors.black,
|
child: Container(
|
||||||
child: Center(
|
color: Colors.black,
|
||||||
child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 3),
|
child: const Center(
|
||||||
|
child: Icon(Icons.videocam, color: Colors.white54, size: 48),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
Container(
|
||||||
}
|
width: 48,
|
||||||
|
height: 48,
|
||||||
// Initialized: VideoPlayer shows frame 0 naturally as thumbnail when paused
|
decoration: BoxDecoration(
|
||||||
final isPlaying = _videoController!.value.isPlaying;
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
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(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Play button — IgnorePointer lets taps pass through to GestureDetector below
|
child: const Icon(Icons.play_arrow, color: Colors.black),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
String buffer = '';
|
String buffer = '';
|
||||||
String? storyTitle;
|
String? storyTitle;
|
||||||
String? storyContent;
|
String? storyContent;
|
||||||
String storyCoverUrl = '';
|
|
||||||
|
|
||||||
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
@ -110,13 +109,9 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
case 'parsing':
|
case 'parsing':
|
||||||
_updateProgress(progress / 100, '正在编制最后的魔法...');
|
_updateProgress(progress / 100, '正在编制最后的魔法...');
|
||||||
break;
|
break;
|
||||||
case 'cover':
|
|
||||||
_updateProgress(progress / 100, '正在绘制故事封面...');
|
|
||||||
break;
|
|
||||||
case 'done':
|
case 'done':
|
||||||
storyTitle = event['title'] as String? ?? '卡皮巴拉的故事';
|
storyTitle = event['title'] as String? ?? '卡皮巴拉的故事';
|
||||||
storyContent = event['content'] as String? ?? '';
|
storyContent = event['content'] as String? ?? '';
|
||||||
storyCoverUrl = event['cover_url'] as String? ?? '';
|
|
||||||
_updateProgress(1.0, '大功告成!');
|
_updateProgress(1.0, '大功告成!');
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
@ -147,7 +142,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
story: {
|
story: {
|
||||||
'title': storyTitle,
|
'title': storyTitle,
|
||||||
'content': storyContent,
|
'content': storyContent,
|
||||||
'cover_url': storyCoverUrl,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -160,7 +154,6 @@ class _StoryLoadingPageState extends State<StoryLoadingPage> {
|
|||||||
'action': 'saved',
|
'action': 'saved',
|
||||||
'title': storyTitle,
|
'title': storyTitle,
|
||||||
'content': storyContent,
|
'content': storyContent,
|
||||||
'cover_url': storyCoverUrl,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Navigator.of(context).pop(result);
|
Navigator.of(context).pop(result);
|
||||||
|
|||||||
@ -2,14 +2,12 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../core/services/ble_provisioning_service.dart';
|
import '../core/services/ble_provisioning_service.dart';
|
||||||
import '../features/device/data/datasources/device_remote_data_source.dart';
|
|
||||||
import '../features/device/presentation/controllers/device_controller.dart';
|
import '../features/device/presentation/controllers/device_controller.dart';
|
||||||
import '../widgets/animated_gradient_background.dart';
|
import '../widgets/animated_gradient_background.dart';
|
||||||
import '../widgets/gradient_button.dart';
|
import '../widgets/gradient_button.dart';
|
||||||
import '../widgets/glass_dialog.dart';
|
|
||||||
import '../theme/product_theme.dart';
|
|
||||||
|
|
||||||
class WifiConfigPage extends ConsumerStatefulWidget {
|
class WifiConfigPage extends ConsumerStatefulWidget {
|
||||||
final Map<String, dynamic>? extra;
|
final Map<String, dynamic>? extra;
|
||||||
@ -134,74 +132,18 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
if (_isBinding) return;
|
if (_isBinding) return;
|
||||||
setState(() => _isBinding = true);
|
setState(() => _isBinding = true);
|
||||||
|
|
||||||
// 绑定前再次检查设备归属(防止 BLE 扫描时检查遗漏)
|
|
||||||
final mac = _deviceInfo['mac'] as String? ?? '';
|
|
||||||
if (mac.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
final dataSource = ref.read(deviceRemoteDataSourceProvider);
|
|
||||||
final macData = await dataSource.queryByMac(mac);
|
|
||||||
final bindStatus = macData['bind_status'] as String? ?? 'unbound';
|
|
||||||
if (bindStatus == 'bound_by_other') {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _isBinding = false);
|
|
||||||
showGlassDialog(
|
|
||||||
context: context,
|
|
||||||
title: '无法绑定',
|
|
||||||
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
|
|
||||||
confirmText: '确定',
|
|
||||||
onConfirm: () => Navigator.of(context).pop(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[WiFi Config] 检查设备归属失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final sn = _deviceInfo['sn'] as String? ?? '';
|
final sn = _deviceInfo['sn'] as String? ?? '';
|
||||||
if (sn.isNotEmpty) {
|
if (sn.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
debugPrint('[WiFi Config] Binding device sn=$sn');
|
debugPrint('[WiFi Config] Binding device sn=$sn');
|
||||||
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
|
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) {
|
} catch (e) {
|
||||||
debugPrint('[WiFi Config] bindDevice 异常: $e');
|
debugPrint('[WiFi Config] bindDevice 异常: $e');
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _isBinding = false);
|
|
||||||
showGlassDialog(
|
|
||||||
context: context,
|
|
||||||
title: '绑定失败',
|
|
||||||
description: '$e',
|
|
||||||
confirmText: '确定',
|
|
||||||
onConfirm: () => Navigator.of(context).pop(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isBinding = false);
|
setState(() => _isBinding = false);
|
||||||
final deviceType = _deviceInfo['type'] as String? ?? '';
|
context.go('/device-control');
|
||||||
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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,7 +298,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
child: Text(
|
child: Text(
|
||||||
'WiFi配网',
|
'WiFi配网',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -420,7 +362,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'选择WiFi网络',
|
'选择WiFi网络',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -562,7 +504,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_selectedWifiSsid,
|
_selectedWifiSsid,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -630,7 +572,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'正在配网...',
|
'正在配网...',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
@ -746,7 +688,7 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'配网成功!',
|
'配网成功!',
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
|
|||||||
@ -29,7 +29,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
|
|
||||||
// ── Error ──
|
// ── Error ──
|
||||||
String? _error;
|
String? _error;
|
||||||
String? _errorTitle; // Which story the error belongs to
|
|
||||||
|
|
||||||
// ── Getters ──
|
// ── Getters ──
|
||||||
bool get isGenerating => _isGenerating;
|
bool get isGenerating => _isGenerating;
|
||||||
@ -40,7 +39,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
String? get completedStoryTitle => _completedStoryTitle;
|
String? get completedStoryTitle => _completedStoryTitle;
|
||||||
bool get justCompleted => _justCompleted;
|
bool get justCompleted => _justCompleted;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
String? get errorTitle => _errorTitle;
|
|
||||||
|
|
||||||
/// Check if audio is ready for a specific story.
|
/// Check if audio is ready for a specific story.
|
||||||
bool hasAudioFor(String title) {
|
bool hasAudioFor(String title) {
|
||||||
@ -184,7 +182,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
if (_audioUrl == null) {
|
if (_audioUrl == null) {
|
||||||
_error = '未获取到音频';
|
_error = '未获取到音频';
|
||||||
_errorTitle = title;
|
|
||||||
_statusMessage = '生成失败';
|
_statusMessage = '生成失败';
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -193,7 +190,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
_isGenerating = false;
|
_isGenerating = false;
|
||||||
_progress = 0.0;
|
_progress = 0.0;
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
_errorTitle = title;
|
|
||||||
_statusMessage = '生成失败';
|
_statusMessage = '生成失败';
|
||||||
_justCompleted = false;
|
_justCompleted = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -216,7 +212,6 @@ class TTSService extends ChangeNotifier {
|
|||||||
_completedStoryTitle = null;
|
_completedStoryTitle = null;
|
||||||
_justCompleted = false;
|
_justCompleted = false;
|
||||||
_error = null;
|
_error = null;
|
||||||
_errorTitle = null;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
// Base text theme with DM Sans (PRD: 正文/UI 字体)
|
// Base text theme with DM Sans (PRD: 正文/UI 字体)
|
||||||
final baseTextTheme = const TextTheme(
|
final baseTextTheme = GoogleFonts.dmSansTextTheme(const TextTheme(
|
||||||
// h1 / Large Headings
|
// h1 / Large Headings
|
||||||
displayLarge: TextStyle(
|
displayLarge: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
@ -39,19 +40,19 @@ class AppTheme {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
).apply(fontFamily: 'DM Sans');
|
));
|
||||||
|
|
||||||
// Apply Outfit to heading styles (PRD: 标题/Display 字体)
|
// Apply Outfit to heading styles (PRD: 标题/Display 字体)
|
||||||
final textTheme = baseTextTheme.copyWith(
|
final textTheme = baseTextTheme.copyWith(
|
||||||
displayLarge: baseTextTheme.displayLarge?.copyWith(fontFamily: 'Outfit'),
|
displayLarge: GoogleFonts.outfit(textStyle: baseTextTheme.displayLarge),
|
||||||
displayMedium: baseTextTheme.displayMedium?.copyWith(fontFamily: 'Outfit'),
|
displayMedium: GoogleFonts.outfit(textStyle: baseTextTheme.displayMedium),
|
||||||
displaySmall: baseTextTheme.displaySmall?.copyWith(fontFamily: 'Outfit'),
|
displaySmall: GoogleFonts.outfit(textStyle: baseTextTheme.displaySmall),
|
||||||
headlineLarge: baseTextTheme.headlineLarge?.copyWith(fontFamily: 'Outfit'),
|
headlineLarge: GoogleFonts.outfit(textStyle: baseTextTheme.headlineLarge),
|
||||||
headlineMedium: baseTextTheme.headlineMedium?.copyWith(fontFamily: 'Outfit'),
|
headlineMedium: GoogleFonts.outfit(textStyle: baseTextTheme.headlineMedium),
|
||||||
headlineSmall: baseTextTheme.headlineSmall?.copyWith(fontFamily: 'Outfit'),
|
headlineSmall: GoogleFonts.outfit(textStyle: baseTextTheme.headlineSmall),
|
||||||
titleLarge: baseTextTheme.titleLarge?.copyWith(fontFamily: 'Outfit'),
|
titleLarge: GoogleFonts.outfit(textStyle: baseTextTheme.titleLarge),
|
||||||
titleMedium: baseTextTheme.titleMedium?.copyWith(fontFamily: 'Outfit'),
|
titleMedium: GoogleFonts.outfit(textStyle: baseTextTheme.titleMedium),
|
||||||
titleSmall: baseTextTheme.titleSmall?.copyWith(fontFamily: 'Outfit'),
|
titleSmall: GoogleFonts.outfit(textStyle: baseTextTheme.titleSmall),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@ -66,7 +67,7 @@ class AppTheme {
|
|||||||
background: AppColors.bgBase,
|
background: AppColors.bgBase,
|
||||||
),
|
),
|
||||||
// PRD: DM Sans 为默认正文字体,回退到系统字体
|
// PRD: DM Sans 为默认正文字体,回退到系统字体
|
||||||
fontFamily: 'DM Sans',
|
fontFamily: GoogleFonts.dmSans().fontFamily,
|
||||||
fontFamilyFallback: const [
|
fontFamilyFallback: const [
|
||||||
'Roboto',
|
'Roboto',
|
||||||
'PingFang SC',
|
'PingFang SC',
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
/// 颜色定义 - 精确还原 Profile PRD
|
/// 颜色定义 - 精确还原 Profile PRD
|
||||||
class AppColors {
|
class AppColors {
|
||||||
@ -110,77 +111,77 @@ class AppColors {
|
|||||||
/// 字体样式 - PRD规范: Outfit(标题) + DM Sans(正文) + Press Start 2P(Logo)
|
/// 字体样式 - PRD规范: Outfit(标题) + DM Sans(正文) + Press Start 2P(Logo)
|
||||||
class AppTextStyles {
|
class AppTextStyles {
|
||||||
// 页面标题 — 统一规范: 17px w600 #1F2937
|
// 页面标题 — 统一规范: 17px w600 #1F2937
|
||||||
static final TextStyle title = TextStyle(fontFamily: 'Outfit',
|
static final TextStyle title = GoogleFonts.outfit(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF1F2937),
|
color: const Color(0xFF1F2937),
|
||||||
);
|
);
|
||||||
|
|
||||||
// User Name → Outfit (heading/display)
|
// User Name → Outfit (heading/display)
|
||||||
static final TextStyle userName = TextStyle(fontFamily: 'Outfit',
|
static final TextStyle userName = GoogleFonts.outfit(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// User ID → DM Sans (body)
|
// User ID → DM Sans (body)
|
||||||
static final TextStyle userId = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle userId = GoogleFonts.dmSans(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Menu Text → DM Sans (body/UI)
|
// Menu Text → DM Sans (body/UI)
|
||||||
static final TextStyle menuText = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle menuText = GoogleFonts.dmSans(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Badge Text → DM Sans (small UI)
|
// Badge Text → DM Sans (small UI)
|
||||||
static final TextStyle badge = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle badge = GoogleFonts.dmSans(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Modal Title → Outfit (heading)
|
// Modal Title → Outfit (heading)
|
||||||
static final TextStyle modalTitle = TextStyle(fontFamily: 'Outfit',
|
static final TextStyle modalTitle = GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Book specific styles → Outfit (heading)
|
// Book specific styles → Outfit (heading)
|
||||||
static final TextStyle bookTitle = TextStyle(fontFamily: 'Outfit',
|
static final TextStyle bookTitle = GoogleFonts.outfit(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Book count → DM Sans (body)
|
// Book count → DM Sans (body)
|
||||||
static final TextStyle bookCount = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle bookCount = GoogleFonts.dmSans(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slot title → DM Sans (small UI)
|
// Slot title → DM Sans (small UI)
|
||||||
static final TextStyle slotTitle = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle slotTitle = GoogleFonts.dmSans(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
);
|
);
|
||||||
|
|
||||||
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
|
// PRD: font-size: 24px, color: #9CA3AF, font-weight: 300, opacity: 0.7
|
||||||
static final TextStyle emptyPlus = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle emptyPlus = GoogleFonts.dmSans(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
|
color: const Color(0xB39CA3AF), // #9CA3AF with 0.7 opacity
|
||||||
);
|
);
|
||||||
|
|
||||||
// Button text → DM Sans (UI)
|
// Button text → DM Sans (UI)
|
||||||
static final TextStyle createStoryBtn = TextStyle(fontFamily: 'DM Sans',
|
static final TextStyle createStoryBtn = GoogleFonts.dmSans(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
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:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'gradient_button.dart';
|
import 'gradient_button.dart';
|
||||||
import '../theme/app_colors.dart' as appclr;
|
import '../theme/app_colors.dart' as appclr;
|
||||||
|
|
||||||
@ -10,8 +11,8 @@ class GlassDialog extends StatelessWidget {
|
|||||||
final String confirmText;
|
final String confirmText;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
final VoidCallback onConfirm;
|
final VoidCallback onConfirm;
|
||||||
final bool isDanger;
|
final bool
|
||||||
final Gradient? confirmGradient;
|
isDanger; // If we need a specific danger style, though CSS shows Pink Gradient default
|
||||||
|
|
||||||
const GlassDialog({
|
const GlassDialog({
|
||||||
super.key,
|
super.key,
|
||||||
@ -23,7 +24,6 @@ class GlassDialog extends StatelessWidget {
|
|||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
required this.onConfirm,
|
required this.onConfirm,
|
||||||
this.isDanger = false,
|
this.isDanger = false,
|
||||||
this.confirmGradient,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -54,7 +54,7 @@ class GlassDialog extends StatelessWidget {
|
|||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(fontFamily: 'Outfit',
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: const Color(0xFF4B2404),
|
color: const Color(0xFF4B2404),
|
||||||
@ -98,7 +98,7 @@ class GlassDialog extends StatelessWidget {
|
|||||||
GradientButton(
|
GradientButton(
|
||||||
text: confirmText,
|
text: confirmText,
|
||||||
height: 48,
|
height: 48,
|
||||||
gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
|
gradient: appclr.AppColors.btnPlushGradient,
|
||||||
onPressed: onConfirm,
|
onPressed: onConfirm,
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
@ -131,7 +131,7 @@ class GlassDialog extends StatelessWidget {
|
|||||||
child: GradientButton(
|
child: GradientButton(
|
||||||
text: confirmText,
|
text: confirmText,
|
||||||
height: 44,
|
height: 44,
|
||||||
gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
|
gradient: appclr.AppColors.btnPlushGradient,
|
||||||
onPressed: onConfirm,
|
onPressed: onConfirm,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -155,7 +155,6 @@ Future<T?> showGlassDialog<T>({
|
|||||||
String confirmText = '确定',
|
String confirmText = '确定',
|
||||||
required VoidCallback onConfirm,
|
required VoidCallback onConfirm,
|
||||||
bool isDanger = false,
|
bool isDanger = false,
|
||||||
Gradient? confirmGradient,
|
|
||||||
}) {
|
}) {
|
||||||
return showGeneralDialog<T>(
|
return showGeneralDialog<T>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -177,7 +176,6 @@ Future<T?> showGlassDialog<T>({
|
|||||||
onCancel: () => Navigator.of(context).pop(),
|
onCancel: () => Navigator.of(context).pop(),
|
||||||
onConfirm: onConfirm,
|
onConfirm: onConfirm,
|
||||||
isDanger: isDanger,
|
isDanger: isDanger,
|
||||||
confirmGradient: confirmGradient,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
transitionBuilder: (context, anim1, anim2, child) {
|
transitionBuilder: (context, anim1, anim2, child) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../theme/product_theme.dart';
|
|
||||||
|
|
||||||
class GradientButton extends StatelessWidget {
|
class GradientButton extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
@ -9,7 +9,6 @@ class GradientButton extends StatelessWidget {
|
|||||||
final double height;
|
final double height;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final Gradient? gradient;
|
final Gradient? gradient;
|
||||||
final List<BoxShadow>? shadows;
|
|
||||||
|
|
||||||
const GradientButton({
|
const GradientButton({
|
||||||
super.key,
|
super.key,
|
||||||
@ -19,53 +18,57 @@ class GradientButton extends StatelessWidget {
|
|||||||
this.height = 48.0, // 统一规范高度
|
this.height = 48.0, // 统一规范高度
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.gradient,
|
this.gradient,
|
||||||
this.shadows,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 从 ProductThemeData 创建,自动匹配渐变和阴影
|
// Check if using plush/capybara gradient
|
||||||
factory GradientButton.fromTheme({
|
bool get _isPlushGradient {
|
||||||
Key? key,
|
if (gradient == null) return false;
|
||||||
required String text,
|
if (gradient is LinearGradient) {
|
||||||
required ProductThemeData theme,
|
final lg = gradient as LinearGradient;
|
||||||
VoidCallback? onPressed,
|
// Check if colors match plush gradient colors
|
||||||
double width = double.infinity,
|
if (lg.colors.length >= 2) {
|
||||||
double height = 48.0,
|
return lg.colors.first.value == 0xFFECCFA8 ||
|
||||||
bool isLoading = false,
|
lg.colors.last.value == 0xFFC99672;
|
||||||
}) {
|
}
|
||||||
return GradientButton(
|
}
|
||||||
key: key,
|
return false;
|
||||||
text: text,
|
|
||||||
onPressed: onPressed,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
isLoading: isLoading,
|
|
||||||
gradient: theme.buttonGradient,
|
|
||||||
shadows: theme.buttonShadows,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<BoxShadow> get _boxShadows {
|
List<BoxShadow> get _boxShadows {
|
||||||
if (shadows != null) return shadows!;
|
if (_isPlushGradient) {
|
||||||
// 根据渐变颜色自动推断阴影
|
// Warm brown glow for Capybara plush gradient
|
||||||
if (gradient is LinearGradient) {
|
return [
|
||||||
final lg = gradient as LinearGradient;
|
BoxShadow(
|
||||||
if (lg.colors.length >= 2) {
|
color: const Color(0xFFC99672).withOpacity(0.35),
|
||||||
final shadowColor = lg.colors[lg.colors.length ~/ 2];
|
offset: Offset.zero,
|
||||||
return [
|
blurRadius: 15,
|
||||||
BoxShadow(
|
),
|
||||||
color: shadowColor.withOpacity(0.4),
|
BoxShadow(
|
||||||
offset: const Offset(0, 4),
|
color: const Color(0xFFC99672).withOpacity(0.25),
|
||||||
blurRadius: 20,
|
offset: Offset.zero,
|
||||||
),
|
blurRadius: 30,
|
||||||
BoxShadow(
|
),
|
||||||
color: lg.colors.last.withOpacity(0.2),
|
BoxShadow(
|
||||||
offset: Offset.zero,
|
color: const Color(0xFFC99672).withOpacity(0.4),
|
||||||
blurRadius: 40,
|
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
|
@override
|
||||||
@ -124,7 +127,7 @@ class GradientButton extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(fontFamily: 'DM Sans',
|
style: GoogleFonts.dmSans(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import file_selector_macos
|
|||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
import just_audio
|
import just_audio
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import video_player_avfoundation
|
|
||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
@ -19,6 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
|
||||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,14 +40,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.10"
|
version: "0.13.10"
|
||||||
archive:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: archive
|
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.0.9"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -240,14 +232,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
csslib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: csslib
|
|
||||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
custom_lint:
|
custom_lint:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -524,6 +508,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
version: "14.8.1"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.3"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -548,14 +540,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.0"
|
version: "4.3.0"
|
||||||
html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: html
|
|
||||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.6"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -580,14 +564,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -964,14 +940,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "1.5.2"
|
||||||
posix:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: posix
|
|
||||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.0"
|
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1297,46 +1265,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -61,14 +61,12 @@ dependencies:
|
|||||||
# Existing dependencies
|
# Existing dependencies
|
||||||
webview_flutter: ^4.4.2
|
webview_flutter: ^4.4.2
|
||||||
permission_handler: ^11.0.0
|
permission_handler: ^11.0.0
|
||||||
# google_fonts removed — local fonts used instead
|
google_fonts: ^6.1.0
|
||||||
flutter_blue_plus: ^1.31.0
|
flutter_blue_plus: ^1.31.0
|
||||||
flutter_svg: ^2.0.9
|
flutter_svg: ^2.0.9
|
||||||
image_picker: ^1.2.1
|
image_picker: ^1.2.1
|
||||||
image: ^4.3.0
|
|
||||||
just_audio: ^0.9.42
|
just_audio: ^0.9.42
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
video_player: ^2.9.2
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@ -90,17 +88,6 @@ flutter:
|
|||||||
weight: 600
|
weight: 600
|
||||||
- asset: assets/fonts/Inter-Bold.ttf
|
- asset: assets/fonts/Inter-Bold.ttf
|
||||||
weight: 700
|
weight: 700
|
||||||
- family: DM Sans
|
|
||||||
fonts:
|
|
||||||
- asset: assets/fonts/DMSans-Variable.ttf
|
|
||||||
- asset: assets/fonts/DMSans-Italic-Variable.ttf
|
|
||||||
style: italic
|
|
||||||
- family: Outfit
|
|
||||||
fonts:
|
|
||||||
- asset: assets/fonts/Outfit-Variable.ttf
|
|
||||||
- family: Press Start 2P
|
|
||||||
fonts:
|
|
||||||
- asset: assets/fonts/PressStart2P-Regular.ttf
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 重新编译并启动 Flutter Web (localhost:8080)
|
# 重新编译并启动 Flutter Web (localhost:8080)
|
||||||
|
|
||||||
# 杀掉占用 8080 端口的进程(强制)
|
# 杀掉占用 8080 端口的进程
|
||||||
PID=$(lsof -ti:8080 2>/dev/null)
|
PID=$(lsof -ti:8080 2>/dev/null)
|
||||||
if [ -n "$PID" ]; then
|
if [ -n "$PID" ]; then
|
||||||
echo "正在强制停止旧进程 (PID: $PID)..."
|
echo "正在停止旧进程 (PID: $PID)..."
|
||||||
kill -9 $PID 2>/dev/null
|
kill $PID 2>/dev/null
|
||||||
# 等待端口真正释放(最多 5 秒)
|
sleep 1
|
||||||
for i in $(seq 1 10); do
|
|
||||||
lsof -ti:8080 > /dev/null 2>&1 || break
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "正在编译并启动 Flutter Web..."
|
echo "正在编译并启动 Flutter Web..."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user