Merge pull request 'fix: auto repair bugs #68, #10, #6, #69' (#4) from fix/auto-20260318-155918 into main
Merge PR #4 (approved via Log Center)
This commit is contained in:
commit
51164ae21a
@ -9,6 +9,8 @@ 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_home_page.dart';
|
||||||
|
import '../../features/badge/presentation/pages/badge_transfer_page.dart';
|
||||||
import '../network/token_manager.dart';
|
import '../network/token_manager.dart';
|
||||||
|
|
||||||
part 'app_router.g.dart';
|
part 'app_router.g.dart';
|
||||||
@ -61,6 +63,19 @@ GoRouter goRouter(Ref ref) {
|
|||||||
path: '/webview_fallback',
|
path: '/webview_fallback',
|
||||||
builder: (context, state) => const WebViewPage(),
|
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,100 @@
|
|||||||
|
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 Uint8List imageBytes,
|
||||||
|
String? prompt,
|
||||||
|
String? style,
|
||||||
|
double strength = 0.7,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _aiService.generateImage2Image(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
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',
|
||||||
|
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,255 @@
|
|||||||
|
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 Uint8List imageBytes,
|
||||||
|
String? prompt,
|
||||||
|
String? style,
|
||||||
|
double strength = 0.7,
|
||||||
|
}) async {
|
||||||
|
await _generateMultipart(
|
||||||
|
endpoint: '/badge/generate/i2i/',
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
fields: {
|
||||||
|
'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 Uint8List imageBytes,
|
||||||
|
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(http.MultipartFile.fromBytes(
|
||||||
|
'image',
|
||||||
|
imageBytes,
|
||||||
|
filename: 'reference.jpg',
|
||||||
|
));
|
||||||
|
|
||||||
|
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,297 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'badge_image.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$BadgeImage {
|
||||||
|
|
||||||
|
String get imageUrl; String get prompt; String? get style; String get source;// t2i, i2i, upload
|
||||||
|
String? get referenceImagePath; double get strength; String? get createdAt;
|
||||||
|
/// Create a copy of BadgeImage
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$BadgeImageCopyWith<BadgeImage> get copyWith => _$BadgeImageCopyWithImpl<BadgeImage>(this as BadgeImage, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this BadgeImage to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is BadgeImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.style, style) || other.style == style)&&(identical(other.source, source) || other.source == source)&&(identical(other.referenceImagePath, referenceImagePath) || other.referenceImagePath == referenceImagePath)&&(identical(other.strength, strength) || other.strength == strength)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,imageUrl,prompt,style,source,referenceImagePath,strength,createdAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'BadgeImage(imageUrl: $imageUrl, prompt: $prompt, style: $style, source: $source, referenceImagePath: $referenceImagePath, strength: $strength, createdAt: $createdAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $BadgeImageCopyWith<$Res> {
|
||||||
|
factory $BadgeImageCopyWith(BadgeImage value, $Res Function(BadgeImage) _then) = _$BadgeImageCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$BadgeImageCopyWithImpl<$Res>
|
||||||
|
implements $BadgeImageCopyWith<$Res> {
|
||||||
|
_$BadgeImageCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final BadgeImage _self;
|
||||||
|
final $Res Function(BadgeImage) _then;
|
||||||
|
|
||||||
|
/// Create a copy of BadgeImage
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? imageUrl = null,Object? prompt = null,Object? style = freezed,Object? source = null,Object? referenceImagePath = freezed,Object? strength = null,Object? createdAt = freezed,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,style: freezed == style ? _self.style : style // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,referenceImagePath: freezed == referenceImagePath ? _self.referenceImagePath : referenceImagePath // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,strength: null == strength ? _self.strength : strength // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [BadgeImage].
|
||||||
|
extension BadgeImagePatterns on BadgeImage {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BadgeImage value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BadgeImage value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BadgeImage value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage() when $default != null:
|
||||||
|
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage():
|
||||||
|
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _BadgeImage() when $default != null:
|
||||||
|
return $default(_that.imageUrl,_that.prompt,_that.style,_that.source,_that.referenceImagePath,_that.strength,_that.createdAt);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _BadgeImage implements BadgeImage {
|
||||||
|
const _BadgeImage({required this.imageUrl, this.prompt = '', this.style, this.source = 't2i', this.referenceImagePath, this.strength = 0.7, this.createdAt});
|
||||||
|
factory _BadgeImage.fromJson(Map<String, dynamic> json) => _$BadgeImageFromJson(json);
|
||||||
|
|
||||||
|
@override final String imageUrl;
|
||||||
|
@override@JsonKey() final String prompt;
|
||||||
|
@override final String? style;
|
||||||
|
@override@JsonKey() final String source;
|
||||||
|
// t2i, i2i, upload
|
||||||
|
@override final String? referenceImagePath;
|
||||||
|
@override@JsonKey() final double strength;
|
||||||
|
@override final String? createdAt;
|
||||||
|
|
||||||
|
/// Create a copy of BadgeImage
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$BadgeImageCopyWith<_BadgeImage> get copyWith => __$BadgeImageCopyWithImpl<_BadgeImage>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$BadgeImageToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BadgeImage&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.prompt, prompt) || other.prompt == prompt)&&(identical(other.style, style) || other.style == style)&&(identical(other.source, source) || other.source == source)&&(identical(other.referenceImagePath, referenceImagePath) || other.referenceImagePath == referenceImagePath)&&(identical(other.strength, strength) || other.strength == strength)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,imageUrl,prompt,style,source,referenceImagePath,strength,createdAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'BadgeImage(imageUrl: $imageUrl, prompt: $prompt, style: $style, source: $source, referenceImagePath: $referenceImagePath, strength: $strength, createdAt: $createdAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$BadgeImageCopyWith<$Res> implements $BadgeImageCopyWith<$Res> {
|
||||||
|
factory _$BadgeImageCopyWith(_BadgeImage value, $Res Function(_BadgeImage) _then) = __$BadgeImageCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String imageUrl, String prompt, String? style, String source, String? referenceImagePath, double strength, String? createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$BadgeImageCopyWithImpl<$Res>
|
||||||
|
implements _$BadgeImageCopyWith<$Res> {
|
||||||
|
__$BadgeImageCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _BadgeImage _self;
|
||||||
|
final $Res Function(_BadgeImage) _then;
|
||||||
|
|
||||||
|
/// Create a copy of BadgeImage
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? imageUrl = null,Object? prompt = null,Object? style = freezed,Object? source = null,Object? referenceImagePath = freezed,Object? strength = null,Object? createdAt = freezed,}) {
|
||||||
|
return _then(_BadgeImage(
|
||||||
|
imageUrl: null == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,prompt: null == prompt ? _self.prompt : prompt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,style: freezed == style ? _self.style : style // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,referenceImagePath: freezed == referenceImagePath ? _self.referenceImagePath : referenceImagePath // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,strength: null == strength ? _self.strength : strength // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'badge_image.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_BadgeImage _$BadgeImageFromJson(Map<String, dynamic> json) => _BadgeImage(
|
||||||
|
imageUrl: json['image_url'] as String,
|
||||||
|
prompt: json['prompt'] as String? ?? '',
|
||||||
|
style: json['style'] as String?,
|
||||||
|
source: json['source'] as String? ?? 't2i',
|
||||||
|
referenceImagePath: json['reference_image_path'] as String?,
|
||||||
|
strength: (json['strength'] as num?)?.toDouble() ?? 0.7,
|
||||||
|
createdAt: json['created_at'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$BadgeImageToJson(_BadgeImage instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'image_url': instance.imageUrl,
|
||||||
|
'prompt': instance.prompt,
|
||||||
|
'style': instance.style,
|
||||||
|
'source': instance.source,
|
||||||
|
'reference_image_path': instance.referenceImagePath,
|
||||||
|
'strength': instance.strength,
|
||||||
|
'created_at': instance.createdAt,
|
||||||
|
};
|
||||||
@ -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 Uint8List imageBytes,
|
||||||
|
String? prompt,
|
||||||
|
String? style,
|
||||||
|
double strength = 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 将图片 URL 下载为字节用于 BLE 传输
|
||||||
|
Future<Either<Failure, Uint8List>> downloadImageBytes(String imageUrl);
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import '../../data/repositories/badge_repository_impl.dart';
|
||||||
|
import '../../data/services/badge_ai_generation_service.dart';
|
||||||
|
import '../../domain/entities/badge_image.dart';
|
||||||
|
|
||||||
|
part 'badge_ai_controller.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class BadgeAiController extends _$BadgeAiController {
|
||||||
|
@override
|
||||||
|
AsyncValue<BadgeImage?> build() => const AsyncData(null);
|
||||||
|
|
||||||
|
/// 文生图
|
||||||
|
Future<bool> generateText2Image({
|
||||||
|
required String prompt,
|
||||||
|
String? style,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final repo = BadgeRepositoryImpl();
|
||||||
|
final result = await repo.generateText2Image(
|
||||||
|
prompt: prompt,
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
if (!ref.mounted) return false;
|
||||||
|
return result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = AsyncError(failure.message, StackTrace.current);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(image) {
|
||||||
|
state = AsyncData(image);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图生图
|
||||||
|
Future<bool> generateImage2Image({
|
||||||
|
required Uint8List imageBytes,
|
||||||
|
String? prompt,
|
||||||
|
String? style,
|
||||||
|
double strength = 0.7,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
final repo = BadgeRepositoryImpl();
|
||||||
|
final result = await repo.generateImage2Image(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
prompt: prompt,
|
||||||
|
style: style,
|
||||||
|
strength: strength,
|
||||||
|
);
|
||||||
|
if (!ref.mounted) return false;
|
||||||
|
return result.fold(
|
||||||
|
(failure) {
|
||||||
|
state = AsyncError(failure.message, StackTrace.current);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(image) {
|
||||||
|
state = AsyncData(image);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 服务进度
|
||||||
|
double get progress => BadgeAiGenerationService.instance.progress;
|
||||||
|
String get statusMessage => BadgeAiGenerationService.instance.statusMessage;
|
||||||
|
bool get isGenerating => BadgeAiGenerationService.instance.isGenerating;
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
state = const AsyncData(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'badge_ai_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(BadgeAiController)
|
||||||
|
const badgeAiControllerProvider = BadgeAiControllerProvider._();
|
||||||
|
|
||||||
|
final class BadgeAiControllerProvider
|
||||||
|
extends $NotifierProvider<BadgeAiController, AsyncValue<BadgeImage?>> {
|
||||||
|
const BadgeAiControllerProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'badgeAiControllerProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$badgeAiControllerHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
BadgeAiController create() => BadgeAiController();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AsyncValue<BadgeImage?> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AsyncValue<BadgeImage?>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$badgeAiControllerHash() => r'b270d32d4d80d40c3eddb5e610682aace3f709f2';
|
||||||
|
|
||||||
|
abstract class _$BadgeAiController extends $Notifier<AsyncValue<BadgeImage?>> {
|
||||||
|
AsyncValue<BadgeImage?> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<AsyncValue<BadgeImage?>, AsyncValue<BadgeImage?>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<BadgeImage?>, AsyncValue<BadgeImage?>>,
|
||||||
|
AsyncValue<BadgeImage?>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,64 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'badge_transfer_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(BadgeTransferController)
|
||||||
|
const badgeTransferControllerProvider = BadgeTransferControllerProvider._();
|
||||||
|
|
||||||
|
final class BadgeTransferControllerProvider
|
||||||
|
extends $NotifierProvider<BadgeTransferController, BadgeTransferState> {
|
||||||
|
const BadgeTransferControllerProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'badgeTransferControllerProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$badgeTransferControllerHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
BadgeTransferController create() => BadgeTransferController();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(BadgeTransferState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<BadgeTransferState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$badgeTransferControllerHash() =>
|
||||||
|
r'6ed7ce6b7aa6eae541366245e297b1c1ed1413f2';
|
||||||
|
|
||||||
|
abstract class _$BadgeTransferController extends $Notifier<BadgeTransferState> {
|
||||||
|
BadgeTransferState build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<BadgeTransferState, BadgeTransferState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<BadgeTransferState, BadgeTransferState>,
|
||||||
|
BadgeTransferState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,504 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../widgets/animated_gradient_background.dart';
|
||||||
|
import '../../data/services/badge_ai_generation_service.dart';
|
||||||
|
import '../controllers/badge_ai_controller.dart';
|
||||||
|
import '../widgets/badge_ai_tab.dart';
|
||||||
|
import '../widgets/badge_upload_tab.dart';
|
||||||
|
|
||||||
|
class BadgeHomePage extends ConsumerStatefulWidget {
|
||||||
|
const BadgeHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BadgeHomePage> createState() => _BadgeHomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BadgeHomePageState extends ConsumerState<BadgeHomePage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final _aiTabKey = GlobalKey<BadgeAiTabState>();
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
String? _uploadedImagePath;
|
||||||
|
|
||||||
|
// AI 生成
|
||||||
|
bool _isGenerating = false;
|
||||||
|
double _genProgress = 0;
|
||||||
|
String _genStatus = '';
|
||||||
|
String? _generatedImageUrl;
|
||||||
|
bool _hasAiResult = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
if (!_tabController.indexIsChanging) setState(() {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查待消费的 AI 结果
|
||||||
|
final pending = BadgeAiGenerationService.instance.consumePendingResult();
|
||||||
|
if (pending != null) {
|
||||||
|
_generatedImageUrl = pending.imageUrl;
|
||||||
|
_hasAiResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册 AI 服务回调
|
||||||
|
final svc = BadgeAiGenerationService.instance;
|
||||||
|
svc.onProgress = (progress, message) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_genProgress = progress;
|
||||||
|
_genStatus = message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
svc.onComplete = (result) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = false;
|
||||||
|
_generatedImageUrl = result.imageUrl;
|
||||||
|
_hasAiResult = true;
|
||||||
|
});
|
||||||
|
_showResultDialog(result.imageUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
svc.onError = (error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isGenerating = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
final svc = BadgeAiGenerationService.instance;
|
||||||
|
svc.onProgress = null;
|
||||||
|
svc.onComplete = null;
|
||||||
|
svc.onError = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showResultDialog(String imageUrl) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'生成完成',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
color: const Color(0xFFF3F4F6),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.broken_image,
|
||||||
|
size: 48, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_handleRetry();
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
width: 1.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'换一张',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(0xFF22D3EE),
|
||||||
|
Color(0xFF3B82F6),
|
||||||
|
Color(0xFF6366F1),
|
||||||
|
Color(0xFF8B5CF6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_handleUseImage();
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'使用此图',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAiGenerate({
|
||||||
|
required String prompt,
|
||||||
|
String? style,
|
||||||
|
Uint8List? imageBytes,
|
||||||
|
double strength = 0.7,
|
||||||
|
}) {
|
||||||
|
setState(() {
|
||||||
|
_isGenerating = true;
|
||||||
|
_generatedImageUrl = null;
|
||||||
|
_hasAiResult = false;
|
||||||
|
_genProgress = 0;
|
||||||
|
_genStatus = '正在连接 AI...';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageBytes != null) {
|
||||||
|
ref.read(badgeAiControllerProvider.notifier).generateImage2Image(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
prompt: prompt,
|
||||||
|
style: style,
|
||||||
|
strength: strength,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ref.read(badgeAiControllerProvider.notifier).generateText2Image(
|
||||||
|
prompt: prompt,
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUploadSelected(String path, Uint8List? bytes) {
|
||||||
|
setState(() {
|
||||||
|
_uploadedImagePath = path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleRetry() {
|
||||||
|
setState(() {
|
||||||
|
_generatedImageUrl = null;
|
||||||
|
_hasAiResult = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUseImage() {
|
||||||
|
final imageSource = _generatedImageUrl ?? _uploadedImagePath;
|
||||||
|
if (imageSource == null) return;
|
||||||
|
context.push('/badge/transfer', extra: {'imageUrl': imageSource});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
const AnimatedGradientBackground(),
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
_buildTabBar(),
|
||||||
|
Expanded(child: _buildTabContent()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isGenerating) _buildGeneratingOverlay(),
|
||||||
|
_buildFixedBottomBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
size: 18, color: Color(0xFF1F2937)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'电子吧唧传图',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabBar() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.6)),
|
||||||
|
),
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF6366F1).withOpacity(0.15),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
labelColor: const Color(0xFF6366F1),
|
||||||
|
unselectedLabelColor: const Color(0xFF6B7280),
|
||||||
|
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
unselectedLabelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'AI 生图'),
|
||||||
|
Tab(text: '上传图片'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabContent() {
|
||||||
|
return TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
BadgeAiTab(
|
||||||
|
key: _aiTabKey,
|
||||||
|
onGenerate: _handleAiGenerate,
|
||||||
|
isGenerating: _isGenerating,
|
||||||
|
),
|
||||||
|
BadgeUploadTab(onImageSelected: _handleUploadSelected),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGeneratingOverlay() {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.4),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFF6366F1),
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_genStatus,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF6366F1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _genProgress / 100,
|
||||||
|
backgroundColor: const Color(0xFF6366F1).withOpacity(0.15),
|
||||||
|
valueColor: const AlwaysStoppedAnimation(Color(0xFF6366F1)),
|
||||||
|
minHeight: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 固定底部按钮栏 — 无背景渐变,无阴影
|
||||||
|
Widget _buildFixedBottomBar() {
|
||||||
|
final isAiTab = _tabController.index == 0;
|
||||||
|
final isUploadTab = _tabController.index == 1;
|
||||||
|
|
||||||
|
Widget? buttonContent;
|
||||||
|
|
||||||
|
if (isAiTab) {
|
||||||
|
// 有结果时由弹窗处理,底部只显示"开始生成"
|
||||||
|
if (!_isGenerating && !_hasAiResult) {
|
||||||
|
buttonContent = _buildGradientButton('开始生成', () {
|
||||||
|
final aiState = _aiTabKey.currentState;
|
||||||
|
if (aiState == null) return;
|
||||||
|
final prompt = aiState.currentPrompt;
|
||||||
|
final isI2I = aiState.referenceImageBytes != null;
|
||||||
|
// 文生图必须输入描述,图生图可选
|
||||||
|
if (!isI2I && prompt.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('请输入图片描述'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handleAiGenerate(
|
||||||
|
prompt: prompt,
|
||||||
|
style: aiState.selectedStyle,
|
||||||
|
imageBytes: aiState.referenceImageBytes,
|
||||||
|
strength: aiState.strength,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (isUploadTab) {
|
||||||
|
if (_uploadedImagePath != null) {
|
||||||
|
buttonContent = _buildGradientButton('使用此图', _handleUseImage);
|
||||||
|
} else {
|
||||||
|
buttonContent = _buildGradientButton('使用此图', () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('请先上传一张图片'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonContent == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20, 16, 20, MediaQuery.of(context).padding.bottom + 16),
|
||||||
|
child: buttonContent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGradientButton(String label, VoidCallback onPressed) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF22D3EE), Color(0xFF3B82F6), Color(0xFF6366F1), Color(0xFF8B5CF6)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,518 @@
|
|||||||
|
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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../widgets/animated_gradient_background.dart';
|
||||||
|
import '../controllers/badge_transfer_controller.dart';
|
||||||
|
import '../widgets/badge_ble_device_card.dart';
|
||||||
|
import '../widgets/transfer_progress_ring.dart';
|
||||||
|
|
||||||
|
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;
|
||||||
|
ScanResult? _selectedDevice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_scanResults = results;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_adapterSubscription =
|
||||||
|
FlutterBluePlus.adapterState.listen((adapterState) {
|
||||||
|
if (!mounted) return;
|
||||||
|
});
|
||||||
|
if (!kIsWeb) {
|
||||||
|
_startScan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startScan() {
|
||||||
|
FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scanSubscription?.cancel();
|
||||||
|
_adapterSubscription?.cancel();
|
||||||
|
FlutterBluePlus.stopScan();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final transferState = ref.watch(badgeTransferControllerProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
const AnimatedGradientBackground(),
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 120),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildBadgePreview(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTransferContent(transferState),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildBottomBar(transferState),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => context.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
size: 18, color: Color(0xFF1F2937)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'传输徽章',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 圆形徽章预览 — 紧凑展示,无卡片包裹
|
||||||
|
Widget _buildBadgePreview() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: const Color(0xFF1F2937),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF6366F1).withOpacity(0.2),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: ClipOval(
|
||||||
|
child: widget.imageUrl.startsWith('http')
|
||||||
|
? Image.network(
|
||||||
|
widget.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
|
Icons.image_not_supported,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.image,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'实际显示效果',
|
||||||
|
style: TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据传输状态显示不同内容
|
||||||
|
Widget _buildTransferContent(BadgeTransferState transferState) {
|
||||||
|
switch (transferState.status) {
|
||||||
|
case TransferStatus.transferring:
|
||||||
|
return _buildTransferringView(transferState);
|
||||||
|
case TransferStatus.done:
|
||||||
|
return _buildDoneView();
|
||||||
|
case TransferStatus.error:
|
||||||
|
return _buildErrorView(transferState);
|
||||||
|
default:
|
||||||
|
return _buildDeviceList(transferState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备选择列表
|
||||||
|
Widget _buildDeviceList(BadgeTransferState transferState) {
|
||||||
|
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: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.bluetooth_searching,
|
||||||
|
size: 20, color: Color(0xFF6366F1)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'选择设备',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _startScan,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6366F1).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'重新扫描',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF6366F1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_scanResults.isEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFF6366F1),
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'正在搜索附近设备...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...List.generate(_scanResults.length, (index) {
|
||||||
|
final result = _scanResults[index];
|
||||||
|
final isSelected = _selectedDevice?.device.remoteId ==
|
||||||
|
result.device.remoteId;
|
||||||
|
return BadgeBleDeviceCard(
|
||||||
|
scanResult: result,
|
||||||
|
selected: isSelected,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedDevice = result;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 传输中视图
|
||||||
|
Widget _buildTransferringView(BadgeTransferState transferState) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TransferProgressRing(progress: transferState.progress),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'正在传输...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'请保持设备靠近,不要关闭蓝牙',
|
||||||
|
style: TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 传输完成视图
|
||||||
|
Widget _buildDoneView() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TransferProgressRing(progress: 1.0, isComplete: true),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'传输完成!',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF10B981),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'图片已成功传输到徽章设备',
|
||||||
|
style: TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误视图
|
||||||
|
Widget _buildErrorView(BadgeTransferState transferState) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'传输失败',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFFEF4444),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
transferState.errorMessage ?? '未知错误',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 13, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部按钮栏
|
||||||
|
Widget _buildBottomBar(BadgeTransferState transferState) {
|
||||||
|
final Widget buttonContent;
|
||||||
|
|
||||||
|
switch (transferState.status) {
|
||||||
|
case TransferStatus.idle:
|
||||||
|
case TransferStatus.scanning:
|
||||||
|
if (_selectedDevice != null) {
|
||||||
|
buttonContent = _buildGradientButton('开始传输', () {
|
||||||
|
ref
|
||||||
|
.read(badgeTransferControllerProvider.notifier)
|
||||||
|
.connectAndTransfer(
|
||||||
|
_selectedDevice!.device,
|
||||||
|
widget.imageUrl,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
buttonContent = _buildDisabledButton('请先选择设备');
|
||||||
|
}
|
||||||
|
case TransferStatus.connecting:
|
||||||
|
buttonContent = _buildDisabledButton('连接中...');
|
||||||
|
case TransferStatus.transferring:
|
||||||
|
buttonContent = _buildDisabledButton('传输中...');
|
||||||
|
case TransferStatus.done:
|
||||||
|
buttonContent = _buildGradientButton('完成', () => context.pop());
|
||||||
|
case TransferStatus.error:
|
||||||
|
buttonContent = Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(
|
||||||
|
color: Colors.black.withOpacity(0.08), width: 1.5),
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.8),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'返回',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _buildGradientButton('重试', () {
|
||||||
|
if (_selectedDevice != null) {
|
||||||
|
ref
|
||||||
|
.read(badgeTransferControllerProvider.notifier)
|
||||||
|
.connectAndTransfer(
|
||||||
|
_selectedDevice!.device,
|
||||||
|
widget.imageUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20, 16, 20, MediaQuery.of(context).padding.bottom + 16),
|
||||||
|
child: buttonContent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGradientButton(String label, VoidCallback onPressed) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 52,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(0xFF22D3EE),
|
||||||
|
Color(0xFF3B82F6),
|
||||||
|
Color(0xFF6366F1),
|
||||||
|
Color(0xFF8B5CF6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDisabledButton(String label) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 52,
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFE5E7EB),
|
||||||
|
disabledBackgroundColor: const Color(0xFFE5E7EB),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,353 @@
|
|||||||
|
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,
|
||||||
|
Uint8List? imageBytes,
|
||||||
|
double strength,
|
||||||
|
}) onGenerate;
|
||||||
|
final bool isGenerating;
|
||||||
|
|
||||||
|
const BadgeAiTab({
|
||||||
|
super.key,
|
||||||
|
required this.onGenerate,
|
||||||
|
this.isGenerating = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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;
|
||||||
|
Uint8List? get referenceImageBytes => _isI2I ? _referenceImageBytes : 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: [
|
||||||
|
// 模式切换
|
||||||
|
_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 _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,352 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../../../../core/network/api_config.dart';
|
||||||
|
|
||||||
|
class BadgeUploadTab extends StatefulWidget {
|
||||||
|
final void Function(String imagePath, Uint8List? bytes) onImageSelected;
|
||||||
|
|
||||||
|
const BadgeUploadTab({
|
||||||
|
super.key,
|
||||||
|
required this.onImageSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BadgeUploadTab> createState() => _BadgeUploadTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BadgeUploadTabState extends State<BadgeUploadTab> {
|
||||||
|
String? _selectedImagePath;
|
||||||
|
Uint8List? _selectedImageBytes;
|
||||||
|
String? _selectedNetworkUrl;
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final file = await picker.pickImage(source: source);
|
||||||
|
if (file != null) {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
setState(() {
|
||||||
|
_selectedImagePath = file.path;
|
||||||
|
_selectedImageBytes = bytes;
|
||||||
|
_selectedNetworkUrl = null;
|
||||||
|
});
|
||||||
|
widget.onImageSelected(file.path, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAiImage(String imageUrl) {
|
||||||
|
setState(() {
|
||||||
|
_selectedNetworkUrl = imageUrl;
|
||||||
|
_selectedImagePath = imageUrl;
|
||||||
|
_selectedImageBytes = null;
|
||||||
|
});
|
||||||
|
widget.onImageSelected(imageUrl, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeImage() {
|
||||||
|
setState(() {
|
||||||
|
_selectedImagePath = null;
|
||||||
|
_selectedImageBytes = null;
|
||||||
|
_selectedNetworkUrl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasImage => _selectedImageBytes != null || _selectedNetworkUrl != null;
|
||||||
|
|
||||||
|
void _showAiHistoryPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => _AiHistoryBottomSheet(
|
||||||
|
onSelect: (url) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_selectAiImage(url);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 100),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_hasImage) ...[
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
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: _selectedImageBytes != null
|
||||||
|
? Image.memory(_selectedImageBytes!, fit: BoxFit.cover)
|
||||||
|
: Image.network(
|
||||||
|
_selectedNetworkUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Center(
|
||||||
|
child: Icon(Icons.broken_image, size: 48, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _removeImage,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.45),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!_hasImage) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildPickerCard(
|
||||||
|
icon: Icons.photo_library_outlined,
|
||||||
|
label: '从相册选择',
|
||||||
|
onTap: () => _pickImage(ImageSource.gallery),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: _buildPickerCard(
|
||||||
|
icon: Icons.camera_alt_outlined,
|
||||||
|
label: '拍照',
|
||||||
|
onTap: () => _pickImage(ImageSource.camera),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildPickerCard(
|
||||||
|
icon: Icons.auto_awesome_outlined,
|
||||||
|
label: 'AI 生成的图片',
|
||||||
|
onTap: _showAiHistoryPicker,
|
||||||
|
aspectRatio: 2.5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPickerCard({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
double aspectRatio = 1,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.65),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.4)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: const Color(0xFF6366F1), size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiHistoryBottomSheet extends StatefulWidget {
|
||||||
|
final void Function(String imageUrl) onSelect;
|
||||||
|
|
||||||
|
const _AiHistoryBottomSheet({required this.onSelect});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AiHistoryBottomSheet> createState() => _AiHistoryBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiHistoryBottomSheetState extends State<_AiHistoryBottomSheet> {
|
||||||
|
List<Map<String, dynamic>> _images = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() 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: 15));
|
||||||
|
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||||
|
final data = body['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
final items = (data['images'] as List<dynamic>? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.where((img) =>
|
||||||
|
img['generation_status'] == 'completed' &&
|
||||||
|
(img['image_url'] as String?)?.isNotEmpty == true)
|
||||||
|
.toList();
|
||||||
|
if (mounted) setState(() { _images = items; _loading = false; });
|
||||||
|
} else {
|
||||||
|
if (mounted) setState(() { _error = '加载失败'; _loading = false; });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _error = '网络错误'; _loading = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE5E7EB),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'选择 AI 生成的图片',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1F2937),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(child: _buildContent()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFF6366F1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Color(0xFF9CA3AF)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(_error!, style: const TextStyle(color: Color(0xFF9CA3AF))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_images.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.image_not_supported_outlined, size: 48, color: Color(0xFF9CA3AF)),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text('还没有 AI 生成的图片', style: TextStyle(color: Color(0xFF9CA3AF), fontSize: 14)),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('去「AI 生图」tab 试试吧', style: TextStyle(color: Color(0xFFD1D5DB), fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 10,
|
||||||
|
mainAxisSpacing: 10,
|
||||||
|
),
|
||||||
|
itemCount: _images.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final img = _images[index];
|
||||||
|
final url = img['image_url'] as String;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onSelect(url),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.black.withOpacity(0.06)),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => const Center(
|
||||||
|
child: Icon(Icons.broken_image, color: Color(0xFF9CA3AF)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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