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/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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user