refactor(receipt-import): streamline category tree loading and enhance error handling

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-03 15:47:35 +02:00
parent 046dad870f
commit 5864a6f111
3 changed files with 145 additions and 58 deletions
@@ -234,14 +234,12 @@ class AdminRepository {
// ── Kategorier ───────────────────────────────────────────────────────────── // ── Kategorier ─────────────────────────────────────────────────────────────
Future<List<AdminCategoryNode>> listCategoryTree() async { Future<List<AdminCategoryNode>> listCategoryTree() =>
final token = await _token(); _getList(
final data = await guardedApiCall( CategoryApiPaths.tree,
_ref, AdminCategoryNode.fromJson,
() => _apiClient.getJson(CategoryApiPaths.tree, token: token), requiresAuth: false,
); );
return _parseList(data, AdminCategoryNode.fromJson);
}
// ── AI-modeller ──────────────────────────────────────────────────────────── // ── AI-modeller ────────────────────────────────────────────────────────────
@@ -17,7 +17,7 @@ class EditDialog extends StatefulWidget {
final ItemEdit current; final ItemEdit current;
final List<ProductOption> products; final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree; final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int categoryId)? onCreate; final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
final ImportProductEntryMode? initialEntryMode; final ImportProductEntryMode? initialEntryMode;
const EditDialog({ const EditDialog({
@@ -212,10 +212,6 @@ class _EditDialogState extends State<EditDialog> {
showGlobalErrorDialog(context, 'Ange ett produktnamn först.'); showGlobalErrorDialog(context, 'Ange ett produktnamn först.');
return; return;
} }
if (_newCategoryId == null) {
showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.');
return;
}
if (widget.onCreate == null) { if (widget.onCreate == null) {
showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.'); showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.');
return; return;
@@ -223,7 +219,7 @@ class _EditDialogState extends State<EditDialog> {
setState(() => _isCreatingProduct = true); setState(() => _isCreatingProduct = true);
try { try {
final newProduct = await widget.onCreate!(trimmedName, _newCategoryId!); final newProduct = await widget.onCreate!(trimmedName, _newCategoryId);
if (newProduct == null || !mounted) { if (newProduct == null || !mounted) {
if (mounted) { if (mounted) {
showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.');
@@ -38,6 +38,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
bool _isLoading = false; bool _isLoading = false;
bool _isSaving = false; bool _isSaving = false;
PlatformFile? _pickedFile; PlatformFile? _pickedFile;
bool _categoryLoadFailed = false;
bool _globalProductsLoadFailed = false;
bool _privateProductsLoadFailed = false;
bool _authLoadFailed = false;
// Session-state lyfts till provider — överlever tabbyte // Session-state lyfts till provider — överlever tabbyte
ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider); ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider);
@@ -72,60 +76,135 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
?.categoryId; ?.categoryId;
} }
List<dynamic> _extractItems(dynamic data) {
if (data is List<dynamic>) return data;
if (data is Map<String, dynamic>) {
return (data['items'] as List<dynamic>?) ?? const [];
}
return const [];
}
Future<void> _loadProducts() async { Future<void> _loadProducts() async {
final api = ref.read(apiClientProvider);
final adminRepo = ref.read(adminRepositoryProvider);
List<AdminCategoryNode> categoryTree = _categoryTree;
List<dynamic> globalList = const [];
List<dynamic> mineList = const [];
var categoryFailed = false;
var globalFailed = false;
var privateFailed = false;
var authFailed = false;
try {
categoryTree = await adminRepo.listCategoryTree();
} catch (e, st) {
categoryFailed = true;
debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e');
debugPrintStack(stackTrace: st);
}
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final adminRepo = ref.read(adminRepositoryProvider);
final results = await Future.wait([
api.getJson(ProductApiPaths.list, token: token),
api.getJson(ProductApiPaths.mine, token: token),
]);
List<AdminCategoryNode> categoryTree = _categoryTree;
try { try {
categoryTree = await adminRepo.listCategoryTree(); final globalData = await api.getJson(ProductApiPaths.list, token: token);
globalList = _extractItems(globalData);
} catch (e, st) { } catch (e, st) {
debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e'); globalFailed = true;
debugPrint('ReceiptImportTab._loadProducts global products failed: $e');
debugPrintStack(stackTrace: st); debugPrintStack(stackTrace: st);
} }
final globalData = results[0]; try {
final mineData = results[1]; final mineData = await api.getJson(ProductApiPaths.mine, token: token);
final globalList = globalData is List mineList = _extractItems(mineData);
? globalData } catch (e, st) {
: ((globalData as Map<String, dynamic>?)?['items'] as List? ?? []); privateFailed = true;
final mineList = mineData is List debugPrint('ReceiptImportTab._loadProducts private products failed: $e');
? mineData debugPrintStack(stackTrace: st);
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
if (mounted) {
final mergedProducts = [
...globalList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
...mineList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
];
final dedupedById = <int, ProductOption>{
for (final product in mergedProducts) product.id: product,
};
setState(() {
_products = dedupedById.values.toList();
_categoryTree = categoryTree;
_lookup = CategoryLookup.fromTree(categoryTree);
});
} }
} catch (e, st) { } catch (e, st) {
debugPrint('ReceiptImportTab._loadProducts failed: $e'); authFailed = true;
debugPrint('ReceiptImportTab._loadProducts auth/token failed: $e');
debugPrintStack(stackTrace: st); debugPrintStack(stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Kunde inte ladda produktlistan. Försök igen.')),
);
}
} }
if (!mounted) return;
final mergedProducts = [
...globalList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
...mineList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
];
final dedupedById = <int, ProductOption>{
for (final product in mergedProducts) product.id: product,
};
setState(() {
_products = dedupedById.values.toList();
_categoryTree = categoryTree;
_lookup = CategoryLookup.fromTree(categoryTree);
_categoryLoadFailed = categoryFailed;
_globalProductsLoadFailed = globalFailed;
_privateProductsLoadFailed = privateFailed;
_authLoadFailed = authFailed;
});
if (_products.isEmpty && _categoryTree.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Kunde inte ladda produkter eller kategorier. Försök igen.')),
);
}
}
bool get _hasLoadDiagnostics =>
_categoryLoadFailed ||
_globalProductsLoadFailed ||
_privateProductsLoadFailed ||
_authLoadFailed;
Widget _buildLoadDiagnosticsBanner(ThemeData theme) {
final issues = <String>[
if (_authLoadFailed) 'auth/token',
if (_categoryLoadFailed) 'categories',
if (_globalProductsLoadFailed) 'products:list',
if (_privateProductsLoadFailed) 'products:mine',
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border.all(color: Colors.amber.shade300),
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'Diagnostik (tillfällig): Problem vid laddning av ${issues.join(', ')}. '
'Vissa funktioner kan vara begränsade.',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.amber.shade900,
),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: _loadProducts,
child: const Text('Försök igen'),
),
],
),
);
} }
Future<void> _loadInventory() async { Future<void> _loadInventory() async {
@@ -228,16 +307,26 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
int index, { int index, {
ImportProductEntryMode? initialEntryMode, ImportProductEntryMode? initialEntryMode,
}) async { }) async {
final needsCategoryTree = initialEntryMode != ImportProductEntryMode.create;
if (_categoryTree.isEmpty) { if (_categoryTree.isEmpty) {
await _loadProducts(); await _loadProducts();
if (!mounted) return; if (!mounted) return;
if (_categoryTree.isEmpty) { if (_categoryTree.isEmpty && needsCategoryTree) {
showGlobalErrorDialog( showGlobalErrorDialog(
context, context,
'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.', 'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.',
); );
return; return;
} }
if (_categoryTree.isEmpty && !needsCategoryTree) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Kategorier kunde inte laddas just nu. Du kan fortfarande skapa produkt utan kategori.',
),
),
);
}
} }
final item = _items![index]; final item = _items![index];
@@ -282,7 +371,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
ProductApiPaths.createPrivate, ProductApiPaths.createPrivate,
body: { body: {
'name': name.trim(), 'name': name.trim(),
'categoryId': categoryId, if (categoryId != null) 'categoryId': categoryId,
}, },
token: token, token: token,
); );
@@ -302,7 +391,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
throw Exception('API-svar saknar produktnamn.'); throw Exception('API-svar saknar produktnamn.');
} }
final returnedCategoryId = raw['categoryId'] is num final int? returnedCategoryId = raw['categoryId'] is num
? (raw['categoryId'] as num).toInt() ? (raw['categoryId'] as num).toInt()
: categoryId; : categoryId;
@@ -524,6 +613,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
icon: const Icon(Icons.receipt_long_outlined), icon: const Icon(Icons.receipt_long_outlined),
label: const Text('Importera kvitto'), label: const Text('Importera kvitto'),
), ),
if (_hasLoadDiagnostics) ...[
const SizedBox(height: 12),
_buildLoadDiagnosticsBanner(theme),
],
// ── Resultatlista ────────────────────────────────────────────── // ── Resultatlista ──────────────────────────────────────────────
if (items != null) ...[ if (items != null) ...[
const SizedBox(height: 24), const SizedBox(height: 24),