From 90609d97a342da7e0b73bf2c13b824d4c868ed83 Mon Sep 17 00:00:00 2001 From: repair-agent Date: Wed, 25 Mar 2026 13:56:23 +0800 Subject: [PATCH] =?UTF-8?q?add=20=E7=94=B5=E5=AD=90=E5=90=A7=E5=94=A7=20?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/problems/problems-report.html | 663 +++++++++++++ airhub_app/ios/Podfile.lock | 7 + airhub_app/lib/core/network/api_config.dart | 4 +- airhub_app/lib/core/router/app_router.dart | 7 + airhub_app/lib/core/router/app_router.g.dart | 2 +- .../data/services/badge_transfer_service.dart | 381 +++++++- .../badge_transfer_controller.dart | 71 +- .../badge_transfer_controller.g.dart | 2 +- .../pages/badge_control_page.dart | 239 +++++ .../presentation/pages/badge_home_page.dart | 64 +- .../pages/badge_transfer_page.dart | 870 +++++++++++++----- .../presentation/widgets/badge_ai_tab.dart | 50 +- .../widgets/badge_ble_device_card.dart | 26 +- .../widgets/badge_style_chip.dart | 8 +- .../widgets/badge_upload_tab.dart | 15 +- .../widgets/transfer_progress_ring.dart | 177 +++- .../device/domain/entities/device.dart | 21 +- .../domain/entities/device.freezed.dart | 18 +- .../device/domain/entities/device.g.dart | 41 +- airhub_app/lib/pages/bluetooth_page.dart | 55 +- .../lib/pages/product_selection_page.dart | 21 +- airhub_app/lib/pages/wifi_config_page.dart | 13 +- airhub_app/lib/theme/product_theme.dart | 190 ++++ airhub_app/lib/theme/product_theme.g.dart | 121 +++ airhub_app/lib/widgets/glass_dialog.dart | 11 +- airhub_app/lib/widgets/gradient_button.dart | 88 +- airhub_app/pubspec.lock | 24 + airhub_app/pubspec.yaml | 1 + 28 files changed, 2712 insertions(+), 478 deletions(-) create mode 100644 airhub_app/android/build/reports/problems/problems-report.html create mode 100644 airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart create mode 100644 airhub_app/lib/theme/product_theme.dart create mode 100644 airhub_app/lib/theme/product_theme.g.dart 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/lib/core/network/api_config.dart b/airhub_app/lib/core/network/api_config.dart index 36c0aa5..e6e3598 100644 --- a/airhub_app/lib/core/network/api_config.dart +++ b/airhub_app/lib/core/network/api_config.dart @@ -1,6 +1,6 @@ class ApiConfig { - /// 后端服务器地址(本地开发环境) - static const String baseUrl = 'http://192.168.124.8:8000'; + /// 后端服务器地址 + static const String baseUrl = 'https://qiyuan-rtc-api.airlabs.art'; /// 一键授权登录专用域名(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..7216857 100644 --- a/airhub_app/lib/core/router/app_router.dart +++ b/airhub_app/lib/core/router/app_router.dart @@ -1,3 +1,4 @@ +import 'dart:typed_data'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -9,6 +10,7 @@ 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_control_page.dart'; import '../../features/badge/presentation/pages/badge_home_page.dart'; import '../../features/badge/presentation/pages/badge_transfer_page.dart'; import '../network/token_manager.dart'; @@ -63,6 +65,10 @@ GoRouter goRouter(Ref ref) { path: '/webview_fallback', builder: (context, state) => const WebViewPage(), ), + GoRoute( + path: '/badge-control', + builder: (context, state) => const BadgeControlPage(), + ), GoRoute( path: '/badge', builder: (context, state) => const BadgeHomePage(), @@ -73,6 +79,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..26cd54c 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'25447f0e21cf92fb17956c2d137db4fe19e43338'; 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 194220b..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,57 +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)).catchError((e) { - // Web 平台: 用户取消 requestDevice() 选择器会抛出异常 - if (!ref.mounted) return; - state = state.copyWith(status: TransferStatus.idle); - }); - - 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_control_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart new file mode 100644 index 0000000..69b3da7 --- /dev/null +++ b/airhub_app/lib/features/badge/presentation/pages/badge_control_page.dart @@ -0,0 +1,239 @@ +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 '../../../../theme/product_theme.dart'; +import '../../../../widgets/animated_gradient_background.dart'; + +class BadgeControlPage extends ConsumerStatefulWidget { + const BadgeControlPage({super.key}); + + @override + ConsumerState createState() => _BadgeControlPageState(); +} + +class _BadgeControlPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _floatController; + late Animation _floatAnimation; + + @override + void initState() { + super.initState(); + _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), + ); + } + + @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(), + SafeArea( + child: Stack( + children: [ + // 居中的吧唧图片 + Center( + child: AnimatedBuilder( + animation: _floatAnimation, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, _floatAnimation.value), + child: child, + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 主题色光晕 + 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), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// 磨砂玻璃圆形图标按钮 — 带按压反馈 +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, + ), + ), + ); + } +} diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart index c795d5b..934c20d 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart @@ -3,6 +3,7 @@ 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'; @@ -23,6 +24,7 @@ class _BadgeHomePageState extends ConsumerState // 上传图片 String? _uploadedImagePath; + Uint8List? _uploadedImageBytes; // AI 生成 bool _isGenerating = false; @@ -84,6 +86,7 @@ class _BadgeHomePageState extends ConsumerState } void _showResultDialog(String imageUrl) { + final theme = ref.read(currentProductThemeProvider); showDialog( context: context, barrierDismissible: false, @@ -160,14 +163,7 @@ class _BadgeHomePageState extends ConsumerState height: 48, child: DecoratedBox( decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFF22D3EE), - Color(0xFF3B82F6), - Color(0xFF6366F1), - Color(0xFF8B5CF6), - ], - ), + gradient: theme.buttonGradient, borderRadius: BorderRadius.circular(14), ), child: ElevatedButton( @@ -232,6 +228,7 @@ class _BadgeHomePageState extends ConsumerState void _handleUploadSelected(String path, Uint8List? bytes) { setState(() { _uploadedImagePath = path; + _uploadedImageBytes = bytes; }); } @@ -244,11 +241,17 @@ class _BadgeHomePageState extends ConsumerState void _handleUseImage() { final imageSource = _generatedImageUrl ?? _uploadedImagePath; if (imageSource == null) return; - context.push('/badge/transfer', extra: {'imageUrl': imageSource}); + 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( @@ -258,13 +261,13 @@ class _BadgeHomePageState extends ConsumerState child: Column( children: [ _buildHeader(), - _buildTabBar(), - Expanded(child: _buildTabContent()), + _buildTabBar(productTheme), + Expanded(child: _buildTabContent(productTheme)), ], ), ), - if (_isGenerating) _buildGeneratingOverlay(), - _buildFixedBottomBar(), + if (_isGenerating) _buildGeneratingOverlay(productTheme), + _buildFixedBottomBar(productTheme), ], ), ); @@ -307,7 +310,7 @@ class _BadgeHomePageState extends ConsumerState ); } - Widget _buildTabBar() { + Widget _buildTabBar(ProductThemeData productTheme) { return Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), padding: const EdgeInsets.all(4), @@ -323,7 +326,7 @@ class _BadgeHomePageState extends ConsumerState borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: const Color(0xFF6366F1).withOpacity(0.15), + color: productTheme.accentColor.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 2), ), @@ -331,7 +334,7 @@ class _BadgeHomePageState extends ConsumerState ), indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, - labelColor: const Color(0xFF6366F1), + labelColor: productTheme.accentColor, unselectedLabelColor: const Color(0xFF6B7280), labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), @@ -343,7 +346,7 @@ class _BadgeHomePageState extends ConsumerState ); } - Widget _buildTabContent() { + Widget _buildTabContent(ProductThemeData productTheme) { return TabBarView( controller: _tabController, children: [ @@ -351,13 +354,17 @@ class _BadgeHomePageState extends ConsumerState key: _aiTabKey, onGenerate: _handleAiGenerate, isGenerating: _isGenerating, + accentColor: productTheme.accentColor, + ), + BadgeUploadTab( + onImageSelected: _handleUploadSelected, + accentColor: productTheme.accentColor, ), - BadgeUploadTab(onImageSelected: _handleUploadSelected), ], ); } - Widget _buildGeneratingOverlay() { + Widget _buildGeneratingOverlay(ProductThemeData productTheme) { return Positioned.fill( child: Container( color: Colors.black.withOpacity(0.4), @@ -372,21 +379,21 @@ class _BadgeHomePageState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( + SizedBox( width: 48, height: 48, child: CircularProgressIndicator( - color: Color(0xFF6366F1), + color: productTheme.accentColor, strokeWidth: 3, ), ), const SizedBox(height: 16), Text( _genStatus, - style: const TextStyle( + style: TextStyle( fontSize: 15, fontWeight: FontWeight.w500, - color: Color(0xFF6366F1), + color: productTheme.accentColor, ), ), const SizedBox(height: 12), @@ -394,8 +401,8 @@ class _BadgeHomePageState extends ConsumerState borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: _genProgress / 100, - backgroundColor: const Color(0xFF6366F1).withOpacity(0.15), - valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)), + backgroundColor: productTheme.accentColor.withOpacity(0.15), + valueColor: AlwaysStoppedAnimation(productTheme.accentColor), minHeight: 4, ), ), @@ -408,7 +415,7 @@ class _BadgeHomePageState extends ConsumerState } /// 固定底部按钮栏 — 无背景渐变,无阴影 - Widget _buildFixedBottomBar() { + Widget _buildFixedBottomBar(ProductThemeData productTheme) { final isAiTab = _tabController.index == 0; final isUploadTab = _tabController.index == 1; @@ -472,13 +479,12 @@ class _BadgeHomePageState extends ConsumerState } Widget _buildGradientButton(String label, VoidCallback? onPressed) { + final theme = ref.read(currentProductThemeProvider); return SizedBox( height: 52, child: DecoratedBox( decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF22D3EE), Color(0xFF3B82F6), Color(0xFF6366F1), Color(0xFF8B5CF6)], - ), + gradient: theme.buttonGradient, borderRadius: BorderRadius.circular(16), ), child: ElevatedButton( diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart index 6cf0b75..5fc97fb 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; @@ -6,49 +8,85 @@ 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'; -import '../widgets/transfer_progress_ring.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}); + const BadgeTransferPage({super.key, required this.imageUrl, this.imageBytes}); @override ConsumerState createState() => _BadgeTransferPageState(); } class _BadgeTransferPageState extends ConsumerState { - List _scanResults = []; + static const _airhubPrefix = 'Airhub_'; + + /// 归一化 MAC:去掉分隔符,统一大写 → "D0CF1303BBF2" + static String _normalizeMac(String mac) => + mac.replaceAll(RegExp(r'[:\-\.]'), '').toUpperCase(); + StreamSubscription>? _scanSubscription; StreamSubscription? _adapterSubscription; - ScanResult? _selectedDevice; + + /// 已解析的设备列表 + final List<_ResolvedDevice> _devices = []; + + /// MAC → API 查询缓存 + final Map> _macInfoCache = {}; + + /// 暂存 BLE 句柄 + final Map _pendingBleResults = {}; + + /// 当前用户已绑定设备的 MAC 集合 + Set _boundMacs = {}; + + /// 已绑定设备的 MAC → product_code 映射(用于按产品类型过滤) + Map _boundMacProductCodes = {}; + + _ResolvedDevice? _selectedDevice; + bool _isScanning = false; + bool _isAutoConnecting = false; @override void initState() { super.initState(); - _scanSubscription = FlutterBluePlus.scanResults.listen((results) { - if (!mounted) return; - setState(() { - _scanResults = results; - }); - }); _adapterSubscription = FlutterBluePlus.adapterState.listen((adapterState) { if (!mounted) return; }); - if (!kIsWeb) { - _startScan(); - } - } - - void _startScan() { - FlutterBluePlus.startScan(timeout: const Duration(seconds: 10)).catchError((e) { - // Web 平台: 用户取消 requestDevice() 选择器会抛出异常 - debugPrint('[Badge BLE] startScan 异常(用户可能取消了选择器): $e'); - }); + _loadBoundDevicesAndScan(); } @override @@ -59,9 +97,211 @@ class _BadgeTransferPageState extends ConsumerState { 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 _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 _queryAndAddDevice(String mac) async { + String displayName = 'Airhub 设备'; + String productCode = ''; + try { + final dataSource = ref.read(deviceRemoteDataSourceProvider); + final data = await dataSource.queryByMac(mac); + _macInfoCache[mac] = data; + + final deviceTypeName = data['device_type']?['name'] as String? ?? ''; + productCode = data['device_type']?['product_code'] as String? ?? ''; + if (deviceTypeName.isNotEmpty) { + displayName = deviceTypeName; + } + } catch (e) { + debugPrint('[Badge BLE] queryByMac 失败($mac): $e'); + } + + if (!mounted) return; + + final scanResult = _pendingBleResults[mac]; + if (scanResult == null) return; + + final normalizedMac = _normalizeMac(mac); + + // 检查是否绑定在当前账号下 + final isBound = _boundMacs.contains(normalizedMac); + if (!isBound) { + debugPrint('[Badge BLE] 设备 $mac 未绑定,不显示'); + return; + } + + // 检查产品类型是否匹配当前业务页面 + // 优先用 queryByMac 返回的 product_code,fallback 到 getMyDevices 缓存的 + final code = productCode.isNotEmpty + ? productCode + : (_boundMacProductCodes[normalizedMac] ?? ''); + final requiredCode = _currentProductCode; + if (requiredCode.isNotEmpty && code.isNotEmpty && code != requiredCode) { + debugPrint('[Badge BLE] 设备 $mac ($code) 不匹配当前产品 ($requiredCode),不显示'); + return; + } + + _addDeviceIfBound( + mac: mac, + displayName: displayName, + scanResult: scanResult, + isBound: true, + ); + } + + /// 添加设备到列表,如果是已绑定设备则自动连接 + void _addDeviceIfBound({ + required String mac, + required String displayName, + required ScanResult scanResult, + required bool isBound, + }) { + if (!mounted) return; + + setState(() { + if (!_devices.any((d) => d.mac == mac)) { + final device = _ResolvedDevice( + mac: mac, + displayName: displayName, + bleDevice: scanResult.device, + rssi: scanResult.rssi, + isBound: isBound, + ); + _devices.add(device); + + // 自动选中并连接第一个已绑定设备 + if (isBound && _selectedDevice == null && !_isAutoConnecting) { + _selectedDevice = device; + _isAutoConnecting = true; + _isScanning = false; + // 停止扫描后自动连接 + FlutterBluePlus.stopScan().catchError((_) {}); + } + } + }); + } + + /// 是否处于传输/完成/错误状态(非设备选择) + bool _isActiveTransfer(TransferStatus status) => + status == TransferStatus.connecting || + status == TransferStatus.transferring || + status == TransferStatus.done || + status == TransferStatus.error; + @override Widget build(BuildContext context) { final transferState = ref.watch(badgeTransferControllerProvider); + final productTheme = ref.watch(currentProductThemeProvider); + final isActive = _isActiveTransfer(transferState.status); return Scaffold( backgroundColor: Colors.white, @@ -73,21 +313,45 @@ class _BadgeTransferPageState extends ConsumerState { children: [ _buildHeader(), Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 120), - child: Column( - children: [ - _buildBadgePreview(), - const SizedBox(height: 16), - _buildTransferContent(transferState), - ], - ), - ), + child: isActive + // 传输中/完成/错误:预览图居中放大 + 状态信息在下方 + ? _buildHeroLayout(transferState, productTheme) + // 设备选择:预览图上方 + 设备列表下方可滚动 + : _buildSelectionLayout(transferState, productTheme), ), ], ), ), - _buildBottomBar(transferState), + _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), ], ), ); @@ -98,11 +362,11 @@ class _BadgeTransferPageState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - GestureDetector( + _PressableButton( onTap: () => context.pop(), child: Container( - width: 40, - height: 40, + width: 44, + height: 44, decoration: BoxDecoration( color: Colors.white.withOpacity(0.65), borderRadius: BorderRadius.circular(14), @@ -124,50 +388,246 @@ class _BadgeTransferPageState extends ConsumerState { ), ), ), - const SizedBox(width: 40), + const SizedBox(width: 44), ], ), ); } - /// 圆形徽章预览 — 紧凑展示,无卡片包裹 - Widget _buildBadgePreview() { + /// 主角预览图:居中大图 + 进度环/完成徽章套在外面 + 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( + 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( + 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( + 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: 140, - height: 140, + width: 120, + height: 120, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF1F2937), boxShadow: [ BoxShadow( - color: const Color(0xFF6366F1).withOpacity(0.2), + color: productTheme.accentColor.withOpacity(0.12), blurRadius: 20, - offset: const Offset(0, 6), + offset: const Offset(0, 4), ), ], ), - child: Padding( - padding: const EdgeInsets.all(6), - child: ClipOval( - child: widget.imageUrl.startsWith('http') - ? Image.network( - widget.imageUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.image_not_supported, - color: Colors.white54, - size: 36, - ), - ) - : const Icon( - Icons.image, - color: Colors.white54, - size: 36, - ), - ), - ), + padding: const EdgeInsets.all(5), + child: ClipOval(child: _buildPreviewImage()), ), const SizedBox(height: 8), const Text( @@ -178,22 +638,51 @@ class _BadgeTransferPageState extends ConsumerState { ); } - /// 根据传输状态显示不同内容 - Widget _buildTransferContent(BadgeTransferState transferState) { - switch (transferState.status) { - case TransferStatus.transferring: - return _buildTransferringView(transferState); - case TransferStatus.done: - return _buildDoneView(); - case TransferStatus.error: - return _buildErrorView(transferState); - default: - return _buildDeviceList(transferState); + /// 根据 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) { + Widget _buildDeviceList(BadgeTransferState transferState, ProductThemeData productTheme) { + final accent = productTheme.accentColor; return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -206,8 +695,8 @@ class _BadgeTransferPageState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.bluetooth_searching, - size: 20, color: Color(0xFF6366F1)), + Icon(Icons.bluetooth_searching, + size: 20, color: accent), const SizedBox(width: 8), const Expanded( child: Text( @@ -220,20 +709,22 @@ class _BadgeTransferPageState extends ConsumerState { ), ), GestureDetector( - onTap: _startScan, + onTap: _isScanning ? null : _loadBoundDevicesAndScan, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: const Color(0xFF6366F1).withOpacity(0.1), + color: accent.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), - child: const Text( - '重新扫描', + child: Text( + _isScanning ? '扫描中...' : '重新扫描', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: Color(0xFF6366F1), + color: _isScanning + ? const Color(0xFF9CA3AF) + : accent, ), ), ), @@ -241,42 +732,57 @@ class _BadgeTransferPageState extends ConsumerState { ], ), const SizedBox(height: 16), - if (_scanResults.isEmpty) + if (_devices.isEmpty) Container( padding: const EdgeInsets.all(32), alignment: Alignment.center, child: Column( children: [ - const SizedBox( - width: 32, - height: 32, - child: CircularProgressIndicator( - color: Color(0xFF6366F1), - strokeWidth: 2.5, + 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), + 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(_scanResults.length, (index) { - final result = _scanResults[index]; - final isSelected = _selectedDevice?.device.remoteId == - result.device.remoteId; + ...List.generate(_devices.length, (index) { + final device = _devices[index]; + final isSelected = _selectedDevice?.mac == device.mac; return BadgeBleDeviceCard( - scanResult: result, + displayName: device.displayName, + rssi: device.rssi, selected: isSelected, + accentColor: productTheme.accentColorLight, onTap: () { setState(() { - _selectedDevice = result; + _selectedDevice = device; }); }, ); @@ -286,102 +792,8 @@ class _BadgeTransferPageState extends ConsumerState { ); } - /// 传输中视图 - Widget _buildTransferringView(BadgeTransferState transferState) { - return Container( - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.65), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.4)), - ), - child: Column( - children: [ - TransferProgressRing(progress: transferState.progress), - const SizedBox(height: 20), - const Text( - '正在传输...', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF374151), - ), - ), - const SizedBox(height: 8), - const Text( - '请保持设备靠近,不要关闭蓝牙', - style: TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)), - ), - ], - ), - ); - } - - /// 传输完成视图 - Widget _buildDoneView() { - return Container( - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.65), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.4)), - ), - child: Column( - children: [ - TransferProgressRing(progress: 1.0, isComplete: true), - const SizedBox(height: 20), - const Text( - '传输完成!', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Color(0xFF10B981), - ), - ), - const SizedBox(height: 8), - const Text( - '图片已成功传输到徽章设备', - style: TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)), - ), - ], - ), - ); - } - - /// 错误视图 - Widget _buildErrorView(BadgeTransferState transferState) { - return Container( - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.65), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.4)), - ), - child: Column( - children: [ - const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), - const SizedBox(height: 16), - const Text( - '传输失败', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFFEF4444), - ), - ), - const SizedBox(height: 8), - Text( - transferState.errorMessage ?? '未知错误', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)), - ), - ], - ), - ); - } - /// 底部按钮栏 - Widget _buildBottomBar(BadgeTransferState transferState) { + Widget _buildBottomBar(BadgeTransferState transferState, ProductThemeData productTheme) { final Widget buttonContent; switch (transferState.status) { @@ -392,12 +804,15 @@ class _BadgeTransferPageState extends ConsumerState { ref .read(badgeTransferControllerProvider.notifier) .connectAndTransfer( - _selectedDevice!.device, + _selectedDevice!.bleDevice, widget.imageUrl, + imageBytes: widget.imageBytes, ); }); } else { - buttonContent = _buildDisabledButton('请先选择设备'); + buttonContent = _buildDisabledButton( + _isScanning ? '正在搜索设备...' : '请先选择设备', + ); } case TransferStatus.connecting: buttonContent = _buildDisabledButton('连接中...'); @@ -438,7 +853,7 @@ class _BadgeTransferPageState extends ConsumerState { ref .read(badgeTransferControllerProvider.notifier) .connectAndTransfer( - _selectedDevice!.device, + _selectedDevice!.bleDevice, widget.imageUrl, ); } @@ -452,27 +867,33 @@ class _BadgeTransferPageState extends ConsumerState { left: 0, right: 0, bottom: 0, - child: Padding( + child: Container( padding: EdgeInsets.fromLTRB( - 20, 16, 20, MediaQuery.of(context).padding.bottom + 16), + 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: const LinearGradient( - colors: [ - Color(0xFF22D3EE), - Color(0xFF3B82F6), - Color(0xFF6366F1), - Color(0xFF8B5CF6), - ], - ), + gradient: theme.buttonGradient, borderRadius: BorderRadius.circular(16), ), child: ElevatedButton( @@ -519,3 +940,34 @@ class _BadgeTransferPageState extends ConsumerState { ); } } + +/// 通用按压反馈按钮(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, + ), + ); + } +} diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart index 8a780a2..577e486 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart @@ -12,11 +12,13 @@ class BadgeAiTab extends StatefulWidget { 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 @@ -64,25 +66,26 @@ class BadgeAiTabState extends State { @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(), + _buildModeToggle(accent), const SizedBox(height: 16), // 图生图:参考图上传 if (_isI2I) ...[ - _buildReferenceImageSection(), + _buildReferenceImageSection(accent), const SizedBox(height: 16), - _buildStrengthSlider(), + _buildStrengthSlider(accent), const SizedBox(height: 16), ], // 提示词输入 - _buildPromptInput(), + _buildPromptInput(accent), const SizedBox(height: 20), // 风格选择 @@ -101,35 +104,35 @@ class BadgeAiTabState extends State { ], ), const SizedBox(height: 12), - _buildStyleGrid(), + _buildStyleGrid(accent), ], ), ); } - Widget _buildModeToggle() { + Widget _buildModeToggle(Color accent) { return Row( children: [ - _buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false)), + _buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false), accent), const SizedBox(width: 8), - _buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true)), + _buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true), accent), ], ); } - Widget _buildModeBtn(String label, bool active, VoidCallback onTap) { + 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 ? const Color(0xFF6366F1) : Colors.transparent, + color: active ? accent : Colors.transparent, borderRadius: BorderRadius.circular(20), border: Border.all( color: active - ? const Color(0xFF6366F1) - : const Color(0xFF6366F1).withOpacity(0.2), + ? accent + : accent.withOpacity(0.2), width: 1.5, ), ), @@ -145,7 +148,7 @@ class BadgeAiTabState extends State { ); } - Widget _buildReferenceImageSection() { + Widget _buildReferenceImageSection(Color accent) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -204,7 +207,7 @@ class BadgeAiTabState extends State { height: 180, decoration: BoxDecoration( border: Border.all( - color: const Color(0xFF6366F1).withOpacity(0.25), + color: accent.withOpacity(0.25), width: 2, ), borderRadius: BorderRadius.circular(16), @@ -213,7 +216,7 @@ class BadgeAiTabState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add, size: 36, color: const Color(0xFFA78BFA)), + 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), @@ -227,7 +230,7 @@ class BadgeAiTabState extends State { ); } - Widget _buildStrengthSlider() { + Widget _buildStrengthSlider(Color accent) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -246,18 +249,18 @@ class BadgeAiTabState extends State { ), Text( (_strength).toStringAsFixed(1), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF6366F1)), + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: accent), ), ], ), const SizedBox(height: 8), SliderTheme( data: SliderTheme.of(context).copyWith( - activeTrackColor: const Color(0xFF6366F1), - inactiveTrackColor: const Color(0xFF6366F1).withOpacity(0.15), + activeTrackColor: accent, + inactiveTrackColor: accent.withOpacity(0.15), thumbColor: Colors.white, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11), - overlayColor: const Color(0xFF6366F1).withOpacity(0.1), + overlayColor: accent.withOpacity(0.1), trackHeight: 6, ), child: Slider( @@ -280,7 +283,7 @@ class BadgeAiTabState extends State { ); } - Widget _buildPromptInput() { + Widget _buildPromptInput(Color accent) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -315,7 +318,7 @@ class BadgeAiTabState extends State { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: Color(0xFFA78BFA)), + borderSide: BorderSide(color: accent), ), contentPadding: const EdgeInsets.all(14), ), @@ -325,7 +328,7 @@ class BadgeAiTabState extends State { ); } - Widget _buildStyleGrid() { + Widget _buildStyleGrid(Color accent) { return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -341,6 +344,7 @@ class BadgeAiTabState extends State { return BadgeStyleChip( style: style, selected: _selectedStyle == style.id, + accentColor: accent, onTap: () { setState(() { _selectedStyle = _selectedStyle == style.id ? null : style.id; diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_ble_device_card.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_ble_device_card.dart index c7f8712..ca4e490 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/badge_ble_device_card.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_ble_device_card.dart @@ -1,25 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; class BadgeBleDeviceCard extends StatelessWidget { - final ScanResult scanResult; + final String displayName; + final int rssi; final bool selected; final VoidCallback onTap; + final Color accentColor; const BadgeBleDeviceCard({ super.key, - required this.scanResult, + required this.displayName, + required this.rssi, required this.selected, required this.onTap, + this.accentColor = const Color(0xFF8B5CF6), }); @override Widget build(BuildContext context) { - final name = scanResult.device.platformName.isNotEmpty - ? scanResult.device.platformName - : '未知设备'; - final rssi = scanResult.rssi; - return GestureDetector( onTap: onTap, child: Container( @@ -27,12 +25,12 @@ class BadgeBleDeviceCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( color: selected - ? const Color(0xFF8B5CF6).withOpacity(0.08) + ? accentColor.withOpacity(0.08) : Colors.white.withOpacity(0.7), borderRadius: BorderRadius.circular(14), border: Border.all( color: selected - ? const Color(0xFF8B5CF6) + ? accentColor : Colors.black.withOpacity(0.06), width: selected ? 2 : 1, ), @@ -42,7 +40,7 @@ class BadgeBleDeviceCard extends StatelessWidget { Icon( Icons.bluetooth, color: selected - ? const Color(0xFF8B5CF6) + ? accentColor : const Color(0xFF9CA3AF), size: 22, ), @@ -52,12 +50,12 @@ class BadgeBleDeviceCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - name, + displayName, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: selected - ? const Color(0xFF8B5CF6) + ? accentColor : const Color(0xFF1F2937), ), ), @@ -72,7 +70,7 @@ class BadgeBleDeviceCard extends StatelessWidget { ), ), if (selected) - const Icon(Icons.check_circle, color: Color(0xFF8B5CF6), size: 22), + Icon(Icons.check_circle, color: accentColor, size: 22), ], ), ), diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_style_chip.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_style_chip.dart index cb40065..bd14daa 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/badge_style_chip.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_style_chip.dart @@ -5,12 +5,14 @@ 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 @@ -21,12 +23,12 @@ class BadgeStyleChip extends StatelessWidget { duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: selected - ? const Color(0xFF6366F1).withOpacity(0.1) + ? accentColor.withOpacity(0.1) : Colors.white.withOpacity(0.65), borderRadius: BorderRadius.circular(14), border: Border.all( color: selected - ? const Color(0xFF6366F1) + ? accentColor : Colors.white.withOpacity(0.4), width: selected ? 2 : 1, ), @@ -42,7 +44,7 @@ class BadgeStyleChip extends StatelessWidget { fontSize: 11, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected - ? const Color(0xFF6366F1) + ? accentColor : const Color(0xFF6B7280), ), ), diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart index ac7617c..3bd1d2f 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart @@ -8,10 +8,12 @@ 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 @@ -66,6 +68,7 @@ class _BadgeUploadTabState extends State { Navigator.of(context).pop(); _selectAiImage(url); }, + accentColor: widget.accentColor, ), ); } @@ -180,7 +183,7 @@ class _BadgeUploadTabState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, color: const Color(0xFF6366F1), size: 48), + Icon(icon, color: widget.accentColor, size: 48), const SizedBox(height: 12), Text( label, @@ -200,8 +203,12 @@ class _BadgeUploadTabState extends State { class _AiHistoryBottomSheet extends StatefulWidget { final void Function(String imageUrl) onSelect; + final Color accentColor; - const _AiHistoryBottomSheet({required this.onSelect}); + const _AiHistoryBottomSheet({ + required this.onSelect, + this.accentColor = const Color(0xFF6366F1), + }); @override State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState(); @@ -286,8 +293,8 @@ class _AiHistoryBottomSheetState extends State<_AiHistoryBottomSheet> { Widget _buildContent() { if (_loading) { - return const Center( - child: CircularProgressIndicator(color: Color(0xFF6366F1)), + return Center( + child: CircularProgressIndicator(color: widget.accentColor), ); } if (_error != null) { diff --git a/airhub_app/lib/features/badge/presentation/widgets/transfer_progress_ring.dart b/airhub_app/lib/features/badge/presentation/widgets/transfer_progress_ring.dart index 12acbea..9b1dc2a 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/transfer_progress_ring.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/transfer_progress_ring.dart @@ -1,79 +1,178 @@ import 'dart:math'; import 'package:flutter/material.dart'; -class TransferProgressRing extends StatelessWidget { +/// 渐变进度环 — 带平滑动画 +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 createState() => _TransferProgressRingState(); +} + +class _TransferProgressRingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _progressAnim; + double _oldProgress = 0.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _progressAnim = Tween(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(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 SizedBox( - width: 120, - height: 120, - child: CustomPaint( - painter: _ProgressRingPainter( - progress: progress, - isComplete: isComplete, - ), - child: Center( - child: isComplete - ? const Icon(Icons.check, color: Color(0xFF10B981), size: 48) - : Text( - '${(progress * 100).toInt()}%', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: Color(0xFF8B5CF6), - ), - ), - ), - ), + 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( + 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 _ProgressRingPainter extends CustomPainter { +class _GradientRingPainter extends CustomPainter { final double progress; final bool isComplete; + final Color accentColor; - _ProgressRingPainter({required this.progress, required this.isComplete}); + _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); - // Background ring + // 背景环 final bgPaint = Paint() - ..color = const Color(0xFFE5E7EB) + ..color = accentColor.withOpacity(0.1) ..style = PaintingStyle.stroke ..strokeWidth = 8 ..strokeCap = StrokeCap.round; canvas.drawCircle(center, radius, bgPaint); - // Progress arc - final color = isComplete ? const Color(0xFF10B981) : const Color(0xFF8B5CF6); - final fgPaint = Paint() - ..color = color + 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.fromCircle(center: center, radius: radius), - -pi / 2, - 2 * pi * progress, - false, - fgPaint, - ); + + canvas.drawArc(rect, -pi / 2, sweepAngle, false, gradientPaint); } @override - bool shouldRepaint(covariant _ProgressRingPainter oldDelegate) => - oldDelegate.progress != progress || oldDelegate.isComplete != isComplete; + bool shouldRepaint(covariant _GradientRingPainter oldDelegate) => + oldDelegate.progress != progress || + oldDelegate.isComplete != isComplete || + oldDelegate.accentColor != accentColor; } diff --git a/airhub_app/lib/features/device/domain/entities/device.dart b/airhub_app/lib/features/device/domain/entities/device.dart index fac6279..3afec50 100644 --- a/airhub_app/lib/features/device/domain/entities/device.dart +++ b/airhub_app/lib/features/device/domain/entities/device.dart @@ -3,6 +3,23 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'device.freezed.dart'; part 'device.g.dart'; +/// API 有时返回完整对象,有时只返回整数 ID,需要容错 +class _SafeDeviceTypeConverter + implements JsonConverter { + const _SafeDeviceTypeConverter(); + + @override + DeviceType? fromJson(Object? json) { + if (json is Map) { + return DeviceType.fromJson(json); + } + return null; // 整数 ID 或 null → 忽略 + } + + @override + Object? toJson(DeviceType? object) => object?.toJson(); +} + @freezed abstract class DeviceType with _$DeviceType { const factory DeviceType({ @@ -24,8 +41,8 @@ abstract class DeviceInfo with _$DeviceInfo { const factory DeviceInfo({ required int id, required String sn, - DeviceType? deviceType, - DeviceType? deviceTypeInfo, + @_SafeDeviceTypeConverter() DeviceType? deviceType, + @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, @Default('') String name, @Default('in_stock') String status, diff --git a/airhub_app/lib/features/device/domain/entities/device.freezed.dart b/airhub_app/lib/features/device/domain/entities/device.freezed.dart index 63b6df9..009ae9f 100644 --- a/airhub_app/lib/features/device/domain/entities/device.freezed.dart +++ b/airhub_app/lib/features/device/domain/entities/device.freezed.dart @@ -296,7 +296,7 @@ as String?, /// @nodoc mixin _$DeviceInfo { - int get id; String get sn; DeviceType? get deviceType; 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;@_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; /// Create a copy of DeviceInfo /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -329,7 +329,7 @@ abstract mixin class $DeviceInfoCopyWith<$Res> { factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl; @useResult $Res call({ - int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt + int id, String sn,@_SafeDeviceTypeConverter() DeviceType? deviceType,@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt }); @@ -468,7 +468,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( int id, String sn, DeviceType? deviceType, 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 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; switch (_that) { case _DeviceInfo() when $default != null: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: @@ -489,7 +489,7 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this; +@optionalTypeArgs TResult when(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; switch (_that) { case _DeviceInfo(): return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: @@ -509,7 +509,7 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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; switch (_that) { case _DeviceInfo() when $default != null: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: @@ -524,13 +524,13 @@ return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.ma @JsonSerializable() class _DeviceInfo implements DeviceInfo { - const _DeviceInfo({required this.id, required this.sn, this.deviceType, 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, @_SafeDeviceTypeConverter() this.deviceType, @_SafeDeviceTypeConverter() this.deviceTypeInfo, this.macAddress, this.name = '', this.status = 'in_stock', this.isOnline = false, this.firmwareVersion = '', this.lastOnlineAt, this.createdAt}); factory _DeviceInfo.fromJson(Map json) => _$DeviceInfoFromJson(json); @override final int id; @override final String sn; -@override final DeviceType? deviceType; -@override final DeviceType? deviceTypeInfo; +@override@_SafeDeviceTypeConverter() final DeviceType? deviceType; +@override@_SafeDeviceTypeConverter() final DeviceType? deviceTypeInfo; @override final String? macAddress; @override@JsonKey() final String name; @override@JsonKey() final String status; @@ -572,7 +572,7 @@ abstract mixin class _$DeviceInfoCopyWith<$Res> implements $DeviceInfoCopyWith<$ factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl; @override @useResult $Res call({ - int id, String sn, DeviceType? deviceType, DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt + int id, String sn,@_SafeDeviceTypeConverter() DeviceType? deviceType,@_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt }); diff --git a/airhub_app/lib/features/device/domain/entities/device.g.dart b/airhub_app/lib/features/device/domain/entities/device.g.dart index 9e87185..0f65008 100644 --- a/airhub_app/lib/features/device/domain/entities/device.g.dart +++ b/airhub_app/lib/features/device/domain/entities/device.g.dart @@ -30,12 +30,10 @@ Map _$DeviceTypeToJson(_DeviceType instance) => _DeviceInfo _$DeviceInfoFromJson(Map json) => _DeviceInfo( id: (json['id'] as num).toInt(), sn: json['sn'] as String, - deviceType: json['device_type'] == null - ? null - : DeviceType.fromJson(json['device_type'] as Map), - deviceTypeInfo: json['device_type_info'] == null - ? null - : DeviceType.fromJson(json['device_type_info'] as Map), + deviceType: const _SafeDeviceTypeConverter().fromJson(json['device_type']), + deviceTypeInfo: const _SafeDeviceTypeConverter().fromJson( + json['device_type_info'], + ), macAddress: json['mac_address'] as String?, name: json['name'] as String? ?? '', status: json['status'] as String? ?? 'in_stock', @@ -45,20 +43,23 @@ _DeviceInfo _$DeviceInfoFromJson(Map json) => _DeviceInfo( createdAt: json['created_at'] as String?, ); -Map _$DeviceInfoToJson(_DeviceInfo instance) => - { - 'id': instance.id, - 'sn': instance.sn, - 'device_type': instance.deviceType, - 'device_type_info': instance.deviceTypeInfo, - 'mac_address': instance.macAddress, - 'name': instance.name, - 'status': instance.status, - 'is_online': instance.isOnline, - 'firmware_version': instance.firmwareVersion, - 'last_online_at': instance.lastOnlineAt, - 'created_at': instance.createdAt, - }; +Map _$DeviceInfoToJson( + _DeviceInfo instance, +) => { + 'id': instance.id, + 'sn': instance.sn, + 'device_type': const _SafeDeviceTypeConverter().toJson(instance.deviceType), + 'device_type_info': const _SafeDeviceTypeConverter().toJson( + instance.deviceTypeInfo, + ), + 'mac_address': instance.macAddress, + 'name': instance.name, + 'status': instance.status, + 'is_online': instance.isOnline, + 'firmware_version': instance.firmwareVersion, + 'last_online_at': instance.lastOnlineAt, + 'created_at': instance.createdAt, +}; _UserDevice _$UserDeviceFromJson(Map json) => _UserDevice( id: (json['id'] as num).toInt(), diff --git a/airhub_app/lib/pages/bluetooth_page.dart b/airhub_app/lib/pages/bluetooth_page.dart index 1da1b51..11b91e6 100644 --- a/airhub_app/lib/pages/bluetooth_page.dart +++ b/airhub_app/lib/pages/bluetooth_page.dart @@ -14,6 +14,7 @@ import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; import '../widgets/glass_dialog.dart'; +import '../theme/product_theme.dart'; /// 设备类型 enum DeviceType { plush, badgeAi, badge } @@ -252,6 +253,7 @@ class _BluetoothPageState extends ConsumerState _macInfoCache[mac] = data; final deviceTypeName = data['device_type']?['name'] as String? ?? ''; + final productCode = data['device_type']?['product_code'] as String? ?? ''; final sn = data['sn'] as String? ?? ''; final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true; final bleDevice = _pendingBleDevices[mac]; @@ -266,7 +268,7 @@ class _BluetoothPageState extends ConsumerState sn: sn, name: displayName, macAddress: mac, - type: _inferDeviceType(displayName), + type: _inferDeviceTypeByCode(productCode, displayName), hasAI: _inferHasAI(displayName), isNetworkRequired: isNetworkRequired, bleDevice: bleDevice, @@ -303,15 +305,29 @@ class _BluetoothPageState extends ConsumerState } /// 根据设备名称推断设备类型 - DeviceType _inferDeviceType(String name) { + /// 优先用 product_code 精确匹配,fallback 到名称推断 + DeviceType _inferDeviceTypeByCode(String productCode, String name) { + // product_code 精确匹配 + switch (productCode) { + case 'KPBL-ON': + return DeviceType.plush; + case 'DZBJ-ON': + return DeviceType.badgeAi; + case 'DZBJ-OFF': + return DeviceType.badge; + } + // fallback: 名称推断 final lower = name.toLowerCase(); - if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯') || lower.contains('airhub')) { + if (lower.contains('plush') || lower.contains('卡皮巴拉') || lower.contains('机芯')) { return DeviceType.plush; } if (lower.contains('ai') || lower.contains('智能')) { return DeviceType.badgeAi; } - return DeviceType.badge; + if (lower.contains('吧唧') || lower.contains('badge')) { + return DeviceType.badge; + } + return DeviceType.plush; } /// 根据设备名称推断是否支持 AI @@ -363,6 +379,21 @@ class _BluetoothPageState extends ConsumerState 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-control'); + case DeviceType.plush: + ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara); + context.go('/device-control'); + } + } + /// 连接设备 Future _handleConnect() async { if (_devices.isEmpty || _isConnecting) return; @@ -377,8 +408,18 @@ class _BluetoothPageState extends ConsumerState debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}'); if (!device.isNetworkRequired) { - // 不需要联网 -> 直接去设备控制页 - context.go('/device-control'); + // 不需要联网 -> 跳过配网,绑定设备后进入业务页 + if (device.sn.isNotEmpty) { + setState(() => _isConnecting = true); + try { + await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); + } catch (e) { + debugPrint('[Bluetooth] bindDevice 异常: $e'); + } + if (!mounted) return; + setState(() => _isConnecting = false); + } + _setThemeAndNavigate(device.type); return; } @@ -391,7 +432,7 @@ class _BluetoothPageState extends ConsumerState } if (!mounted) return; setState(() => _isConnecting = false); - context.go('/device-control'); + _setThemeAndNavigate(device.type); return; } diff --git a/airhub_app/lib/pages/product_selection_page.dart b/airhub_app/lib/pages/product_selection_page.dart index 4688a0b..6eb7df4 100644 --- a/airhub_app/lib/pages/product_selection_page.dart +++ b/airhub_app/lib/pages/product_selection_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../widgets/animated_gradient_background.dart'; import '../features/device/presentation/controllers/device_controller.dart'; import '../features/device/domain/entities/device.dart'; +import '../theme/product_theme.dart'; class ProductSelectionPage extends ConsumerStatefulWidget { const ProductSelectionPage({super.key}); @@ -38,6 +39,13 @@ class _ProductSelectionPageState extends ConsumerState { 'badge-basic': ['DZBJ-OFF'], }; + /// 产品 ID 到 ProductType 的映射 + static const Map _productTypeMap = { + 'capybara': ProductType.capybara, + 'badge-ai': ProductType.badgeAi, + 'badge-basic': ProductType.badgeBasic, + }; + /// 查找用户是否已绑定该产品类型的设备 UserDevice? _findBoundDevice(String productId, List devices) { final codes = _productCodeMap[productId]; @@ -190,9 +198,18 @@ class _ProductSelectionPageState extends ConsumerState { fadeStartY: headerHeight + 16, fadeEndY: safeTop, onTap: () { + // 设置当前产品主题 + final productType = _productTypeMap[product['id']] ?? ProductType.common; + ref.read(currentProductTypeProvider.notifier).set(productType); + if (boundDevice != null) { - // 已绑定 → 直接进入设备控制页 - context.go('/device-control'); + // 已绑定 → 根据产品类型进入对应控制页 + final pid = product['id'] as String; + if (pid == 'badge-ai' || pid == 'badge-basic') { + context.go('/badge-control'); + } else { + context.go('/device-control'); + } } else { // 未绑定 → 跳转蓝牙搜索页 context.go('/bluetooth'); diff --git a/airhub_app/lib/pages/wifi_config_page.dart b/airhub_app/lib/pages/wifi_config_page.dart index faccfa5..87d74c8 100644 --- a/airhub_app/lib/pages/wifi_config_page.dart +++ b/airhub_app/lib/pages/wifi_config_page.dart @@ -8,6 +8,7 @@ import '../core/services/ble_provisioning_service.dart'; import '../features/device/presentation/controllers/device_controller.dart'; import '../widgets/animated_gradient_background.dart'; import '../widgets/gradient_button.dart'; +import '../theme/product_theme.dart'; class WifiConfigPage extends ConsumerStatefulWidget { final Map? extra; @@ -143,7 +144,17 @@ class _WifiConfigPageState extends ConsumerState } if (!mounted) return; setState(() => _isBinding = false); - context.go('/device-control'); + final deviceType = _deviceInfo['type'] as String? ?? ''; + if (deviceType == 'badgeAi') { + ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi); + context.go('/badge-control'); + } else if (deviceType == 'badge') { + ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic); + context.go('/badge-control'); + } else { + ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara); + context.go('/device-control'); + } return; } diff --git a/airhub_app/lib/theme/product_theme.dart b/airhub_app/lib/theme/product_theme.dart new file mode 100644 index 0000000..7741c12 --- /dev/null +++ b/airhub_app/lib/theme/product_theme.dart @@ -0,0 +1,190 @@ +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 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)); +} diff --git a/airhub_app/lib/theme/product_theme.g.dart b/airhub_app/lib/theme/product_theme.g.dart new file mode 100644 index 0000000..3fd7294 --- /dev/null +++ b/airhub_app/lib/theme/product_theme.g.dart @@ -0,0 +1,121 @@ +// 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 { + /// 全局当前产品类型 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(value), + ); + } +} + +String _$currentProductTypeHash() => + r'53603ab5884787f0a4bb1aed5de18ff33089b5e7'; + +/// 全局当前产品类型 Notifier + +abstract class _$CurrentProductType extends $Notifier { + ProductType build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ProductType, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// 当前产品主题(派生自产品类型) + +@ProviderFor(currentProductTheme) +const currentProductThemeProvider = CurrentProductThemeProvider._(); + +/// 当前产品主题(派生自产品类型) + +final class CurrentProductThemeProvider + extends + $FunctionalProvider< + ProductThemeData, + ProductThemeData, + ProductThemeData + > + with $Provider { + /// 当前产品主题(派生自产品类型) + 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 $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(value), + ); + } +} + +String _$currentProductThemeHash() => + r'a4e7be1ce8791e6e3323950346ef72e4f5d07fa9'; diff --git a/airhub_app/lib/widgets/glass_dialog.dart b/airhub_app/lib/widgets/glass_dialog.dart index 2b1aba4..339578c 100644 --- a/airhub_app/lib/widgets/glass_dialog.dart +++ b/airhub_app/lib/widgets/glass_dialog.dart @@ -11,8 +11,8 @@ class GlassDialog extends StatelessWidget { final String confirmText; final VoidCallback onCancel; final VoidCallback onConfirm; - final bool - isDanger; // If we need a specific danger style, though CSS shows Pink Gradient default + final bool isDanger; + final Gradient? confirmGradient; const GlassDialog({ super.key, @@ -24,6 +24,7 @@ class GlassDialog extends StatelessWidget { required this.onCancel, required this.onConfirm, this.isDanger = false, + this.confirmGradient, }); @override @@ -98,7 +99,7 @@ class GlassDialog extends StatelessWidget { GradientButton( text: confirmText, height: 48, - gradient: appclr.AppColors.btnPlushGradient, + gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient, onPressed: onConfirm, ), ] else ...[ @@ -131,7 +132,7 @@ class GlassDialog extends StatelessWidget { child: GradientButton( text: confirmText, height: 44, - gradient: appclr.AppColors.btnPlushGradient, + gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient, onPressed: onConfirm, ), ), @@ -155,6 +156,7 @@ Future showGlassDialog({ String confirmText = '确定', required VoidCallback onConfirm, bool isDanger = false, + Gradient? confirmGradient, }) { return showGeneralDialog( context: context, @@ -176,6 +178,7 @@ Future showGlassDialog({ onCancel: () => Navigator.of(context).pop(), onConfirm: onConfirm, isDanger: isDanger, + confirmGradient: confirmGradient, ); }, transitionBuilder: (context, anim1, anim2, child) { diff --git a/airhub_app/lib/widgets/gradient_button.dart b/airhub_app/lib/widgets/gradient_button.dart index 26155dc..1c8f577 100644 --- a/airhub_app/lib/widgets/gradient_button.dart +++ b/airhub_app/lib/widgets/gradient_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; +import '../theme/product_theme.dart'; class GradientButton extends StatelessWidget { final String text; @@ -9,6 +10,7 @@ class GradientButton extends StatelessWidget { final double height; final bool isLoading; final Gradient? gradient; + final List? shadows; const GradientButton({ super.key, @@ -18,57 +20,53 @@ class GradientButton extends StatelessWidget { this.height = 48.0, // 统一规范高度 this.isLoading = false, this.gradient, + this.shadows, }); - // Check if using plush/capybara gradient - bool get _isPlushGradient { - if (gradient == null) return false; - if (gradient is LinearGradient) { - final lg = gradient as LinearGradient; - // Check if colors match plush gradient colors - if (lg.colors.length >= 2) { - return lg.colors.first.value == 0xFFECCFA8 || - lg.colors.last.value == 0xFFC99672; - } - } - return false; + /// 从 ProductThemeData 创建,自动匹配渐变和阴影 + factory GradientButton.fromTheme({ + Key? key, + required String text, + required ProductThemeData theme, + VoidCallback? onPressed, + double width = double.infinity, + double height = 48.0, + bool isLoading = false, + }) { + return GradientButton( + key: key, + text: text, + onPressed: onPressed, + width: width, + height: height, + isLoading: isLoading, + gradient: theme.buttonGradient, + shadows: theme.buttonShadows, + ); } List get _boxShadows { - if (_isPlushGradient) { - // Warm brown glow for Capybara plush gradient - return [ - BoxShadow( - color: const Color(0xFFC99672).withOpacity(0.35), - offset: Offset.zero, - blurRadius: 15, - ), - BoxShadow( - color: const Color(0xFFC99672).withOpacity(0.25), - offset: Offset.zero, - blurRadius: 30, - ), - BoxShadow( - color: const Color(0xFFC99672).withOpacity(0.4), - offset: const Offset(0, 6), - blurRadius: 20, - ), - ]; - } else { - // Purple/indigo glow for primary gradient - return [ - BoxShadow( - color: const Color(0xFF6366F1).withOpacity(0.4), - offset: const Offset(0, 4), - blurRadius: 20, - ), - BoxShadow( - color: const Color(0xFF8B5CF6).withOpacity(0.2), - offset: Offset.zero, - blurRadius: 40, - ), - ]; + if (shadows != null) return shadows!; + // 根据渐变颜色自动推断阴影 + if (gradient is LinearGradient) { + final lg = gradient as LinearGradient; + if (lg.colors.length >= 2) { + final shadowColor = lg.colors[lg.colors.length ~/ 2]; + return [ + BoxShadow( + color: shadowColor.withOpacity(0.4), + offset: const Offset(0, 4), + blurRadius: 20, + ), + BoxShadow( + color: lg.colors.last.withOpacity(0.2), + offset: Offset.zero, + blurRadius: 40, + ), + ]; + } } + return AppColors.shadowPrimaryButton; } @override diff --git a/airhub_app/pubspec.lock b/airhub_app/pubspec.lock index 7393331..324c64d 100644 --- a/airhub_app/pubspec.lock +++ b/airhub_app/pubspec.lock @@ -40,6 +40,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.10" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -580,6 +588,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -956,6 +972,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" pub_semver: dependency: transitive description: diff --git a/airhub_app/pubspec.yaml b/airhub_app/pubspec.yaml index 3869e7b..b7fa786 100644 --- a/airhub_app/pubspec.yaml +++ b/airhub_app/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: flutter_blue_plus: ^1.31.0 flutter_svg: ^2.0.9 image_picker: ^1.2.1 + image: ^4.3.0 just_audio: ^0.9.42 http: ^1.2.0 video_player: ^2.9.2