fix: auto repair bugs #68, #10, #6, #69 #4

Merged
zyc merged 2 commits from fix/auto-20260318-155918 into main 2026-03-18 16:59:55 +08:00
12 changed files with 1816 additions and 179 deletions
Showing only changes of commit 1c1220c9c5 - Show all commits

View File

@ -48,14 +48,14 @@ class BadgeRepositoryImpl implements BadgeRepository {
@override
Future<Either<Failure, BadgeImage>> generateImage2Image({
required String imagePath,
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
}) async {
try {
await _aiService.generateImage2Image(
imagePath: imagePath,
imageBytes: imageBytes,
prompt: prompt,
style: style,
strength: strength,
@ -72,7 +72,6 @@ class BadgeRepositoryImpl implements BadgeRepository {
prompt: prompt ?? '',
style: style,
source: 'i2i',
referenceImagePath: imagePath,
strength: strength,
));
} catch (e) {

View File

@ -62,16 +62,16 @@ class BadgeAiGenerationService {
///
Future<void> generateImage2Image({
required String imagePath,
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,
}) async {
await _generateMultipart(
endpoint: '/badge/generate/i2i/',
imagePath: imagePath,
imageBytes: imageBytes,
fields: {
if (prompt != null) 'prompt': prompt,
'prompt': prompt ?? '',
if (style != null) 'style': style,
'strength': strength.toString(),
},
@ -119,7 +119,7 @@ class BadgeAiGenerationService {
/// Multipart
Future<void> _generateMultipart({
required String endpoint,
required String imagePath,
required Uint8List imageBytes,
required Map<String, String> fields,
}) async {
if (_isGenerating) return;
@ -133,8 +133,11 @@ class BadgeAiGenerationService {
);
if (token != null) request.headers['Authorization'] = 'Bearer $token';
request.fields.addAll(fields);
request.files
.add(await http.MultipartFile.fromPath('image', imagePath));
request.files.add(http.MultipartFile.fromBytes(
'image',
imageBytes,
filename: 'reference.jpg',
));
final client = http.Client();
final streamedResponse =

View File

@ -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

View File

@ -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,
};

View File

@ -13,7 +13,7 @@ abstract class BadgeRepository {
/// AI
Future<Either<Failure, BadgeImage>> generateImage2Image({
required String imagePath,
required Uint8List imageBytes,
String? prompt,
String? style,
double strength = 0.7,

View File

@ -1,5 +1,7 @@
import 'dart:typed_data';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
@ -7,53 +9,66 @@ part 'badge_ai_controller.g.dart';
@riverpod
class BadgeAiController extends _$BadgeAiController {
@override
AsyncValue<BadgeImage?> build() {
return const AsyncValue.data(null);
}
AsyncValue<BadgeImage?> build() => const AsyncData(null);
Future<void> generateText2Image(
String prompt,
///
Future<bool> generateText2Image({
required String prompt,
String? style,
) async {
state = const AsyncValue.loading();
try {
await Future.delayed(const Duration(seconds: 1));
if (!ref.mounted) return;
final image = BadgeImage(
imageUrl: 'https://example.com/generated.png',
}) async {
state = const AsyncLoading();
final repo = BadgeRepositoryImpl();
final result = await repo.generateText2Image(
prompt: prompt,
style: style,
source: 't2i',
);
if (!ref.mounted) return;
state = AsyncValue.data(image);
} catch (e, st) {
if (!ref.mounted) return;
state = AsyncValue.error(e, st);
}
if (!ref.mounted) return false;
return result.fold(
(failure) {
state = AsyncError(failure.message, StackTrace.current);
return false;
},
(image) {
state = AsyncData(image);
return true;
},
);
}
Future<void> generateImage2Image(
String referenceImagePath,
String prompt,
double strength,
) async {
state = const AsyncValue.loading();
try {
await Future.delayed(const Duration(seconds: 1));
if (!ref.mounted) return;
final image = BadgeImage(
imageUrl: 'https://example.com/generated.png',
///
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,
source: 'i2i',
referenceImagePath: referenceImagePath,
style: style,
strength: strength,
);
if (!ref.mounted) return;
state = AsyncValue.data(image);
} catch (e, st) {
if (!ref.mounted) return;
state = AsyncValue.error(e, st);
}
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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -1,44 +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 ConsumerWidget {
class BadgeHomePage extends ConsumerStatefulWidget {
const BadgeHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final aiState = ref.watch(badgeAiControllerProvider);
ConsumerState<BadgeHomePage> createState() => _BadgeHomePageState();
}
return Scaffold(
appBar: AppBar(title: const Text('徽章')),
body: Center(
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(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
aiState.when(
data: (image) => image != null
? Column(
children: [
Image.network(image.imageUrl, height: 200),
const Text(
'生成完成',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.push(
'/badge/transfer',
extra: {'imageUrl': image.imageUrl},
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),
),
),
),
),
child: const Text('传输到设备'),
),
],
)
: const Text('还没有徽章图片'),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('错误: $e'),
),
],
),
),
),
);
}
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),
),
),
),
);
}
}

View File

@ -4,8 +4,12 @@ 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;
@ -20,6 +24,7 @@ class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
List<ScanResult> _scanResults = [];
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothAdapterState>? _adapterSubscription;
ScanResult? _selectedDevice;
@override
void initState() {
@ -33,9 +38,7 @@ class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
_adapterSubscription =
FlutterBluePlus.adapterState.listen((adapterState) {
if (!mounted) return;
// Handle adapter state changes
});
// On web, BLE scan requires user gesture do not auto-scan
if (!kIsWeb) {
_startScan();
}
@ -49,7 +52,6 @@ class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
void dispose() {
_scanSubscription?.cancel();
_adapterSubscription?.cancel();
// Bug #68 fix: call FlutterBluePlus.stopScan() directly instead of using ref
FlutterBluePlus.stopScan();
super.dispose();
}
@ -59,66 +61,458 @@ class _BadgeTransferPageState extends ConsumerState<BadgeTransferPage> {
final transferState = ref.watch(badgeTransferControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('传输徽章')),
body: Column(
backgroundColor: Colors.white,
body: Stack(
children: [
if (kIsWeb)
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: _startScan,
child: const Text('扫描设备'),
),
),
const AnimatedGradientBackground(),
SafeArea(
child: Column(
children: [
_buildHeader(),
Expanded(
child: ListView.builder(
itemCount: _scanResults.length,
itemBuilder: (context, index) {
final result = _scanResults[index];
return ListTile(
title: Text(
result.device.platformName.isNotEmpty
? result.device.platformName
: '未知设备',
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 120),
child: Column(
children: [
_buildBadgePreview(),
const SizedBox(height: 16),
_buildTransferContent(transferState),
],
),
subtitle: Text(result.device.remoteId.str),
trailing: ElevatedButton(
onPressed: () {
ref
.read(badgeTransferControllerProvider.notifier)
.connectAndTransfer(
result.device,
widget.imageUrl,
);
},
child: const Text('传输'),
),
),
],
),
),
_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)),
),
),
if (transferState.status == TransferStatus.transferring)
Padding(
padding: const EdgeInsets.all(16.0),
child: LinearProgressIndicator(
value: transferState.progress,
),
),
if (transferState.status == TransferStatus.done)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('传输完成!', style: TextStyle(color: Colors.green)),
),
if (transferState.status == TransferStatus.error)
Padding(
padding: const EdgeInsets.all(16.0),
const Expanded(
child: Center(
child: Text(
'错误: ${transferState.errorMessage}',
style: const TextStyle(color: Colors.red),
'传输徽章',
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),
),
),
),
);
}
}

