diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 78c86bed..fe5405af 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -234,14 +234,12 @@ class AdminRepository { // ── Kategorier ───────────────────────────────────────────────────────────── - Future> listCategoryTree() async { - final token = await _token(); - final data = await guardedApiCall( - _ref, - () => _apiClient.getJson(CategoryApiPaths.tree, token: token), - ); - return _parseList(data, AdminCategoryNode.fromJson); - } + Future> listCategoryTree() => + _getList( + CategoryApiPaths.tree, + AdminCategoryNode.fromJson, + requiresAuth: false, + ); // ── AI-modeller ──────────────────────────────────────────────────────────── diff --git a/flutter/lib/features/import/presentation/edit_dialog.dart b/flutter/lib/features/import/presentation/edit_dialog.dart index b133585a..ecf7dcb1 100644 --- a/flutter/lib/features/import/presentation/edit_dialog.dart +++ b/flutter/lib/features/import/presentation/edit_dialog.dart @@ -17,7 +17,7 @@ class EditDialog extends StatefulWidget { final ItemEdit current; final List products; final List categoryTree; - final Future Function(String name, int categoryId)? onCreate; + final Future Function(String name, int? categoryId)? onCreate; final ImportProductEntryMode? initialEntryMode; const EditDialog({ @@ -212,10 +212,6 @@ class _EditDialogState extends State { showGlobalErrorDialog(context, 'Ange ett produktnamn först.'); return; } - if (_newCategoryId == null) { - showGlobalErrorDialog(context, 'Välj kategori innan du skapar produkten.'); - return; - } if (widget.onCreate == null) { showGlobalErrorDialog(context, 'Produktskapande är inte tillgängligt i den här vyn.'); return; @@ -223,7 +219,7 @@ class _EditDialogState extends State { setState(() => _isCreatingProduct = true); try { - final newProduct = await widget.onCreate!(trimmedName, _newCategoryId!); + final newProduct = await widget.onCreate!(trimmedName, _newCategoryId); if (newProduct == null || !mounted) { if (mounted) { showGlobalErrorDialog(context, 'Kunde inte skapa produkten. Försök igen.'); diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 649a7723..c2e815b9 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -38,6 +38,10 @@ class _ReceiptImportTabState extends ConsumerState { bool _isLoading = false; bool _isSaving = false; PlatformFile? _pickedFile; + bool _categoryLoadFailed = false; + bool _globalProductsLoadFailed = false; + bool _privateProductsLoadFailed = false; + bool _authLoadFailed = false; // Session-state lyfts till provider — överlever tabbyte ReceiptImportSession? get _session => ref.read(receiptImportSessionProvider); @@ -72,60 +76,135 @@ class _ReceiptImportTabState extends ConsumerState { ?.categoryId; } + List _extractItems(dynamic data) { + if (data is List) return data; + if (data is Map) { + return (data['items'] as List?) ?? const []; + } + return const []; + } + Future _loadProducts() async { + final api = ref.read(apiClientProvider); + final adminRepo = ref.read(adminRepositoryProvider); + + List categoryTree = _categoryTree; + List globalList = const []; + List 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 { 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 categoryTree = _categoryTree; try { - categoryTree = await adminRepo.listCategoryTree(); + final globalData = await api.getJson(ProductApiPaths.list, token: token); + globalList = _extractItems(globalData); } catch (e, st) { - debugPrint('ReceiptImportTab._loadProducts categoryTree failed: $e'); + globalFailed = true; + debugPrint('ReceiptImportTab._loadProducts global products failed: $e'); debugPrintStack(stackTrace: st); } - final globalData = results[0]; - final mineData = results[1]; - final globalList = globalData is List - ? globalData - : ((globalData as Map?)?['items'] as List? ?? []); - final mineList = mineData is List - ? mineData - : ((mineData as Map?)?['items'] as List? ?? []); - if (mounted) { - final mergedProducts = [ - ...globalList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), - ...mineList - .cast>() - .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), - ]; - final dedupedById = { - for (final product in mergedProducts) product.id: product, - }; - - setState(() { - _products = dedupedById.values.toList(); - _categoryTree = categoryTree; - _lookup = CategoryLookup.fromTree(categoryTree); - }); + try { + final mineData = await api.getJson(ProductApiPaths.mine, token: token); + mineList = _extractItems(mineData); + } catch (e, st) { + privateFailed = true; + debugPrint('ReceiptImportTab._loadProducts private products failed: $e'); + debugPrintStack(stackTrace: st); } } catch (e, st) { - debugPrint('ReceiptImportTab._loadProducts failed: $e'); + authFailed = true; + debugPrint('ReceiptImportTab._loadProducts auth/token failed: $e'); 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((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ...mineList + .cast>() + .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())), + ]; + final dedupedById = { + 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 = [ + 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 _loadInventory() async { @@ -228,16 +307,26 @@ class _ReceiptImportTabState extends ConsumerState { int index, { ImportProductEntryMode? initialEntryMode, }) async { + final needsCategoryTree = initialEntryMode != ImportProductEntryMode.create; if (_categoryTree.isEmpty) { await _loadProducts(); if (!mounted) return; - if (_categoryTree.isEmpty) { + if (_categoryTree.isEmpty && needsCategoryTree) { showGlobalErrorDialog( context, 'Inga kategorier kunde laddas. Prova att uppdatera kategorier i Admin > Databas och försök igen.', ); 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]; @@ -282,7 +371,7 @@ class _ReceiptImportTabState extends ConsumerState { ProductApiPaths.createPrivate, body: { 'name': name.trim(), - 'categoryId': categoryId, + if (categoryId != null) 'categoryId': categoryId, }, token: token, ); @@ -302,7 +391,7 @@ class _ReceiptImportTabState extends ConsumerState { throw Exception('API-svar saknar produktnamn.'); } - final returnedCategoryId = raw['categoryId'] is num + final int? returnedCategoryId = raw['categoryId'] is num ? (raw['categoryId'] as num).toInt() : categoryId; @@ -524,6 +613,10 @@ class _ReceiptImportTabState extends ConsumerState { icon: const Icon(Icons.receipt_long_outlined), label: const Text('Importera kvitto'), ), + if (_hasLoadDiagnostics) ...[ + const SizedBox(height: 12), + _buildLoadDiagnosticsBanner(theme), + ], // ── Resultatlista ────────────────────────────────────────────── if (items != null) ...[ const SizedBox(height: 24),