mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improve error reporting
This commit is contained in:
@@ -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,
|
||||
);
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
|
||||
@@ -77,7 +80,7 @@ void _showCustomToast({
|
||||
overlay.insert(entry);
|
||||
}
|
||||
|
||||
class _ToastOverlay extends StatefulWidget {
|
||||
class _ToastOverlay extends HookWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final IconData icon;
|
||||
@@ -94,73 +97,49 @@ class _ToastOverlay extends StatefulWidget {
|
||||
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
|
||||
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 maxHeight = screenHeight * 0.7;
|
||||
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
|
||||
final backgroundColor = isError
|
||||
@@ -181,84 +160,86 @@ class _ToastOverlayState extends State<_ToastOverlay>
|
||||
top: topPadding + 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) {
|
||||
setState(() => _isHovered = false);
|
||||
_startAutoClose();
|
||||
child: FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => isHovered.value = true,
|
||||
onExit: (_) {
|
||||
isHovered.value = false;
|
||||
// Restart auto-close for toasts without actions
|
||||
if (actions.isEmpty) {
|
||||
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(
|
||||
onTap: _dismiss,
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null &&
|
||||
details.primaryVelocity! < -100) {
|
||||
_dismiss();
|
||||
}
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
spreadRadius: 0,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
BoxShadow(
|
||||
color: accentColor.withValues(alpha: 0.08),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 4),
|
||||
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(
|
||||
color: accentColor.withValues(alpha: 0.08),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 4),
|
||||
spreadRadius: 0,
|
||||
// Content
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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 Color iconBgColor;
|
||||
final Color accentColor;
|
||||
@@ -289,18 +270,12 @@ class _ToastContent extends StatefulWidget {
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ToastContent> createState() => _ToastContentState();
|
||||
}
|
||||
|
||||
class _ToastContentState extends State<_ToastContent> {
|
||||
bool _detailsExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDescription =
|
||||
widget.description != null && widget.description!.isNotEmpty;
|
||||
final hasActions = widget.actions.isNotEmpty;
|
||||
final detailsExpanded = useState(false);
|
||||
|
||||
final hasDescription = description != null && description!.isNotEmpty;
|
||||
final hasActions = actions.isNotEmpty;
|
||||
final hasExtraContent = hasDescription || hasActions;
|
||||
|
||||
// Use Stack to position X at top-right always
|
||||
@@ -320,10 +295,10 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.iconBgColor,
|
||||
color: iconBgColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(widget.icon, color: widget.accentColor, size: 20),
|
||||
child: Icon(icon, color: accentColor, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
// Text content
|
||||
@@ -334,7 +309,7 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.title,
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontFamily,
|
||||
fontSize: 16,
|
||||
@@ -348,15 +323,13 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_detailsExpanded = !_detailsExpanded;
|
||||
});
|
||||
detailsExpanded.value = !detailsExpanded.value;
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_detailsExpanded
|
||||
detailsExpanded.value
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 16,
|
||||
@@ -376,7 +349,7 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
),
|
||||
),
|
||||
// Expanded description
|
||||
if (_detailsExpanded) ...[
|
||||
if (detailsExpanded.value) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -385,7 +358,7 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: SelectableText(
|
||||
widget.description!,
|
||||
description!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
@@ -403,15 +376,15 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.actions
|
||||
children: actions
|
||||
.map(
|
||||
(action) => _ToastActionButton(
|
||||
label: action.$1,
|
||||
onPressed: () {
|
||||
widget.onDismiss();
|
||||
onDismiss();
|
||||
action.$2();
|
||||
},
|
||||
accentColor: widget.accentColor,
|
||||
accentColor: accentColor,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -428,7 +401,7 @@ class _ToastContentState extends State<_ToastContent> {
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onDismiss,
|
||||
onTap: onDismiss,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -375,20 +375,20 @@ final class AndroidPackageManager extends PackageManager {
|
||||
/// Technical details are preserved in the description field.
|
||||
String _getUserFriendlyMessage(FailureType type) {
|
||||
return switch (type) {
|
||||
FailureType.downloadFailed => 'Download failed. Please try again.',
|
||||
FailureType.downloadFailed => 'Download failed.',
|
||||
FailureType.hashMismatch =>
|
||||
'File verification failed. Please try downloading again.',
|
||||
'File integrity check failed. Possibly a malicious file, aborting installation.',
|
||||
FailureType.invalidFile =>
|
||||
'Invalid app file. Please try downloading again.',
|
||||
'Invalid app file. The download may be corrupt.',
|
||||
FailureType.certMismatch =>
|
||||
'Update signed by different developer. Uninstall current version to update.',
|
||||
FailureType.permissionDenied =>
|
||||
'Permission required. Please grant install permission and try again.',
|
||||
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 =>
|
||||
'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(
|
||||
target: target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Download link unavailable. Please try again later.',
|
||||
message: 'Download link unavailable.',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
@@ -626,7 +626,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.installFailed,
|
||||
message: 'Update failed. Please try again.',
|
||||
message: 'Update failed.',
|
||||
description: errorMessage,
|
||||
filePath: filePath,
|
||||
),
|
||||
@@ -938,7 +938,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
OperationFailed(
|
||||
target: target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Failed to access downloaded file. Please try again.',
|
||||
message: 'Failed to access downloaded file.',
|
||||
description: '$e',
|
||||
),
|
||||
);
|
||||
@@ -975,7 +975,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Download link unavailable. Please try again later.',
|
||||
message: 'Download link unavailable.',
|
||||
),
|
||||
);
|
||||
continue;
|
||||
@@ -1115,10 +1115,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
final userMessage = isCertMismatch
|
||||
? 'Update signed by different developer. Uninstall current version to update.'
|
||||
: isHashMismatch
|
||||
? 'File verification failed. Please try downloading again.'
|
||||
? 'Hash mismatch. Possibly a malicious file, aborting installation.'
|
||||
: isInvalidFile
|
||||
? 'Invalid app file. Please try downloading again.'
|
||||
: 'Installation failed. Please try again.';
|
||||
? 'Invalid app file. The download may be corrupt.'
|
||||
: 'Installation failed.';
|
||||
|
||||
setOperation(
|
||||
appId,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/trusted_signers_service.dart';
|
||||
@@ -46,7 +45,7 @@ class InstallButton extends ConsumerWidget {
|
||||
// Listen for errors to show toasts
|
||||
ref.listen(installOperationProvider(app.identifier), (prev, next) {
|
||||
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) {
|
||||
final errorMessage = e.toString();
|
||||
if (!errorMessage.contains('cancelled')) {
|
||||
context.showError(
|
||||
'Uninstall failed. Please try again.',
|
||||
description: errorMessage,
|
||||
);
|
||||
context.showError('Uninstall failed', description: errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,77 +632,50 @@ class InstallButton extends ConsumerWidget {
|
||||
final operation = ref.read(installOperationProvider(app.identifier));
|
||||
if (operation is! OperationFailed) return;
|
||||
|
||||
context.showError(
|
||||
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',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
_showErrorDetails(context, operation);
|
||||
|
||||
// Always clear error after showing it (reckless mode removed).
|
||||
final pm = ref.read(packageManagerProvider.notifier);
|
||||
pm.dismissError(app.identifier);
|
||||
}
|
||||
|
||||
void _showErrorToast(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
OperationFailed operation,
|
||||
) {
|
||||
final reportAction = (
|
||||
'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],
|
||||
);
|
||||
void _showErrorToast(BuildContext context, OperationFailed operation) {
|
||||
// Show toast for errors with technical details or specific types
|
||||
if (operation.description != null ||
|
||||
operation.type == FailureType.certMismatch ||
|
||||
operation.type == FailureType.incompatible) {
|
||||
_showErrorDetails(context, operation);
|
||||
}
|
||||
// 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(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
@@ -799,10 +768,7 @@ class InstallButton extends ConsumerWidget {
|
||||
} catch (e) {
|
||||
final errorMessage = e.toString();
|
||||
if (context.mounted && !errorMessage.contains('cancelled')) {
|
||||
context.showError(
|
||||
'Update failed. Please try again.',
|
||||
description: errorMessage,
|
||||
);
|
||||
context.showError('Update failed', description: errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
- 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user