Files
recipe-app/flutter/lib/features/admin/presentation/admin_aliases_panel.dart
T

307 lines
9.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.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;
@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 {
await ref.read(adminRepositoryProvider).upsertReceiptAlias(
receiptName: rawAlias,
productId: productId,
isGlobal: true,
);
if (!mounted) return;
_aliasController.clear();
setState(() => _selectedProductId = null);
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias sparad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
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 Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: const Text('Försök igen')),
],
),
);
}
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();
Widget buildAliasCard(ReceiptAlias alias) {
return Card(
child: ListTile(
leading: const Icon(Icons.link_outlined),
title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)),
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: 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: [
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: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _aliasController,
decoration: const InputDecoration(
labelText: 'Kvittonamn (alias)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<int>(
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),
FilledButton.icon(
onPressed: _isSaving ? null : _upsertAlias,
icon: _isSaving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('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) const Text('Inga alias hittades.'),
],
);
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),
],
),
);
}
}