import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../core/logging/logger.dart'; import '../../../../data/network/api_client.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/apis/feedback_api.dart'; import '../../data/models/feedback.dart'; import '../../data/repositories/feedback_repository.dart'; class FeedbackScreen extends StatefulWidget { const FeedbackScreen({super.key, required this.apiClient}); final ApiClient apiClient; @override State createState() => _FeedbackScreenState(); } class _FeedbackScreenState extends State { final Logger _logger = getLogger('features.settings.feedback_screen'); final ImagePicker _imagePicker = ImagePicker(); final TextEditingController _contentController = TextEditingController(); FeedbackType _selectedType = FeedbackType.bug; List _selectedImages = []; bool _isAnonymous = false; bool _isSubmitting = false; static const int _maxImages = 3; static const int _maxContentSize = 500; static const int _maxImageSizeBytes = 5 * 1024 * 1024; @override void dispose() { _contentController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: colors.surfaceContainerLow, appBar: AppBar( title: Text(l10n.feedbackTitle), centerTitle: true, backgroundColor: colors.surfaceContainerLow, surfaceTintColor: colors.surfaceContainerLow, ), body: ListView( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, AppSpacing.md, AppSpacing.lg, AppSpacing.xxl, ), children: [ Text( l10n.feedbackTypeLabel, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.sm), SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: FeedbackType.bug, label: Text(l10n.feedbackTypeBug), ), ButtonSegment( value: FeedbackType.suggestion, label: Text(l10n.feedbackTypeSuggestion), ), ButtonSegment( value: FeedbackType.other, label: Text(l10n.feedbackTypeOther), ), ], selected: {_selectedType}, onSelectionChanged: (selection) { setState(() { _selectedType = selection.first; }); }, ), const SizedBox(height: AppSpacing.xl), Text( l10n.feedbackContentLabel, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.sm), TextField( controller: _contentController, maxLines: 8, maxLength: _maxContentSize, decoration: InputDecoration( hintText: l10n.feedbackContentHint, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), ), ), ), const SizedBox(height: AppSpacing.xl), Text( l10n.feedbackImagesLabel, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.sm), _buildImagePickerRow(colors), const SizedBox(height: AppSpacing.xl), CheckboxListTile( value: _isAnonymous, onChanged: (value) { setState(() { _isAnonymous = value ?? false; }); }, title: Text(l10n.feedbackAnonymousLabel), subtitle: Text( l10n.feedbackAnonymousHint, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), ), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), const SizedBox(height: AppSpacing.xl), SizedBox( width: double.infinity, child: FilledButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting ? SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, color: colors.onPrimary, ), ) : Text(l10n.feedbackSubmit), ), ), ], ), ); } Widget _buildImagePickerRow(ColorScheme colors) { return Wrap( spacing: AppSpacing.sm, runSpacing: AppSpacing.sm, children: [ ..._selectedImages.asMap().entries.map((entry) { final index = entry.key; final file = entry.value; return Stack( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all(color: colors.outlineVariant), ), clipBehavior: Clip.antiAlias, child: kIsWeb ? Image.network( file.path, fit: BoxFit.cover, errorBuilder: (_, e, _) => const Icon(Icons.broken_image), ) : Image.file( File(file.path), fit: BoxFit.cover, errorBuilder: (_, e, _) => const Icon(Icons.broken_image), ), ), Positioned( top: 4, right: 4, child: GestureDetector( onTap: () => _removeImage(index), child: Container( width: 22, height: 22, decoration: BoxDecoration( color: colors.error, shape: BoxShape.circle, ), child: Icon(Icons.close, size: 14, color: colors.onError), ), ), ), ], ); }), if (_selectedImages.length < _maxImages) GestureDetector( onTap: _pickImage, child: Container( width: 80, height: 80, decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all(color: colors.outlineVariant), color: colors.surfaceContainerHighest, ), child: Icon( Icons.add_photo_alternate_outlined, color: colors.onSurfaceVariant, ), ), ), ], ); } Future _pickImage() async { final l10n = AppLocalizations.of(context)!; if (_selectedImages.length >= _maxImages) { Toast.show(context, l10n.feedbackTooManyImages, type: ToastType.warning); return; } XFile? picked; try { picked = await _imagePicker.pickImage( source: ImageSource.gallery, maxWidth: 1920, imageQuality: 85, requestFullMetadata: false, ); } catch (error, stackTrace) { _logger.error( message: 'Image picker failed', error: error, stackTrace: stackTrace, ); return; } if (picked == null || !mounted) return; final fileSize = await picked.length(); if (fileSize > _maxImageSizeBytes) { if (!mounted) return; Toast.show(context, l10n.feedbackImageTooLarge, type: ToastType.warning); return; } setState(() { _selectedImages = [..._selectedImages, picked!]; }); } void _removeImage(int index) { setState(() { _selectedImages = List.from(_selectedImages)..removeAt(index); }); } Future _submit() async { final l10n = AppLocalizations.of(context)!; final content = _contentController.text.trim(); if (content.isEmpty) { Toast.show( context, l10n.feedbackContentRequired, type: ToastType.warning, ); return; } if (content.length > _maxContentSize) { Toast.show(context, l10n.feedbackContentTooLong, type: ToastType.warning); return; } setState(() { _isSubmitting = true; }); try { final feedbackApi = FeedbackApi(apiClient: widget.apiClient); final repository = FeedbackRepositoryImpl(feedbackApi: feedbackApi); await repository.submitFeedback( type: _selectedType, content: content, deviceInfo: await _collectDeviceInfo(), appVersion: await _appVersion(), osVersion: await _osVersion(), images: _selectedImages, isAnonymous: _isAnonymous, ); if (!mounted) return; Toast.show(context, l10n.feedbackSuccess, type: ToastType.success); Navigator.of(context).pop(); } catch (error, stackTrace) { _logger.error( message: 'Submit feedback failed', error: error, stackTrace: stackTrace, ); if (!mounted) return; Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error); } finally { if (mounted) { setState(() { _isSubmitting = false; }); } } } Future _collectDeviceInfo() async { final deviceInfo = DeviceInfoPlugin(); if (Platform.isAndroid) { final android = await deviceInfo.androidInfo; return DeviceInfo( platform: 'android', model: '${android.brand} ${android.model}', ); } else if (Platform.isIOS) { final ios = await deviceInfo.iosInfo; return DeviceInfo(platform: 'ios', model: _iosDeviceName(ios)); } return DeviceInfo(platform: defaultTargetPlatform.name, model: 'unknown'); } String _iosDeviceName(IosDeviceInfo ios) { final machine = ios.utsname.machine; final deviceName = _iosDeviceMapping[machine] ?? machine; return deviceName; } static const Map _iosDeviceMapping = { 'iPhone13,2': 'iPhone 12', 'iPhone13,3': 'iPhone 12 Pro', 'iPhone13,4': 'iPhone 12 Pro Max', 'iPhone14,2': 'iPhone 13 Pro', 'iPhone14,3': 'iPhone 13 Pro Max', 'iPhone14,4': 'iPhone 13 mini', 'iPhone14,5': 'iPhone 13', 'iPhone15,2': 'iPhone 14 Pro', 'iPhone15,3': 'iPhone 14 Pro Max', 'iPhone14,7': 'iPhone 14', 'iPhone14,8': 'iPhone 14 Plus', 'iPhone15,4': 'iPhone 15', 'iPhone15,5': 'iPhone 15 Plus', 'iPhone16,1': 'iPhone 15 Pro', 'iPhone16,2': 'iPhone 15 Pro Max', 'iPhone17,1': 'iPhone 16 Pro', 'iPhone17,2': 'iPhone 16 Pro Max', 'iPhone17,3': 'iPhone 16', 'iPhone17,4': 'iPhone 16 Plus', }; Future _appVersion() async { final info = await PackageInfo.fromPlatform(); return '${info.version}+${info.buildNumber}'; } Future _osVersion() async { final deviceInfo = DeviceInfoPlugin(); if (Platform.isAndroid) { final android = await deviceInfo.androidInfo; return 'Android ${android.version.release} (API ${android.version.sdkInt})'; } else if (Platform.isIOS) { final ios = await deviceInfo.iosInfo; return 'iOS ${ios.systemVersion}'; } return defaultTargetPlatform.name; } }