refactor(receipt-import): streamline category tree loading and enhance error handling
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -234,14 +234,12 @@ class AdminRepository {
|
||||
|
||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(CategoryApiPaths.tree, token: token),
|
||||
);
|
||||
return _parseList(data, AdminCategoryNode.fromJson);
|
||||
}
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
_getList(
|
||||
CategoryApiPaths.tree,
|
||||
AdminCategoryNode.fromJson,
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
// ── AI-modeller ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class EditDialog extends StatefulWidget {
|
||||
final ItemEdit current;
|
||||
final List<ProductOption> products;
|
||||
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;
|
||||
|
||||
const EditDialog({
|
||||
@@ -212,10 +212,6 @@ class _EditDialogState extends State<EditDialog> {
|
||||
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<EditDialog> {
|
||||
|
||||
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.');
|
||||
|
||||
@@ -38,6 +38,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
?.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 {
|
||||
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 {
|
||||
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 {
|
||||
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<String, dynamic>?)?['items'] as List? ?? []);
|
||||
final mineList = mineData is List
|
||||
? mineData
|
||||
: ((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);
|
||||
});
|
||||
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<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 {
|
||||
@@ -228,16 +307,26 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
ProductApiPaths.createPrivate,
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
'categoryId': categoryId,
|
||||
if (categoryId != null) 'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
@@ -302,7 +391,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user