From 1c1220c9c5aa2013dc24f5b8717441fb9283429b Mon Sep 17 00:00:00 2001 From: repair-agent Date: Wed, 18 Mar 2026 16:58:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=94=B5=E5=AD=90?= =?UTF-8?q?=E5=90=A7=E5=94=A7=E4=BC=A0=E5=9B=BE=E5=8A=9F=E8=83=BD=20?= =?UTF-8?q?=E2=80=94=20AI=E7=94=9F=E5=9B=BE=E5=BC=B9=E7=AA=97=E3=80=81?= =?UTF-8?q?=E5=9B=BE=E7=94=9F=E5=9B=BEWeb=E5=85=BC=E5=AE=B9=E3=80=81?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E9=A1=B5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图生图改用 MultipartFile.fromBytes 兼容 Web 平台 - AI 生成结果改为弹窗展示(换一张/使用此图) - 图生图模式 prompt 改为可选,始终发送 prompt 字段 - 传输页移除多余按钮,圆形预览改为紧凑展示 - 新增上传标签页支持选择 AI 生成历史图片 Co-Authored-By: Claude Opus 4.6 --- .../repositories/badge_repository_impl.dart | 5 +- .../services/badge_ai_generation_service.dart | 15 +- .../domain/entities/badge_image.freezed.dart | 297 ++++++++++ .../badge/domain/entities/badge_image.g.dart | 28 + .../domain/repositories/badge_repository.dart | 2 +- .../controllers/badge_ai_controller.dart | 105 ++-- .../controllers/badge_ai_controller.g.dart | 64 +++ .../badge_transfer_controller.g.dart | 64 +++ .../presentation/pages/badge_home_page.dart | 512 +++++++++++++++++- .../pages/badge_transfer_page.dart | 508 +++++++++++++++-- .../presentation/widgets/badge_ai_tab.dart | 43 +- .../widgets/badge_upload_tab.dart | 352 ++++++++++++ 12 files changed, 1816 insertions(+), 179 deletions(-) create mode 100644 airhub_app/lib/features/badge/domain/entities/badge_image.freezed.dart create mode 100644 airhub_app/lib/features/badge/domain/entities/badge_image.g.dart create mode 100644 airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.g.dart create mode 100644 airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart create mode 100644 airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart diff --git a/airhub_app/lib/features/badge/data/repositories/badge_repository_impl.dart b/airhub_app/lib/features/badge/data/repositories/badge_repository_impl.dart index b82494d..e0b6558 100644 --- a/airhub_app/lib/features/badge/data/repositories/badge_repository_impl.dart +++ b/airhub_app/lib/features/badge/data/repositories/badge_repository_impl.dart @@ -48,14 +48,14 @@ class BadgeRepositoryImpl implements BadgeRepository { @override Future> 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) { diff --git a/airhub_app/lib/features/badge/data/services/badge_ai_generation_service.dart b/airhub_app/lib/features/badge/data/services/badge_ai_generation_service.dart index 572c4ad..f463cba 100644 --- a/airhub_app/lib/features/badge/data/services/badge_ai_generation_service.dart +++ b/airhub_app/lib/features/badge/data/services/badge_ai_generation_service.dart @@ -62,16 +62,16 @@ class BadgeAiGenerationService { /// 图生图 Future 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 _generateMultipart({ required String endpoint, - required String imagePath, + required Uint8List imageBytes, required Map 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 = diff --git a/airhub_app/lib/features/badge/domain/entities/badge_image.freezed.dart b/airhub_app/lib/features/badge/domain/entities/badge_image.freezed.dart new file mode 100644 index 0000000..6e96707 --- /dev/null +++ b/airhub_app/lib/features/badge/domain/entities/badge_image.freezed.dart @@ -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 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 get copyWith => _$BadgeImageCopyWithImpl(this as BadgeImage, _$identity); + + /// Serializes this BadgeImage to a JSON map. + Map 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 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 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? 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 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 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? 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 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 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 diff --git a/airhub_app/lib/features/badge/domain/entities/badge_image.g.dart b/airhub_app/lib/features/badge/domain/entities/badge_image.g.dart new file mode 100644 index 0000000..2febc67 --- /dev/null +++ b/airhub_app/lib/features/badge/domain/entities/badge_image.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'badge_image.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BadgeImage _$BadgeImageFromJson(Map 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 _$BadgeImageToJson(_BadgeImage instance) => + { + '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, + }; diff --git a/airhub_app/lib/features/badge/domain/repositories/badge_repository.dart b/airhub_app/lib/features/badge/domain/repositories/badge_repository.dart index 9f5fc47..6236ff5 100644 --- a/airhub_app/lib/features/badge/domain/repositories/badge_repository.dart +++ b/airhub_app/lib/features/badge/domain/repositories/badge_repository.dart @@ -13,7 +13,7 @@ abstract class BadgeRepository { /// AI 图生图 Future> generateImage2Image({ - required String imagePath, + required Uint8List imageBytes, String? prompt, String? style, double strength = 0.7, diff --git a/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.dart b/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.dart index cd4b63a..6684f7e 100644 --- a/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.dart +++ b/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.dart @@ -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 build() { - return const AsyncValue.data(null); - } + AsyncValue build() => const AsyncData(null); - Future generateText2Image( - String prompt, + /// 文生图 + Future 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', - 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); - } + }) 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 generateImage2Image( - String referenceImagePath, - String prompt, - double strength, - ) async { - state = const AsyncValue.loading(); - try { - await Future.delayed(const Duration(seconds: 1)); - if (!ref.mounted) return; - final image = BadgeImage( - imageUrl: 'https://example.com/generated.png', - prompt: prompt, - source: 'i2i', - referenceImagePath: referenceImagePath, - strength: strength, - ); - if (!ref.mounted) return; - state = AsyncValue.data(image); - } catch (e, st) { - if (!ref.mounted) return; - state = AsyncValue.error(e, st); - } + /// 图生图 + Future 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); } } diff --git a/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.g.dart b/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.g.dart new file mode 100644 index 0000000..f764e6f --- /dev/null +++ b/airhub_app/lib/features/badge/presentation/controllers/badge_ai_controller.g.dart @@ -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> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$badgeAiControllerHash() => r'b270d32d4d80d40c3eddb5e610682aace3f709f2'; + +abstract class _$BadgeAiController extends $Notifier> { + AsyncValue build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref as $Ref, AsyncValue>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AsyncValue>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart new file mode 100644 index 0000000..0ce2a68 --- /dev/null +++ b/airhub_app/lib/features/badge/presentation/controllers/badge_transfer_controller.g.dart @@ -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 { + 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(value), + ); + } +} + +String _$badgeTransferControllerHash() => + r'6ed7ce6b7aa6eae541366245e297b1c1ed1413f2'; + +abstract class _$BadgeTransferController extends $Notifier { + BadgeTransferState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + BadgeTransferState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart index da24f2e..57620cc 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_home_page.dart @@ -1,43 +1,503 @@ +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 createState() => _BadgeHomePageState(); +} - return Scaffold( - appBar: AppBar(title: const Text('徽章')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - aiState.when( - data: (image) => image != null - ? Column( - children: [ - Image.network(image.imageUrl, height: 200), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => context.push( - '/badge/transfer', - extra: {'imageUrl': image.imageUrl}, - ), - child: const Text('传输到设备'), +class _BadgeHomePageState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final _aiTabKey = GlobalKey(); + + // 上传图片 + 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)), ), - ], - ) - : const Text('还没有徽章图片'), - loading: () => const CircularProgressIndicator(), - error: (e, _) => Text('错误: $e'), + 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), + ), + ), ), ); } diff --git a/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart b/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart index e701305..2b29938 100644 --- a/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart +++ b/airhub_app/lib/features/badge/presentation/pages/badge_transfer_page.dart @@ -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 { List _scanResults = []; StreamSubscription>? _scanSubscription; StreamSubscription? _adapterSubscription; + ScanResult? _selectedDevice; @override void initState() { @@ -33,9 +38,7 @@ class _BadgeTransferPageState extends ConsumerState { _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 { 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 { 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('扫描设备'), - ), - ), - 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 - : '未知设备', + 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), + ], + ), ), - subtitle: Text(result.device.remoteId.str), - trailing: ElevatedButton( - onPressed: () { - ref - .read(badgeTransferControllerProvider.notifier) - .connectAndTransfer( - result.device, - widget.imageUrl, - ); - }, - child: const Text('传输'), - ), - ); - }, + ), + ], ), ), - if (transferState.status == TransferStatus.transferring) - Padding( - padding: const EdgeInsets.all(16.0), - child: LinearProgressIndicator( - value: transferState.progress, - ), - ), - if (transferState.status == TransferStatus.done) - const Padding( - padding: EdgeInsets.all(16.0), - child: Text('传输完成!', style: TextStyle(color: Colors.green)), - ), - if (transferState.status == TransferStatus.error) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - '错误: ${transferState.errorMessage}', - style: const TextStyle(color: Colors.red), - ), - ), + _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), + ), + ), + ), + ); + } } diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart index 9c1e8f7..8a780a2 100644 --- a/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_ai_tab.dart @@ -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 { 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 { 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 { ); } - 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: [ diff --git a/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart b/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart new file mode 100644 index 0000000..ac7617c --- /dev/null +++ b/airhub_app/lib/features/badge/presentation/widgets/badge_upload_tab.dart @@ -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 createState() => _BadgeUploadTabState(); +} + +class _BadgeUploadTabState extends State { + String? _selectedImagePath; + Uint8List? _selectedImageBytes; + String? _selectedNetworkUrl; + + Future _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> _images = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _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; + final data = body['data'] as Map? ?? {}; + final items = (data['images'] as List? ?? []) + .cast>() + .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)), + ), + ), + ), + ), + ); + }, + ); + } +}