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 <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
alltheseas
2026-01-15 22:56:05 -06:00
co-authored by Claude Opus 4.5
parent 176610b211
commit 73a6763c4d
3 changed files with 263 additions and 0 deletions
+2
View File
@@ -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(),
],
),
);
@@ -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<CrashReport> 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'),
),
],
);
}
}
@@ -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<bool>((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<AsyncValue<void>>(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<void> _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<CrashReportConsentResult>(
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
}
}
}