diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ca7fb7c..b04769b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -35,6 +35,7 @@ "filterTooltip": "Filter", "profileTooltip": "Profile", "signInTooltip": "Sign In", + "feedbackTooltip": "Feedback", "submitTrickButton": "Submit Trick", "failedToLoadTricks": "Failed to load tricks", "retryButton": "Retry", @@ -264,6 +265,12 @@ "pendingTipsSection": "Pending Tips", "declineButton": "Decline", + "noPendingFeedback": "No pending feedback.", + "pendingFeedbackSection": "Pending Feedback", + "markReviewedButton": "Mark Reviewed", + "dismissButton": "Dismiss", + "viewAttachmentButton": "View Attachment", + "submittedOnLabel": "Submitted on {date}", "@submittedOnLabel": { "placeholders": { @@ -377,5 +384,13 @@ "lowStorageTitle": "Low storage", "lowStorageMessage": "Less than 1 GB of storage is available. Continue anyway?", "continueButton": "Continue", - "deleteVideoMessage": "Delete the saved video from your device?" + "deleteVideoMessage": "Delete the saved video from your device?", + + "submitFeedbackTitle": "Send Feedback", + "submitFeedbackButton": "Send Feedback", + "feedbackMessageLabel": "Help us improve the app! Send us feedback, feature requests, or bug reports :)", + "feedbackMessageHint": "Please make this app awesome!", + "attachFileButton": "Attach Photo or Video", + "removeAttachmentTooltip": "Remove attachment", + "feedbackSubmitted": "Feedback sent. Thank you!" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2abb937..af5d2aa 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -30,6 +30,7 @@ "filterTooltip": "Filtrar", "profileTooltip": "Perfil", "signInTooltip": "Iniciar sesión", + "feedbackTooltip": "Comentarios", "submitTrickButton": "Enviar truco", "failedToLoadTricks": "Error al cargar los trucos", "retryButton": "Reintentar", @@ -219,6 +220,12 @@ "pendingTipsSection": "Consejos pendientes", "declineButton": "Rechazar", + "noPendingFeedback": "No hay comentarios pendientes.", + "pendingFeedbackSection": "Comentarios pendientes", + "markReviewedButton": "Marcar como revisado", + "dismissButton": "Descartar", + "viewAttachmentButton": "Ver adjunto", + "submittedOnLabel": "Enviado el {date}", "@submittedOnLabel": { "placeholders": { @@ -307,5 +314,13 @@ "lowStorageTitle": "Almacenamiento bajo", "lowStorageMessage": "Hay menos de 1 GB de almacenamiento disponible. ¿Continuar de todas formas?", "continueButton": "Continuar", - "deleteVideoMessage": "¿Eliminar el video guardado de tu dispositivo?" + "deleteVideoMessage": "¿Eliminar el video guardado de tu dispositivo?", + + "submitFeedbackTitle": "Enviar comentarios", + "submitFeedbackButton": "Enviar comentarios", + "feedbackMessageLabel": "¡Ayúdanos a mejorar la app! Envíanos comentarios, ideas o reportes de errores :)", + "feedbackMessageHint": "¡Haz que esta app sea increíble!", + "attachFileButton": "Adjuntar foto o video", + "removeAttachmentTooltip": "Quitar adjunto", + "feedbackSubmitted": "¡Comentarios enviados. Gracias!" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 397191f..36d2257 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -30,6 +30,7 @@ "filterTooltip": "Filtrer", "profileTooltip": "Profil", "signInTooltip": "Se connecter", + "feedbackTooltip": "Retours", "submitTrickButton": "Soumettre un trick", "failedToLoadTricks": "Échec du chargement des tricks", "retryButton": "Réessayer", @@ -219,6 +220,12 @@ "pendingTipsSection": "Conseils en attente", "declineButton": "Refuser", + "noPendingFeedback": "Aucun retour en attente.", + "pendingFeedbackSection": "Retours en attente", + "markReviewedButton": "Marquer comme examiné", + "dismissButton": "Rejeter", + "viewAttachmentButton": "Voir la pièce jointe", + "submittedOnLabel": "Soumis le {date}", "@submittedOnLabel": { "placeholders": { @@ -307,5 +314,13 @@ "lowStorageTitle": "Stockage faible", "lowStorageMessage": "Moins de 1 Go de stockage disponible. Continuer quand même ?", "continueButton": "Continuer", - "deleteVideoMessage": "Supprimer la vidéo enregistrée de votre appareil ?" + "deleteVideoMessage": "Supprimer la vidéo enregistrée de votre appareil ?", + + "submitFeedbackTitle": "Envoyer des retours", + "submitFeedbackButton": "Envoyer des retours", + "feedbackMessageLabel": "Aidez-nous à améliorer l'appli ! Envoyez-nous vos retours, idées ou bugs :)", + "feedbackMessageHint": "Rendez cette appli géniale !", + "attachFileButton": "Joindre une photo ou une vidéo", + "removeAttachmentTooltip": "Retirer la pièce jointe", + "feedbackSubmitted": "Retours envoyés. Merci !" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b881bbb..e3d15ac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -256,6 +256,12 @@ abstract class AppLocalizations { /// **'Sign In'** String get signInTooltip; + /// No description provided for @feedbackTooltip. + /// + /// In en, this message translates to: + /// **'Feedback'** + String get feedbackTooltip; + /// No description provided for @submitTrickButton. /// /// In en, this message translates to: @@ -1264,6 +1270,36 @@ abstract class AppLocalizations { /// **'Decline'** String get declineButton; + /// No description provided for @noPendingFeedback. + /// + /// In en, this message translates to: + /// **'No pending feedback.'** + String get noPendingFeedback; + + /// No description provided for @pendingFeedbackSection. + /// + /// In en, this message translates to: + /// **'Pending Feedback'** + String get pendingFeedbackSection; + + /// No description provided for @markReviewedButton. + /// + /// In en, this message translates to: + /// **'Mark Reviewed'** + String get markReviewedButton; + + /// No description provided for @dismissButton. + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get dismissButton; + + /// No description provided for @viewAttachmentButton. + /// + /// In en, this message translates to: + /// **'View Attachment'** + String get viewAttachmentButton; + /// No description provided for @submittedOnLabel. /// /// In en, this message translates to: @@ -1581,6 +1617,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Delete the saved video from your device?'** String get deleteVideoMessage; + + /// No description provided for @submitFeedbackTitle. + /// + /// In en, this message translates to: + /// **'Send Feedback'** + String get submitFeedbackTitle; + + /// No description provided for @submitFeedbackButton. + /// + /// In en, this message translates to: + /// **'Send Feedback'** + String get submitFeedbackButton; + + /// No description provided for @feedbackMessageLabel. + /// + /// In en, this message translates to: + /// **'Help us improve the app! Send us feedback, feature requests, or bug reports :)'** + String get feedbackMessageLabel; + + /// No description provided for @feedbackMessageHint. + /// + /// In en, this message translates to: + /// **'Please make this app awesome!'** + String get feedbackMessageHint; + + /// No description provided for @attachFileButton. + /// + /// In en, this message translates to: + /// **'Attach Photo or Video'** + String get attachFileButton; + + /// No description provided for @removeAttachmentTooltip. + /// + /// In en, this message translates to: + /// **'Remove attachment'** + String get removeAttachmentTooltip; + + /// No description provided for @feedbackSubmitted. + /// + /// In en, this message translates to: + /// **'Feedback sent. Thank you!'** + String get feedbackSubmitted; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f3a9ebb..4e0354d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -88,6 +88,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get signInTooltip => 'Sign In'; + @override + String get feedbackTooltip => 'Feedback'; + @override String get submitTrickButton => 'Submit Trick'; @@ -614,6 +617,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get declineButton => 'Decline'; + @override + String get noPendingFeedback => 'No pending feedback.'; + + @override + String get pendingFeedbackSection => 'Pending Feedback'; + + @override + String get markReviewedButton => 'Mark Reviewed'; + + @override + String get dismissButton => 'Dismiss'; + + @override + String get viewAttachmentButton => 'View Attachment'; + @override String submittedOnLabel(String date) { return 'Submitted on $date'; @@ -795,4 +813,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteVideoMessage => 'Delete the saved video from your device?'; + + @override + String get submitFeedbackTitle => 'Send Feedback'; + + @override + String get submitFeedbackButton => 'Send Feedback'; + + @override + String get feedbackMessageLabel => + 'Help us improve the app! Send us feedback, feature requests, or bug reports :)'; + + @override + String get feedbackMessageHint => 'Please make this app awesome!'; + + @override + String get attachFileButton => 'Attach Photo or Video'; + + @override + String get removeAttachmentTooltip => 'Remove attachment'; + + @override + String get feedbackSubmitted => 'Feedback sent. Thank you!'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 51eadea..4d6e238 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -88,6 +88,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get signInTooltip => 'Iniciar sesión'; + @override + String get feedbackTooltip => 'Comentarios'; + @override String get submitTrickButton => 'Enviar truco'; @@ -617,6 +620,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get declineButton => 'Rechazar'; + @override + String get noPendingFeedback => 'No hay comentarios pendientes.'; + + @override + String get pendingFeedbackSection => 'Comentarios pendientes'; + + @override + String get markReviewedButton => 'Marcar como revisado'; + + @override + String get dismissButton => 'Descartar'; + + @override + String get viewAttachmentButton => 'Ver adjunto'; + @override String submittedOnLabel(String date) { return 'Enviado el $date'; @@ -803,4 +821,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get deleteVideoMessage => '¿Eliminar el video guardado de tu dispositivo?'; + + @override + String get submitFeedbackTitle => 'Enviar comentarios'; + + @override + String get submitFeedbackButton => 'Enviar comentarios'; + + @override + String get feedbackMessageLabel => + '¡Ayúdanos a mejorar la app! Envíanos comentarios, ideas o reportes de errores :)'; + + @override + String get feedbackMessageHint => '¡Haz que esta app sea increíble!'; + + @override + String get attachFileButton => 'Adjuntar foto o video'; + + @override + String get removeAttachmentTooltip => 'Quitar adjunto'; + + @override + String get feedbackSubmitted => '¡Comentarios enviados. Gracias!'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5c89cda..1fff999 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -88,6 +88,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get signInTooltip => 'Se connecter'; + @override + String get feedbackTooltip => 'Retours'; + @override String get submitTrickButton => 'Soumettre un trick'; @@ -621,6 +624,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get declineButton => 'Refuser'; + @override + String get noPendingFeedback => 'Aucun retour en attente.'; + + @override + String get pendingFeedbackSection => 'Retours en attente'; + + @override + String get markReviewedButton => 'Marquer comme examiné'; + + @override + String get dismissButton => 'Rejeter'; + + @override + String get viewAttachmentButton => 'Voir la pièce jointe'; + @override String submittedOnLabel(String date) { return 'Soumis le $date'; @@ -808,4 +826,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get deleteVideoMessage => 'Supprimer la vidéo enregistrée de votre appareil ?'; + + @override + String get submitFeedbackTitle => 'Envoyer des retours'; + + @override + String get submitFeedbackButton => 'Envoyer des retours'; + + @override + String get feedbackMessageLabel => + 'Aidez-nous à améliorer l\'appli ! Envoyez-nous vos retours, idées ou bugs :)'; + + @override + String get feedbackMessageHint => 'Rendez cette appli géniale !'; + + @override + String get attachFileButton => 'Joindre une photo ou une vidéo'; + + @override + String get removeAttachmentTooltip => 'Retirer la pièce jointe'; + + @override + String get feedbackSubmitted => 'Retours envoyés. Merci !'; } diff --git a/lib/models/feedback_item.dart b/lib/models/feedback_item.dart new file mode 100644 index 0000000..d5deccc --- /dev/null +++ b/lib/models/feedback_item.dart @@ -0,0 +1,38 @@ +class FeedbackItem { + final int id; + final int? submittedBy; + final String message; + final String? attachmentPath; + final String status; + final DateTime createdAt; + + const FeedbackItem({ + required this.id, + this.submittedBy, + required this.message, + this.attachmentPath, + required this.status, + required this.createdAt, + }); + + bool get isImageAttachment { + final path = attachmentPath; + if (path == null) return false; + final ext = path.toLowerCase(); + return ext.endsWith('.jpg') || + ext.endsWith('.jpeg') || + ext.endsWith('.png') || + ext.endsWith('.webp') || + ext.endsWith('.heic') || + ext.endsWith('.heif'); + } + + factory FeedbackItem.fromJson(Map json) => FeedbackItem( + id: json['id'] as int, + submittedBy: json['submitted_by'] as int?, + message: json['message'] as String, + attachmentPath: json['attachment_path'] as String?, + status: json['status'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + ); +} diff --git a/lib/models/screen_data.dart b/lib/models/screen_data.dart index 08442df..aa03aad 100644 --- a/lib/models/screen_data.dart +++ b/lib/models/screen_data.dart @@ -1,3 +1,4 @@ +import 'feedback_item.dart'; import 'position.dart'; import 'profile.dart'; import 'tip.dart'; @@ -83,6 +84,8 @@ class AdminData { final List pendingSuggestions; final Map originalTricks; final List pendingTips; + final List pendingFeedback; + final Map feedbackAttachmentUrls; final Profile? profile; const AdminData({ @@ -90,6 +93,8 @@ class AdminData { required this.pendingSuggestions, required this.originalTricks, required this.pendingTips, + this.pendingFeedback = const [], + this.feedbackAttachmentUrls = const {}, this.profile, }); } diff --git a/lib/router.dart b/lib/router.dart index 7d54c1c..ade99f0 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -12,6 +12,7 @@ import 'screens/auth/register_screen.dart'; import 'screens/auth/reset_password_screen.dart'; import 'screens/submit_trick_screen.dart'; import 'screens/submit_tip_screen.dart'; +import 'screens/submit_feedback_screen.dart'; import 'screens/admin_screen.dart'; import 'screens/profile_screen.dart'; import 'screens/not_found_screen.dart'; @@ -57,6 +58,7 @@ class AppRouter { ), ), GoRoute(path: '/tips/submit', builder: (_, __) => const SubmitTipScreen()), + GoRoute(path: '/feedback', builder: (_, __) => const SubmitFeedbackScreen()), GoRoute(path: '/admin', builder: (_, __) => const AdminScreen()), GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), StatefulShellRoute.indexedStack( diff --git a/lib/screens/admin_screen.dart b/lib/screens/admin_screen.dart index 57b2dae..a045164 100644 --- a/lib/screens/admin_screen.dart +++ b/lib/screens/admin_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_localizations_extension.dart'; import '../l10n/enum_localizations.dart'; import '../models/approval_status.dart'; +import '../models/feedback_item.dart'; import '../models/screen_data.dart'; import '../models/tip.dart'; import '../models/trick.dart'; import '../models/trick_suggestion.dart'; import '../services/auth_service.dart'; +import '../services/feedback_service.dart'; import '../services/tips_service.dart'; import '../services/tricks_service.dart'; import '../utils/date_formatters.dart'; @@ -68,17 +71,28 @@ class _AdminScreenState extends State { final tricksFuture = TricksService.getPendingTricks(); final suggestionsFuture = TricksService.getPendingSuggestions(); final tipsFuture = TipsService.getPendingTips(); + final feedbackFuture = FeedbackService.getPendingFeedback(); final tricks = await tricksFuture; final suggestions = await suggestionsFuture; final tips = await tipsFuture; + final feedback = await feedbackFuture; final trickIds = suggestions.map((s) => s.trickId).toSet().toList(); final origList = await TricksService.getTricksByIds(trickIds); final originalTricks = {for (final t in origList) t.id: t}; + final feedbackAttachmentUrls = {}; + for (final f in feedback) { + final path = f.attachmentPath; + if (path != null) { + feedbackAttachmentUrls[f.id] = await FeedbackService.getAttachmentUrl(path); + } + } return AdminData( pendingTricks: tricks, pendingSuggestions: suggestions, originalTricks: originalTricks, pendingTips: tips, + pendingFeedback: feedback, + feedbackAttachmentUrls: feedbackAttachmentUrls, profile: profile, ); } @@ -110,6 +124,11 @@ class _AdminScreenState extends State { _refresh(); } + Future _resolveFeedback(FeedbackItem item, String status) async { + await FeedbackService.resolveFeedback(item, status); + _refresh(); + } + Future _addPosition(BuildContext context) async { final l10n = context.l10n; final ctrl = TextEditingController(); @@ -195,8 +214,10 @@ class _AdminScreenState extends State { final tricks = snap.data!.pendingTricks; final suggestions = snap.data!.pendingSuggestions; final tips = snap.data!.pendingTips; + final feedback = snap.data!.pendingFeedback; + final feedbackAttachmentUrls = snap.data!.feedbackAttachmentUrls; - if (tricks.isEmpty && suggestions.isEmpty && tips.isEmpty) { + if (tricks.isEmpty && suggestions.isEmpty && tips.isEmpty && feedback.isEmpty) { return RefreshIndicator( onRefresh: () async => _refresh(), child: ListView( @@ -207,6 +228,8 @@ class _AdminScreenState extends State { Center(child: Text(l10n.noPendingSuggestions)), const SizedBox(height: 8), Center(child: Text(l10n.noPendingTips)), + const SizedBox(height: 8), + Center(child: Text(l10n.noPendingFeedback)), ], ), ); @@ -276,6 +299,24 @@ class _AdminScreenState extends State { ), ], ], + if (feedback.isNotEmpty) ...[ + const SizedBox(height: 20), + Text(l10n.pendingFeedbackSection, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + for (int i = 0; i < feedback.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _PendingFeedbackCard( + item: feedback[i], + attachmentUrl: feedbackAttachmentUrls[feedback[i].id], + onMarkReviewed: () => _resolveFeedback(feedback[i], 'reviewed'), + onDismiss: () => _resolveFeedback(feedback[i], 'dismissed'), + ), + ], + ], ]; return RefreshIndicator( @@ -557,5 +598,75 @@ class _PendingTipCard extends StatelessWidget { ), ); } +} + +class _PendingFeedbackCard extends StatelessWidget { + final FeedbackItem item; + final String? attachmentUrl; + final VoidCallback onMarkReviewed; + final VoidCallback onDismiss; + + const _PendingFeedbackCard({ + required this.item, + required this.attachmentUrl, + required this.onMarkReviewed, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.message), + const SizedBox(height: 8), + Text( + l10n.submittedDate(formatShortDate(item.createdAt)), + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline), + ), + if (attachmentUrl != null) ...[ + const SizedBox(height: 12), + if (item.isImageAttachment) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network(attachmentUrl!, height: 160, fit: BoxFit.cover), + ) + else + OutlinedButton.icon( + onPressed: () => launchUrl(Uri.parse(attachmentUrl!), + mode: LaunchMode.externalApplication), + icon: const Icon(Icons.play_circle_outline, size: 18), + label: Text(l10n.viewAttachmentButton), + ), + ], + const SizedBox(height: 12), + OverflowBar( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: onMarkReviewed, + icon: const Icon(Icons.check, size: 18), + label: Text(l10n.markReviewedButton), + style: _approveButtonStyle(theme), + ), + FilledButton.icon( + onPressed: onDismiss, + icon: const Icon(Icons.close, size: 18), + label: Text(l10n.dismissButton), + style: _rejectButtonStyle(theme), + ), + ], + ), + ], + ), + ), + ); + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a9bb197..c0533a6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -216,6 +216,11 @@ class _HomeScreenState extends State { appBar: AppBar( title: const AppLogo.big(height: 36), actions: [ + IconButton( + icon: const Icon(Icons.feedback_outlined), + tooltip: l10n.feedbackTooltip, + onPressed: () => context.push('/feedback'), + ), if (_profile?.canEditTricks == true) IconButton( icon: const Icon(Icons.admin_panel_settings_outlined), diff --git a/lib/screens/submit_feedback_screen.dart b/lib/screens/submit_feedback_screen.dart new file mode 100644 index 0000000..5d89280 --- /dev/null +++ b/lib/screens/submit_feedback_screen.dart @@ -0,0 +1,199 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../l10n/app_localizations_extension.dart'; +import '../services/auth_service.dart'; +import '../services/feedback_service.dart'; + +class SubmitFeedbackScreen extends StatefulWidget { + const SubmitFeedbackScreen({super.key}); + + @override + State createState() => _SubmitFeedbackScreenState(); +} + +class _SubmitFeedbackScreenState extends State { + final _formKey = GlobalKey(); + final _messageCtrl = TextEditingController(); + final _picker = ImagePicker(); + + Uint8List? _attachmentBytes; + String? _attachmentName; + String? _attachmentExtension; + String? _attachmentMimeType; + bool _attachmentIsImage = false; + bool _saving = false; + + @override + void dispose() { + _messageCtrl.dispose(); + super.dispose(); + } + + static const _imageExtensions = {'jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'}; + + String? _mimeTypeFromExtension(String extension) => switch (extension) { + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'heic' => 'image/heic', + 'heif' => 'image/heif', + 'mp4' => 'video/mp4', + 'mov' => 'video/quicktime', + 'webm' => 'video/webm', + _ => null, + }; + + Future _pickAttachment() async { + final file = await _picker.pickMedia(); + if (file == null) return; + final bytes = await file.readAsBytes(); + final extension = file.name.contains('.') ? file.name.split('.').last.toLowerCase() : null; + final mimeType = file.mimeType ?? (extension != null ? _mimeTypeFromExtension(extension) : null); + final isImage = mimeType != null + ? mimeType.startsWith('image') + : _imageExtensions.contains(extension); + if (!mounted) return; + setState(() { + _attachmentBytes = bytes; + _attachmentName = file.name; + _attachmentExtension = extension ?? (isImage ? 'jpg' : 'mp4'); + _attachmentMimeType = mimeType; + _attachmentIsImage = isImage; + }); + } + + void _removeAttachment() { + setState(() { + _attachmentBytes = null; + _attachmentName = null; + _attachmentExtension = null; + _attachmentMimeType = null; + }); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final profile = await AuthService.getCurrentProfile(); + if (profile == null) throw StateError('Not signed in'); + await FeedbackService.submitFeedback( + message: _messageCtrl.text.trim(), + submittedBy: profile.intId, + attachmentBytes: _attachmentBytes, + attachmentExtension: _attachmentExtension, + attachmentMimeType: _attachmentMimeType, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.feedbackSubmitted)), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.errorWithDetail(e.toString())), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + setState(() => _saving = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar(title: Text(l10n.submitFeedbackTitle)), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(l10n.feedbackMessageLabel, style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + TextFormField( + controller: _messageCtrl, + minLines: 5, + maxLines: 10, + maxLength: 2000, + decoration: InputDecoration( + hintText: l10n.feedbackMessageHint, + border: const OutlineInputBorder(), + ), + validator: (v) => + v == null || v.trim().isEmpty ? l10n.requiredValidator : null, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 16), + if (_attachmentBytes == null) + OutlinedButton.icon( + onPressed: _pickAttachment, + icon: const Icon(Icons.attach_file), + label: Text(l10n.attachFileButton), + ) + else + _AttachmentPreview( + bytes: _attachmentBytes!, + name: _attachmentName ?? '', + isImage: _attachmentIsImage, + onRemove: _removeAttachment, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _submit, + child: _saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.submitFeedbackButton), + ), + ], + ), + ), + ); + } +} + +class _AttachmentPreview extends StatelessWidget { + final Uint8List bytes; + final String name; + final bool isImage; + final VoidCallback onRemove; + + const _AttachmentPreview({ + required this.bytes, + required this.name, + required this.isImage, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: isImage + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.memory(bytes, width: 48, height: 48, fit: BoxFit.cover), + ) + : const Icon(Icons.videocam), + title: Text(name, overflow: TextOverflow.ellipsis), + trailing: IconButton( + icon: const Icon(Icons.close), + tooltip: context.l10n.removeAttachmentTooltip, + onPressed: onRemove, + ), + ), + ); + } +} diff --git a/lib/screens/tips_screen.dart b/lib/screens/tips_screen.dart index 7a7a9bd..2158730 100644 --- a/lib/screens/tips_screen.dart +++ b/lib/screens/tips_screen.dart @@ -106,6 +106,11 @@ class _TipsScreenState extends State { appBar: AppBar( title: Text(l10n.tipsNavLabel), actions: [ + IconButton( + icon: const Icon(Icons.feedback_outlined), + tooltip: l10n.feedbackTooltip, + onPressed: () => context.push('/feedback'), + ), if (_profile?.canEditTricks == true) IconButton( icon: const Icon(Icons.admin_panel_settings_outlined), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart new file mode 100644 index 0000000..dc571fe --- /dev/null +++ b/lib/services/feedback_service.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/feedback_item.dart'; + +class FeedbackService { + static final _client = Supabase.instance.client; + static const _bucket = 'feedback-attachments'; + + static Future submitFeedback({ + required String message, + required int submittedBy, + Uint8List? attachmentBytes, + String? attachmentExtension, + String? attachmentMimeType, + }) async { + String? attachmentPath; + if (attachmentBytes != null && attachmentExtension != null) { + attachmentPath = + '$submittedBy/${DateTime.now().millisecondsSinceEpoch}.$attachmentExtension'; + await _client.storage.from(_bucket).uploadBinary( + attachmentPath, + attachmentBytes, + fileOptions: FileOptions(contentType: attachmentMimeType), + ); + } + await _client.from('feedback').insert({ + 'submitted_by': submittedBy, + 'message': message, + if (attachmentPath != null) 'attachment_path': attachmentPath, + }); + } + + static Future> getPendingFeedback() async { + final data = await _client + .from('feedback') + .select() + .eq('status', 'new') + .order('created_at', ascending: true); + return (data as List).map((e) => FeedbackItem.fromJson(e)).toList(); + } + + static Future getAttachmentUrl(String path) async { + return _client.storage.from(_bucket).createSignedUrl(path, 3600); + } + + static Future resolveFeedback(FeedbackItem item, String status) async { + if (item.attachmentPath != null) { + await _client.storage.from(_bucket).remove([item.attachmentPath!]); + } + await _client.from('feedback').update({'status': status}).eq('id', item.id); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index ac3d4e7..b6f5658 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,12 +6,16 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 863d8a0..d0b32dd 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux gtk media_kit_libs_linux media_kit_video diff --git a/pubspec.lock b/pubspec.lock index b7a306a..ccb03d7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -217,6 +225,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -267,6 +307,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.8" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_svg: dependency: "direct main" description: @@ -365,6 +413,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -1219,5 +1331,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 511304a..d574a55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: path: ^1.9.0 device_info_plus: ^10.0.0 flutter_svg: ^2.0.10 + image_picker: ^1.1.2 dev_dependencies: flutter_test: diff --git a/supabase/add_feedback_table.sql b/supabase/add_feedback_table.sql new file mode 100644 index 0000000..94170ce --- /dev/null +++ b/supabase/add_feedback_table.sql @@ -0,0 +1,58 @@ +-- Feedback: in-app user feedback with optional screenshot/video attachment. +-- status: new/reviewed/dismissed. Attachment is deleted from storage once +-- an editor resolves the item (reviewed or dismissed) — nothing is kept +-- once the "issue" behind it is closed. +create table feedback ( + id integer generated always as identity primary key, + submitted_by integer references profiles(int_id) on delete set null, + message text not null check (char_length(message) between 1 and 2000), + attachment_path text, + status text not null default 'new' check (status in ('new', 'reviewed', 'dismissed')), + created_at timestamptz not null default now() +); + +alter table feedback enable row level security; + +create policy "feedback_insert" on feedback for insert + with check (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "feedback_read_admin" on feedback for select + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "feedback_update_admin" on feedback for update + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +grant select, insert, update on feedback to authenticated; + +-- Private bucket for feedback attachments, capped at 25MB per file. +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'feedback-attachments', + 'feedback-attachments', + false, + 26214400, + array['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'video/mp4', 'video/webm', 'video/quicktime'] +) +on conflict (id) do update set + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +-- Uploads live under "/" so ownership is just a path check. +create policy "feedback_attachments_insert" on storage.objects for insert to authenticated + with check ( + bucket_id = 'feedback-attachments' + and (storage.foldername(name))[1] = (select int_id::text from profiles where id = auth.uid()) + ); + +create policy "feedback_attachments_select" on storage.objects for select to authenticated + using ( + bucket_id = 'feedback-attachments' + and ( + (storage.foldername(name))[1] = (select int_id::text from profiles where id = auth.uid()) + or exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) + ) + ); + +create policy "feedback_attachments_delete_admin" on storage.objects for delete to authenticated + using ( + bucket_id = 'feedback-attachments' + and exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) + ); diff --git a/supabase/schema.sql b/supabase/schema.sql index a8a0a76..5383acb 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -116,6 +116,18 @@ create table trick_annotations ( language text not null default 'en' ); +-- Feedback: in-app user feedback with optional screenshot/video attachment. +-- status: new/reviewed/dismissed. Attachment is deleted from storage once +-- an editor resolves the item (reviewed or dismissed). +create table feedback ( + id integer generated always as identity primary key, + submitted_by integer references profiles(int_id) on delete set null, + message text not null check (char_length(message) between 1 and 2000), + attachment_path text, + status text not null default 'new' check (status in ('new', 'reviewed', 'dismissed')), + created_at timestamptz not null default now() +); + -- ============================================================ -- Row Level Security -- ============================================================ @@ -127,6 +139,7 @@ alter table positions enable row level security; alter table trick_suggestions enable row level security; alter table tips enable row level security; alter table trick_annotations enable row level security; +alter table feedback enable row level security; -- Profiles create policy "profiles_read" on profiles for select using (true); @@ -200,6 +213,48 @@ create policy "annotations_update" on trick_annotations for update create policy "annotations_delete" on trick_annotations for delete using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +-- Feedback +create policy "feedback_insert" on feedback for insert + with check (submitted_by = (select int_id from profiles where id = auth.uid())); +create policy "feedback_read_admin" on feedback for select + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); +create policy "feedback_update_admin" on feedback for update + using (exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1)); + +-- Feedback attachments: private bucket, uploads live under "/" +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'feedback-attachments', + 'feedback-attachments', + false, + 26214400, + array['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'video/mp4', 'video/webm', 'video/quicktime'] +) +on conflict (id) do update set + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +create policy "feedback_attachments_insert" on storage.objects for insert to authenticated + with check ( + bucket_id = 'feedback-attachments' + and (storage.foldername(name))[1] = (select int_id::text from profiles where id = auth.uid()) + ); + +create policy "feedback_attachments_select" on storage.objects for select to authenticated + using ( + bucket_id = 'feedback-attachments' + and ( + (storage.foldername(name))[1] = (select int_id::text from profiles where id = auth.uid()) + or exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) + ) + ); + +create policy "feedback_attachments_delete_admin" on storage.objects for delete to authenticated + using ( + bucket_id = 'feedback-attachments' + and exists (select 1 from profiles where id = auth.uid() and (flags & 1) = 1) + ); + -- ============================================================ -- Grants -- ============================================================ @@ -218,6 +273,7 @@ grant update, delete on tips to authenticated; grant select on trick_annotations to anon, authenticated; grant insert, update, delete on trick_annotations to authenticated; grant usage, select on sequence trick_annotations_id_seq to authenticated; +grant select, insert, update on feedback to authenticated; -- ============================================================ -- Functions diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7f9143f..1b902a6 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AppLinksPluginCApi")); ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 04c6686..1a9424c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links connectivity_plus + file_selector_windows media_kit_libs_windows_video media_kit_video url_launcher_windows