View File

@ -8,17 +8,15 @@ class BadgeAiTab extends StatefulWidget {
final void Function({
required String prompt,
String? style,
String? imagePath,
Uint8List? imageBytes,
double strength,
}) onGenerate;
final bool isGenerating;
final String? generatedImageUrl;
const BadgeAiTab({
super.key,
required this.onGenerate,
this.isGenerating = false,
this.generatedImageUrl,
});
@override
@ -36,6 +34,7 @@ class BadgeAiTabState extends State<BadgeAiTab> {
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
@ -70,12 +69,6 @@ class BadgeAiTabState extends State<BadgeAiTab> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// AI
if (widget.generatedImageUrl != null) ...[
_buildResultPreview(),
const SizedBox(height: 20),
],
//
_buildModeToggle(),
const SizedBox(height: 16),
@ -114,38 +107,6 @@ class BadgeAiTabState extends State<BadgeAiTab> {
);
}
Widget _buildResultPreview() {
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: AspectRatio(
aspectRatio: 1,
child: Image.network(
widget.generatedImageUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFF3F4F6),
child: const Center(
child: Icon(Icons.broken_image, size: 48, color: Color(0xFF9CA3AF)),
),
),
),
),
),
);
}
Widget _buildModeToggle() {
return Row(
children: [

View File

@ -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)),
),
),
),
),
);
},
);
}
}