Compare commits

..

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

61 changed files with 266 additions and 7419 deletions

View File

@ -1,294 +0,0 @@
# Airhub App 开发规范
> 所有新功能开发、代码修改、Bug 修复均需遵循本规范
---
## 一、项目架构
```
lib/
├── core/ # 基础设施层(全局共享)
│ ├── errors/ # 异常类 & Failure 类
│ ├── network/ # ApiClient, TokenManager, ApiConfig
│ ├── router/ # go_router 路由配置
│ └── services/ # 全局服务LogCenter 等)
├── features/ # 功能模块(按业务领域划分)
│ ├── auth/ # 认证
│ ├── badge/ # 电子吧唧
│ ├── device/ # 设备管理
│ ├── notification/ # 通知
│ ├── spirit/ # 精灵/角色
│ ├── user/ # 用户
│ └── system/ # 系统(版本检查等)
├── pages/ # 独立页面(未归入 feature 的页面)
├── widgets/ # 全局可复用 Widget
├── theme/ # 主题、颜色、Design Tokens
├── services/ # 应用级服务TTS、音乐生成等
└── main.dart # 入口
```
### Feature 模块内部结构
```
features/xxx/
├── data/
│ ├── datasources/ # 远程/本地数据源
│ ├── models/ # 数据模型Freezed
│ └── repositories/ # Repository 实现
├── domain/
│ └── repositories/ # Repository 接口
└── presentation/
├── controllers/ # Riverpod 控制器
├── pages/ # 页面
└── widgets/ # 功能专属 Widget
```
---
## 二、状态管理Riverpod
### 规则
1. **使用 `@riverpod` 注解**声明 Provider不手写 `StateNotifierProvider`
2. Controller 使用 `AsyncValue<T>` 管理异步状态
3. Repository 返回 `Either<Failure, T>`fpdartController 内 `fold` 处理
4. **页面中优先使用 `ConsumerWidget`**(无状态);需要动画/本地状态时用 `ConsumerStatefulWidget`
5. **`ref.watch()` 用于 UI 绑定**(触发重建),**`ref.read()` 用于事件处理**(不触发重建)
6. **`ref.listen()` 用于副作用**弹窗、导航、Toast不触发 Widget 重建
### 示例
```dart
@riverpod
class MyController extends _$MyController {
@override
FutureOr<MyState> build() async {
final repo = ref.watch(myRepositoryProvider);
final result = await repo.fetchData();
return result.fold(
(failure) => throw failure,
(data) => MyState(data: data),
);
}
}
```
---
## 三、网络层
### 规则
1. 所有 API 调用通过 `ApiClient`Dio 封装),不直接使用 `http` 包或 `Dio()`
2. `ApiClient` 自动处理Token 附加、401 自动刷新、错误上报
3. 后端统一返回 `{code, message, data}``ApiClient._request()` 自动解析
4. 异常类型:`ServerException`code != 0`NetworkException`(连接失败)
5. **不在 Widget/Controller 中直接调用 ApiClient**,应通过 Repository 层
### 错误处理链
```
DataSource (throw Exception)
→ Repository (catch → Either<Failure, T>)
→ Controller (fold → AsyncValue)
→ UI (AsyncValue.when: loading/data/error)
```
---
## 四、路由go_router
### 规则
1. 所有路由定义在 `core/router/app_router.dart`
2. 使用 `context.go()` 进行声明式导航,避免 `Navigator.push()`(页面内二级跳转除外)
3. 登录态检查在 `redirect` 中统一处理
4. 产品类型 → 路由映射在 `_productRoutes` Map 中维护
---
## 五、性能规范
### 内存管理
1. **所有 Stream 订阅必须存入变量dispose 时取消**
```dart
// ✅ 正确
StreamSubscription<Duration>? _positionSub;
_positionSub = stream.listen((data) { ... });
@override
void dispose() {
_positionSub?.cancel();
super.dispose();
}
// ❌ 错误 — 无法取消,造成内存泄漏
stream.listen((data) { ... });
```
2. **AnimationController、VideoPlayerController、AudioPlayer 等必须在 dispose 中释放**
3. **页面离开时停止 BLE 扫描**
```dart
@override
void dispose() {
FlutterBluePlus.stopScan();
_scanSubscription?.cancel();
super.dispose();
}
```
### Widget 重建优化
4. **能用 `const` 的地方必须加 `const`**
```dart
// ✅
const SizedBox(height: 16)
const EdgeInsets.all(16)
const Text('Hello')
// ❌
SizedBox(height: 16)
EdgeInsets.all(16)
```
5. **动画区域用 `RepaintBoundary` 包裹**,防止全页面重绘
```dart
RepaintBoundary(
child: AnimatedGradientBackground(),
)
```
6. **`AnimatedBuilder` 必须使用 `child` 参数**,将不变的子树传入 child不要在 builder 中重建
```dart
// ✅
AnimatedBuilder(
animation: _controller,
child: const HeavyWidget(), // 只构建一次
builder: (context, child) => Transform.rotate(
angle: _controller.value,
child: child, // 复用
),
)
```
### API 调用
7. **多个独立 API 调用使用 `Future.wait` 并行**,不要顺序 await
```dart
// ✅ 并行
final results = await Future.wait([
api.get('/shelves/'),
api.get('/stories/'),
]);
// ❌ 顺序(慢 N 倍)
final shelves = await api.get('/shelves/');
final stories = await api.get('/stories/');
```
8. **耗时计算JSON 解析、图片处理)放到 Isolate**
```dart
final result = await Isolate.run(() => jsonDecode(bigJson));
```
### 图片
9. **网络图片使用 `Image.network` + `fit: BoxFit.cover`**,不要手动指定 `cacheWidth/cacheHeight`(会导致显示异常)
10. **大量图片列表使用 `ListView.builder`**,不要 `ListView(children: [...])`
---
## 六、构建配置
### Android Release
- `build.gradle.kts` 中 release 配置:
- `isMinifyEnabled = true` — R8 代码压缩
- `isShrinkResources = true` — 移除未使用资源
- ProGuard 规则在 `proguard-rules.pro` 维护
- 打包命令:`flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info`
### iOS Release
- 构建命令:`flutter build ipa --release --obfuscate --split-debug-info=./debug-info`
---
## 七、命名规范
### 文件命名
| 类型 | 格式 | 示例 |
|------|------|------|
| 页面 | `xxx_page.dart` | `login_page.dart` |
| 控制器 | `xxx_controller.dart` | `auth_controller.dart` |
| 数据源 | `xxx_remote_data_source.dart` | `spirit_remote_data_source.dart` |
| 仓库实现 | `xxx_repository_impl.dart` | `auth_repository_impl.dart` |
| 仓库接口 | `xxx_repository.dart` | `auth_repository.dart` |
| 模型 | `xxx_model.dart` | `user_model.dart` |
| Widget | `xxx_widget.dart` 或直接描述 | `gradient_button.dart` |
| 生成文件 | `*.g.dart` / `*.freezed.dart` | 自动生成,不手动编辑 |
### 类命名
- PascalCase`AuthController``DeviceControlPage``ServerException`
- 私有前缀:`_buildHeader()``_positionSub`
### 变量命名
- camelCase`currentUser``isPlaying``_audioPlayer`
- Provider`xxxProvider`(由 `@riverpod` 自动生成)
---
## 八、主题系统
### 颜色
- 全局颜色定义在 `theme/app_colors.dart`
- 产品主题颜色在 `theme/product_theme.dart`4 种产品类型)
- **不要在 Widget 中硬编码颜色值**,优先使用 `AppColors.xxx``Theme.of(context)`
### 字体
- 主字体DM Sans正文、Outfit标题、Press Start 2P品牌像素风
- 通过 `google_fonts` 包引用,**运行时自动下载并缓存**
- 本地字体Inter`pubspec.yaml` 中声明)
### 间距
- 使用 `AppSpacing.xs/sm/md/lg/xl` 常量
- 不要在 Widget 中硬编码间距数值
---
## 九、Git 规范
### 分支命名
- 功能分支:`fix/auto-YYYYMMDD-HHMMSS`
- 合并目标:`main`
### Commit 规范
- `fix:` — Bug 修复
- `feat:` — 新功能
- `refactor:` — 重构(不改变行为)
- `chore:` — 构建配置、依赖更新等
---
## 十、检查清单PR 提交前)
- [ ] `dart analyze` 无 ErrorWarning/Info 可接受)
- [ ] 所有 Stream 订阅已在 dispose 中取消
- [ ] 所有 Controller/Player 已在 dispose 中释放
- [ ] 多个独立 API 调用已用 `Future.wait` 并行化
- [ ] 动画 Widget 使用了 `RepaintBoundary``AnimatedBuilder(child:)`
- [ ] 新增的 Widget 构造函数尽可能标记为 `const`
- [ ] 列表使用 `ListView.builder`,而非 `ListView(children:)`
- [ ] 未引入新的硬编码颜色值(使用 AppColors 或 theme
- [ ] Android release 构建正常minify + shrinkResources 不会崩溃)

