Files
recipe-app/flutter/lib/features/admin/presentation/admin_aliases_panel.dart
T
Nils-Johan Gynther 46b9be4791
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / quick-import-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 22s
Test Suite / flutter-quality (push) Failing after 4s
feat: implement update functionality for receipt aliases and add corresponding tests
2026-05-12 21:25:48 +02:00

405 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import 'admin_form_shared.dart';
import '../data/admin_repository.dart';
import '../domain/admin_product.dart';
import '../domain/receipt_alias.dart';
class AdminAliasesPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminAliasesPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminAliasesPanel> createState() => _AdminAliasesPanelState();
}
class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
bool _isLoading = true;
bool _isSaving = false;
String? _error;
String _search = '';
List<ReceiptAlias> _aliases = [];
List<AdminProduct> _products = [];
final TextEditingController _aliasController = TextEditingController();
int? _selectedProductId;
int? _editingAliasId;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_aliasController.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listReceiptAliases(),
ref.read(adminRepositoryProvider).listGlobalProducts(),
]);
if (!mounted) return;
setState(() {
_aliases = (results[0] as List<ReceiptAlias>)
..sort((a, b) => a.receiptName.compareTo(b.receiptName));
_products = (results[1] as List<AdminProduct>)
..sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _upsertAlias() async {
final rawAlias = _aliasController.text.trim();
final productId = _selectedProductId;
if (rawAlias.isEmpty || productId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ange alias och välj produkt.')),
);
return;
}
setState(() => _isSaving = true);
try {
final repo = ref.read(adminRepositoryProvider);
final isEditing = _editingAliasId != null;
if (isEditing) {
await repo.updateReceiptAlias(
_editingAliasId!,
receiptName: rawAlias,
productId: productId,
);
} else {
await repo.upsertReceiptAlias(
receiptName: rawAlias,
productId: productId,
isGlobal: true,
);
}
if (!mounted) return;
_aliasController.clear();
setState(() {
_selectedProductId = null;
_editingAliasId = null;
});
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(isEditing ? 'Alias uppdaterat.' : 'Alias sparad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _startEditAlias(ReceiptAlias alias) {
if (!alias.isGlobal) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privata alias redigeras av respektive användare.')),
);
return;
}
setState(() {
_editingAliasId = alias.id;
_aliasController.text = alias.receiptName;
_selectedProductId = alias.productId;
});
}
void _cancelEditAlias() {
setState(() {
_editingAliasId = null;
_aliasController.clear();
_selectedProductId = null;
});
}
Future<void> _removeAlias(ReceiptAlias alias) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ta bort alias'),
content: Text('Ta bort alias "${alias.receiptName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Ta bort'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias borttaget.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa alias',
);
}
final filteredAliases = _aliases.where((alias) {
final query = _search.trim().toLowerCase();
if (query.isEmpty) return true;
return alias.receiptName.contains(query) ||
alias.displayProductName.toLowerCase().contains(query);
}).toList();
final productById = <int, AdminProduct>{
for (final product in _products) product.id: product,
};
Widget buildAliasCard(ReceiptAlias alias) {
final product = productById[alias.productId];
final categoryPath = product?.categoryPath ?? 'okänd';
return Card(
child: ListTile(
leading: const Icon(Icons.link_outlined),
title: Row(
children: [
Expanded(child: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500))),
buildCategoryPathChip(categoryPath),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${alias.displayProductName}',
style: const TextStyle(fontWeight: FontWeight.w400),
),
Text(
'Produkt-ID: ${alias.productId}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(height: 4),
Chip(
visualDensity: VisualDensity.compact,
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _startEditAlias(alias),
icon: const Icon(Icons.edit_outlined),
tooltip: alias.isGlobal
? 'Redigera alias'
: 'Privata alias redigeras av användaren',
),
IconButton(
onPressed: () => _removeAlias(alias),
icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort alias',
color: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Alias', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Fallback')),
Chip(label: Text('Global')),
Chip(label: Text('Receipt import')),
],
),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _aliasController,
decoration: const InputDecoration(
labelText: 'Kvittonamn (alias)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<int>(
key: ValueKey<int?>(_selectedProductId),
initialValue: _selectedProductId,
decoration: const InputDecoration(
labelText: 'Produkt',
border: OutlineInputBorder(),
),
items: _products
.map(
(product) => DropdownMenuItem<int>(
value: product.id,
child: Text(product.displayName),
),
)
.toList(),
onChanged: _isSaving
? null
: (value) => setState(() => _selectedProductId = value),
),
),
const SizedBox(width: 8),
if (_editingAliasId != null) ...[
OutlinedButton(
onPressed: _isSaving ? null : _cancelEditAlias,
child: const Text('Avbryt'),
),
const SizedBox(width: 8),
],
FilledButton.icon(
onPressed: _isSaving ? null : _upsertAlias,
icon: _isSaving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(_editingAliasId != null ? Icons.edit_outlined : Icons.save_outlined),
label: Text(_editingAliasId != null ? 'Uppdatera' : 'Spara'),
),
],
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Sök alias',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (value) => setState(() => _search = value),
),
const SizedBox(height: 12),
if (filteredAliases.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Inga alias hittades.',
style: theme.textTheme.bodyMedium,
),
),
),
],
);
if (!widget.embedded) {
if (filteredAliases.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: [content],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
content,
const SizedBox(height: 8),
...filteredAliases.map(buildAliasCard),
],
);
}
if (filteredAliases.isEmpty) return content;
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
content,
const SizedBox(height: 8),
...filteredAliases.map(buildAliasCard),
],
),
);
}
}