diff --git a/airhub_app/android/build/reports/problems/problems-report.html b/airhub_app/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..8720698
--- /dev/null
+++ b/airhub_app/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/airhub_app/ios/Podfile.lock b/airhub_app/ios/Podfile.lock
index d68fb16..49e3574 100644
--- a/airhub_app/ios/Podfile.lock
+++ b/airhub_app/ios/Podfile.lock
@@ -23,6 +23,9 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
+ - video_player_avfoundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
@@ -36,6 +39,7 @@ DEPENDENCIES:
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
@@ -60,6 +64,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ video_player_avfoundation:
+ :path: ".symlinks/plugins/video_player_avfoundation/darwin"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
@@ -74,6 +80,7 @@ SPEC CHECKSUMS:
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
diff --git a/airhub_app/ios/Runner/Info.plist b/airhub_app/ios/Runner/Info.plist
index 943fe1e..a5ebd0d 100644
--- a/airhub_app/ios/Runner/Info.plist
+++ b/airhub_app/ios/Runner/Info.plist
@@ -26,6 +26,10 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ NSCameraUsageDescription
+ 需要相机权限来拍照传图到徽章设备
+ NSPhotoLibraryUsageDescription
+ 需要相册权限来选择图片传图到徽章设备
NSBluetoothAlwaysUsageDescription
需要蓝牙权限来搜索和连接您的设备
NSBluetoothPeripheralUsageDescription
diff --git a/airhub_app/lib/core/network/api_client.dart b/airhub_app/lib/core/network/api_client.dart
index 3b3b8bb..cae9f7e 100644
--- a/airhub_app/lib/core/network/api_client.dart
+++ b/airhub_app/lib/core/network/api_client.dart
@@ -143,7 +143,6 @@ class _AuthInterceptor extends Interceptor {
'/auth/phone-login/',
'/auth/refresh/',
'/version/check/',
- '/devices/query-by-mac/',
];
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));
diff --git a/airhub_app/lib/core/network/api_config.dart b/airhub_app/lib/core/network/api_config.dart
index 36c0aa5..b4011e3 100644
--- a/airhub_app/lib/core/network/api_config.dart
+++ b/airhub_app/lib/core/network/api_config.dart
@@ -1,6 +1,14 @@
+import 'package:flutter/foundation.dart' show kIsWeb;
+
class ApiConfig {
- /// 后端服务器地址(本地开发环境)
- static const String baseUrl = 'http://192.168.124.8:8000';
+ /// 本地开发地址(Web 调试用)
+ static const String _localUrl = 'http://192.168.124.8:8000';
+
+ /// 线上地址(APP 用)
+ static const String _prodUrl = 'https://qiyuan-rtc-api.airlabs.art';
+
+ /// 根据运行环境自动选择:Web → 本地,APP → 线上
+ static String get baseUrl => kIsWeb ? _localUrl : _prodUrl;
/// 一键授权登录专用域名(HTTPS,用于阿里云号码认证)
static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art';
diff --git a/airhub_app/lib/core/router/app_router.dart b/airhub_app/lib/core/router/app_router.dart
index a2b0d79..1dc2bfb 100644
--- a/airhub_app/lib/core/router/app_router.dart
+++ b/airhub_app/lib/core/router/app_router.dart
@@ -1,3 +1,5 @@
+import 'dart:typed_data';
+import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -9,12 +11,23 @@ import '../../pages/product_selection_page.dart';
import '../../pages/profile/profile_page.dart';
import '../../pages/webview_page.dart';
import '../../pages/wifi_config_page.dart';
+import '../../features/badge/presentation/pages/badge_basic_control_page.dart';
+import '../../features/badge/presentation/pages/badge_control_page.dart';
import '../../features/badge/presentation/pages/badge_home_page.dart';
import '../../features/badge/presentation/pages/badge_transfer_page.dart';
+import '../../features/device/data/datasources/device_remote_data_source.dart';
+import '../../theme/product_theme.dart';
import '../network/token_manager.dart';
part 'app_router.g.dart';
+/// 产品代码 → 路由 + ProductType 的映射
+const _productCodeRoutes = {
+ 'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
+ 'DZBJ-ON': (route: '/badge-control', type: ProductType.badgeAi),
+ 'DZBJ-OFF': (route: '/badge-basic-control', type: ProductType.badgeBasic),
+};
+
@riverpod
GoRouter goRouter(Ref ref) {
final tokenManager = ref.watch(tokenManagerProvider);
@@ -30,6 +43,37 @@ GoRouter goRouter(Ref ref) {
return '/login';
}
if (hasToken && isLoginRoute) {
+ // 登录成功 → 跳到最近使用的设备业务页
+ try {
+ final dataSource = ref.read(deviceRemoteDataSourceProvider);
+ final devices = await dataSource.getMyDevices();
+ debugPrint('[Router] 已绑定设备数: ${devices.length}');
+ if (devices.isNotEmpty) {
+ // 按 last_online_at 降序,取最近使用的设备
+ devices.sort((a, b) {
+ final ta = a.device.lastOnlineAt ?? '';
+ final tb = b.device.lastOnlineAt ?? '';
+ return tb.compareTo(ta);
+ });
+ final recent = devices.first;
+ final dt = recent.device.deviceType;
+ final dti = recent.device.deviceTypeInfo;
+ debugPrint('[Router] 最近设备 sn=${recent.device.sn}');
+ debugPrint('[Router] deviceType=$dt');
+ debugPrint('[Router] deviceTypeInfo=$dti');
+ final resolvedDt = dt ?? dti;
+ final code = resolvedDt?.productCode ?? '';
+ debugPrint('[Router] productCode=$code');
+ final mapping = _productCodeRoutes[code];
+ debugPrint('[Router] mapping=$mapping → route=${mapping?.route}');
+ if (mapping != null) {
+ ref.read(currentProductTypeProvider.notifier).set(mapping.type);
+ return mapping.route;
+ }
+ }
+ } catch (e) {
+ debugPrint('[Router] 获取设备失败: $e');
+ }
return '/home';
}
return null;
@@ -63,6 +107,14 @@ GoRouter goRouter(Ref ref) {
path: '/webview_fallback',
builder: (context, state) => const WebViewPage(),
),
+ GoRoute(
+ path: '/badge-control',
+ builder: (context, state) => const BadgeControlPage(),
+ ),
+ GoRoute(
+ path: '/badge-basic-control',
+ builder: (context, state) => const BadgeBasicControlPage(),
+ ),
GoRoute(
path: '/badge',
builder: (context, state) => const BadgeHomePage(),
@@ -73,6 +125,7 @@ GoRouter goRouter(Ref ref) {
final extra = state.extra as Map? ?? {};
return BadgeTransferPage(
imageUrl: extra['imageUrl'] as String? ?? '',
+ imageBytes: extra['imageBytes'] as Uint8List?,
);
},
),
diff --git a/airhub_app/lib/core/router/app_router.g.dart b/airhub_app/lib/core/router/app_router.g.dart
index 32607e6..2b81a6b 100644
--- a/airhub_app/lib/core/router/app_router.g.dart
+++ b/airhub_app/lib/core/router/app_router.g.dart
@@ -48,4 +48,4 @@ final class GoRouterProvider
}
}
-String _$goRouterHash() => r'4b725bca8dba655c6abb2f19069fca97c8bebbb6';
+String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c';
diff --git a/airhub_app/lib/features/auth/presentation/pages/login_page.dart b/airhub_app/lib/features/auth/presentation/pages/login_page.dart
index bf9e330..9d0465d 100644
--- a/airhub_app/lib/features/auth/presentation/pages/login_page.dart
+++ b/airhub_app/lib/features/auth/presentation/pages/login_page.dart
@@ -219,7 +219,7 @@ class _LoginPageState extends ConsumerState {
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
debugPrint('[Login] tokenLogin 结果: $success');
if (success && mounted) {
- await _navigateAfterLogin();
+ context.go('/login');
}
}
@@ -274,25 +274,8 @@ class _LoginPageState extends ConsumerState {
.read(authControllerProvider.notifier)
.codeLogin(_phoneController.text, _codeController.text);
if (success && mounted) {
- await _navigateAfterLogin();
- }
- }
-
- Future _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');
+ // 登录成功后跳到 /login,触发 router redirect 统一处理跳转
+ context.go('/login');
}
}
diff --git a/airhub_app/lib/features/badge/data/services/badge_transfer_service.dart b/airhub_app/lib/features/badge/data/services/badge_transfer_service.dart
index 0b5cb95..6fc0646 100644
--- a/airhub_app/lib/features/badge/data/services/badge_transfer_service.dart
+++ b/airhub_app/lib/features/badge/data/services/badge_transfer_service.dart
@@ -1,38 +1,361 @@
import 'dart:async';
-import 'dart:typed_data';
+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;
-enum TransferState { idle, transferring, complete, error }
+/// 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;
-class BadgeTransferState {
- final TransferState transferState;
- final List scannedDevices;
- final BluetoothDevice? selectedDevice;
- final double progress;
- final String? errorMessage;
+ /// 设备端约定 MTU = 512(见 ble_service_config.h)
+ /// requestMtu 在 iOS/Web 上可能不可用,但实际协商仍由底层完成
+ static const _defaultMtu = 512;
- const BadgeTransferState({
- this.transferState = TransferState.idle,
- this.scannedDevices = const [],
- this.selectedDevice,
- this.progress = 0,
- this.errorMessage,
- });
+ /// ATT 协议头占 3 字节 + GATT Handle 占 2 字节
+ static const _attOverhead = 3;
- BadgeTransferState copyWith({
- TransferState? transferState,
- List? scannedDevices,
- BluetoothDevice? selectedDevice,
- double? progress,
- String? errorMessage,
- }) {
- return BadgeTransferState(
- transferState: transferState ?? this.transferState,
- scannedDevices: scannedDevices ?? this.scannedDevices,
- selectedDevice: selectedDevice ?? this.selectedDevice,
- progress: progress ?? this.progress,
- errorMessage: errorMessage ?? this.errorMessage,
- );
+ /// 数据帧头: 包序号(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 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 _doTransfer({
+ required BluetoothDevice device,
+ required String filename,
+ required Uint8List imageData,
+ void Function(double progress, String message)? onProgress,
+ }) async {
+ StreamSubscription? 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 _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 _prepareImage(
+ String imageUrl, Uint8List? localBytes) async {
+ // ── 1. 获取原始字节 ──
+ Uint8List rawBytes;
+ if (localBytes != null) {
+ rawBytes = localBytes;
+ } else if (imageUrl.startsWith('http')) {
+ final response = await http.get(Uri.parse(imageUrl));
+ if (response.statusCode != 200) {
+ throw Exception('下载图片失败 (${response.statusCode})');
+ }
+ rawBytes = response.bodyBytes;
+ } else if (!kIsWeb) {
+ final file = File(imageUrl);
+ if (!await file.exists()) {
+ throw Exception('本地图片不存在: $imageUrl');
+ }
+ rawBytes = await file.readAsBytes();
+ } else {
+ throw Exception('无法加载图片: $imageUrl');
+ }
+
+ // ── 2. 在 isolate 中解码 → 裁剪 → 编码 JPEG ──
+ final jpegBytes = await compute(_processImageToJpeg, rawBytes);
+ debugPrint(
+ '[BLE Transfer] 图片处理完成: ${rawBytes.length} → ${jpegBytes.length} 字节');
+ return jpegBytes;
+ }
+
+ /// 纯函数:解码任意格式图片 → 居中裁剪为正方形 → 缩放 360×360 → JPEG 编码
+ /// 在 isolate 中运行,不阻塞 UI
+ static Uint8List _processImageToJpeg(Uint8List rawBytes) {
+ final decoded = img.decodeImage(rawBytes);
+ if (decoded == null) {
+ throw Exception('图片解码失败,不支持的格式');
+ }
+
+ // 居中裁剪为正方形
+ final cropSide =
+ decoded.width < decoded.height ? decoded.width : decoded.height;
+ final cropX = (decoded.width - cropSide) ~/ 2;
+ final cropY = (decoded.height - cropSide) ~/ 2;
+
+ img.Image cropped = img.copyCrop(decoded,
+ x: cropX, y: cropY, width: cropSide, height: cropSide);
+
+ // 缩放到目标尺寸
+ if (cropped.width != _targetSize || cropped.height != _targetSize) {
+ cropped = img.copyResize(cropped,
+ width: _targetSize,
+ height: _targetSize,
+ interpolation: img.Interpolation.linear);
+ }
+
+ // 编码为 JPEG(quality 85 兼顾质量和大小)
+ return Uint8List.fromList(img.encodeJpg(cropped, quality: 85));
+ }
+
+ /// 发送图片到设备(前序帧 + 混合写入模式分包)
+ Future _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 _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 = [];
+ 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(' + ');
}
}
diff --git a/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.dart b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.dart
index c0f7c57..3c83847 100644
--- a/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.dart
+++ b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.dart
@@ -1,6 +1,10 @@
+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 }
@@ -9,12 +13,14 @@ 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,
});
@@ -22,12 +28,14 @@ class BadgeTransferState {
TransferStatus? status,
BluetoothDevice? device,
double? progress,
+ String? statusMessage,
String? errorMessage,
}) {
return BadgeTransferState(
status: status ?? this.status,
device: device ?? this.device,
progress: progress ?? this.progress,
+ statusMessage: statusMessage ?? this.statusMessage,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -35,6 +43,8 @@ class BadgeTransferState {
@riverpod
class BadgeTransferController extends _$BadgeTransferController {
+ final _transferService = BadgeBleTransferService();
+
@override
BadgeTransferState build() {
ref.onDispose(() {
@@ -43,53 +53,50 @@ class BadgeTransferController extends _$BadgeTransferController {
return const BadgeTransferState();
}
- void startScan() {
- if (!ref.mounted) return;
- state = state.copyWith(status: TransferStatus.scanning);
- FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
-
- FlutterBluePlus.scanResults.listen((results) {
- if (!ref.mounted) return;
- // Process scan results
- });
-
- FlutterBluePlus.adapterState.listen((adapterState) {
- if (!ref.mounted) return;
- // Handle adapter state changes
- });
- }
-
- void stopScan() {
- FlutterBluePlus.stopScan();
- if (!ref.mounted) return;
- state = state.copyWith(status: TransferStatus.idle);
- }
-
+ /// 连接设备并传输图片
Future connectAndTransfer(
BluetoothDevice device,
- String imageUrl,
- ) async {
+ String imageUrl, {
+ Uint8List? imageBytes,
+ }) async {
if (!ref.mounted) return;
state = state.copyWith(
status: TransferStatus.connecting,
device: device,
+ progress: 0.0,
+ statusMessage: '正在连接设备...',
);
+
try {
- await device.connect();
- if (!ref.mounted) return;
- state = state.copyWith(status: TransferStatus.transferring);
- // Transfer logic here
- await Future.delayed(const Duration(seconds: 2));
+ 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(),
+ errorMessage: e.toString().replaceFirst('Exception: ', ''),
);
}
}
diff --git a/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart
index 0ce2a68..7a3253c 100644
--- a/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart
+++ b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart
@@ -42,7 +42,7 @@ final class BadgeTransferControllerProvider
}
String _$badgeTransferControllerHash() =>
- r'6ed7ce6b7aa6eae541366245e297b1c1ed1413f2';
+ r'5f06a9bcf5c9fcf0caf428497d2b9901b1b7f626';
abstract class _$BadgeTransferController extends $Notifier {
BadgeTransferState build();
diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart
new file mode 100644
index 0000000..7bd9819
--- /dev/null
+++ b/airhub_app/lib/features/badge/presentation/pages/badge_basic_control_page.dart
@@ -0,0 +1,424 @@
+import 'dart:convert';
+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 'package:http/http.dart' as http;
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../../../../core/network/api_config.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 createState() =>
+ _BadgeBasicControlPageState();
+}
+
+class _BadgeBasicControlPageState extends ConsumerState
+ with SingleTickerProviderStateMixin {
+ int _currentTab = 0;
+ late AnimationController _floatController;
+ late Animation _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(begin: -8, end: 8).animate(
+ CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
+ );
+ _loadLastImage();
+ }
+
+ @override
+ void dispose() {
+ _floatController.dispose();
+ super.dispose();
+ }
+
+ Future _loadLastImage() 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: 10));
+
+ if (resp.statusCode == 200) {
+ final body = jsonDecode(resp.body) as Map;
+ final data = body['data'] as Map? ?? {};
+ final images = (data['images'] as List? ?? [])
+ .cast