From cacb32c25f2dbf5334d55bd9b23dbff1ad931c26 Mon Sep 17 00:00:00 2001 From: ivan Date: Sat, 21 Mar 2026 23:39:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8C=85=EF=BC=9A=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=91=E5=B8=831.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 1 - design/pencil-app-client.pen | 352 ++++++++++++++++++ docs/feedback_flow.md | 82 ++++ ios/Flutter/Generated.xcconfig | 4 +- ios/Flutter/flutter_export_environment.sh | 4 +- lib/core/api/api_config.dart | 4 +- lib/core/api/services/feedback_api.dart | 38 ++ lib/core/api/services/user_api.dart | 15 + .../gallery/models/gallery_task_item.dart | 57 ++- .../generation_result_screen.dart | 292 ++++++++++++++- lib/features/profile/profile_screen.dart | 263 ++++++++++++- .../recharge/payment_webview_screen.dart | 38 +- pubspec.yaml | 4 +- 13 files changed, 1114 insertions(+), 40 deletions(-) create mode 100644 docs/feedback_flow.md create mode 100644 lib/core/api/services/feedback_api.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0db669f..b0586f2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ - getUploadPresignedUrl({ + required String fileName, + }) async { + return _client.request( + path: '/v1/feedback/upload-presigned-url', + method: 'POST', + body: {'layer': fileName}, + ); + } + + /// 提交反馈 + /// body: inventory (fileUrls), cloak (content), pauldron (contentType) + static Future submit({ + required List fileUrls, + required String content, + required String contentType, + }) async { + return _client.request( + path: '/v1/feedback/submit', + method: 'POST', + body: { + 'inventory': fileUrls, + 'cloak': content, + 'pauldron': contentType, + }, + ); + } +} diff --git a/lib/core/api/services/user_api.dart b/lib/core/api/services/user_api.dart index b7e7ef8..c5be95c 100644 --- a/lib/core/api/services/user_api.dart +++ b/lib/core/api/services/user_api.dart @@ -97,4 +97,19 @@ abstract final class UserApi { }, ); } + + /// 删除账号 + static Future deleteAccount({ + required String sentinel, + String? asset, + }) async { + return _client.request( + path: '/v1/user/delete', + method: 'GET', + queryParams: { + 'sentinel': sentinel, + if (asset != null) 'asset': asset, + }, + ); + } } diff --git a/lib/features/gallery/models/gallery_task_item.dart b/lib/features/gallery/models/gallery_task_item.dart index cba8b81..6cfcc77 100644 --- a/lib/features/gallery/models/gallery_task_item.dart +++ b/lib/features/gallery/models/gallery_task_item.dart @@ -32,26 +32,49 @@ class GalleryTaskItem { factory GalleryTaskItem.fromJson(Map json) { final downsample = json['downsample'] as List? ?? []; final items = []; - for (final item in downsample) { - if (item is String) { - items.add(GalleryMediaItem(imageUrl: item)); - } else if (item is Map) { - final reconfigure = item['reconfigure'] as String?; - if (reconfigure == null || reconfigure.isEmpty) continue; - // reconnect(imgType): 0=视频,1=图片,其他默认当图片 - final reconnect = item['reconnect']; - final imgType = reconnect is int - ? reconnect - : reconnect is num - ? reconnect.toInt() - : 1; - if (imgType == 2) { - items.add(GalleryMediaItem(videoUrl: reconfigure)); - } else { - items.add(GalleryMediaItem(imageUrl: reconfigure)); + // 只取downsample的array[0] + if (downsample.isNotEmpty) { + final first = downsample[0]; + if (first is String) { + items.add(GalleryMediaItem(imageUrl: first)); + } else if (first is Map) { + final reconfigure = first['reconfigure'] as String?; + if (reconfigure != null && reconfigure.isNotEmpty) { + final reconnect = first['reconnect']; + final imgType = reconnect is int + ? reconnect + : reconnect is num + ? reconnect.toInt() + : 1; + if (imgType == 2) { + items.add(GalleryMediaItem(videoUrl: reconfigure)); + } else { + items.add(GalleryMediaItem(imageUrl: reconfigure)); + } } } + // 后续下标均忽略 } + // for (final item in downsample) { + // if (item is String) { + // items.add(GalleryMediaItem(imageUrl: item)); + // } else if (item is Map) { + // final reconfigure = item['reconfigure'] as String?; + // if (reconfigure == null || reconfigure.isEmpty) continue; + // // reconnect(imgType): 0=视频,1=图片,其他默认当图片 + // final reconnect = item['reconnect']; + // final imgType = reconnect is int + // ? reconnect + // : reconnect is num + // ? reconnect.toInt() + // : 1; + // if (imgType == 2) { + // items.add(GalleryMediaItem(videoUrl: reconfigure)); + // } else { + // items.add(GalleryMediaItem(imageUrl: reconfigure)); + // } + // } + // } return GalleryTaskItem( taskId: (json['tree'] as num?)?.toInt() ?? 0, state: json['listing']?.toString() ?? '', diff --git a/lib/features/generate_video/generation_result_screen.dart b/lib/features/generate_video/generation_result_screen.dart index 6aba378..c241e34 100644 --- a/lib/features/generate_video/generation_result_screen.dart +++ b/lib/features/generate_video/generation_result_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:gal/gal.dart'; import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:video_player/video_player.dart'; +import '../../core/api/services/feedback_api.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; @@ -228,9 +230,7 @@ class _MediaDisplay extends StatelessWidget { top: 16, right: 20, child: GestureDetector( - onTap: () { - // TODO: Report action - }, + onTap: () => _ReportDialog.show(context), child: Container( width: 44, height: 44, @@ -399,6 +399,292 @@ class _DownloadButton extends StatelessWidget { } } +/// Report dialog - matches Pencil 5qKUB +class _ReportDialog extends StatefulWidget { + const _ReportDialog({required this.parentContext}); + + final BuildContext parentContext; + + static void show(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black54, + builder: (_) => _ReportDialog(parentContext: context), + ); + } + + @override + State<_ReportDialog> createState() => _ReportDialogState(); +} + +class _ReportDialogState extends State<_ReportDialog> { + final _controller = TextEditingController(); + File? _pickedImage; + final _picker = ImagePicker(); + bool _submitting = false; + String? _errorText; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _pickImage() async { + final x = await _picker.pickImage(source: ImageSource.gallery); + if (x != null && mounted) { + setState(() => _pickedImage = File(x.path)); + } + } + + Future _submit() async { + final content = _controller.text.trim(); + if (content.isEmpty) { + setState(() => _errorText = 'Please describe the issue'); + return; + } + if (_pickedImage == null) { + setState(() => _errorText = 'Please upload an image'); + return; + } + + setState(() { + _errorText = null; + _submitting = true; + }); + try { + final file = _pickedImage!; + final ext = file.path.split('.').last.toLowerCase(); + final contentType = ext == 'png' + ? 'image/png' + : ext == 'gif' + ? 'image/gif' + : 'image/jpeg'; + final fileName = + 'feedback_${DateTime.now().millisecondsSinceEpoch}.$ext'; + + final presignedRes = + await FeedbackApi.getUploadPresignedUrl(fileName: fileName); + if (!presignedRes.isSuccess || presignedRes.data == null) { + throw Exception( + presignedRes.msg.isNotEmpty ? presignedRes.msg : 'Failed to get upload URL'); + } + + final data = presignedRes.data as Map; + final uploadUrl = data['shed'] as String?; + final filePath = data['hunt'] as String?; + + if (uploadUrl == null || + uploadUrl.isEmpty || + filePath == null || + filePath.isEmpty) { + throw Exception('Invalid presigned URL response'); + } + + final bytes = await file.readAsBytes(); + final uploadResponse = await http.put( + Uri.parse(uploadUrl), + headers: {'Content-Type': contentType}, + body: bytes, + ); + + if (uploadResponse.statusCode < 200 || + uploadResponse.statusCode >= 300) { + throw Exception('Upload failed: ${uploadResponse.statusCode}'); + } + + final submitRes = await FeedbackApi.submit( + fileUrls: [filePath], + content: content, + contentType: 'text/plain', + ); + + if (!submitRes.isSuccess) { + throw Exception( + submitRes.msg.isNotEmpty ? submitRes.msg : 'Failed to submit report'); + } + + if (!mounted) return; + Navigator.of(context).pop(); + if (widget.parentContext.mounted) { + ScaffoldMessenger.of(widget.parentContext).showSnackBar( + const SnackBar( + content: Text('Report submitted'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _errorText = e.toString().replaceAll('Exception: ', '')); + } + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + width: 342, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Report', + style: AppTypography.bodyMedium.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const SizedBox( + width: 40, + height: 40, + child: Icon( + LucideIcons.x, + size: 24, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + height: 120, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: TextField( + controller: _controller, + maxLines: null, + decoration: const InputDecoration( + hintText: 'Describe the issue...', + hintStyle: TextStyle(color: AppColors.textMuted), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ), + ), + const SizedBox(height: 20), + GestureDetector( + onTap: _pickImage, + child: Container( + height: 120, + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFD4D4D8), + width: 2, + ), + ), + child: _pickedImage != null + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + _pickedImage!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.image_plus, + size: 32, + color: AppColors.textMuted, + ), + const SizedBox(height: 8), + Text( + 'Tap to upload image', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), + ), + ], + ), + ), + ), + if (_errorText != null) ...[ + const SizedBox(height: 12), + Text( + _errorText!, + style: TextStyle( + color: AppColors.accentOrange, + fontSize: 14, + ), + ), + ], + const SizedBox(height: 20), + GestureDetector( + onTap: _submitting ? null : _submit, + child: Container( + height: 52, + decoration: BoxDecoration( + color: _submitting + ? AppColors.textMuted + : const Color(0xFF7C3AED), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: _submitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.surface, + ), + ) + : Text( + 'Submit', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.surface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + class _ShareButton extends StatelessWidget { const _ShareButton({required this.onShare}); diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index dd78a23..e57fe92 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import '../../core/api/api_client.dart'; +import '../../core/api/api_config.dart'; +import '../../core/api/services/user_api.dart'; import '../../core/user/account_refresh.dart'; import '../recharge/payment_webview_screen.dart'; import '../../core/theme/app_colors.dart'; @@ -81,7 +84,7 @@ class _ProfileScreenState extends State { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => const PaymentWebViewScreen( - paymentUrl: 'http://www.petsheroai.xyz/privacy.html', + paymentUrl: 'https://www.petsheroai.xyz/privacy.html', title: 'Privacy Policy', ), ), @@ -93,12 +96,18 @@ class _ProfileScreenState extends State { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => const PaymentWebViewScreen( - paymentUrl: 'http://www.petsheroai.xyz/terms.html', + paymentUrl: 'https://www.petsheroai.xyz/terms.html', title: 'User Agreement', ), ), ), ), + _MenuItem( + title: 'Delete Account', + icon: LucideIcons.trash_2, + iconColor: const Color(0xFFDC2626), + onTap: () => _DeleteAccountDialog.show(context), + ), ], ), ], @@ -296,11 +305,13 @@ class _MenuItem extends StatelessWidget { required this.title, required this.icon, required this.onTap, + this.iconColor, }); final String title; final IconData icon; final VoidCallback onTap; + final Color? iconColor; @override Widget build(BuildContext context) { @@ -328,13 +339,257 @@ class _MenuItem extends StatelessWidget { Text( title, style: AppTypography.bodyRegular.copyWith( - color: AppColors.textPrimary, + color: iconColor ?? AppColors.textPrimary, ), ), - Icon(icon, size: 20, color: AppColors.textMuted), + Icon(icon, size: 20, color: iconColor ?? AppColors.textMuted), ], ), ), ); } } + +/// Delete Account confirmation dialog - matches Pencil Xp6Qz +class _DeleteAccountDialog extends StatefulWidget { + const _DeleteAccountDialog({required this.parentContext}); + + final BuildContext parentContext; + + static void show(BuildContext context) { + showDialog( + context: context, + barrierColor: const Color(0x80000000), + builder: (_) => _DeleteAccountDialog(parentContext: context), + ); + } + + @override + State<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); +} + +class _DeleteAccountDialogState extends State<_DeleteAccountDialog> { + bool _deleting = false; + String? _errorText; + final _verifyController = TextEditingController(); + + static const _verifyCode = 'DELETE'; + + bool get _isVerifyMatch => + _verifyController.text.trim().toUpperCase() == _verifyCode; + + @override + void initState() { + super.initState(); + _verifyController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _verifyController.dispose(); + super.dispose(); + } + + Future _onDelete() async { + setState(() { + _errorText = null; + _deleting = true; + }); + try { + final res = await UserApi.deleteAccount( + sentinel: ApiConfig.appId, + asset: UserState.userId.value, + ); + if (!mounted) return; + if (res.isSuccess) { + // Clear user state and token + UserState.setCredits(null); + UserState.setUserId(null); + UserState.setAvatar(null); + UserState.setUserName(null); + UserState.setNavigate(null); + ApiClient.instance.setUserToken(null); + Navigator.of(context).pop(); + if (widget.parentContext.mounted) { + ScaffoldMessenger.of(widget.parentContext).showSnackBar( + const SnackBar( + content: Text('Account deleted'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } else { + setState(() => _errorText = res.msg.isNotEmpty ? res.msg : 'Delete failed'); + } + } catch (e) { + if (mounted) { + setState(() => _errorText = e.toString().replaceAll('Exception: ', '')); + } + } finally { + if (mounted) { + setState(() => _deleting = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: Material( + color: Colors.transparent, + child: Container( + width: 342, + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x26000000), + blurRadius: 24, + offset: Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Delete Account?', + style: AppTypography.bodyLarge.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + GestureDetector( + onTap: _deleting ? null : () => Navigator.of(context).pop(), + child: const SizedBox( + width: 40, + height: 40, + child: Icon(LucideIcons.x, size: 24, color: AppColors.textMuted), + ), + ), + ], + ), + const SizedBox(height: 20), + Text( + 'This action cannot be undone. All your data will be permanently deleted.', + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textMuted, + fontSize: 14, + ), + ), + const SizedBox(height: 20), + Text( + 'Type $_verifyCode to confirm', + style: AppTypography.label.copyWith( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _verifyController, + decoration: InputDecoration( + hintText: 'Type $_verifyCode here', + hintStyle: AppTypography.bodyRegular.copyWith( + color: AppColors.textMuted, + fontSize: 14, + ), + filled: true, + fillColor: const Color(0xFFFAFAFA), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE4E4E7)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE4E4E7)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + style: AppTypography.bodyRegular.copyWith( + color: AppColors.textPrimary, + fontSize: 14, + ), + textCapitalization: TextCapitalization.characters, + ), + if (_errorText != null) ...[ + const SizedBox(height: 12), + Text( + _errorText!, + style: AppTypography.caption.copyWith(color: Colors.red), + ), + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _deleting ? null : () => Navigator.of(context).pop(), + child: Container( + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFF4F4F5), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + 'Cancel', + style: AppTypography.bodyMedium.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: (_deleting || !_isVerifyMatch) ? null : _onDelete, + child: Container( + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _isVerifyMatch + ? const Color(0xFFDC2626) + : Color(0xFFDC2626).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + ), + child: _deleting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + 'Delete', + style: AppTypography.bodyMedium.copyWith( + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/recharge/payment_webview_screen.dart b/lib/features/recharge/payment_webview_screen.dart index 8f480e6..c9a28a4 100644 --- a/lib/features/recharge/payment_webview_screen.dart +++ b/lib/features/recharge/payment_webview_screen.dart @@ -23,6 +23,7 @@ class PaymentWebViewScreen extends StatefulWidget { class _PaymentWebViewScreenState extends State { late final WebViewController _controller; + int _loadingProgress = 0; @override void initState() { @@ -31,8 +32,15 @@ class _PaymentWebViewScreenState extends State { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( - onPageStarted: (_) {}, - onPageFinished: (_) {}, + onProgress: (progress) { + if (mounted) setState(() => _loadingProgress = progress); + }, + onPageStarted: (_) { + if (mounted) setState(() => _loadingProgress = 0); + }, + onPageFinished: (_) { + if (mounted) setState(() => _loadingProgress = 100); + }, onWebResourceError: (e) {}, ), ) @@ -44,11 +52,27 @@ class _PaymentWebViewScreenState extends State { return Scaffold( backgroundColor: AppColors.surface, appBar: PreferredSize( - preferredSize: const Size.fromHeight(56), - child: TopNavBar( - title: widget.title, - showBackButton: true, - onBack: () => Navigator.of(context).pop(), + preferredSize: const Size.fromHeight(59), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TopNavBar( + title: widget.title, + showBackButton: true, + onBack: () => Navigator.of(context).pop(), + ), + if (_loadingProgress < 100) + SizedBox( + height: 3, + child: LinearProgressIndicator( + value: _loadingProgress / 100, + backgroundColor: AppColors.surfaceAlt, + valueColor: + const AlwaysStoppedAnimation(AppColors.primary), + ), + ), + ], ), ), body: SafeArea( diff --git a/pubspec.yaml b/pubspec.yaml index 68dc8d5..833e110 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pets_hero_ai description: PetsHero AI Application. publish_to: 'none' -version: 1.0.5+6 +version: 1.1.1+12 environment: sdk: '>=3.0.0 <4.0.0' @@ -31,13 +31,13 @@ dependencies: in_app_purchase: ^3.2.0 webview_flutter: ^4.10.0 screen_secure: ^1.0.3 + flutter_native_splash: ^2.4.7 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 flutter_launcher_icons: ^0.14.4 - flutter_native_splash: ^2.4.7 flutter_launcher_icons: android: true