Improve error reporting

This commit is contained in:
franzap
2026-02-06 13:20:47 -03:00
parent 5883b671a9
commit 38dcc3981f
6 changed files with 186 additions and 421 deletions
-179
View File
@@ -1,179 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart';
/// Service for reporting errors via NIP-44 encrypted DMs to Zapstore team.
///
/// Generates an ephemeral signer per report so errors can be sent even when
/// the user is not signed in.
class ErrorReportingService {
ErrorReportingService(this.ref);
final Ref ref;
/// Track reported errors to avoid spam (error hash -> timestamp)
final Map<int, DateTime> _reportedErrors = {};
/// Rate limit: one report per error type per session
static const _rateLimitDuration = Duration(minutes: 30);
/// Maximum reports per session to prevent abuse
static const _maxReportsPerSession = 10;
int _sessionReportCount = 0;
/// Report an error to the Zapstore team via encrypted DM.
///
/// Rate-limited to prevent spam. Only sends one report per unique
/// error type per session.
Future<void> reportError(Object exception, StackTrace? stackTrace) async {
// Don't report in debug mode
if (kDebugMode) {
return;
}
// Check session limit
if (_sessionReportCount >= _maxReportsPerSession) {
return;
}
// Rate limit by error type
final errorHash = exception.toString().hashCode;
final lastReport = _reportedErrors[errorHash];
if (lastReport != null &&
DateTime.now().difference(lastReport) < _rateLimitDuration) {
return;
}
try {
// Create ephemeral signer for error reporting
final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref);
await signer.signIn(setAsActive: false, registerSigner: false);
// Format error report
final report = _formatErrorReport(exception, stackTrace);
// Create encrypted DM to Zapstore pubkey (uses NIP-44 by default)
final dm = PartialDirectMessage(
content: report,
receiver: kZapstorePubkey,
);
// Sign and publish
final signedDm = await dm.signWith(signer);
await ref.read(storageNotifierProvider.notifier).publish({
signedDm,
}, source: const RemoteSource(relays: 'social'));
// Update rate limiting
_reportedErrors[errorHash] = DateTime.now();
_sessionReportCount++;
} catch (e) {
// Silently fail - we don't want error reporting to cause more errors
}
}
/// User-initiated error report (e.g., from "Report issue" button).
///
/// Returns true if the report was sent successfully.
/// Rate-limited to prevent accidental spam from repeated taps.
Future<bool> reportUserError({
required String title,
String? technicalDetails,
}) async {
// Check session limit
if (_sessionReportCount >= _maxReportsPerSession) {
return false;
}
// Rate limit by error content
final errorHash = '$title\n${technicalDetails ?? ''}'.hashCode;
final lastReport = _reportedErrors[errorHash];
if (lastReport != null &&
DateTime.now().difference(lastReport) < _rateLimitDuration) {
return false;
}
try {
// Create ephemeral signer for error reporting
final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref);
await signer.signIn(setAsActive: false, registerSigner: false);
// Format error report
final report = _formatUserErrorReport(title, technicalDetails);
// Create encrypted DM to Zapstore pubkey (uses NIP-44 by default)
final dm = PartialDirectMessage(
content: report,
receiver: kZapstorePubkey,
);
// Sign and publish
final signedDm = await dm.signWith(signer);
await ref.read(storageNotifierProvider.notifier).publish({
signedDm,
}, source: const RemoteSource(relays: 'social'));
// Update rate limiting
_reportedErrors[errorHash] = DateTime.now();
_sessionReportCount++;
return true;
} catch (e) {
debugPrint('[ErrorReporting] Failed to send user report: $e');
return false;
}
}
String _formatUserErrorReport(String title, String? technicalDetails) {
final buffer = StringBuffer();
buffer.writeln('=== ZAPSTORE USER ERROR REPORT ===');
buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}');
buffer.writeln('Platform: ${Platform.operatingSystem}');
buffer.writeln('OS Version: ${Platform.operatingSystemVersion}');
buffer.writeln('');
buffer.writeln('Error: $title');
if (technicalDetails != null && technicalDetails.isNotEmpty) {
buffer.writeln('');
buffer.writeln('Technical Details:');
buffer.writeln(technicalDetails);
}
return buffer.toString();
}
String _formatErrorReport(Object exception, StackTrace? stackTrace) {
final buffer = StringBuffer();
buffer.writeln('=== ZAPSTORE ERROR REPORT ===');
buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}');
buffer.writeln('Platform: ${Platform.operatingSystem}');
buffer.writeln('OS Version: ${Platform.operatingSystemVersion}');
buffer.writeln('');
buffer.writeln('Exception:');
buffer.writeln(exception.toString());
buffer.writeln('');
if (stackTrace != null) {
buffer.writeln('Stack Trace:');
// Limit stack trace length to avoid huge messages
final stackLines = stackTrace.toString().split('\n');
final limitedStack = stackLines.take(30).join('\n');
buffer.writeln(limitedStack);
if (stackLines.length > 30) {
buffer.writeln('... (${stackLines.length - 30} more lines)');
}
}
return buffer.toString();
}
}
/// Provider for the error reporting service
final errorReportingServiceProvider = Provider<ErrorReportingService>(
ErrorReportingService.new,
);
+132 -159
View File
@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:zapstore/router.dart'; import 'package:zapstore/router.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
@@ -77,7 +80,7 @@ void _showCustomToast({
overlay.insert(entry); overlay.insert(entry);
} }
class _ToastOverlay extends StatefulWidget { class _ToastOverlay extends HookWidget {
final String title; final String title;
final String? description; final String? description;
final IconData icon; final IconData icon;
@@ -94,73 +97,49 @@ class _ToastOverlay extends StatefulWidget {
required this.onDismiss, required this.onDismiss,
}); });
@override
State<_ToastOverlay> createState() => _ToastOverlayState();
}
class _ToastOverlayState extends State<_ToastOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_controller.forward();
_startAutoClose();
}
void _startAutoClose() {
// Longer duration when there are actions, or for errors
final hasActions = widget.actions.isNotEmpty;
final duration = hasActions
? const Duration(seconds: 10)
: widget.type == _ToastType.error
? const Duration(seconds: 8)
: const Duration(seconds: 6);
Future.delayed(duration, () {
if (mounted && !_isHovered) {
_dismiss();
}
});
}
void _dismiss() async {
await _controller.reverse();
widget.onDismiss();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final fadeAnimation = useMemoized(
() => CurvedAnimation(parent: controller, curve: Curves.easeOut),
[controller],
);
final isHovered = useState(false);
// Forward animation on mount
useEffect(() {
controller.forward();
return null;
}, const []);
// Auto-close only for toasts WITHOUT actions
useEffect(() {
if (actions.isNotEmpty) return null;
final duration = type == _ToastType.error
? const Duration(seconds: 8)
: const Duration(seconds: 6);
final timer = Timer(duration, () {
if (!isHovered.value) {
controller.reverse().then((_) => onDismiss());
}
});
return timer.cancel;
}, const []);
void dismiss() {
controller.reverse().then((_) => onDismiss());
}
final screenHeight = MediaQuery.of(context).size.height; final screenHeight = MediaQuery.of(context).size.height;
final maxHeight = screenHeight * 0.7; final maxHeight = screenHeight * 0.7;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
final isError = widget.type == _ToastType.error; final isError = type == _ToastType.error;
// Colors - info: darker light blue, error: red // Colors - info: darker light blue, error: red
final backgroundColor = isError final backgroundColor = isError
@@ -181,84 +160,86 @@ class _ToastOverlayState extends State<_ToastOverlay>
top: topPadding + 8, top: topPadding + 8,
left: 12, left: 12,
right: 12, right: 12,
child: SlideTransition( child: FadeTransition(
position: _slideAnimation, opacity: fadeAnimation,
child: FadeTransition( child: MouseRegion(
opacity: _fadeAnimation, onEnter: (_) => isHovered.value = true,
child: MouseRegion( onExit: (_) {
onEnter: (_) => setState(() => _isHovered = true), isHovered.value = false;
onExit: (_) { // Restart auto-close for toasts without actions
setState(() => _isHovered = false); if (actions.isEmpty) {
_startAutoClose(); Future.delayed(const Duration(seconds: 3), () {
if (!isHovered.value) dismiss();
});
}
},
child: GestureDetector(
onTap: actions.isEmpty ? dismiss : null,
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null &&
details.primaryVelocity! < -100) {
dismiss();
}
}, },
child: GestureDetector( child: ConstrainedBox(
onTap: _dismiss, constraints: BoxConstraints(maxHeight: maxHeight),
onVerticalDragEnd: (details) { child: Material(
if (details.primaryVelocity != null && color: Colors.transparent,
details.primaryVelocity! < -100) { child: Container(
_dismiss(); decoration: BoxDecoration(
} color: backgroundColor,
}, borderRadius: BorderRadius.circular(14),
child: ConstrainedBox( border: Border.all(color: borderColor, width: 1),
constraints: BoxConstraints(maxHeight: maxHeight), boxShadow: [
child: Material( BoxShadow(
color: Colors.transparent, color: Colors.black.withValues(alpha: 0.4),
child: Container( blurRadius: 24,
decoration: BoxDecoration( offset: const Offset(0, 8),
color: backgroundColor, spreadRadius: 0,
borderRadius: BorderRadius.circular(14), ),
border: Border.all(color: borderColor, width: 1), BoxShadow(
boxShadow: [ color: accentColor.withValues(alpha: 0.08),
BoxShadow( blurRadius: 40,
color: Colors.black.withValues(alpha: 0.4), offset: const Offset(0, 4),
blurRadius: 24, spreadRadius: 0,
offset: const Offset(0, 8), ),
spreadRadius: 0, ],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Accent line at top
Container(
height: 3,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
accentColor.withValues(alpha: 0.8),
accentColor.withValues(alpha: 0.4),
accentColor.withValues(alpha: 0.1),
],
),
),
), ),
BoxShadow( // Content
color: accentColor.withValues(alpha: 0.08), Flexible(
blurRadius: 40, child: Padding(
offset: const Offset(0, 4), padding: const EdgeInsets.all(16),
spreadRadius: 0, child: _ToastContent(
icon: icon,
iconBgColor: iconBgColor,
accentColor: accentColor,
title: title,
description: description,
actions: actions,
onDismiss: dismiss,
),
),
), ),
], ],
), ),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Accent line at top
Container(
height: 3,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
accentColor.withValues(alpha: 0.8),
accentColor.withValues(alpha: 0.4),
accentColor.withValues(alpha: 0.1),
],
),
),
),
// Content
Flexible(
child: Padding(
padding: const EdgeInsets.all(16),
child: _ToastContent(
icon: widget.icon,
iconBgColor: iconBgColor,
accentColor: accentColor,
title: widget.title,
description: widget.description,
actions: widget.actions,
onDismiss: _dismiss,
),
),
),
],
),
),
), ),
), ),
), ),
@@ -270,7 +251,7 @@ class _ToastOverlayState extends State<_ToastOverlay>
} }
} }
class _ToastContent extends StatefulWidget { class _ToastContent extends HookWidget {
final IconData icon; final IconData icon;
final Color iconBgColor; final Color iconBgColor;
final Color accentColor; final Color accentColor;
@@ -289,18 +270,12 @@ class _ToastContent extends StatefulWidget {
required this.onDismiss, required this.onDismiss,
}); });
@override
State<_ToastContent> createState() => _ToastContentState();
}
class _ToastContentState extends State<_ToastContent> {
bool _detailsExpanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasDescription = final detailsExpanded = useState(false);
widget.description != null && widget.description!.isNotEmpty;
final hasActions = widget.actions.isNotEmpty; final hasDescription = description != null && description!.isNotEmpty;
final hasActions = actions.isNotEmpty;
final hasExtraContent = hasDescription || hasActions; final hasExtraContent = hasDescription || hasActions;
// Use Stack to position X at top-right always // Use Stack to position X at top-right always
@@ -320,10 +295,10 @@ class _ToastContentState extends State<_ToastContent> {
width: 38, width: 38,
height: 38, height: 38,
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.iconBgColor, color: iconBgColor,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon(widget.icon, color: widget.accentColor, size: 20), child: Icon(icon, color: accentColor, size: 20),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
// Text content // Text content
@@ -334,7 +309,7 @@ class _ToastContentState extends State<_ToastContent> {
children: [ children: [
// Title // Title
Text( Text(
widget.title, title,
style: const TextStyle( style: const TextStyle(
fontFamily: kFontFamily, fontFamily: kFontFamily,
fontSize: 16, fontSize: 16,
@@ -348,15 +323,13 @@ class _ToastContentState extends State<_ToastContent> {
const SizedBox(height: 8), const SizedBox(height: 8),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
setState(() { detailsExpanded.value = !detailsExpanded.value;
_detailsExpanded = !_detailsExpanded;
});
}, },
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
_detailsExpanded detailsExpanded.value
? Icons.expand_less ? Icons.expand_less
: Icons.expand_more, : Icons.expand_more,
size: 16, size: 16,
@@ -376,7 +349,7 @@ class _ToastContentState extends State<_ToastContent> {
), ),
), ),
// Expanded description // Expanded description
if (_detailsExpanded) ...[ if (detailsExpanded.value) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -385,7 +358,7 @@ class _ToastContentState extends State<_ToastContent> {
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: SelectableText( child: SelectableText(
widget.description!, description!,
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 11, fontSize: 11,
@@ -403,15 +376,15 @@ class _ToastContentState extends State<_ToastContent> {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: widget.actions children: actions
.map( .map(
(action) => _ToastActionButton( (action) => _ToastActionButton(
label: action.$1, label: action.$1,
onPressed: () { onPressed: () {
widget.onDismiss(); onDismiss();
action.$2(); action.$2();
}, },
accentColor: widget.accentColor, accentColor: accentColor,
), ),
) )
.toList(), .toList(),
@@ -428,7 +401,7 @@ class _ToastContentState extends State<_ToastContent> {
top: 0, top: 0,
right: 0, right: 0,
child: GestureDetector( child: GestureDetector(
onTap: widget.onDismiss, onTap: onDismiss,
child: Container( child: Container(
width: 28, width: 28,
height: 28, height: 28,
@@ -375,20 +375,20 @@ final class AndroidPackageManager extends PackageManager {
/// Technical details are preserved in the description field. /// Technical details are preserved in the description field.
String _getUserFriendlyMessage(FailureType type) { String _getUserFriendlyMessage(FailureType type) {
return switch (type) { return switch (type) {
FailureType.downloadFailed => 'Download failed. Please try again.', FailureType.downloadFailed => 'Download failed.',
FailureType.hashMismatch => FailureType.hashMismatch =>
'File verification failed. Please try downloading again.', 'File integrity check failed. Possibly a malicious file, aborting installation.',
FailureType.invalidFile => FailureType.invalidFile =>
'Invalid app file. Please try downloading again.', 'Invalid app file. The download may be corrupt.',
FailureType.certMismatch => FailureType.certMismatch =>
'Update signed by different developer. Uninstall current version to update.', 'Update signed by different developer. Uninstall current version to update.',
FailureType.permissionDenied => FailureType.permissionDenied =>
'Permission required. Please grant install permission and try again.', 'Permission required. Please grant install permission and try again.',
FailureType.insufficientStorage => FailureType.insufficientStorage =>
'Not enough storage space. Please free up space and try again.', 'Not enough storage space. Free up space to continue and try again.',
FailureType.incompatible => FailureType.incompatible =>
'This app is not compatible with your device.', 'This app is not compatible with your device.',
FailureType.installFailed => 'Installation failed. Please try again.', FailureType.installFailed => 'Installation failed.',
}; };
} }
@@ -350,7 +350,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed( OperationFailed(
target: target, target: target,
type: FailureType.downloadFailed, type: FailureType.downloadFailed,
message: 'Download link unavailable. Please try again later.', message: 'Download link unavailable.',
), ),
); );
return false; return false;
@@ -626,7 +626,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed( OperationFailed(
target: op.target, target: op.target,
type: FailureType.installFailed, type: FailureType.installFailed,
message: 'Update failed. Please try again.', message: 'Update failed.',
description: errorMessage, description: errorMessage,
filePath: filePath, filePath: filePath,
), ),
@@ -938,7 +938,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed( OperationFailed(
target: target, target: target,
type: FailureType.downloadFailed, type: FailureType.downloadFailed,
message: 'Failed to access downloaded file. Please try again.', message: 'Failed to access downloaded file.',
description: '$e', description: '$e',
), ),
); );
@@ -975,7 +975,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed( OperationFailed(
target: op.target, target: op.target,
type: FailureType.downloadFailed, type: FailureType.downloadFailed,
message: 'Download link unavailable. Please try again later.', message: 'Download link unavailable.',
), ),
); );
continue; continue;
@@ -1115,10 +1115,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final userMessage = isCertMismatch final userMessage = isCertMismatch
? 'Update signed by different developer. Uninstall current version to update.' ? 'Update signed by different developer. Uninstall current version to update.'
: isHashMismatch : isHashMismatch
? 'File verification failed. Please try downloading again.' ? 'Hash mismatch. Possibly a malicious file, aborting installation.'
: isInvalidFile : isInvalidFile
? 'Invalid app file. Please try downloading again.' ? 'Invalid app file. The download may be corrupt.'
: 'Installation failed. Please try again.'; : 'Installation failed.';
setOperation( setOperation(
appId, appId,
+37 -71
View File
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/services/error_reporting_service.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/trusted_signers_service.dart'; import 'package:zapstore/services/trusted_signers_service.dart';
@@ -46,7 +45,7 @@ class InstallButton extends ConsumerWidget {
// Listen for errors to show toasts // Listen for errors to show toasts
ref.listen(installOperationProvider(app.identifier), (prev, next) { ref.listen(installOperationProvider(app.identifier), (prev, next) {
if (next is OperationFailed && prev is! OperationFailed) { if (next is OperationFailed && prev is! OperationFailed) {
_showErrorToast(context, ref, next); _showErrorToast(context, next);
} }
}); });
@@ -623,10 +622,7 @@ class InstallButton extends ConsumerWidget {
if (context.mounted) { if (context.mounted) {
final errorMessage = e.toString(); final errorMessage = e.toString();
if (!errorMessage.contains('cancelled')) { if (!errorMessage.contains('cancelled')) {
context.showError( context.showError('Uninstall failed', description: errorMessage);
'Uninstall failed. Please try again.',
description: errorMessage,
);
} }
} }
} }
@@ -636,77 +632,50 @@ class InstallButton extends ConsumerWidget {
final operation = ref.read(installOperationProvider(app.identifier)); final operation = ref.read(installOperationProvider(app.identifier));
if (operation is! OperationFailed) return; if (operation is! OperationFailed) return;
context.showError( _showErrorDetails(context, operation);
operation.message,
description: operation.description,
actions: [
(
'Report issue',
() async {
final reporter = ref.read(errorReportingServiceProvider);
final success = await reporter.reportUserError(
title: operation.message,
technicalDetails: operation.description,
);
if (context.mounted) {
context.showInfo(
success ? 'Report sent' : 'Failed to send report',
);
}
},
),
],
);
// Always clear error after showing it (reckless mode removed). // Always clear error after showing it (reckless mode removed).
final pm = ref.read(packageManagerProvider.notifier); final pm = ref.read(packageManagerProvider.notifier);
pm.dismissError(app.identifier); pm.dismissError(app.identifier);
} }
void _showErrorToast( void _showErrorToast(BuildContext context, OperationFailed operation) {
BuildContext context, // Show toast for errors with technical details or specific types
WidgetRef ref, if (operation.description != null ||
OperationFailed operation, operation.type == FailureType.certMismatch ||
) { operation.type == FailureType.incompatible) {
final reportAction = ( _showErrorDetails(context, operation);
'Report issue',
() async {
final reporter = ref.read(errorReportingServiceProvider);
final success = await reporter.reportUserError(
title: operation.message,
technicalDetails: operation.description,
);
if (context.mounted) {
context.showInfo(success ? 'Report sent' : 'Failed to send report');
}
},
);
if (operation.type == FailureType.certMismatch) {
context.showError(
'Update signed by different developer',
description:
'To install this update, you\'ll need to uninstall the current version first. This will remove app data.',
actions: [reportAction],
);
} else if (operation.type == FailureType.incompatible) {
context.showError(
'Device incompatible',
description:
'This app is not compatible with your device. It may require a different architecture or Android version.',
actions: [reportAction],
);
} else if (operation.description != null) {
// Errors with descriptions (from Kotlin) are shown as toasts
context.showError(
operation.message,
description: operation.description,
actions: [reportAction],
);
} }
// Other errors are shown when user taps the error button // Other errors are shown when user taps the error button
} }
void _showErrorDetails(BuildContext context, OperationFailed operation) {
final errorText = [
operation.message,
if (operation.description != null) '\n${operation.description}',
].join();
context.showError(
operation.message,
description: operation.description,
actions: [
(
'Copy',
() async {
await Clipboard.setData(ClipboardData(text: errorText));
if (context.mounted) {
context.showInfo(
'Copied to clipboard',
description:
'Share on nostr or the zapstore Signal support group.',
);
}
},
),
],
);
}
Future<void> _showForceUpdateDialog( Future<void> _showForceUpdateDialog(
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
@@ -799,10 +768,7 @@ class InstallButton extends ConsumerWidget {
} catch (e) { } catch (e) {
final errorMessage = e.toString(); final errorMessage = e.toString();
if (context.mounted && !errorMessage.contains('cancelled')) { if (context.mounted && !errorMessage.contains('cancelled')) {
context.showError( context.showError('Update failed', description: errorMessage);
'Update failed. Please try again.',
description: errorMessage,
);
} }
} }
} }
+5
View File
@@ -45,6 +45,11 @@ If any invariant is violated, the implementation is incorrect.
- All user-visible processes must have explicit states (loading, empty, success, error). - All user-visible processes must have explicit states (loading, empty, success, error).
- Silent failures are unacceptable. - Silent failures are unacceptable.
## Widget State Management
- Use Flutter Hooks (`HookWidget`, `HookConsumerWidget`) instead of `StatefulWidget` for local widget state.
- Use `useState`, `useAnimationController`, `useEffect`, etc. over manual `State` classes.
## Reproducible Android builds ## Reproducible Android builds