From 73a6763c4dbb2527f699754d768f494d946e7df3 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Thu, 15 Jan 2026 22:56:05 -0600 Subject: [PATCH] Add crash report consent UI - Add CrashReportConsentDialog for user opt-in before sending reports - Add CrashReportPromptListener to check for cached crashes on startup - Wire listener into main scaffold Part of #268 Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- lib/screens/main_scaffold.dart | 2 + lib/widgets/crash_report_consent_dialog.dart | 182 ++++++++++++++++++ lib/widgets/crash_report_prompt_listener.dart | 79 ++++++++ 3 files changed, 263 insertions(+) create mode 100644 lib/widgets/crash_report_consent_dialog.dart create mode 100644 lib/widgets/crash_report_prompt_listener.dart diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 7b88380..8a6bc85 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -5,6 +5,7 @@ import 'package:models/models.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/badges.dart'; +import 'package:zapstore/widgets/crash_report_prompt_listener.dart'; import 'package:zapstore/widgets/zapstore_update_prompt_listener.dart'; import '../widgets/common/profile_avatar.dart'; import '../theme.dart'; @@ -76,6 +77,7 @@ class MainScaffold extends StatelessWidget { }, ), const ZapstoreUpdatePromptListener(), + const CrashReportPromptListener(), ], ), ); diff --git a/lib/widgets/crash_report_consent_dialog.dart b/lib/widgets/crash_report_consent_dialog.dart new file mode 100644 index 0000000..f20de15 --- /dev/null +++ b/lib/widgets/crash_report_consent_dialog.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/services/crash_report_cache_service.dart'; +import 'package:zapstore/services/error_reporting_service.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; + +/// Result of the crash report consent dialog. +enum CrashReportConsentResult { sent, kept, discarded } + +/// Dialog that prompts user to send, keep, or discard cached crash reports. +class CrashReportConsentDialog extends HookConsumerWidget { + const CrashReportConsentDialog({super.key, required this.crashes}); + + final List crashes; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = useState(false); + final commentController = useTextEditingController(); + final theme = Theme.of(context); + final crashCount = crashes.length; + final firstCrash = crashes.first; + + return BaseDialog( + titleIcon: Icon( + Icons.bug_report_outlined, + color: theme.colorScheme.error, + ), + titleIconColor: theme.colorScheme.error, + title: BaseDialogTitle( + crashCount == 1 ? 'Crash Report' : '$crashCount Crash Reports', + ), + content: BaseDialogContent( + children: [ + Text( + 'Zapstore encountered ${crashCount == 1 ? 'an error' : '$crashCount errors'} ' + 'during your last session. Would you like to send ' + '${crashCount == 1 ? 'a report' : 'reports'} to help improve the app?', + style: theme.textTheme.bodyMedium, + ), + const Gap(16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (firstCrash.appVersion != null) ...[ + Text( + 'Version: ${firstCrash.appVersion}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Gap(4), + ], + Text( + firstCrash.exceptionType, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + const Gap(4), + Text( + firstCrash.message.length > 200 + ? '${firstCrash.message.substring(0, 200)}...' + : firstCrash.message, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Gap(16), + TextField( + controller: commentController, + decoration: InputDecoration( + hintText: 'What were you doing when this happened? (optional)', + hintStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.all(12), + ), + maxLines: 3, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + ), + const Gap(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.lock_outline, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const Gap(8), + Expanded( + child: Text( + 'Reports are encrypted and automatically deleted after 30 days. ' + 'No personal data is collected.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: isLoading.value + ? null + : () { + Navigator.of(context).pop(CrashReportConsentResult.discarded); + }, + child: const Text('Discard'), + ), + TextButton( + onPressed: isLoading.value + ? null + : () { + Navigator.of(context).pop(CrashReportConsentResult.kept); + }, + child: const Text('Keep for Later'), + ), + FilledButton( + onPressed: isLoading.value + ? null + : () async { + isLoading.value = true; + try { + final comment = commentController.text.trim(); + await ref + .read(errorReportingServiceProvider) + .sendCachedCrashReports( + crashes, + userComment: comment.isNotEmpty ? comment : null, + ); + if (context.mounted) { + Navigator.of(context).pop(CrashReportConsentResult.sent); + } + } catch (e) { + if (context.mounted) { + context.showError( + 'Failed to send report', + description: 'Please try again later.', + ); + } + isLoading.value = false; + } + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(crashCount == 1 ? 'Send Report' : 'Send Reports'), + ), + ], + ); + } +} diff --git a/lib/widgets/crash_report_prompt_listener.dart b/lib/widgets/crash_report_prompt_listener.dart new file mode 100644 index 0000000..27754b4 --- /dev/null +++ b/lib/widgets/crash_report_prompt_listener.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/main.dart'; +import 'package:zapstore/services/crash_report_cache_service.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/crash_report_consent_dialog.dart'; + +/// Tracks whether the crash report prompt has been shown this session. +final _crashPromptHandledProvider = StateProvider((ref) => false); + +/// Widget that listens for pending crash reports after app initialization +/// and shows a consent dialog if any are found. +class CrashReportPromptListener extends ConsumerWidget { + const CrashReportPromptListener({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen>(appInitializationProvider, (previous, next) { + // Only proceed once initialization completes + if (next is! AsyncData) return; + + // Only show once per session + final hasHandled = ref.read(_crashPromptHandledProvider); + if (hasHandled) return; + + // Check for pending crashes + _checkForCrashes(context, ref); + }); + + return const SizedBox.shrink(); + } + + Future _checkForCrashes(BuildContext context, WidgetRef ref) async { + try { + final cacheService = ref.read(crashReportCacheServiceProvider); + final crashes = await cacheService.getPendingCrashes(); + + if (crashes.isEmpty) return; + + // Mark as handled to prevent showing again this session + ref.read(_crashPromptHandledProvider.notifier).state = true; + + // Show dialog after frame completes + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!context.mounted) return; + + final result = await showBaseDialog( + context: context, + dialog: CrashReportConsentDialog(crashes: crashes), + ); + + // Handle result + switch (result) { + case CrashReportConsentResult.sent: + // Clear all crashes after sending + await cacheService.clearAllCrashes(); + ref.invalidate(pendingCrashesProvider); + if (context.mounted) { + context.showInfo( + 'Crash report sent', + description: 'Thank you for helping improve Zapstore.', + ); + } + case CrashReportConsentResult.discarded: + // Clear all crashes + await cacheService.clearAllCrashes(); + ref.invalidate(pendingCrashesProvider); + case CrashReportConsentResult.kept: + case null: + // Do nothing - keep crashes for next time + break; + } + }); + } catch (_) { + // Silently fail - don't disrupt user experience + } + } +}