Merge fix/auto-20260324-130656: 电子吧唧业务页面、设备绑定状态检查、BLE传输优化

This commit is contained in:
repair-agent 2026-03-26 10:09:34 +08:00
commit 134da153d5
34 changed files with 3405 additions and 540 deletions

File diff suppressed because one or more lines are too long

View File

@ -23,6 +23,9 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1): - webview_flutter_wkwebview (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@ -36,6 +39,7 @@ DEPENDENCIES:
- just_audio (from `.symlinks/plugins/just_audio/darwin`) - just_audio (from `.symlinks/plugins/just_audio/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS: SPEC REPOS:
@ -60,6 +64,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
webview_flutter_wkwebview: webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
@ -74,6 +80,7 @@ SPEC CHECKSUMS:
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@ -26,6 +26,10 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>需要相机权限来拍照传图到徽章设备</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要相册权限来选择图片传图到徽章设备</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限来搜索和连接您的设备</string> <string>需要蓝牙权限来搜索和连接您的设备</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>

View File

@ -143,7 +143,6 @@ class _AuthInterceptor extends Interceptor {
'/auth/phone-login/', '/auth/phone-login/',
'/auth/refresh/', '/auth/refresh/',
'/version/check/', '/version/check/',
'/devices/query-by-mac/',
]; ];
final needsAuth = !noAuthPaths.any((p) => options.path.contains(p)); final needsAuth = !noAuthPaths.any((p) => options.path.contains(p));

View File

@ -1,6 +1,14 @@
import 'package:flutter/foundation.dart' show kIsWeb;
class ApiConfig { class ApiConfig {
/// /// Web
static const String baseUrl = 'http://192.168.124.8:8000'; static const String _localUrl = 'http://192.168.124.8:8000';
/// 线APP
static const String _prodUrl = 'https://qiyuan-rtc-api.airlabs.art';
/// Web APP 线
static String get baseUrl => kIsWeb ? _localUrl : _prodUrl;
/// HTTPS /// HTTPS
static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art'; static const String authBaseUrl = 'https://qiyuan-rtc-api.airlabs.art';

View File

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -9,12 +11,23 @@ import '../../pages/product_selection_page.dart';
import '../../pages/profile/profile_page.dart'; import '../../pages/profile/profile_page.dart';
import '../../pages/webview_page.dart'; import '../../pages/webview_page.dart';
import '../../pages/wifi_config_page.dart'; import '../../pages/wifi_config_page.dart';
import '../../features/badge/presentation/pages/badge_basic_control_page.dart';
import '../../features/badge/presentation/pages/badge_control_page.dart';
import '../../features/badge/presentation/pages/badge_home_page.dart'; import '../../features/badge/presentation/pages/badge_home_page.dart';
import '../../features/badge/presentation/pages/badge_transfer_page.dart'; import '../../features/badge/presentation/pages/badge_transfer_page.dart';
import '../../features/device/data/datasources/device_remote_data_source.dart';
import '../../theme/product_theme.dart';
import '../network/token_manager.dart'; import '../network/token_manager.dart';
part 'app_router.g.dart'; part 'app_router.g.dart';
/// + ProductType
const _productCodeRoutes = {
'KPBL-ON': (route: '/device-control', type: ProductType.capybara),
'DZBJ-ON': (route: '/badge-control', type: ProductType.badgeAi),
'DZBJ-OFF': (route: '/badge-basic-control', type: ProductType.badgeBasic),
};
@riverpod @riverpod
GoRouter goRouter(Ref ref) { GoRouter goRouter(Ref ref) {
final tokenManager = ref.watch(tokenManagerProvider); final tokenManager = ref.watch(tokenManagerProvider);
@ -30,6 +43,37 @@ GoRouter goRouter(Ref ref) {
return '/login'; return '/login';
} }
if (hasToken && isLoginRoute) { if (hasToken && isLoginRoute) {
// 使
try {
final dataSource = ref.read(deviceRemoteDataSourceProvider);
final devices = await dataSource.getMyDevices();
debugPrint('[Router] 已绑定设备数: ${devices.length}');
if (devices.isNotEmpty) {
// last_online_at 使
devices.sort((a, b) {
final ta = a.device.lastOnlineAt ?? '';
final tb = b.device.lastOnlineAt ?? '';
return tb.compareTo(ta);
});
final recent = devices.first;
final dt = recent.device.deviceType;
final dti = recent.device.deviceTypeInfo;
debugPrint('[Router] 最近设备 sn=${recent.device.sn}');
debugPrint('[Router] deviceType=$dt');
debugPrint('[Router] deviceTypeInfo=$dti');
final resolvedDt = dt ?? dti;
final code = resolvedDt?.productCode ?? '';
debugPrint('[Router] productCode=$code');
final mapping = _productCodeRoutes[code];
debugPrint('[Router] mapping=$mapping → route=${mapping?.route}');
if (mapping != null) {
ref.read(currentProductTypeProvider.notifier).set(mapping.type);
return mapping.route;
}
}
} catch (e) {
debugPrint('[Router] 获取设备失败: $e');
}
return '/home'; return '/home';
} }
return null; return null;
@ -63,6 +107,14 @@ GoRouter goRouter(Ref ref) {
path: '/webview_fallback', path: '/webview_fallback',
builder: (context, state) => const WebViewPage(), builder: (context, state) => const WebViewPage(),
), ),
GoRoute(
path: '/badge-control',
builder: (context, state) => const BadgeControlPage(),
),
GoRoute(
path: '/badge-basic-control',
builder: (context, state) => const BadgeBasicControlPage(),
),
GoRoute( GoRoute(
path: '/badge', path: '/badge',
builder: (context, state) => const BadgeHomePage(), builder: (context, state) => const BadgeHomePage(),
@ -73,6 +125,7 @@ GoRouter goRouter(Ref ref) {
final extra = state.extra as Map<String, dynamic>? ?? {}; final extra = state.extra as Map<String, dynamic>? ?? {};
return BadgeTransferPage( return BadgeTransferPage(
imageUrl: extra['imageUrl'] as String? ?? '', imageUrl: extra['imageUrl'] as String? ?? '',
imageBytes: extra['imageBytes'] as Uint8List?,
); );
}, },
), ),

View File

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

View File

@ -219,7 +219,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final success = await ref.read(authControllerProvider.notifier).tokenLogin(token); final success = await ref.read(authControllerProvider.notifier).tokenLogin(token);
debugPrint('[Login] tokenLogin 结果: $success'); debugPrint('[Login] tokenLogin 结果: $success');
if (success && mounted) { if (success && mounted) {
await _navigateAfterLogin(); context.go('/login');
} }
} }
@ -274,25 +274,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
.read(authControllerProvider.notifier) .read(authControllerProvider.notifier)
.codeLogin(_phoneController.text, _codeController.text); .codeLogin(_phoneController.text, _codeController.text);
if (success && mounted) { if (success && mounted) {
await _navigateAfterLogin(); // /login router redirect
} context.go('/login');
}
Future<void> _navigateAfterLogin() async {
if (!mounted) return;
try {
final devices = await ref.read(deviceControllerProvider.future);
if (!mounted) return;
if (devices.isNotEmpty) {
debugPrint('[Login] User has ${devices.length} device(s), navigating to device control');
context.go('/device-control');
} else {
debugPrint('[Login] No devices, navigating to home');
context.go('/home');
}
} catch (e) {
debugPrint('[Login] Device check failed: $e');
if (mounted) context.go('/home');
} }
} }

View File

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

View File

@ -1,6 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/services/badge_transfer_service.dart';
part 'badge_transfer_controller.g.dart'; part 'badge_transfer_controller.g.dart';
enum TransferStatus { idle, scanning, connecting, transferring, done, error } enum TransferStatus { idle, scanning, connecting, transferring, done, error }
@ -9,12 +13,14 @@ class BadgeTransferState {
final TransferStatus status; final TransferStatus status;
final BluetoothDevice? device; final BluetoothDevice? device;
final double progress; final double progress;
final String? statusMessage;
final String? errorMessage; final String? errorMessage;
const BadgeTransferState({ const BadgeTransferState({
this.status = TransferStatus.idle, this.status = TransferStatus.idle,
this.device, this.device,
this.progress = 0.0, this.progress = 0.0,
this.statusMessage,
this.errorMessage, this.errorMessage,
}); });
@ -22,12 +28,14 @@ class BadgeTransferState {
TransferStatus? status, TransferStatus? status,
BluetoothDevice? device, BluetoothDevice? device,
double? progress, double? progress,
String? statusMessage,
String? errorMessage, String? errorMessage,
}) { }) {
return BadgeTransferState( return BadgeTransferState(
status: status ?? this.status, status: status ?? this.status,
device: device ?? this.device, device: device ?? this.device,
progress: progress ?? this.progress, progress: progress ?? this.progress,
statusMessage: statusMessage ?? this.statusMessage,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@ -35,6 +43,8 @@ class BadgeTransferState {
@riverpod @riverpod
class BadgeTransferController extends _$BadgeTransferController { class BadgeTransferController extends _$BadgeTransferController {
final _transferService = BadgeBleTransferService();
@override @override
BadgeTransferState build() { BadgeTransferState build() {
ref.onDispose(() { ref.onDispose(() {
@ -43,53 +53,50 @@ class BadgeTransferController extends _$BadgeTransferController {
return const BadgeTransferState(); return const BadgeTransferState();
} }
void startScan() { ///
if (!ref.mounted) return;
state = state.copyWith(status: TransferStatus.scanning);
FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
FlutterBluePlus.scanResults.listen((results) {
if (!ref.mounted) return;
// Process scan results
});
FlutterBluePlus.adapterState.listen((adapterState) {
if (!ref.mounted) return;
// Handle adapter state changes
});
}
void stopScan() {
FlutterBluePlus.stopScan();
if (!ref.mounted) return;
state = state.copyWith(status: TransferStatus.idle);
}
Future<void> connectAndTransfer( Future<void> connectAndTransfer(
BluetoothDevice device, BluetoothDevice device,
String imageUrl, String imageUrl, {
) async { Uint8List? imageBytes,
}) async {
if (!ref.mounted) return; if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
status: TransferStatus.connecting, status: TransferStatus.connecting,
device: device, device: device,
progress: 0.0,
statusMessage: '正在连接设备...',
); );
try { try {
await device.connect(); await _transferService.connectAndTransfer(
if (!ref.mounted) return; device: device,
state = state.copyWith(status: TransferStatus.transferring); imageUrl: imageUrl,
// Transfer logic here imageBytes: imageBytes,
await Future.delayed(const Duration(seconds: 2)); 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; if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
status: TransferStatus.done, status: TransferStatus.done,
progress: 1.0, progress: 1.0,
statusMessage: '传输完成',
); );
} catch (e) { } catch (e) {
debugPrint('[BadgeTransfer] 传输失败: $e');
if (!ref.mounted) return; if (!ref.mounted) return;
state = state.copyWith( state = state.copyWith(
status: TransferStatus.error, status: TransferStatus.error,
errorMessage: e.toString(), errorMessage: e.toString().replaceFirst('Exception: ', ''),
); );
} }
} }

View File

@ -42,7 +42,7 @@ final class BadgeTransferControllerProvider
} }
String _$badgeTransferControllerHash() => String _$badgeTransferControllerHash() =>
r'6ed7ce6b7aa6eae541366245e297b1c1ed1413f2'; r'5f06a9bcf5c9fcf0caf428497d2b9901b1b7f626';
abstract class _$BadgeTransferController extends $Notifier<BadgeTransferState> { abstract class _$BadgeTransferController extends $Notifier<BadgeTransferState> {
BadgeTransferState build(); BadgeTransferState build();

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../theme/product_theme.dart';
import '../../../../widgets/animated_gradient_background.dart'; import '../../../../widgets/animated_gradient_background.dart';
import '../../data/services/badge_ai_generation_service.dart'; import '../../data/services/badge_ai_generation_service.dart';
import '../controllers/badge_ai_controller.dart'; import '../controllers/badge_ai_controller.dart';
@ -23,13 +24,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
// //
String? _uploadedImagePath; String? _uploadedImagePath;
Uint8List? _uploadedImageBytes;
// AI // AI
bool _isGenerating = false; bool _isGenerating = false;
double _genProgress = 0; double _genProgress = 0;
String _genStatus = ''; String _genStatus = '';
String? _generatedImageUrl; String? _generatedImageUrl;
bool _hasAiResult = false;
@override @override
void initState() { void initState() {
@ -43,7 +44,6 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
final pending = BadgeAiGenerationService.instance.consumePendingResult(); final pending = BadgeAiGenerationService.instance.consumePendingResult();
if (pending != null) { if (pending != null) {
_generatedImageUrl = pending.imageUrl; _generatedImageUrl = pending.imageUrl;
_hasAiResult = true;
} }
// AI // AI
@ -61,7 +61,6 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
setState(() { setState(() {
_isGenerating = false; _isGenerating = false;
_generatedImageUrl = result.imageUrl; _generatedImageUrl = result.imageUrl;
_hasAiResult = true;
}); });
_showResultDialog(result.imageUrl); _showResultDialog(result.imageUrl);
} }
@ -87,6 +86,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
} }
void _showResultDialog(String imageUrl) { void _showResultDialog(String imageUrl) {
final theme = ref.read(currentProductThemeProvider);
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -163,14 +163,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
height: 48, height: 48,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: theme.buttonGradient,
colors: [
Color(0xFF22D3EE),
Color(0xFF3B82F6),
Color(0xFF6366F1),
Color(0xFF8B5CF6),
],
),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: ElevatedButton( child: ElevatedButton(
@ -213,7 +206,6 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
setState(() { setState(() {
_isGenerating = true; _isGenerating = true;
_generatedImageUrl = null; _generatedImageUrl = null;
_hasAiResult = false;
_genProgress = 0; _genProgress = 0;
_genStatus = '正在连接 AI...'; _genStatus = '正在连接 AI...';
}); });
@ -236,24 +228,30 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
void _handleUploadSelected(String path, Uint8List? bytes) { void _handleUploadSelected(String path, Uint8List? bytes) {
setState(() { setState(() {
_uploadedImagePath = path; _uploadedImagePath = path;
_uploadedImageBytes = bytes;
}); });
} }
void _handleRetry() { void _handleRetry() {
setState(() { setState(() {
_generatedImageUrl = null; _generatedImageUrl = null;
_hasAiResult = false;
}); });
} }
void _handleUseImage() { void _handleUseImage() {
final imageSource = _generatedImageUrl ?? _uploadedImagePath; final imageSource = _generatedImageUrl ?? _uploadedImagePath;
if (imageSource == null) return; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final productTheme = ref.watch(currentProductThemeProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
body: Stack( body: Stack(
@ -263,13 +261,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
child: Column( child: Column(
children: [ children: [
_buildHeader(), _buildHeader(),
_buildTabBar(), _buildTabBar(productTheme),
Expanded(child: _buildTabContent()), Expanded(child: _buildTabContent(productTheme)),
], ],
), ),
), ),
if (_isGenerating) _buildGeneratingOverlay(), if (_isGenerating) _buildGeneratingOverlay(productTheme),
_buildFixedBottomBar(), _buildFixedBottomBar(productTheme),
], ],
), ),
); );
@ -312,7 +310,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
); );
} }
Widget _buildTabBar() { Widget _buildTabBar(ProductThemeData productTheme) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@ -328,7 +326,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.15), color: productTheme.accentColor.withOpacity(0.15),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),
@ -336,7 +334,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
), ),
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
labelColor: const Color(0xFF6366F1), labelColor: productTheme.accentColor,
unselectedLabelColor: const Color(0xFF6B7280), unselectedLabelColor: const Color(0xFF6B7280),
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
@ -348,7 +346,7 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
); );
} }
Widget _buildTabContent() { Widget _buildTabContent(ProductThemeData productTheme) {
return TabBarView( return TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
@ -356,13 +354,17 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
key: _aiTabKey, key: _aiTabKey,
onGenerate: _handleAiGenerate, onGenerate: _handleAiGenerate,
isGenerating: _isGenerating, isGenerating: _isGenerating,
accentColor: productTheme.accentColor,
),
BadgeUploadTab(
onImageSelected: _handleUploadSelected,
accentColor: productTheme.accentColor,
), ),
BadgeUploadTab(onImageSelected: _handleUploadSelected),
], ],
); );
} }
Widget _buildGeneratingOverlay() { Widget _buildGeneratingOverlay(ProductThemeData productTheme) {
return Positioned.fill( return Positioned.fill(
child: Container( child: Container(
color: Colors.black.withOpacity(0.4), color: Colors.black.withOpacity(0.4),
@ -377,21 +379,21 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox( SizedBox(
width: 48, width: 48,
height: 48, height: 48,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Color(0xFF6366F1), color: productTheme.accentColor,
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_genStatus, _genStatus,
style: const TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF6366F1), color: productTheme.accentColor,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -399,8 +401,8 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: _genProgress / 100, value: _genProgress / 100,
backgroundColor: const Color(0xFF6366F1).withOpacity(0.15), backgroundColor: productTheme.accentColor.withOpacity(0.15),
valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)), valueColor: AlwaysStoppedAnimation(productTheme.accentColor),
minHeight: 4, minHeight: 4,
), ),
), ),
@ -413,38 +415,40 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
} }
/// ///
Widget _buildFixedBottomBar() { Widget _buildFixedBottomBar(ProductThemeData productTheme) {
final isAiTab = _tabController.index == 0; final isAiTab = _tabController.index == 0;
final isUploadTab = _tabController.index == 1; final isUploadTab = _tabController.index == 1;
Widget? buttonContent; Widget? buttonContent;
if (isAiTab) { if (isAiTab) {
// "开始生成" buttonContent = _buildGradientButton(
if (!_isGenerating && !_hasAiResult) { '开始生成',
buttonContent = _buildGradientButton('开始生成', () { _isGenerating
final aiState = _aiTabKey.currentState; ? null
if (aiState == null) return; : () {
final prompt = aiState.currentPrompt; final aiState = _aiTabKey.currentState;
final isI2I = aiState.referenceImageBytes != null; if (aiState == null) return;
// final prompt = aiState.currentPrompt;
if (!isI2I && prompt.isEmpty) { final isI2I = aiState.referenceImageBytes != null;
ScaffoldMessenger.of(context).showSnackBar( //
const SnackBar( if (!isI2I && prompt.isEmpty) {
content: Text('请输入图片描述'), ScaffoldMessenger.of(context).showSnackBar(
backgroundColor: Colors.orange, const SnackBar(
), content: Text('请输入图片描述'),
); backgroundColor: Colors.orange,
return; ),
} );
_handleAiGenerate( return;
prompt: prompt, }
style: aiState.selectedStyle, _handleAiGenerate(
imageBytes: aiState.referenceImageBytes, prompt: prompt,
strength: aiState.strength, style: aiState.selectedStyle,
); imageBytes: aiState.referenceImageBytes,
}); strength: aiState.strength,
} );
},
);
} else if (isUploadTab) { } else if (isUploadTab) {
if (_uploadedImagePath != null) { if (_uploadedImagePath != null) {
buttonContent = _buildGradientButton('使用此图', _handleUseImage); buttonContent = _buildGradientButton('使用此图', _handleUseImage);
@ -474,14 +478,13 @@ class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
); );
} }
Widget _buildGradientButton(String label, VoidCallback onPressed) { Widget _buildGradientButton(String label, VoidCallback? onPressed) {
final theme = ref.read(currentProductThemeProvider);
return SizedBox( return SizedBox(
height: 52, height: 52,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: theme.buttonGradient,
colors: [Color(0xFF22D3EE), Color(0xFF3B82F6), Color(0xFF6366F1), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: ElevatedButton( child: ElevatedButton(

View File

@ -12,11 +12,13 @@ class BadgeAiTab extends StatefulWidget {
double strength, double strength,
}) onGenerate; }) onGenerate;
final bool isGenerating; final bool isGenerating;
final Color accentColor;
const BadgeAiTab({ const BadgeAiTab({
super.key, super.key,
required this.onGenerate, required this.onGenerate,
this.isGenerating = false, this.isGenerating = false,
this.accentColor = const Color(0xFF6366F1),
}); });
@override @override
@ -64,25 +66,26 @@ class BadgeAiTabState extends State<BadgeAiTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = widget.accentColor;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 100), padding: const EdgeInsets.fromLTRB(20, 16, 20, 100),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// //
_buildModeToggle(), _buildModeToggle(accent),
const SizedBox(height: 16), const SizedBox(height: 16),
// //
if (_isI2I) ...[ if (_isI2I) ...[
_buildReferenceImageSection(), _buildReferenceImageSection(accent),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildStrengthSlider(), _buildStrengthSlider(accent),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// //
_buildPromptInput(), _buildPromptInput(accent),
const SizedBox(height: 20), const SizedBox(height: 20),
// //
@ -101,35 +104,35 @@ class BadgeAiTabState extends State<BadgeAiTab> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildStyleGrid(), _buildStyleGrid(accent),
], ],
), ),
); );
} }
Widget _buildModeToggle() { Widget _buildModeToggle(Color accent) {
return Row( return Row(
children: [ children: [
_buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false)), _buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false), accent),
const SizedBox(width: 8), 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( return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? const Color(0xFF6366F1) : Colors.transparent, color: active ? accent : Colors.transparent,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: active color: active
? const Color(0xFF6366F1) ? accent
: const Color(0xFF6366F1).withOpacity(0.2), : accent.withOpacity(0.2),
width: 1.5, width: 1.5,
), ),
), ),
@ -145,7 +148,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
); );
} }
Widget _buildReferenceImageSection() { Widget _buildReferenceImageSection(Color accent) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -204,7 +207,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
height: 180, height: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: const Color(0xFF6366F1).withOpacity(0.25), color: accent.withOpacity(0.25),
width: 2, width: 2,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@ -213,7 +216,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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 SizedBox(height: 8),
const Text('点击上传参考图', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))), const Text('点击上传参考图', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -227,7 +230,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
); );
} }
Widget _buildStrengthSlider() { Widget _buildStrengthSlider(Color accent) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -246,18 +249,18 @@ class BadgeAiTabState extends State<BadgeAiTab> {
), ),
Text( Text(
(_strength).toStringAsFixed(1), (_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), const SizedBox(height: 8),
SliderTheme( SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
activeTrackColor: const Color(0xFF6366F1), activeTrackColor: accent,
inactiveTrackColor: const Color(0xFF6366F1).withOpacity(0.15), inactiveTrackColor: accent.withOpacity(0.15),
thumbColor: Colors.white, thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11), thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11),
overlayColor: const Color(0xFF6366F1).withOpacity(0.1), overlayColor: accent.withOpacity(0.1),
trackHeight: 6, trackHeight: 6,
), ),
child: Slider( child: Slider(
@ -280,7 +283,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
); );
} }
Widget _buildPromptInput() { Widget _buildPromptInput(Color accent) {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -315,7 +318,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFA78BFA)), borderSide: BorderSide(color: accent),
), ),
contentPadding: const EdgeInsets.all(14), contentPadding: const EdgeInsets.all(14),
), ),
@ -325,7 +328,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
); );
} }
Widget _buildStyleGrid() { Widget _buildStyleGrid(Color accent) {
return GridView.builder( return GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@ -341,6 +344,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
return BadgeStyleChip( return BadgeStyleChip(
style: style, style: style,
selected: _selectedStyle == style.id, selected: _selectedStyle == style.id,
accentColor: accent,
onTap: () { onTap: () {
setState(() { setState(() {
_selectedStyle = _selectedStyle == style.id ? null : style.id; _selectedStyle = _selectedStyle == style.id ? null : style.id;

View File

@ -1,25 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class BadgeBleDeviceCard extends StatelessWidget { class BadgeBleDeviceCard extends StatelessWidget {
final ScanResult scanResult; final String displayName;
final int rssi;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
final Color accentColor;
const BadgeBleDeviceCard({ const BadgeBleDeviceCard({
super.key, super.key,
required this.scanResult, required this.displayName,
required this.rssi,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
this.accentColor = const Color(0xFF8B5CF6),
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final name = scanResult.device.platformName.isNotEmpty
? scanResult.device.platformName
: '未知设备';
final rssi = scanResult.rssi;
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
@ -27,12 +25,12 @@ class BadgeBleDeviceCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color: selected
? const Color(0xFF8B5CF6).withOpacity(0.08) ? accentColor.withOpacity(0.08)
: Colors.white.withOpacity(0.7), : Colors.white.withOpacity(0.7),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: selected color: selected
? const Color(0xFF8B5CF6) ? accentColor
: Colors.black.withOpacity(0.06), : Colors.black.withOpacity(0.06),
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
@ -42,7 +40,7 @@ class BadgeBleDeviceCard extends StatelessWidget {
Icon( Icon(
Icons.bluetooth, Icons.bluetooth,
color: selected color: selected
? const Color(0xFF8B5CF6) ? accentColor
: const Color(0xFF9CA3AF), : const Color(0xFF9CA3AF),
size: 22, size: 22,
), ),
@ -52,12 +50,12 @@ class BadgeBleDeviceCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
name, displayName,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: selected color: selected
? const Color(0xFF8B5CF6) ? accentColor
: const Color(0xFF1F2937), : const Color(0xFF1F2937),
), ),
), ),
@ -72,7 +70,7 @@ class BadgeBleDeviceCard extends StatelessWidget {
), ),
), ),
if (selected) if (selected)
const Icon(Icons.check_circle, color: Color(0xFF8B5CF6), size: 22), Icon(Icons.check_circle, color: accentColor, size: 22),
], ],
), ),
), ),

View File

@ -5,12 +5,14 @@ class BadgeStyleChip extends StatelessWidget {
final BadgeStyle style; final BadgeStyle style;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
final Color accentColor;
const BadgeStyleChip({ const BadgeStyleChip({
super.key, super.key,
required this.style, required this.style,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
this.accentColor = const Color(0xFF6366F1),
}); });
@override @override
@ -21,12 +23,12 @@ class BadgeStyleChip extends StatelessWidget {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color: selected
? const Color(0xFF6366F1).withOpacity(0.1) ? accentColor.withOpacity(0.1)
: Colors.white.withOpacity(0.65), : Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: selected color: selected
? const Color(0xFF6366F1) ? accentColor
: Colors.white.withOpacity(0.4), : Colors.white.withOpacity(0.4),
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
@ -42,7 +44,7 @@ class BadgeStyleChip extends StatelessWidget {
fontSize: 11, fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500, fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected color: selected
? const Color(0xFF6366F1) ? accentColor
: const Color(0xFF6B7280), : const Color(0xFF6B7280),
), ),
), ),

View File

@ -8,10 +8,12 @@ import '../../../../core/network/api_config.dart';
class BadgeUploadTab extends StatefulWidget { class BadgeUploadTab extends StatefulWidget {
final void Function(String imagePath, Uint8List? bytes) onImageSelected; final void Function(String imagePath, Uint8List? bytes) onImageSelected;
final Color accentColor;
const BadgeUploadTab({ const BadgeUploadTab({
super.key, super.key,
required this.onImageSelected, required this.onImageSelected,
this.accentColor = const Color(0xFF6366F1),
}); });
@override @override
@ -66,6 +68,7 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
Navigator.of(context).pop(); Navigator.of(context).pop();
_selectAiImage(url); _selectAiImage(url);
}, },
accentColor: widget.accentColor,
), ),
); );
} }
@ -180,7 +183,7 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: const Color(0xFF6366F1), size: 48), Icon(icon, color: widget.accentColor, size: 48),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
label, label,
@ -200,8 +203,12 @@ class _BadgeUploadTabState extends State<BadgeUploadTab> {
class _AiHistoryBottomSheet extends StatefulWidget { class _AiHistoryBottomSheet extends StatefulWidget {
final void Function(String imageUrl) onSelect; 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 @override
State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState(); State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState();
@ -286,8 +293,8 @@ class _AiHistoryBottomSheetState extends State<_AiHistoryBottomSheet> {
Widget _buildContent() { Widget _buildContent() {
if (_loading) { if (_loading) {
return const Center( return Center(
child: CircularProgressIndicator(color: Color(0xFF6366F1)), child: CircularProgressIndicator(color: widget.accentColor),
); );
} }
if (_error != null) { if (_error != null) {

View File

@ -1,79 +1,178 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TransferProgressRing extends StatelessWidget { ///
class TransferProgressRing extends StatefulWidget {
final double progress; final double progress;
final bool isComplete; final bool isComplete;
final Color accentColor;
const TransferProgressRing({ const TransferProgressRing({
super.key, super.key,
required this.progress, required this.progress,
this.isComplete = false, this.isComplete = false,
this.accentColor = const Color(0xFF8B5CF6),
}); });
@override
State<TransferProgressRing> createState() => _TransferProgressRingState();
}
class _TransferProgressRingState extends State<TransferProgressRing>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _progressAnim;
double _oldProgress = 0.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_progressAnim = Tween<double>(begin: 0, end: widget.progress)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller.forward();
}
@override
void didUpdateWidget(TransferProgressRing oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_oldProgress = _progressAnim.value;
_progressAnim = Tween<double>(begin: _oldProgress, end: widget.progress)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_controller
..reset()
..forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return AnimatedBuilder(
width: 120, animation: _progressAnim,
height: 120, builder: (context, child) {
child: CustomPaint( final p = _progressAnim.value.clamp(0.0, 1.0);
painter: _ProgressRingPainter( return SizedBox(
progress: progress, width: 120,
isComplete: isComplete, height: 120,
), child: CustomPaint(
child: Center( painter: _GradientRingPainter(
child: isComplete progress: p,
? const Icon(Icons.check, color: Color(0xFF10B981), size: 48) isComplete: widget.isComplete,
: Text( accentColor: widget.accentColor,
'${(progress * 100).toInt()}%', ),
style: const TextStyle( child: Center(
fontSize: 24, child: widget.isComplete
fontWeight: FontWeight.w700, ? TweenAnimationBuilder<double>(
color: Color(0xFF8B5CF6), 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 double progress;
final bool isComplete; final bool isComplete;
final Color accentColor;
_ProgressRingPainter({required this.progress, required this.isComplete}); _GradientRingPainter({
required this.progress,
required this.isComplete,
required this.accentColor,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2); final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 6; final radius = size.width / 2 - 6;
final rect = Rect.fromCircle(center: center, radius: radius);
// Background ring //
final bgPaint = Paint() final bgPaint = Paint()
..color = const Color(0xFFE5E7EB) ..color = accentColor.withOpacity(0.1)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 8 ..strokeWidth = 8
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint); canvas.drawCircle(center, radius, bgPaint);
// Progress arc if (progress <= 0) return;
final color = isComplete ? const Color(0xFF10B981) : const Color(0xFF8B5CF6);
final fgPaint = Paint() //
..color = color 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 ..style = PaintingStyle.stroke
..strokeWidth = 8 ..strokeWidth = 8
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius), canvas.drawArc(rect, -pi / 2, sweepAngle, false, gradientPaint);
-pi / 2,
2 * pi * progress,
false,
fgPaint,
);
} }
@override @override
bool shouldRepaint(covariant _ProgressRingPainter oldDelegate) => bool shouldRepaint(covariant _GradientRingPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.isComplete != isComplete; oldDelegate.progress != progress ||
oldDelegate.isComplete != isComplete ||
oldDelegate.accentColor != accentColor;
} }

View File

@ -3,6 +3,23 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'device.freezed.dart'; part 'device.freezed.dart';
part 'device.g.dart'; part 'device.g.dart';
/// API ID
class _SafeDeviceTypeConverter
implements JsonConverter<DeviceType?, Object?> {
const _SafeDeviceTypeConverter();
@override
DeviceType? fromJson(Object? json) {
if (json is Map<String, dynamic>) {
return DeviceType.fromJson(json);
}
return null; // ID null
}
@override
Object? toJson(DeviceType? object) => object?.toJson();
}
@freezed @freezed
abstract class DeviceType with _$DeviceType { abstract class DeviceType with _$DeviceType {
const factory DeviceType({ const factory DeviceType({
@ -24,8 +41,8 @@ abstract class DeviceInfo with _$DeviceInfo {
const factory DeviceInfo({ const factory DeviceInfo({
required int id, required int id,
required String sn, required String sn,
DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceType,
DeviceType? deviceTypeInfo, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo,
String? macAddress, String? macAddress,
@Default('') String name, @Default('') String name,
@Default('in_stock') String status, @Default('in_stock') String status,

View File

@ -296,7 +296,7 @@ as String?,
/// @nodoc /// @nodoc
mixin _$DeviceInfo { 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 /// Create a copy of DeviceInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -329,7 +329,7 @@ abstract mixin class $DeviceInfoCopyWith<$Res> {
factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl; factory $DeviceInfoCopyWith(DeviceInfo value, $Res Function(DeviceInfo) _then) = _$DeviceInfoCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
int id, String sn, 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 extends Object?>(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 extends Object?>(TResult Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo() when $default != null: case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.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 extends Object?>(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 extends Object?>(TResult Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo(): case _DeviceInfo():
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.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 extends Object?>(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 extends Object?>(TResult? Function( int id, String sn, @_SafeDeviceTypeConverter() DeviceType? deviceType, @_SafeDeviceTypeConverter() DeviceType? deviceTypeInfo, String? macAddress, String name, String status, bool isOnline, String firmwareVersion, String? lastOnlineAt, String? createdAt)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DeviceInfo() when $default != null: case _DeviceInfo() when $default != null:
return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.isOnline,_that.firmwareVersion,_that.lastOnlineAt,_that.createdAt);case _: return $default(_that.id,_that.sn,_that.deviceType,_that.deviceTypeInfo,_that.macAddress,_that.name,_that.status,_that.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() @JsonSerializable()
class _DeviceInfo implements DeviceInfo { 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<String, dynamic> json) => _$DeviceInfoFromJson(json); factory _DeviceInfo.fromJson(Map<String, dynamic> json) => _$DeviceInfoFromJson(json);
@override final int id; @override final int id;
@override final String sn; @override final String sn;
@override final DeviceType? deviceType; @override@_SafeDeviceTypeConverter() final DeviceType? deviceType;
@override final DeviceType? deviceTypeInfo; @override@_SafeDeviceTypeConverter() final DeviceType? deviceTypeInfo;
@override final String? macAddress; @override final String? macAddress;
@override@JsonKey() final String name; @override@JsonKey() final String name;
@override@JsonKey() final String status; @override@JsonKey() final String status;
@ -572,7 +572,7 @@ abstract mixin class _$DeviceInfoCopyWith<$Res> implements $DeviceInfoCopyWith<$
factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl; factory _$DeviceInfoCopyWith(_DeviceInfo value, $Res Function(_DeviceInfo) _then) = __$DeviceInfoCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
int id, String sn, 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
}); });

View File

@ -30,12 +30,10 @@ Map<String, dynamic> _$DeviceTypeToJson(_DeviceType instance) =>
_DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo( _DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
sn: json['sn'] as String, sn: json['sn'] as String,
deviceType: json['device_type'] == null deviceType: const _SafeDeviceTypeConverter().fromJson(json['device_type']),
? null deviceTypeInfo: const _SafeDeviceTypeConverter().fromJson(
: DeviceType.fromJson(json['device_type'] as Map<String, dynamic>), json['device_type_info'],
deviceTypeInfo: json['device_type_info'] == null ),
? null
: DeviceType.fromJson(json['device_type_info'] as Map<String, dynamic>),
macAddress: json['mac_address'] as String?, macAddress: json['mac_address'] as String?,
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
status: json['status'] as String? ?? 'in_stock', status: json['status'] as String? ?? 'in_stock',
@ -45,20 +43,23 @@ _DeviceInfo _$DeviceInfoFromJson(Map<String, dynamic> json) => _DeviceInfo(
createdAt: json['created_at'] as String?, createdAt: json['created_at'] as String?,
); );
Map<String, dynamic> _$DeviceInfoToJson(_DeviceInfo instance) => Map<String, dynamic> _$DeviceInfoToJson(
<String, dynamic>{ _DeviceInfo instance,
'id': instance.id, ) => <String, dynamic>{
'sn': instance.sn, 'id': instance.id,
'device_type': instance.deviceType, 'sn': instance.sn,
'device_type_info': instance.deviceTypeInfo, 'device_type': const _SafeDeviceTypeConverter().toJson(instance.deviceType),
'mac_address': instance.macAddress, 'device_type_info': const _SafeDeviceTypeConverter().toJson(
'name': instance.name, instance.deviceTypeInfo,
'status': instance.status, ),
'is_online': instance.isOnline, 'mac_address': instance.macAddress,
'firmware_version': instance.firmwareVersion, 'name': instance.name,
'last_online_at': instance.lastOnlineAt, 'status': instance.status,
'created_at': instance.createdAt, 'is_online': instance.isOnline,
}; 'firmware_version': instance.firmwareVersion,
'last_online_at': instance.lastOnlineAt,
'created_at': instance.createdAt,
};
_UserDevice _$UserDeviceFromJson(Map<String, dynamic> json) => _UserDevice( _UserDevice _$UserDeviceFromJson(Map<String, dynamic> json) => _UserDevice(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),

View File

@ -19,16 +19,17 @@ class DeviceController extends _$DeviceController {
); );
} }
Future<bool> bindDevice(String sn, {int? spiritId}) async { /// null
Future<String?> bindDevice(String sn, {int? spiritId}) async {
final repository = ref.read(deviceRepositoryProvider); final repository = ref.read(deviceRepositoryProvider);
final result = await repository.bindDevice(sn, spiritId: spiritId); final result = await repository.bindDevice(sn, spiritId: spiritId);
if (!ref.mounted) return false; if (!ref.mounted) return '组件已卸载';
return result.fold( return result.fold(
(failure) => false, (failure) => failure.message,
(bindingId) { (bindingId) {
if (!ref.mounted) return false; if (!ref.mounted) return '组件已卸载';
ref.invalidateSelf(); ref.invalidateSelf();
return true; return null;
}, },
); );
} }

View File

@ -14,6 +14,7 @@ import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart'; import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart'; import '../widgets/glass_dialog.dart';
import '../theme/product_theme.dart';
/// ///
enum DeviceType { plush, badgeAi, badge } enum DeviceType { plush, badgeAi, badge }
@ -26,6 +27,7 @@ class MockDevice {
final DeviceType type; final DeviceType type;
final bool hasAI; final bool hasAI;
final bool isNetworkRequired; final bool isNetworkRequired;
final String bindStatus; // unbound / bound_by_me / bound_by_other
final BluetoothDevice? bleDevice; final BluetoothDevice? bleDevice;
const MockDevice({ const MockDevice({
@ -35,9 +37,12 @@ class MockDevice {
required this.type, required this.type,
required this.hasAI, required this.hasAI,
this.isNetworkRequired = true, this.isNetworkRequired = true,
this.bindStatus = 'unbound',
this.bleDevice, this.bleDevice,
}); });
bool get isBoundByOther => bindStatus == 'bound_by_other';
String get iconPath { String get iconPath {
switch (type) { switch (type) {
case DeviceType.plush: case DeviceType.plush:
@ -218,10 +223,19 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
}); });
// _queryDeviceByMac // _queryDeviceByMac
await FlutterBluePlus.startScan( try {
timeout: const Duration(seconds: 30), await FlutterBluePlus.startScan(
androidUsesFineLocation: true, timeout: const Duration(seconds: 30),
); androidUsesFineLocation: true,
);
} catch (e) {
// Web : requestDevice() FlutterBluePlusException
debugPrint('[BLE Scan] startScan 异常(用户可能取消了选择器): $e');
if (mounted) {
setState(() => _isSearching = false);
}
return;
}
// 30 // 30
if (mounted && _isSearching) { if (mounted && _isSearching) {
@ -243,8 +257,10 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
_macInfoCache[mac] = data; _macInfoCache[mac] = data;
final deviceTypeName = data['device_type']?['name'] as String? ?? ''; final deviceTypeName = data['device_type']?['name'] as String? ?? '';
final productCode = data['device_type']?['product_code'] as String? ?? '';
final sn = data['sn'] as String? ?? ''; final sn = data['sn'] as String? ?? '';
final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true; final isNetworkRequired = data['device_type']?['is_network_required'] as bool? ?? true;
final bindStatus = data['bind_status'] as String? ?? 'unbound';
final bleDevice = _pendingBleDevices[mac]; final bleDevice = _pendingBleDevices[mac];
// API // API
@ -257,9 +273,10 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
sn: sn, sn: sn,
name: displayName, name: displayName,
macAddress: mac, macAddress: mac,
type: _inferDeviceType(displayName), type: _inferDeviceTypeByCode(productCode, displayName),
hasAI: _inferHasAI(displayName), hasAI: _inferHasAI(displayName),
isNetworkRequired: isNetworkRequired, isNetworkRequired: isNetworkRequired,
bindStatus: bindStatus,
bleDevice: bleDevice, bleDevice: bleDevice,
)); ));
} }
@ -294,15 +311,29 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
} }
/// ///
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(); 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; return DeviceType.plush;
} }
if (lower.contains('ai') || lower.contains('智能')) { if (lower.contains('ai') || lower.contains('智能')) {
return DeviceType.badgeAi; return DeviceType.badgeAi;
} }
return DeviceType.badge; if (lower.contains('吧唧') || lower.contains('badge')) {
return DeviceType.badge;
}
return DeviceType.plush;
} }
/// AI /// AI
@ -354,6 +385,21 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
bool _isConnecting = false; bool _isConnecting = false;
///
void _setThemeAndNavigate(DeviceType type) {
switch (type) {
case DeviceType.badgeAi:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeAi);
context.go('/badge-control');
case DeviceType.badge:
ref.read(currentProductTypeProvider.notifier).set(ProductType.badgeBasic);
context.go('/badge-basic-control');
case DeviceType.plush:
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
context.go('/device-control');
}
}
/// ///
Future<void> _handleConnect() async { Future<void> _handleConnect() async {
if (_devices.isEmpty || _isConnecting) return; if (_devices.isEmpty || _isConnecting) return;
@ -365,11 +411,45 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
} }
final device = _devices[_currentIndex]; final device = _devices[_currentIndex];
debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, needsNetwork=${device.isNetworkRequired}'); debugPrint('[Bluetooth] 连接设备: sn=${device.sn}, mac=${device.macAddress}, bindStatus=${device.bindStatus}');
//
if (device.isBoundByOther) {
showGlassDialog(
context: context,
title: '无法连接',
description: '该设备已被其他用户绑定,无法使用。如需解绑请联系设备所有者。',
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
if (!device.isNetworkRequired) { if (!device.isNetworkRequired) {
// -> // ->
context.go('/device-control'); if (device.sn.isNotEmpty) {
setState(() => _isConnecting = true);
try {
final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
} catch (e) {
debugPrint('[Bluetooth] bindDevice 异常: $e');
}
if (!mounted) return;
setState(() => _isConnecting = false);
}
_setThemeAndNavigate(device.type);
return; return;
} }
@ -378,11 +458,23 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}'); debugPrint('[Bluetooth] Web 环境,直接绑定 sn=${device.sn}');
setState(() => _isConnecting = true); setState(() => _isConnecting = true);
if (device.sn.isNotEmpty) { if (device.sn.isNotEmpty) {
await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn); final error = await ref.read(deviceControllerProvider.notifier).bindDevice(device.sn);
if (!mounted) return;
if (error != null) {
setState(() => _isConnecting = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
} }
if (!mounted) return; if (!mounted) return;
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
context.go('/device-control'); _setThemeAndNavigate(device.type);
return; return;
} }
@ -473,7 +565,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
children: [ children: [
// - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border // - CSS: border-radius: 12px, bg: rgba(255,255,255,0.6), no border
GestureDetector( GestureDetector(
onTap: () => context.go('/home'), onTap: () => context.pop(),
child: Container( child: Container(
width: 40, width: 40,
height: 40, height: 40,
@ -738,9 +830,22 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: const Color(0xFF1F2937), color: device.isBoundByOther
? const Color(0xFF9CA3AF)
: const Color(0xFF1F2937),
), ),
), ),
//
if (device.isBoundByOther) ...[
const SizedBox(height: 4),
Text(
'已被其他用户绑定',
style: TextStyle(
fontSize: 12,
color: const Color(0xFFEF4444),
),
),
],
], ],
); );
} }
@ -798,7 +903,7 @@ class _BluetoothPageState extends ConsumerState<BluetoothPage>
children: [ children: [
// - HTML: frosted glass with border // - HTML: frosted glass with border
GestureDetector( GestureDetector(
onTap: () => context.go('/home'), onTap: () => context.pop(),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
child: Container( child: Container(

View File

@ -19,6 +19,7 @@ import '../widgets/ios_toast.dart';
import '../widgets/animated_gradient_background.dart'; import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
import '../features/device/presentation/controllers/device_controller.dart'; import '../features/device/presentation/controllers/device_controller.dart';
import '../theme/product_theme.dart';
class DeviceControlPage extends ConsumerStatefulWidget { class DeviceControlPage extends ConsumerStatefulWidget {
const DeviceControlPage({super.key}); const DeviceControlPage({super.key});
@ -48,6 +49,7 @@ class _DeviceControlPageState extends ConsumerState<DeviceControlPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
Future.microtask(() => ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara));
_mascotAnimController = AnimationController( _mascotAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),

View File

@ -6,6 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import '../widgets/animated_gradient_background.dart'; import '../widgets/animated_gradient_background.dart';
import '../features/device/presentation/controllers/device_controller.dart'; import '../features/device/presentation/controllers/device_controller.dart';
import '../features/device/domain/entities/device.dart'; import '../features/device/domain/entities/device.dart';
import '../theme/product_theme.dart';
class ProductSelectionPage extends ConsumerStatefulWidget { class ProductSelectionPage extends ConsumerStatefulWidget {
const ProductSelectionPage({super.key}); const ProductSelectionPage({super.key});
@ -38,6 +39,13 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
'badge-basic': ['DZBJ-OFF'], 'badge-basic': ['DZBJ-OFF'],
}; };
/// ID ProductType
static const Map<String, ProductType> _productTypeMap = {
'capybara': ProductType.capybara,
'badge-ai': ProductType.badgeAi,
'badge-basic': ProductType.badgeBasic,
};
/// ///
UserDevice? _findBoundDevice(String productId, List<UserDevice> devices) { UserDevice? _findBoundDevice(String productId, List<UserDevice> devices) {
final codes = _productCodeMap[productId]; final codes = _productCodeMap[productId];
@ -190,12 +198,23 @@ class _ProductSelectionPageState extends ConsumerState<ProductSelectionPage> {
fadeStartY: headerHeight + 16, fadeStartY: headerHeight + 16,
fadeEndY: safeTop, fadeEndY: safeTop,
onTap: () { onTap: () {
//
final productType = _productTypeMap[product['id']] ?? ProductType.common;
ref.read(currentProductTypeProvider.notifier).set(productType);
if (boundDevice != null) { if (boundDevice != null) {
// //
context.go('/device-control'); final pid = product['id'] as String;
if (pid == 'badge-ai') {
context.go('/badge-control');
} else if (pid == 'badge-basic') {
context.go('/badge-basic-control');
} else {
context.go('/device-control');
}
} else { } else {
// // push
context.go('/bluetooth'); context.push('/bluetooth');
} }
}, },
); );

View File

@ -8,6 +8,8 @@ import '../core/services/ble_provisioning_service.dart';
import '../features/device/presentation/controllers/device_controller.dart'; import '../features/device/presentation/controllers/device_controller.dart';
import '../widgets/animated_gradient_background.dart'; import '../widgets/animated_gradient_background.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
import '../widgets/glass_dialog.dart';
import '../theme/product_theme.dart';
class WifiConfigPage extends ConsumerStatefulWidget { class WifiConfigPage extends ConsumerStatefulWidget {
final Map<String, dynamic>? extra; final Map<String, dynamic>? extra;
@ -136,14 +138,36 @@ class _WifiConfigPageState extends ConsumerState<WifiConfigPage>
if (sn.isNotEmpty) { if (sn.isNotEmpty) {
try { try {
debugPrint('[WiFi Config] Binding device sn=$sn'); debugPrint('[WiFi Config] Binding device sn=$sn');
await ref.read(deviceControllerProvider.notifier).bindDevice(sn); final error = await ref.read(deviceControllerProvider.notifier).bindDevice(sn);
if (!mounted) return;
if (error != null) {
setState(() => _isBinding = false);
showGlassDialog(
context: context,
title: '绑定失败',
description: error,
confirmText: '确定',
onConfirm: () => Navigator.of(context).pop(),
);
return;
}
} catch (e) { } catch (e) {
debugPrint('[WiFi Config] bindDevice 异常: $e'); debugPrint('[WiFi Config] bindDevice 异常: $e');
} }
} }
if (!mounted) return; if (!mounted) return;
setState(() => _isBinding = false); 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-basic-control');
} else {
ref.read(currentProductTypeProvider.notifier).set(ProductType.capybara);
context.go('/device-control');
}
return; return;
} }

View File

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

View File

@ -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<CurrentProductType, ProductType> {
/// Notifier
const CurrentProductTypeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentProductTypeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentProductTypeHash();
@$internal
@override
CurrentProductType create() => CurrentProductType();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProductType value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProductType>(value),
);
}
}
String _$currentProductTypeHash() =>
r'53603ab5884787f0a4bb1aed5de18ff33089b5e7';
/// Notifier
abstract class _$CurrentProductType extends $Notifier<ProductType> {
ProductType build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ProductType, ProductType>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ProductType, ProductType>,
ProductType,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
///
@ProviderFor(currentProductTheme)
const currentProductThemeProvider = CurrentProductThemeProvider._();
///
final class CurrentProductThemeProvider
extends
$FunctionalProvider<
ProductThemeData,
ProductThemeData,
ProductThemeData
>
with $Provider<ProductThemeData> {
///
const CurrentProductThemeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentProductThemeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentProductThemeHash();
@$internal
@override
$ProviderElement<ProductThemeData> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
ProductThemeData create(Ref ref) {
return currentProductTheme(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProductThemeData value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProductThemeData>(value),
);
}
}
String _$currentProductThemeHash() =>
r'a4e7be1ce8791e6e3323950346ef72e4f5d07fa9';

View File

@ -11,8 +11,8 @@ class GlassDialog extends StatelessWidget {
final String confirmText; final String confirmText;
final VoidCallback onCancel; final VoidCallback onCancel;
final VoidCallback onConfirm; final VoidCallback onConfirm;
final bool final bool isDanger;
isDanger; // If we need a specific danger style, though CSS shows Pink Gradient default final Gradient? confirmGradient;
const GlassDialog({ const GlassDialog({
super.key, super.key,
@ -24,6 +24,7 @@ class GlassDialog extends StatelessWidget {
required this.onCancel, required this.onCancel,
required this.onConfirm, required this.onConfirm,
this.isDanger = false, this.isDanger = false,
this.confirmGradient,
}); });
@override @override
@ -98,7 +99,7 @@ class GlassDialog extends StatelessWidget {
GradientButton( GradientButton(
text: confirmText, text: confirmText,
height: 48, height: 48,
gradient: appclr.AppColors.btnPlushGradient, gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
onPressed: onConfirm, onPressed: onConfirm,
), ),
] else ...[ ] else ...[
@ -131,7 +132,7 @@ class GlassDialog extends StatelessWidget {
child: GradientButton( child: GradientButton(
text: confirmText, text: confirmText,
height: 44, height: 44,
gradient: appclr.AppColors.btnPlushGradient, gradient: confirmGradient ?? appclr.AppColors.btnPlushGradient,
onPressed: onConfirm, onPressed: onConfirm,
), ),
), ),
@ -155,6 +156,7 @@ Future<T?> showGlassDialog<T>({
String confirmText = '确定', String confirmText = '确定',
required VoidCallback onConfirm, required VoidCallback onConfirm,
bool isDanger = false, bool isDanger = false,
Gradient? confirmGradient,
}) { }) {
return showGeneralDialog<T>( return showGeneralDialog<T>(
context: context, context: context,
@ -176,6 +178,7 @@ Future<T?> showGlassDialog<T>({
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onConfirm: onConfirm, onConfirm: onConfirm,
isDanger: isDanger, isDanger: isDanger,
confirmGradient: confirmGradient,
); );
}, },
transitionBuilder: (context, anim1, anim2, child) { transitionBuilder: (context, anim1, anim2, child) {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/product_theme.dart';
class GradientButton extends StatelessWidget { class GradientButton extends StatelessWidget {
final String text; final String text;
@ -9,6 +10,7 @@ class GradientButton extends StatelessWidget {
final double height; final double height;
final bool isLoading; final bool isLoading;
final Gradient? gradient; final Gradient? gradient;
final List<BoxShadow>? shadows;
const GradientButton({ const GradientButton({
super.key, super.key,
@ -18,57 +20,53 @@ class GradientButton extends StatelessWidget {
this.height = 48.0, // this.height = 48.0, //
this.isLoading = false, this.isLoading = false,
this.gradient, this.gradient,
this.shadows,
}); });
// Check if using plush/capybara gradient /// ProductThemeData
bool get _isPlushGradient { factory GradientButton.fromTheme({
if (gradient == null) return false; Key? key,
if (gradient is LinearGradient) { required String text,
final lg = gradient as LinearGradient; required ProductThemeData theme,
// Check if colors match plush gradient colors VoidCallback? onPressed,
if (lg.colors.length >= 2) { double width = double.infinity,
return lg.colors.first.value == 0xFFECCFA8 || double height = 48.0,
lg.colors.last.value == 0xFFC99672; bool isLoading = false,
} }) {
} return GradientButton(
return false; key: key,
text: text,
onPressed: onPressed,
width: width,
height: height,
isLoading: isLoading,
gradient: theme.buttonGradient,
shadows: theme.buttonShadows,
);
} }
List<BoxShadow> get _boxShadows { List<BoxShadow> get _boxShadows {
if (_isPlushGradient) { if (shadows != null) return shadows!;
// Warm brown glow for Capybara plush gradient //
return [ if (gradient is LinearGradient) {
BoxShadow( final lg = gradient as LinearGradient;
color: const Color(0xFFC99672).withOpacity(0.35), if (lg.colors.length >= 2) {
offset: Offset.zero, final shadowColor = lg.colors[lg.colors.length ~/ 2];
blurRadius: 15, return [
), BoxShadow(
BoxShadow( color: shadowColor.withOpacity(0.4),
color: const Color(0xFFC99672).withOpacity(0.25), offset: const Offset(0, 4),
offset: Offset.zero, blurRadius: 20,
blurRadius: 30, ),
), BoxShadow(
BoxShadow( color: lg.colors.last.withOpacity(0.2),
color: const Color(0xFFC99672).withOpacity(0.4), offset: Offset.zero,
offset: const Offset(0, 6), blurRadius: 40,
blurRadius: 20, ),
), ];
]; }
} else {
// Purple/indigo glow for primary gradient
return [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
offset: const Offset(0, 4),
blurRadius: 20,
),
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.2),
offset: Offset.zero,
blurRadius: 40,
),
];
} }
return AppColors.shadowPrimaryButton;
} }
@override @override

View File

@ -40,6 +40,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.10" version: "0.13.10"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -580,6 +588,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -956,6 +972,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:

View File

@ -65,6 +65,7 @@ dependencies:
flutter_blue_plus: ^1.31.0 flutter_blue_plus: ^1.31.0
flutter_svg: ^2.0.9 flutter_svg: ^2.0.9
image_picker: ^1.2.1 image_picker: ^1.2.1
image: ^4.3.0
just_audio: ^0.9.42 just_audio: ^0.9.42
http: ^1.2.0 http: ^1.2.0
video_player: ^2.9.2 video_player: ^2.9.2