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:
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Visar en global dialogruta med ett felmeddelande och en kopieringsknapp.
|
||||||
|
void showGlobalErrorDialog(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!')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// En återanvändbar widget för att visa felmeddelanden med en kopieringsknapp.
|
||||||
|
class ErrorMessageWidget extends StatelessWidget {
|
||||||
|
final String errorMessage;
|
||||||
|
|
||||||
|
const ErrorMessageWidget({Key? key, required this.errorMessage}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(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!')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,15 +71,18 @@ class ImportRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final parsed = _parseResponse(response);
|
final parsed = _parseResponse(response);
|
||||||
final items = (parsed['items'] as List?) ?? parsed as List?;
|
|
||||||
|
|
||||||
if (items == null) {
|
// Check if the response is a ReceiptImportResult
|
||||||
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
|
if (parsed is Map<String, dynamic> && parsed.containsKey('items')) {
|
||||||
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
|
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');
|
developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data');
|
||||||
return items.map((e) => ParsedReceiptItem.fromJson(e as Map<String, dynamic>)).toList();
|
throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/utils/global_error_handler.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../domain/parsed_receipt_item.dart';
|
import '../domain/parsed_receipt_item.dart';
|
||||||
@@ -34,11 +35,17 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
|
if (_pickedFile == null) {
|
||||||
|
setState(() => _error = 'Vänligen välj en fil först');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
_items = null;
|
_items = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final repo = ref.read(importRepositoryProvider);
|
final repo = ref.read(importRepositoryProvider);
|
||||||
@@ -50,10 +57,11 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _items = items);
|
setState(() => _items = items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
|
||||||
} finally {
|
} 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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/utils/global_error_handler.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
|
|
||||||
@@ -54,9 +55,14 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submit ───────────────────────────────────────────────────────────────
|
// ── Import ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
|
if (_pickedFile == null && _method == _Method.file) {
|
||||||
|
setState(() => _error = 'Vänligen välj en fil först');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -65,7 +71,6 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final repo = ref.read(importRepositoryProvider);
|
final repo = ref.read(importRepositoryProvider);
|
||||||
|
|
||||||
final result = _method == _Method.file
|
final result = _method == _Method.file
|
||||||
? await repo.importFile(
|
? await repo.importFile(
|
||||||
bytes: _pickedFile!.bytes!,
|
bytes: _pickedFile!.bytes!,
|
||||||
@@ -73,22 +78,18 @@ class _RecipeImportTabState extends ConsumerState<RecipeImportTab> {
|
|||||||
token: token,
|
token: token,
|
||||||
)
|
)
|
||||||
: await repo.importUrl(
|
: await repo.importUrl(
|
||||||
url: _urlCtrl.text.trim(),
|
url: _urlCtrl.text,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Explicitly typed as <String, dynamic> so the router's
|
context.push('/recipes/create', extra: result);
|
||||||
// 'is Map<String, dynamic>' runtime check succeeds.
|
|
||||||
context.push('/recipes/create', extra: <String, dynamic>{
|
|
||||||
'markdown': result.markdown,
|
|
||||||
'imageUrl': result.imageUrl,
|
|
||||||
}).then((_) => context.go('/recipes'));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
showGlobalErrorDialog(context, 'Ett fel uppstod vid import: $e');
|
||||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user