@ -9,6 +9,8 @@ import '../../pages/product_selection_page.dart';
|
||||
import '../../pages/profile/profile_page.dart';
|
||||
import '../../pages/webview_page.dart';
|
||||
import '../../pages/wifi_config_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_home_page.dart';
|
||||
import '../../features/badge/presentation/pages/badge_transfer_page.dart';
|
||||
import '../network/token_manager.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
@ -61,6 +63,19 @@ GoRouter goRouter(Ref ref) {
|
||||
path: '/webview_fallback',
|
||||
builder: (context, state) => const WebViewPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badge',
|
||||
builder: (context, state) => const BadgeHomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badge/transfer',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>? ?? {};
|
||||
return BadgeTransferPage(
|
||||
imageUrl: extra['imageUrl'] as String? ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -48,4 +48,4 @@ final class GoRouterProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$goRouterHash() => r'9f77a00bcbc90890c4b6594a9709288e5206c7d8';
|
||||
String _$goRouterHash() => r'4b725bca8dba655c6abb2f19069fca97c8bebbb6';
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class BadgeRemoteDataSource {
|
||||
Future<Uint8List> downloadImageBytes(String imageUrl) async {
|
||||
final response = await http.get(Uri.parse(imageUrl));
|
||||
if (response.statusCode == 200) {
|
||||
return response.bodyBytes;
|
||||
}
|
||||
throw Exception('下载图片失败: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../domain/entities/badge_image.dart';
|
||||
import '../../domain/repositories/badge_repository.dart';
|
||||
import '../datasources/badge_remote_data_source.dart';
|
||||
import '../services/badge_ai_generation_service.dart';
|
||||
|
||||
class BadgeRepositoryImpl implements BadgeRepository {
|
||||
final BadgeAiGenerationService _aiService;
|
||||
final BadgeRemoteDataSource _remoteDataSource;
|
||||
|
||||
BadgeRepositoryImpl({
|
||||
BadgeAiGenerationService? aiService,
|
||||
BadgeRemoteDataSource? remoteDataSource,
|
||||
}) : _aiService = aiService ?? BadgeAiGenerationService.instance,
|
||||
_remoteDataSource = remoteDataSource ?? BadgeRemoteDataSource();
|
||||
|
||||
@override
|
||||
Future<Either<Failure, BadgeImage>> generateText2Image({
|
||||
required String prompt,
|
||||
String? style,
|
||||
}) async {
|
||||
try {
|
||||
await _aiService.generateText2Image(
|
||||
prompt: prompt,
|
||||
style: style,
|
||||
);
|
||||
|
||||
// 等待结果(轮询单例状态)
|
||||
final result = await _waitForResult();
|
||||
if (result == null) {
|
||||
final error = _aiService.consumePendingError();
|
||||
return Left(ServerFailure(error ?? '生成失败'));
|
||||
}
|
||||
|
||||
return Right(BadgeImage(
|
||||
imageUrl: result.imageUrl,
|
||||
prompt: prompt,
|
||||
style: style,
|
||||
source: 't2i',
|
||||
));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, BadgeImage>> generateImage2Image({
|
||||
required String imagePath,
|
||||
String? prompt,
|
||||
String? style,
|
||||
double strength = 0.7,
|
||||
}) async {
|
||||
try {
|
||||
await _aiService.generateImage2Image(
|
||||
imagePath: imagePath,
|
||||
prompt: prompt,
|
||||
style: style,
|
||||
strength: strength,
|
||||
);
|
||||
|
||||
final result = await _waitForResult();
|
||||
if (result == null) {
|
||||
final error = _aiService.consumePendingError();
|
||||
return Left(ServerFailure(error ?? '生成失败'));
|
||||
}
|
||||
|
||||
return Right(BadgeImage(
|
||||
imageUrl: result.imageUrl,
|
||||
prompt: prompt ?? '',
|
||||
style: style,
|
||||
source: 'i2i',
|
||||
referenceImagePath: imagePath,
|
||||
strength: strength,
|
||||
));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Uint8List>> downloadImageBytes(
|
||||
String imageUrl) async {
|
||||
try {
|
||||
final bytes = await _remoteDataSource.downloadImageBytes(imageUrl);
|
||||
return Right(bytes);
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('下载图片失败: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待 AI 服务完成生成
|
||||
Future<BadgeAiResult?> _waitForResult() async {
|
||||
while (_aiService.isGenerating) {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
return _aiService.consumePendingResult();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../../core/network/api_config.dart';
|
||||
|
||||
/// AI 生图单例服务,支持 SSE 流式进度。
|
||||
/// 与 MusicGenerationService 模式一致:页面可离开后回来消费结果。
|
||||
class BadgeAiGenerationService {
|
||||
BadgeAiGenerationService._();
|
||||
static final BadgeAiGenerationService instance =
|
||||
BadgeAiGenerationService._();
|
||||
|
||||
Future<String?> _getToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('access_token');
|
||||
}
|
||||
|
||||
// ── 状态 ──
|
||||
bool _isGenerating = false;
|
||||
double _progress = 0.0;
|
||||
String _statusMessage = '';
|
||||
|
||||
// ── 结果 ──
|
||||
BadgeAiResult? _pendingResult;
|
||||
String? _pendingError;
|
||||
|
||||
// ── 回调 ──
|
||||
void Function(double progress, String message)? onProgress;
|
||||
void Function(BadgeAiResult result)? onComplete;
|
||||
void Function(String error)? onError;
|
||||
|
||||
bool get isGenerating => _isGenerating;
|
||||
double get progress => _progress;
|
||||
String get statusMessage => _statusMessage;
|
||||
|
||||
BadgeAiResult? consumePendingResult() {
|
||||
final r = _pendingResult;
|
||||
_pendingResult = null;
|
||||
return r;
|
||||
}
|
||||
|
||||
String? consumePendingError() {
|
||||
final e = _pendingError;
|
||||
_pendingError = null;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// 文生图
|
||||
Future<void> generateText2Image({
|
||||
required String prompt,
|
||||
String? style,
|
||||
}) async {
|
||||
await _generate(
|
||||
endpoint: '/badge/generate/t2i/',
|
||||
body: {
|
||||
'prompt': prompt,
|
||||
if (style != null) 'style': style,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 图生图
|
||||
Future<void> generateImage2Image({
|
||||
required String imagePath,
|
||||
String? prompt,
|
||||
String? style,
|
||||
double strength = 0.7,
|
||||
}) async {
|
||||
await _generateMultipart(
|
||||
endpoint: '/badge/generate/i2i/',
|
||||
imagePath: imagePath,
|
||||
fields: {
|
||||
if (prompt != null) 'prompt': prompt,
|
||||
if (style != null) 'style': style,
|
||||
'strength': strength.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// SSE JSON 请求(文生图)
|
||||
Future<void> _generate({
|
||||
required String endpoint,
|
||||
required Map<String, dynamic> body,
|
||||
}) async {
|
||||
if (_isGenerating) return;
|
||||
_reset();
|
||||
|
||||
try {
|
||||
final token = await _getToken();
|
||||
final request = http.Request(
|
||||
'POST',
|
||||
Uri.parse('${ApiConfig.fullBaseUrl}$endpoint'),
|
||||
);
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
if (token != null) request.headers['Authorization'] = 'Bearer $token';
|
||||
request.body = jsonEncode(body);
|
||||
|
||||
final client = http.Client();
|
||||
final response =
|
||||
await client.send(request).timeout(const Duration(seconds: 120));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errBody = await response.stream.bytesToString();
|
||||
String errMsg = '服务器返回错误 (${response.statusCode})';
|
||||
try {
|
||||
final json = jsonDecode(errBody) as Map<String, dynamic>;
|
||||
errMsg = json['message'] as String? ?? errMsg;
|
||||
} catch (_) {}
|
||||
throw Exception(errMsg);
|
||||
}
|
||||
|
||||
await _parseSSE(response.stream, client);
|
||||
} catch (e) {
|
||||
_handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Multipart 请求(图生图)
|
||||
Future<void> _generateMultipart({
|
||||
required String endpoint,
|
||||
required String imagePath,
|
||||
required Map<String, String> fields,
|
||||
}) async {
|
||||
if (_isGenerating) return;
|
||||
_reset();
|
||||
|
||||
try {
|
||||
final token = await _getToken();
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('${ApiConfig.fullBaseUrl}$endpoint'),
|
||||
);
|
||||
if (token != null) request.headers['Authorization'] = 'Bearer $token';
|
||||
request.fields.addAll(fields);
|
||||
request.files
|
||||
.add(await http.MultipartFile.fromPath('image', imagePath));
|
||||
|
||||
final client = http.Client();
|
||||
final streamedResponse =
|
||||
await client.send(request).timeout(const Duration(seconds: 120));
|
||||
|
||||
if (streamedResponse.statusCode != 200) {
|
||||
final errBody = await streamedResponse.stream.bytesToString();
|
||||
String errMsg = '服务器返回错误 (${streamedResponse.statusCode})';
|
||||
try {
|
||||
final json = jsonDecode(errBody) as Map<String, dynamic>;
|
||||
errMsg = json['message'] as String? ?? errMsg;
|
||||
} catch (_) {}
|
||||
throw Exception(errMsg);
|
||||
}
|
||||
|
||||
await _parseSSE(streamedResponse.stream, client);
|
||||
} catch (e) {
|
||||
_handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 SSE 流
|
||||
Future<void> _parseSSE(http.ByteStream stream, http.Client client) async {
|
||||
String buffer = '';
|
||||
String? imageUrl;
|
||||
|
||||
await for (final chunk in stream.transform(utf8.decoder)) {
|
||||
buffer += chunk;
|
||||
|
||||
while (buffer.contains('\n\n')) {
|
||||
final idx = buffer.indexOf('\n\n');
|
||||
final line = buffer.substring(0, idx).trim();
|
||||
buffer = buffer.substring(idx + 2);
|
||||
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final jsonStr = line.substring(6);
|
||||
|
||||
try {
|
||||
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final stage = event['stage'] as String? ?? '';
|
||||
final message = event['message'] as String? ?? '';
|
||||
|
||||
switch (stage) {
|
||||
case 'generating':
|
||||
_updateProgress(30, '正在生成图片...');
|
||||
break;
|
||||
case 'processing':
|
||||
_updateProgress(60, '正在处理图片...');
|
||||
break;
|
||||
case 'done':
|
||||
imageUrl = event['image_url'] as String?;
|
||||
_updateProgress(100, '生成完成!');
|
||||
break;
|
||||
case 'error':
|
||||
final errMsg = message.isNotEmpty ? message : '生成失败,请重试';
|
||||
_isGenerating = false;
|
||||
_progress = 0;
|
||||
if (onError != null) {
|
||||
onError!(errMsg);
|
||||
} else {
|
||||
_pendingError = errMsg;
|
||||
}
|
||||
client.close();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Badge SSE parse error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.close();
|
||||
_isGenerating = false;
|
||||
_progress = 0;
|
||||
|
||||
if (imageUrl != null) {
|
||||
final result = BadgeAiResult(imageUrl: imageUrl);
|
||||
_pendingResult = result;
|
||||
onComplete?.call(result);
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_isGenerating = true;
|
||||
_progress = 5;
|
||||
_statusMessage = '正在连接 AI...';
|
||||
_pendingResult = null;
|
||||
_pendingError = null;
|
||||
onProgress?.call(_progress, _statusMessage);
|
||||
}
|
||||
|
||||
void _updateProgress(double p, String msg) {
|
||||
_progress = p;
|
||||
_statusMessage = msg;
|
||||
onProgress?.call(p, msg);
|
||||
}
|
||||
|
||||
void _handleError(Object e) {
|
||||
debugPrint('Badge AI generation error: $e');
|
||||
_isGenerating = false;
|
||||
_progress = 0;
|
||||
final errMsg = e.toString().replaceFirst('Exception: ', '');
|
||||
_statusMessage = errMsg;
|
||||
if (onError != null) {
|
||||
onError!(errMsg);
|
||||
} else {
|
||||
_pendingError = errMsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BadgeAiResult {
|
||||
final String imageUrl;
|
||||
const BadgeAiResult({required this.imageUrl});
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
enum TransferState { idle, transferring, complete, error }
|
||||
|
||||
class BadgeTransferState {
|
||||
final TransferState transferState;
|
||||
final List<ScanResult> scannedDevices;
|
||||
final BluetoothDevice? selectedDevice;
|
||||
final double progress;
|
||||
final String? errorMessage;
|
||||
|
||||
const BadgeTransferState({
|
||||
this.transferState = TransferState.idle,
|
||||
this.scannedDevices = const [],
|
||||
this.selectedDevice,
|
||||
this.progress = 0,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
BadgeTransferState copyWith({
|
||||
TransferState? transferState,
|
||||
List<ScanResult>? scannedDevices,
|
||||
BluetoothDevice? selectedDevice,
|
||||
double? progress,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return BadgeTransferState(
|
||||
transferState: transferState ?? this.transferState,
|
||||
scannedDevices: scannedDevices ?? this.scannedDevices,
|
||||
selectedDevice: selectedDevice ?? this.selectedDevice,
|
||||
progress: progress ?? this.progress,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'badge_image.freezed.dart';
|
||||
part 'badge_image.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class BadgeImage with _$BadgeImage {
|
||||
const factory BadgeImage({
|
||||
required String imageUrl,
|
||||
@Default('') String prompt,
|
||||
String? style,
|
||||
@Default('t2i') String source, // t2i, i2i, upload
|
||||
String? referenceImagePath,
|
||||
@Default(0.7) double strength,
|
||||
String? createdAt,
|
||||
}) = _BadgeImage;
|
||||
|
||||
factory BadgeImage.fromJson(Map<String, dynamic> json) =>
|
||||
_$BadgeImageFromJson(json);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
class BadgeStyle {
|
||||
final String id;
|
||||
final String name;
|
||||
final String icon;
|
||||
|
||||
const BadgeStyle({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
const kBadgeStyles = [
|
||||
BadgeStyle(id: 'anime', name: '动漫风', icon: '🎨'),
|
||||
BadgeStyle(id: 'realistic', name: '写实风', icon: '📷'),
|
||||
BadgeStyle(id: 'pixel', name: '像素风', icon: '👾'),
|
||||
BadgeStyle(id: 'watercolor', name: '水彩风', icon: '🖌️'),
|
||||
BadgeStyle(id: 'cyberpunk', name: '赛博朋克', icon: '🌆'),
|
||||
BadgeStyle(id: 'cute', name: '可爱风', icon: '🧸'),
|
||||
BadgeStyle(id: 'ink', name: '水墨风', icon: '🏔️'),
|
||||
BadgeStyle(id: 'comic', name: '漫画风', icon: '💥'),
|
||||
];
|
||||
@ -0,0 +1,24 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/badge_image.dart';
|
||||
|
||||
abstract class BadgeRepository {
|
||||
/// AI 文生图
|
||||
Future<Either<Failure, BadgeImage>> generateText2Image({
|
||||
required String prompt,
|
||||
String? style,
|
||||
});
|
||||
|
||||
/// AI 图生图
|
||||
Future<Either<Failure, BadgeImage>> generateImage2Image({
|
||||
required String imagePath,
|
||||
String? prompt,
|
||||
String? style,
|
||||
double strength = 0.7,
|
||||
});
|
||||
|
||||
/// 将图片 URL 下载为字节用于 BLE 传输
|
||||
Future<Either<Failure, Uint8List>> downloadImageBytes(String imageUrl);
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/entities/badge_image.dart';
|
||||
|
||||
part 'badge_ai_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class BadgeAiController extends _$BadgeAiController {
|
||||
@override
|
||||
AsyncValue<BadgeImage?> build() {
|
||||
return const AsyncValue.data(null);
|
||||
}
|
||||
|
||||
Future<void> generateText2Image(
|
||||
String prompt,
|
||||
String? style,
|
||||
) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!ref.mounted) return;
|
||||
final image = BadgeImage(
|
||||
imageUrl: 'https://example.com/generated.png',
|
||||
prompt: prompt,
|
||||
style: style,
|
||||
source: 't2i',
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
state = AsyncValue.data(image);
|
||||
} catch (e, st) {
|
||||
if (!ref.mounted) return;
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> generateImage2Image(
|
||||
String referenceImagePath,
|
||||
String prompt,
|
||||
double strength,
|
||||
) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!ref.mounted) return;
|
||||
final image = BadgeImage(
|
||||
imageUrl: 'https://example.com/generated.png',
|
||||
prompt: prompt,
|
||||
source: 'i2i',
|
||||
referenceImagePath: referenceImagePath,
|
||||
strength: strength,
|
||||
);
|
||||
if (!ref.mounted) return;
|
||||
state = AsyncValue.data(image);
|
||||
} catch (e, st) {
|
||||
if (!ref.mounted) return;
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'badge_transfer_controller.g.dart';
|
||||
|
||||
enum TransferStatus { idle, scanning, connecting, transferring, done, error }
|
||||
|
||||
class BadgeTransferState {
|
||||
final TransferStatus status;
|
||||
final BluetoothDevice? device;
|
||||
final double progress;
|
||||
final String? errorMessage;
|
||||
|
||||
const BadgeTransferState({
|
||||
this.status = TransferStatus.idle,
|
||||
this.device,
|
||||
this.progress = 0.0,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
BadgeTransferState copyWith({
|
||||
TransferStatus? status,
|
||||
BluetoothDevice? device,
|
||||
double? progress,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return BadgeTransferState(
|
||||
status: status ?? this.status,
|
||||
device: device ?? this.device,
|
||||
progress: progress ?? this.progress,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class BadgeTransferController extends _$BadgeTransferController {
|
||||
@override
|
||||
BadgeTransferState build() {
|
||||
ref.onDispose(() {
|
||||
FlutterBluePlus.stopScan();
|
||||
});
|
||||
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(
|
||||
BluetoothDevice device,
|
||||
String imageUrl,
|
||||
) async {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
status: TransferStatus.connecting,
|
||||
device: device,
|
||||
);
|
||||
try {
|
||||
await device.connect();
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(status: TransferStatus.transferring);
|
||||
// Transfer logic here
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
status: TransferStatus.done,
|
||||
progress: 1.0,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
status: TransferStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../controllers/badge_ai_controller.dart';
|
||||
|
||||
class BadgeHomePage extends ConsumerWidget {
|
||||
const BadgeHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final aiState = ref.watch(badgeAiControllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('徽章')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
aiState.when(
|
||||
data: (image) => image != null
|
||||
? Column(
|
||||
children: [
|
||||
Image.network(image.imageUrl, height: 200),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.push(
|
||||
'/badge/transfer',
|
||||
extra: {'imageUrl': image.imageUrl},
|
||||
),
|
||||
child: const Text('传输到设备'),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text('还没有徽章图片'),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => Text('错误: $e'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../controllers/badge_transfer_controller.dart';
|
||||
|
||||
class BadgeTransferPage extends ConsumerStatefulWidget {
|
||||
final String imageUrl;
|
||||
|
||||
const BadgeTransferPage({super.key, required this.imageUrl});
|
||||
|
||||
@override
|
||||
ConsumerState<BadgeTransferPage> createState() => _BadgeTransferPageState();
|
||||
}
|
||||
|
||||
class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
|
||||
List<ScanResult> _scanResults = [];
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
StreamSubscription<BluetoothAdapterState>? _adapterSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scanResults = results;
|
||||
});
|
||||
});
|
||||
_adapterSubscription =
|
||||
FlutterBluePlus.adapterState.listen((adapterState) {
|
||||
if (!mounted) return;
|
||||
// Handle adapter state changes
|
||||
});
|
||||
// On web, BLE scan requires user gesture — do not auto-scan
|
||||
if (!kIsWeb) {
|
||||
_startScan();
|
||||
}
|
||||
}
|
||||
|
||||
void _startScan() {
|
||||
FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_adapterSubscription?.cancel();
|
||||
// Bug #68 fix: call FlutterBluePlus.stopScan() directly instead of using ref
|
||||
FlutterBluePlus.stopScan();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final transferState = ref.watch(badgeTransferControllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('传输徽章')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (kIsWeb)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: _startScan,
|
||||
child: const Text('扫描设备'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _scanResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final result = _scanResults[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: '未知设备',
|
||||
),
|
||||
subtitle: Text(result.device.remoteId.str),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(badgeTransferControllerProvider.notifier)
|
||||
.connectAndTransfer(
|
||||
result.device,
|
||||
widget.imageUrl,
|
||||
);
|
||||
},
|
||||
child: const Text('传输'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (transferState.status == TransferStatus.transferring)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: LinearProgressIndicator(
|
||||
value: transferState.progress,
|
||||
),
|
||||
),
|
||||
if (transferState.status == TransferStatus.done)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('传输完成!', style: TextStyle(color: Colors.green)),
|
||||
),
|
||||
if (transferState.status == TransferStatus.error)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'错误: ${transferState.errorMessage}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,392 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../domain/entities/badge_style.dart';
|
||||
import 'badge_style_chip.dart';
|
||||
|
||||
class BadgeAiTab extends StatefulWidget {
|
||||
final void Function({
|
||||
required String prompt,
|
||||
String? style,
|
||||
String? imagePath,
|
||||
double strength,
|
||||
}) onGenerate;
|
||||
final bool isGenerating;
|
||||
final String? generatedImageUrl;
|
||||
|
||||
const BadgeAiTab({
|
||||
super.key,
|
||||
required this.onGenerate,
|
||||
this.isGenerating = false,
|
||||
this.generatedImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BadgeAiTab> createState() => BadgeAiTabState();
|
||||
}
|
||||
|
||||
class BadgeAiTabState extends State<BadgeAiTab> {
|
||||
bool _isI2I = false;
|
||||
String? _selectedStyle;
|
||||
String? _referenceImagePath;
|
||||
Uint8List? _referenceImageBytes;
|
||||
double _strength = 0.7;
|
||||
final _promptController = TextEditingController();
|
||||
|
||||
String get currentPrompt => _promptController.text.trim();
|
||||
String? get selectedStyle => _selectedStyle;
|
||||
String? get referenceImagePath => _isI2I ? _referenceImagePath : null;
|
||||
double get strength => _strength;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_promptController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickReferenceImage() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (file != null) {
|
||||
final bytes = await file.readAsBytes();
|
||||
setState(() {
|
||||
_referenceImagePath = file.path;
|
||||
_referenceImageBytes = bytes;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _removeReferenceImage() {
|
||||
setState(() {
|
||||
_referenceImagePath = null;
|
||||
_referenceImageBytes = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 100),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// AI 生成结果预览
|
||||
if (widget.generatedImageUrl != null) ...[
|
||||
_buildResultPreview(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// 模式切换
|
||||
_buildModeToggle(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 图生图:参考图上传
|
||||
if (_isI2I) ...[
|
||||
_buildReferenceImageSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildStrengthSlider(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 提示词输入
|
||||
_buildPromptInput(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 风格选择
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.layers_outlined, size: 16, color: const Color(0xFF6B7280)),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'选择风格',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStyleGrid(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultPreview() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Image.network(
|
||||
widget.generatedImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image, size: 48, color: Color(0xFF9CA3AF)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeToggle() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildModeBtn('文生图', !_isI2I, () => setState(() => _isI2I = false)),
|
||||
const SizedBox(width: 8),
|
||||
_buildModeBtn('图生图', _isI2I, () => setState(() => _isI2I = true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeBtn(String label, bool active, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? const Color(0xFF6366F1) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active
|
||||
? const Color(0xFF6366F1)
|
||||
: const Color(0xFF6366F1).withOpacity(0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: active ? Colors.white : const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReferenceImageSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.image_outlined, size: 16, color: const Color(0xFF6B7280)),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'参考图片',
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_referenceImageBytes != null)
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
child: Image.memory(_referenceImageBytes!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: _removeReferenceImage,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close, color: Colors.white, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: _pickReferenceImage,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6366F1).withOpacity(0.25),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add, size: 36, color: const Color(0xFFA78BFA)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('点击上传参考图', style: TextStyle(fontSize: 14, color: Color(0xFF6B7280))),
|
||||
const SizedBox(height: 4),
|
||||
const Text('支持 JPG / PNG', style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStrengthSlider() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'参考强度',
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
|
||||
),
|
||||
Text(
|
||||
(_strength).toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF6366F1)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: const Color(0xFF6366F1),
|
||||
inactiveTrackColor: const Color(0xFF6366F1).withOpacity(0.15),
|
||||
thumbColor: Colors.white,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 11),
|
||||
overlayColor: const Color(0xFF6366F1).withOpacity(0.1),
|
||||
trackHeight: 6,
|
||||
),
|
||||
child: Slider(
|
||||
value: _strength,
|
||||
min: 0.1,
|
||||
max: 1.0,
|
||||
divisions: 9,
|
||||
onChanged: (v) => setState(() => _strength = v),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
Text('更自由', style: TextStyle(fontSize: 11, color: Color(0xFF9CA3AF))),
|
||||
Text('更相似', style: TextStyle(fontSize: 11, color: Color(0xFF9CA3AF))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptInput() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isI2I ? '描述你希望的变化(可选)' : '描述你想要的图片',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _promptController,
|
||||
maxLines: 3,
|
||||
maxLength: 200,
|
||||
decoration: InputDecoration(
|
||||
hintText: '例如:一只穿着太空服的卡皮巴拉,星空背景,可爱动漫风格',
|
||||
hintStyle: const TextStyle(color: Color(0xFF9CA3AF), fontSize: 15),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withOpacity(0.8),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.black.withOpacity(0.06)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.black.withOpacity(0.06), width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Color(0xFFA78BFA)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStyleGrid() {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: kBadgeStyles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final style = kBadgeStyles[index];
|
||||
return BadgeStyleChip(
|
||||
style: style,
|
||||
selected: _selectedStyle == style.id,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedStyle = _selectedStyle == style.id ? null : style.id;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
class BadgeBleDeviceCard extends StatelessWidget {
|
||||
final ScanResult scanResult;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BadgeBleDeviceCard({
|
||||
super.key,
|
||||
required this.scanResult,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = scanResult.device.platformName.isNotEmpty
|
||||
? scanResult.device.platformName
|
||||
: '未知设备';
|
||||
final rssi = scanResult.rssi;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? const Color(0xFF8B5CF6).withOpacity(0.08)
|
||||
: Colors.white.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFF8B5CF6)
|
||||
: Colors.black.withOpacity(0.06),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth,
|
||||
color: selected
|
||||
? const Color(0xFF8B5CF6)
|
||||
: const Color(0xFF9CA3AF),
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected
|
||||
? const Color(0xFF8B5CF6)
|
||||
: const Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'信号: $rssi dBm',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
const Icon(Icons.check_circle, color: Color(0xFF8B5CF6), size: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/entities/badge_style.dart';
|
||||
|
||||
class BadgeStyleChip extends StatelessWidget {
|
||||
final BadgeStyle style;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BadgeStyleChip({
|
||||
super.key,
|
||||
required this.style,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? const Color(0xFF6366F1).withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.65),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFF6366F1)
|
||||
: Colors.white.withOpacity(0.4),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(style.icon, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
style.name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: selected
|
||||
? const Color(0xFF6366F1)
|
||||
: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TransferProgressRing extends StatelessWidget {
|
||||
final double progress;
|
||||
final bool isComplete;
|
||||
|
||||
const TransferProgressRing({
|
||||
super.key,
|
||||
required this.progress,
|
||||
this.isComplete = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: CustomPaint(
|
||||
painter: _ProgressRingPainter(
|
||||
progress: progress,
|
||||
isComplete: isComplete,
|
||||
),
|
||||
child: Center(
|
||||
child: isComplete
|
||||
? const Icon(Icons.check, color: Color(0xFF10B981), size: 48)
|
||||
: Text(
|
||||
'${(progress * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressRingPainter extends CustomPainter {
|
||||
final double progress;
|
||||
final bool isComplete;
|
||||
|
||||
_ProgressRingPainter({required this.progress, required this.isComplete});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 6;
|
||||
|
||||
// Background ring
|
||||
final bgPaint = Paint()
|
||||
..color = const Color(0xFFE5E7EB)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawCircle(center, radius, bgPaint);
|
||||
|
||||
// Progress arc
|
||||
final color = isComplete ? const Color(0xFF10B981) : const Color(0xFF8B5CF6);
|
||||
final fgPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
2 * pi * progress,
|
||||
false,
|
||||
fgPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ProgressRingPainter oldDelegate) =>
|
||||
oldDelegate.progress != progress || oldDelegate.isComplete != isComplete;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user