Merge fix/auto-20260324-130656: 电子吧唧业务页面、设备绑定状态检查、BLE传输优化
This commit is contained in:
commit
134da153d5
663
airhub_app/android/build/reports/problems/problems-report.html
Normal file
663
airhub_app/android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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?,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -48,4 +48,4 @@ final class GoRouterProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$goRouterHash() => r'4b725bca8dba655c6abb2f19069fca97c8bebbb6';
|
String _$goRouterHash() => r'276ed56c903c6bd5bb43569f7b8f58103d73198c';
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码为 JPEG(quality 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(' + ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: ', ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
190
airhub_app/lib/theme/product_theme.dart
Normal file
190
airhub_app/lib/theme/product_theme.dart
Normal 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));
|
||||||
|
}
|
||||||
121
airhub_app/lib/theme/product_theme.g.dart
Normal file
121
airhub_app/lib/theme/product_theme.g.dart
Normal 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';
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user