Files
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

333 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/realtime/realtime_sync.dart';
import '../../admin/data/admin_repository.dart';
import '../../admin/domain/receipt_alias.dart';
class UserAliasesScreen extends ConsumerStatefulWidget {
const UserAliasesScreen({super.key});
@override
ConsumerState<UserAliasesScreen> createState() => _UserAliasesScreenState();
}
class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
List<ReceiptAlias> _aliases = [];
bool _isLoading = true;
String? _error;
ProviderSubscription<int>? _realtimeTickSubscription;
@override
void initState() {
super.initState();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_load();
},
);
_load();
}
@override
void dispose() {
_realtimeTickSubscription?.close();
super.dispose();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases();
if (!mounted) return;
setState(() {
_aliases = [...aliases]
..sort((a, b) {
if (a.isGlobal != b.isGlobal) return a.isGlobal ? 1 : -1;
return a.receiptName.compareTo(b.receiptName);
});
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _delete(ReceiptAlias alias) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ta bort alias'),
content: Text('Ta bort alias "${alias.receiptName}" → ${alias.displayProductName}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Avbryt')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ta bort'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id);
await _load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias borttaget.')),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kunde inte ta bort alias: $e')),
);
}
}
Future<void> _editAlias(ReceiptAlias alias) async {
if (!alias.isPrivate) return;
final controller = TextEditingController(text: alias.receiptName);
final newAliasName = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Redigera alias'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Kvittonamn (alias)',
border: OutlineInputBorder(),
),
onSubmitted: (value) => Navigator.pop(ctx, value.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: const Text('Spara'),
),
],
),
);
controller.dispose();
if (!mounted || newAliasName == null) return;
final normalizedNew = newAliasName.trim();
if (normalizedNew.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
);
return;
}
if (normalizedNew == alias.receiptName.trim()) return;
try {
final repo = ref.read(adminRepositoryProvider);
await repo.updateReceiptAlias(
alias.id,
receiptName: normalizedNew,
productId: alias.productId,
);
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Alias uppdaterat.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kunde inte uppdatera alias: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Mina kvittoalias'),
actions: [
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh),
tooltip: 'Uppdatera',
),
],
),
body: Builder(builder: (_) {
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa alias',
);
}
if (_aliases.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Privat alias')),
Chip(label: Text('Global fallback')),
Chip(label: Text('Receipt-import')),
],
),
],
),
),
),
const SizedBox(height: 16),
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 16),
Text(
'Inga alias sparade ännu.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Alias skapas när du väljer att lära in dem under kvittoimporten.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
),
),
],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Privat alias')),
Chip(label: Text('Global fallback')),
Chip(label: Text('Receipt-import')),
],
),
],
),
),
),
const SizedBox(height: 16),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _aliases.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final alias = _aliases[i];
return ListTile(
leading: Icon(
alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
color: theme.colorScheme.primary,
),
title: Text(
alias.receiptName,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${alias.displayProductName}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
Chip(
visualDensity: VisualDensity.compact,
label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'),
),
],
),
],
),
trailing: alias.isPrivate
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Redigera alias',
onPressed: () => _editAlias(alias),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort alias',
color: theme.colorScheme.error,
onPressed: () => _delete(alias),
),
],
)
: null,
);
},
),
],
);
}),
);
}
}