View File

@ -1,703 +0,0 @@
# Flutter App 全面优化方案
> 针对 airhub_app 项目的从头到尾优化整改方案
> 目标:减小包大小、降低发热、消除卡顿
---
## 一、项目现状分析
| 指标 | 当前值 | 问题 |
|------|--------|------|
| Flutter 版本 | 3.41.2 (Dart 3.11.0) | 较新,支持 Impeller |
| 代码规模 | 132 个 Dart 文件 / 28k 行 | 中等规模 |
| assets 总大小 | **~135MB** | **严重超标** |
| storybook_videos/ | **92MB** (11个mp4最大48MB) | **主要元凶** |
| music/ | **15MB** (10个mp3) | 应该CDN下载 |
| story_covers/ | **7.4MB** (PNG图片) | 需压缩或远程加载 |
| 背景图 PNG | 单张 4.5-4.7MB | **未压缩的巨型PNG** |
| 字体文件 | 1.6MB (4个Inter字重) | 可裁剪子集 |
| google_fonts 依赖 | 存在 | 会下载额外字体 |
| Android release 配置 | 未启用 minifyEnabled | 缺少代码压缩 |
| ProGuard | 已引用但未启用 shrinkResources | 资源未裁剪 |
| iOS 构建 | 默认配置 | 未做符号裁剪优化 |
---
## 二、包大小优化(预估可减少 60-70%
### 阶段 1资源外迁预估减少 110MB+ → 约 80% 的包大小)
这是**最关键也是收益最大**的一步。
#### 1.1 视频资源迁移到 CDN / 服务器
```
当前: assets/www/storybook_videos/ → 92MB 全部打进包
目标: 视频从服务端按需下载,本地缓存
```
**实施方案:**
- 将所有 mp4 文件上传到后端服务器或 OSS/CDN
- App 端改为流式播放或首次使用时下载到本地缓存
- 使用 `video_player` 的网络播放功能直接播放远程URL
- 实现下载进度显示和本地缓存管理
```dart
// 改为网络播放
VideoPlayerController.networkUrl(Uri.parse('https://cdn.example.com/videos/magic_broom.mp4'))
// 或按需下载到缓存
final cacheDir = await getTemporaryDirectory();
final file = File('${cacheDir.path}/magic_broom.mp4');
if (!file.existsSync()) {
await dio.download(url, file.path, onReceiveProgress: (received, total) {
// 显示下载进度
});
}
```
#### 1.2 音频资源迁移
```
当前: assets/www/music/ → 15MB
目标: 音频按需下载或流式播放
```
- `just_audio` 本身支持网络URL播放
- 改为 `AudioSource.uri(Uri.parse('https://cdn.example.com/music/xxx.mp3'))`
#### 1.3 故事封面图迁移
```
当前: assets/www/story_covers/ → 7.4MB
目标: 网络图片 + 本地缓存
```
- 添加 `cached_network_image` 依赖
- 封面图改为网络加载,自动缓存
#### 1.4 背景图压缩
```
当前: 首页背景2.png (4.7MB) + 首页底图.png (4.5MB)
目标: 压缩到 200-500KB
```
**方案A转 WebP 格式**
```bash
# 安装 cwebp
brew install webp
# 转换 (质量80通常可减少 85%+)
cwebp -q 80 "assets/www/首页背景2.png" -o "assets/www/首页背景2.webp"
cwebp -q 80 "assets/www/首页底图.png" -o "assets/www/首页底图.webp"
```
**方案B用 pngquant 压缩**
```bash
pngquant --quality=65-80 "assets/www/首页背景2.png" --output "assets/www/首页背景2.png"
```
预期效果4.7MB → 300-500KB减少 **90%**
### 阶段 2构建配置优化预估再减 30-40%
#### 2.1 Android 构建优化
**修改 `android/app/build.gradle.kts`**
```kotlin
android {
buildTypes {
release {
signingConfig = signingConfigs.getByName("release") // 换正式签名
// 启用代码压缩和资源裁剪
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
```
**构建命令使用 `--split-per-abi`**
```bash
# 分架构打包,每个 APK 减少 30-40%
flutter build apk --release --split-per-abi --obfuscate --split-debug-info=./debug-info
# 或 App BundleGoogle Play 自动按设备裁剪)
flutter build appbundle --release --obfuscate --split-debug-info=./debug-info
```
| 参数 | 作用 | 预估收益 |
|------|------|----------|
| `--split-per-abi` | 按 CPU 架构分包 | 减少 30-40% |
| `--obfuscate` | 代码混淆 | 减少 5-10% |
| `--split-debug-info` | 分离调试符号 | 减少 5-10% |
| `minifyEnabled=true` | R8 代码压缩 | 减少 15-25% |
| `shrinkResources=true` | 移除未使用资源 | 减少 5-10% |
#### 2.2 iOS 构建优化
**Xcode Build Settings 调整:**
- Strip Debug Symbols During Copy → YES
- Strip Linked Product → YES
- Dead Code Stripping → YES
- Optimization Level → -Oz (Smallest)
**构建命令:**
```bash
flutter build ipa --release --obfuscate --split-debug-info=./debug-info
```
### 阶段 3依赖瘦身
#### 3.1 审计当前依赖
| 依赖 | 大小影响 | 建议 |
|------|----------|------|
| `google_fonts` | 较大,会下载字体 | 已内嵌 Inter 字体,考虑移除 |
| `webview_flutter` | ~4MB 原生库 | 仅在需要时加载(deferred) |
| `video_player` | ~3-4MB | 考虑 deferred import |
| `image` (dart包) | 纯 Dart 图像处理 | 检查是否真的需要 |
| `http` | 与 `dio` 重复 | 统一用 dio移除 http |
| `flutter_svg` | 中等 | 保留SVG 比 PNG 小得多) |
#### 3.2 移除 `google_fonts`
你已经在 `pubspec.yaml` 中内嵌了 Inter 字体,`google_fonts` 是多余的:
```yaml
# 移除这个依赖
# google_fonts: ^6.1.0
# 已有本地字体
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
# ...
```
代码中改用 `TextStyle(fontFamily: 'Inter')` 替代 `GoogleFonts.inter()`
#### 3.3 移除重复的 `http`
```yaml
# 移除 - 已有 dio 做网络请求
# http: ^1.2.0
```
#### 3.4 字体子集化
当前 4 个 Inter 字体文件共 1.6MB。如果只需要中英文:
```bash
# 安装 fonttools
pip install fonttools brotli
# 裁剪只保留需要的字符集(中文 + 英文 + 数字 + 标点)
pyftsubset Inter-Regular.ttf \
--text-file=used_chars.txt \
--output-file=Inter-Regular-subset.ttf
# 通常可从 400KB → 50-80KB
```
---
## 三、性能优化:降低发热与 CPU 占用
### 3.1 Widget 重建优化(最重要)
#### 使用 `const` 构造函数
```dart
// ❌ 每次 build 都创建新实例
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16), // 每次创建新对象
child: Text('Hello'),
);
}
// ✅ 编译期常量,不会触发重建
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: const Text('Hello'),
);
}
```
**全项目检查:** 运行 `dart analyze` 查看 `prefer_const_constructors` 警告。
#### 使用 `RepaintBoundary` 隔离重绘区域
```dart
// 对频繁更新的局部 Widget 包裹 RepaintBoundary
RepaintBoundary(
child: AnimatedWidget(...), // 只重绘这部分,不影响外部
)
```
适用场景:
- 动画区域
- 频繁刷新的倒计时/进度条
- BLE 数据实时显示
#### 拆分 Widget避免大范围重建
```dart
// ❌ 整个页面一个大 build 方法
class MyPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(someProvider); // 任何变化都重建整个页面
return Scaffold(
body: Column(children: [Header(), Content(state), Footer()]),
);
}
}
// ✅ 拆成独立 Widget只监听需要的部分
class _Content extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(someProvider.select((s) => s.data)); // 精准监听
return ...;
}
}
```
### 3.2 Riverpod 状态管理优化
#### 使用 `select` 精准订阅
```dart
// ❌ 监听整个 state任何字段变化都重建
final state = ref.watch(deviceProvider);
// ✅ 只监听需要的字段
final deviceName = ref.watch(deviceProvider.select((s) => s.name));
final isConnected = ref.watch(deviceProvider.select((s) => s.isConnected));
```
#### 区分 `watch``listen`
```dart
// watch: 用于 UI 显示的数据(需要重建)
final name = ref.watch(provider.select((s) => s.name));
// listen: 用于副作用(弹窗、导航、日志),不触发重建
ref.listen(provider, (prev, next) {
if (next.hasError) showErrorDialog(context, next.error);
});
```
### 3.3 BLE 通信优化(降低发热的关键)
你的 app 大量使用蓝牙(`flutter_blue_plus`),这是发热的主要来源之一。
```dart
// ❌ 持续高频扫描
FlutterBluePlus.startScan(timeout: Duration(seconds: 30));
// ✅ 扫到目标设备后立即停止
FlutterBluePlus.startScan(timeout: Duration(seconds: 10));
// 找到目标后
FlutterBluePlus.stopScan();
// ❌ 不必要的频繁读取特征值
Timer.periodic(Duration(milliseconds: 100), (_) => characteristic.read());
// ✅ 使用 notify 替代轮询
await characteristic.setNotifyValue(true);
characteristic.onValueReceived.listen((value) {
// 只在有新数据时处理
});
```
**BLE 省电策略:**
- 连接成功后立即停止扫描
- 使用 notification 而非 polling
- 非活跃页面暂停 BLE 数据监听
- 合理设置连接间隔参数
### 3.4 图片与内存管理
#### 网络图片缓存
```yaml
# 添加依赖
dependencies:
cached_network_image: ^3.3.0
```
```dart
// 使用缓存图片组件
CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 300, // 限制内存中图片尺寸
memCacheHeight: 300,
placeholder: (_, __) => const CircularProgressIndicator(),
errorWidget: (_, __, ___) => const Icon(Icons.error),
)
```
#### 大图降采样
```dart
// ❌ 直接加载 4.7MB 的 PNG 到内存
Image.asset('assets/www/首页背景2.png')
// ✅ 指定缓存尺寸,降低内存占用
Image.asset(
'assets/www/首页背景2.webp',
cacheWidth: 750, // 按屏幕宽度限制
cacheHeight: 1334,
)
```
#### 页面销毁时释放资源
```dart
class _MyPageState extends State<MyPage> {
VideoPlayerController? _videoController;
@override
void dispose() {
_videoController?.dispose(); // 必须释放!
super.dispose();
}
}
```
### 3.5 Isolate 处理耗时任务
```dart
// 图片处理、数据解析等放到 Isolate
import 'package:flutter/foundation.dart';
// 简单任务用 compute
final result = await compute(processImage, imageData);
// 复杂任务用 Isolate
Future<Uint8List> processImageInIsolate(Uint8List rawData) async {
return await Isolate.run(() {
// 在独立线程处理图片
return heavyImageProcessing(rawData);
});
}
```
适用场景:
- BLE 接收的大量数据解析
- 图片编解码badge 图片处理)
- JSON 大数据解析
---
## 四、UI 流畅度优化(消除卡顿)
### 4.1 列表优化
#### 使用 `ListView.builder` 而非 `ListView`
```dart
// ❌ 一次性创建所有子 Widget
ListView(children: items.map((e) => ItemWidget(e)).toList())
// ✅ 按需创建可见项
ListView.builder(
itemCount: items.length,
itemExtent: 80, // 固定高度可提升滚动性能
addAutomaticKeepAlives: false, // 不需要保活的列表关闭此项
addRepaintBoundaries: true,
itemBuilder: (context, index) => ItemWidget(items[index]),
)
```
#### 图片列表优化
```dart
ListView.builder(
cacheExtent: 500, // 预缓存区域
itemBuilder: (context, index) {
return RepaintBoundary(
child: CachedNetworkImage(
imageUrl: items[index].imageUrl,
memCacheWidth: 200, // 限制内存图片大小
),
);
},
)
```
### 4.2 Impeller 渲染引擎Flutter 3.41.2 默认启用)
你的 Flutter 版本已默认使用 Impeller 渲染引擎,优势:
- **消除 Shader 编译卡顿**Skia 的最大痛点)
- 更稳定的帧率
- 更低的 GPU 内存占用
验证 Impeller 是否启用:
```bash
# iOS 默认启用Android 3.22+ 默认启用
flutter run --profile # 然后查看 DevTools
```
如果遇到兼容性问题可临时回退:
```bash
flutter run --no-enable-impeller
```
### 4.3 Shader 预热(如仍用 Skia
```dart
// 在 main.dart 中
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 预编译 shader 缓存
await ShaderWarmUp().execute();
runApp(const MyApp());
}
```
SkSL 收集命令:
```bash
# 1. 收集 shader
flutter run --profile --cache-sksl --purge-persistent-cache
# 2. 操作 app 中所有页面和动画
# 3. 按 M 导出 flutter_01.sksl.json
# 4. 使用缓存构建
flutter build apk --bundle-sksl-path flutter_01.sksl.json
```
### 4.4 页面切换优化
```dart
// go_router 中预加载关键页面
GoRoute(
path: '/device-control',
pageBuilder: (context, state) => CustomTransitionPage(
transitionDuration: const Duration(milliseconds: 200), // 缩短动画时间
child: const DeviceControlPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child); // 用 Fade 替代 Slide
},
),
)
```
### 4.5 WebView 优化
```dart
// WebView 是重量级组件
// 1. 延迟初始化
late final WebViewController _controller;
@override
void initState() {
super.initState();
// 延迟到页面完全展示后再初始化 WebView
WidgetsBinding.instance.addPostFrameCallback((_) {
_initWebView();
});
}
// 2. 页面销毁时清理
@override
void dispose() {
_controller.clearCache();
_controller.clearLocalStorage();
super.dispose();
}
```
---
## 五、工具与监控
### 5.1 包大小分析
```bash
# 分析 APK 大小组成
flutter build apk --analyze-size
# 分析 iOS 包大小
flutter build ipa --analyze-size
```
### 5.2 性能分析
```bash
# Profile 模式运行(有性能数据但接近 release 性能)
flutter run --profile
# 打开 DevTools
flutter pub global activate devtools
flutter pub global run devtools
```
### 5.3 检测无用代码和依赖
```bash
# 检测未使用的依赖
dart pub deps --no-dev
# 分析代码问题
dart analyze
```
### 5.4 GlanceAPM 监控库)
```yaml
# 生产环境卡顿检测
dependencies:
glance_flutter: ^0.x.x # 实验性
```
---
## 六、实施优先级与预期效果
### P0 紧急1-2天收益巨大
| 任务 | 预期收益 |
|------|----------|
| 视频/音频迁移到 CDN | 包大小 -107MB (80%) |
| 背景图转 WebP 压缩 | 包大小 -8MB |
| 启用 `minifyEnabled` + `shrinkResources` | 包大小 -15-25% |
| `--split-per-abi` 分架构打包 | APK -30-40% |
### P1 重要3-5天
| 任务 | 预期收益 |
|------|----------|
| 移除 `google_fonts``http` 包 | 包大小 -2-4MB |
| 字体子集化 | 包大小 -1.2MB |
| `--obfuscate --split-debug-info` | 包大小 -5-10% |
| BLE 扫描/通信优化 | 降低发热 30-50% |
| 图片内存管理 (cacheWidth/Height) | 降低内存 40% |
### P2 优化1-2周
| 任务 | 预期收益 |
|------|----------|
| 全项目 const 构造函数审查 | 减少 Widget 重建 |
| Riverpod select 精准订阅 | 减少不必要重建 |
| 列表 ListView.builder 优化 | 滚动更流畅 |
| 耗时任务移入 Isolate | 消除主线程卡顿 |
| RepaintBoundary 隔离动画区域 | 减少重绘范围 |
| Deferred import (webview/video) | 减小初始加载 |
### P3 持续(长期)
| 任务 | 预期收益 |
|------|----------|
| 接入性能监控 (Glance/Sentry) | 线上问题感知 |
| 定期 `flutter build --analyze-size` | 包大小回归检测 |
| CI 集成包大小检查 | 防止包大小退化 |
---
## 七、预期总体效果
| 指标 | 优化前 | 优化后(预估) | 改善 |
|------|--------|----------------|------|
| APK 大小 | ~150-180MB | ~25-35MB | **80%+** |
| IPA 大小 | ~200MB+ | ~40-60MB | **70%+** |
| 冷启动时间 | 3-5s | 1-2s | **60%** |
| 内存占用 | 较高 | 降低 40%+ | **40%+** |
| 手机发热 | 明显 | 温热 | **显著改善** |
| 滚动帧率 | 有掉帧 | 稳定 60fps | **流畅** |
---
## 八、Impeller 渲染引擎性能数据
Flutter 3.41.2 已默认启用 Impeller替代 Skia关键性能数据
| 维度 | Skia | Impeller | 改善 |
|------|------|---------|------|
| Shader 编译 | 运行时 JIT首次卡顿 20-200ms | 构建时 AOT 预编译 | **消除 Shader 卡顿** |
| GPU 光栅化耗时 | 4.05ms | 2.81ms | **-30%** |
| 120Hz 帧率达标率 | 67.1% | 91.6% | **+36%** |
| 复杂场景掉帧率 | ~12% | ~1.5% | **-87%** |
确认 Impeller 状态:
```bash
flutter run --profile # 然后在 DevTools 中查看渲染后端
```
Android 持久化配置(`AndroidManifest.xml`
```xml
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
```
---
## 九、低端设备适配策略
针对不同档次手机动态降级:
```dart
// 设备分级
enum DeviceTier { low, mid, high }
DeviceTier getDeviceTier() {
final memory = SysInfo.getTotalPhysicalMemory(); // 需原生获取
if (memory < 3 * 1024 * 1024 * 1024) return DeviceTier.low;
if (memory < 6 * 1024 * 1024 * 1024) return DeviceTier.mid;
return DeviceTier.high;
}
```
| 策略 | 低端 | 中端 | 高端 |
|------|------|------|------|
| 动画 | 关闭粒子/复杂动画 | 简化动画 | 全特效 |
| 图片 | WebP 降分辨率 30% | 标准分辨率 | 高清 |
| BackdropFilter | 纯色替代 | 简化模糊 | 全高斯模糊 |
| 并发网络请求 | 限 2 个 | 限 4 个 | 不限制 |
---
## 十、生产环境性能监控
| 工具 | 类型 | 用途 |
|------|------|------|
| [Glance](https://github.com/littleGnAl/glance) | 开源 APM | 采集卡顿时的堆栈,定位具体函数 |
| [flutter_smooth](https://github.com/fzyzcjy/flutter_smooth) | 开源库 | 即使 Widget 树极重也能达到 ~60fps |
| Firebase Performance | 云端 APM | 启动时间、网络延迟、自定义追踪 |
| Sentry | 错误+性能 | 崩溃报告+性能事务 |
### 自动化性能测试CI 集成)
```dart
// integration_test/performance_test.dart
import 'package:integration_test/integration_test.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('scrolling performance', (tester) async {
await tester.pumpWidget(const MyApp());
await binding.traceAction(() async {
await tester.fling(find.byType(ListView), const Offset(0, -500), 10000);
await tester.pumpAndSettle();
}, reportKey: 'scrolling_timeline');
});
}
```
---
## 十一、参考资源
### 官方文档
- [Flutter Performance Best Practices](https://docs.flutter.dev/perf/best-practices)
- [App Size Optimization](https://docs.flutter.dev/perf/app-size)
- [Impeller Rendering Engine](https://docs.flutter.dev/perf/impeller)
- [Deferred Components](https://docs.flutter.dev/perf/deferred-components)
- [Shader Compilation Jank](https://docs.flutter.dev/perf/shader)
### 优秀开源项目参考
- [flutter_deer](https://github.com/simplezhli/flutter_deer) — 完善的 Flutter 实践项目,代码规范
- [Best-Flutter-UI-Templates](https://github.com/mitesh77/Best-Flutter-UI-Templates) — UI 模板合集
- [Solido/awesome-flutter](https://github.com/Solido/awesome-flutter) — Flutter 最全资源汇总
### 工具
- [Glance](https://pub.dev/packages/glance_flutter) — APM 卡顿检测
- [TinyPNG](https://tinypng.com/) — PNG/WebP 压缩
- [pngquant](https://pngquant.org/) — 批量 PNG 压缩
- [pyftsubset](https://fonttools.readthedocs.io/) — 字体子集化

File diff suppressed because one or more lines are too long

View File

@ -23,9 +23,6 @@ PODS:
- shared_preferences_foundation (0.0.1): - 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

View File

@ -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>

View File

@ -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));

View File

@ -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';

View File

@ -1,9 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package: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);
}
}

View File

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

View File

@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,103 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/services/badge_transfer_service.dart';
part 'badge_transfer_controller.g.dart';
enum TransferStatus { idle, scanning, connecting, transferring, done, error }
class BadgeTransferState {
final TransferStatus status;
final BluetoothDevice? device;
final double progress;
final String? statusMessage;
final String? errorMessage;
const BadgeTransferState({
this.status = TransferStatus.idle,
this.device,
this.progress = 0.0,
this.statusMessage,
this.errorMessage,
});
BadgeTransferState copyWith({
TransferStatus? status,
BluetoothDevice? device,
double? progress,
String? statusMessage,
String? errorMessage,
}) {
return BadgeTransferState(
status: status ?? this.status,
device: device ?? this.device,
progress: progress ?? this.progress,
statusMessage: statusMessage ?? this.statusMessage,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
@riverpod
class BadgeTransferController extends _$BadgeTransferController {
final _transferService = BadgeBleTransferService();
@override
BadgeTransferState build() {
ref.onDispose(() {
FlutterBluePlus.stopScan();
});
return const BadgeTransferState();
}
///
Future<void> connectAndTransfer(
BluetoothDevice device,
String imageUrl, {
Uint8List? imageBytes,
}) async {
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.connecting,
device: device,
progress: 0.0,
statusMessage: '正在连接设备...',
);
try {
await _transferService.connectAndTransfer(
device: device,
imageUrl: imageUrl,
imageBytes: imageBytes,
onProgress: (progress, message) {
if (!ref.mounted) return;
final newStatus = progress < 0.15
? TransferStatus.connecting
: TransferStatus.transferring;
state = state.copyWith(
status: newStatus,
progress: progress,
statusMessage: message,
);
},
);
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.done,
progress: 1.0,
statusMessage: '传输完成',
);
} catch (e) {
debugPrint('[BadgeTransfer] 传输失败: $e');
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.error,
errorMessage: e.toString().replaceFirst('Exception: ', ''),
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,56 +0,0 @@
import 'package:flutter/material.dart';
import '../../domain/entities/badge_style.dart';
class BadgeStyleChip extends StatelessWidget {
final BadgeStyle style;
final bool selected;
final VoidCallback onTap;
final Color accentColor;
const BadgeStyleChip({
super.key,
required this.style,
required this.selected,
required this.onTap,
this.accentColor = const Color(0xFF6366F1),
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: selected
? accentColor.withOpacity(0.1)
: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? accentColor
: Colors.white.withOpacity(0.4),
width: selected ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(style.icon, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 4),
Text(
style.name,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected
? accentColor
: const Color(0xFF6B7280),
),
),
],
),
),
);
}
}

View File

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

View File

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

View File

@ -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,

View File

@ -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?,

View File

@ -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(),

View File

@ -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;
}, },
); );
} }

View File

@ -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(

View File

@ -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();

View File

@ -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,

View File

@ -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),

View File

@ -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

View File

@ -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,

View File

@ -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),

View File

@ -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),
),
),
],
),
); );
} }

View File

@ -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);

View File

@ -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),

View File

@ -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();
} }
} }

View File

@ -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',

View File

@ -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,

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package: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) {

View File

@ -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,

View File

@ -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"))
} }

View File

@ -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:

View File

@ -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

View File

@ -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..."