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