mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
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:
co-authored by
Claude Opus 4.5
parent
176610b211
commit
73a6763c4d
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user