feat: implement global error handling with reusable dialog and widget for improved user feedback

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-04-30 12:01:47 +02:00
parent df1da1da2b
commit 5231ca42a7
6 changed files with 149 additions and 21 deletions
@@ -71,15 +71,18 @@ class ImportRepository {
}
final parsed = _parseResponse(response);
final items = (parsed['items'] as List?) ?? parsed as List?;
if (items == null) {
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
// Check if the response is a ReceiptImportResult
if (parsed is Map<String, dynamic> && parsed.containsKey('items')) {
final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e)).toList();
if (items != null) {
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
return items;
}
}
developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository');
return items.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
} catch (e) {
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
rethrow;
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Visar en dialogruta med ett felmeddelande och en kopieringsknapp.
void showErrorDialog(BuildContext context, String errorMessage) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Fel'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(errorMessage),
],
),
actions: <Widget>[
TextButton(
child: const Text('Stäng'),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('Kopiera'),
onPressed: () {
Clipboard.setData(ClipboardData(text: errorMessage));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Felmeddelande kopierat!')),
);
},
),
],
);
},
);
}
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
import '../domain/parsed_receipt_item.dart';
@@ -34,11 +35,17 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
}
Future<void> _submit() async {
if (_pickedFile == null) {
setState(() => _error = 'Vänligen välj en fil först');
return;
}
setState(() {
_isLoading = true;
_error = null;
_items = null;
});
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
@@ -50,10 +57,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
if (!mounted) return;
setState(() => _items = items);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/utils/global_error_handler.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
@@ -54,9 +55,14 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
});
}
// ── Submit ───────────────────────────────────────────────────────────────
// ── Import ────────────────────────────────────────────────────────────
Future<void> _submit() async {
if (_pickedFile == null && _method == _Method.file) {
setState(() => _error = 'Vänligen välj en fil först');
return;
}
setState(() {
_isLoading = true;
_error = null;
@@ -65,7 +71,6 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final result = _method == _Method.file
? await repo.importFile(
bytes: _pickedFile!.bytes!,
@@ -73,22 +78,18 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
token: token,
)
: await repo.importUrl(
url: _urlCtrl.text.trim(),
url: _urlCtrl.text,
token: token,
);
if (!mounted) return;
// Explicitly typed as <String, dynamic> so the router's
// 'is Map<String, dynamic>' runtime check succeeds.
context.push('/recipes/create', extra: <String, dynamic>{
'markdown': result.markdown,
'imageUrl': result.imageUrl,
}).then((_) => context.go('/recipes'));
context.push('/recipes/create', extra: result);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
setState(() => _isLoading = false);
}
}
}