feat: add "See receipt" button and preview modal in receipt import flow
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
# Implementeringsplan: "Se kvitto"-Modal för Kvittoimporten
|
||||||
|
|
||||||
|
**Mål**: MVP-vägen för split-view UX – lägg till modal som visar OCR-text från parsade kvittoraderna.
|
||||||
|
**Scope**: 2-3 timmar
|
||||||
|
**Status**: Planering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ändringar i `receipt_import_tab.dart`
|
||||||
|
|
||||||
|
### 1.1 Lägg till knapp "Se kvitto" i header-raden (rad ~745-752)
|
||||||
|
|
||||||
|
**Plats**: Höger om "Välj alla/Avmarkera alla"-knappen
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Innan: Row med bara "Välj alla"-knapp
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||||
|
TextButton(...), // "Välj alla/Avmarkera alla"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Efter: Lägg till "Se kvitto"-knapp
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: items.isEmpty ? null : () => _showReceiptPreview(context, items),
|
||||||
|
icon: const Icon(Icons.description_outlined),
|
||||||
|
label: const Text('Se kvitto'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(...), // Befintlig "Välj alla"
|
||||||
|
child: Text(...),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Implementera `_showReceiptPreview`-metod
|
||||||
|
|
||||||
|
Lägg till denna metod i `_ReceiptImportTabState`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _showReceiptPreview(BuildContext context, List<ParsedReceiptItem> items) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _ReceiptPreviewDialog(items: items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ny widget: `_ReceiptPreviewDialog`
|
||||||
|
|
||||||
|
Lägg till denna widget **i samma fil** (`receipt_import_tab.dart`), efter `_ReceiptImportResultRow`-klassen:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class _ReceiptPreviewDialog extends StatelessWidget {
|
||||||
|
final List<ParsedReceiptItem> items;
|
||||||
|
|
||||||
|
const _ReceiptPreviewDialog({required this.items});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Kvittotexten i sin helhet'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 600, // Responsiv bredd på desktop
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Här visas all OCR-parsad text från kvittot. En rad per artikel:',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLowest,
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: items.isEmpty
|
||||||
|
? [TextSpan(text: '(Inga rader)', style: theme.textTheme.bodySmall)]
|
||||||
|
: items
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) {
|
||||||
|
final item = entry.value;
|
||||||
|
final lineNumber = entry.key + 1;
|
||||||
|
final lineText = _formatReceiptLine(item);
|
||||||
|
return TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$lineNumber. ',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: lineText,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: '\n'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Stäng'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatReceiptLine(ParsedReceiptItem item) {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (item.quantity != null) {
|
||||||
|
parts.add('${item.quantity}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.unit != null) {
|
||||||
|
parts.add(item.unit!);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.add(item.rawName);
|
||||||
|
|
||||||
|
if (item.price != null) {
|
||||||
|
parts.add('— ${item.price} kr');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementeringssteg (steg-för-steg)
|
||||||
|
|
||||||
|
1. **Läs receipt_import_tab.dart** och identifiera raden med "Välj alla/Avmarkera alla"-knappen
|
||||||
|
2. **Refaktorera Row**: Lägg "Se kvitto"-knapp bredvid befintliga knapp
|
||||||
|
3. **Lägg till `_showReceiptPreview()`-metod** i `_ReceiptImportTabState`
|
||||||
|
4. **Implementera `_ReceiptPreviewDialog`-widget** på slutet av filen
|
||||||
|
5. **Testa**:
|
||||||
|
- Ladda ett kvitto
|
||||||
|
- Klicka "Se kvitto"-knappen
|
||||||
|
- Verifiera att texten är lesbar och formaterad
|
||||||
|
- Testa responsive bredd (dialog behöver minska på mobil)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Responsiv förbättring (optional)
|
||||||
|
|
||||||
|
Om dialogen behöver anpassas för mobil:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// I _ReceiptPreviewDialog.build():
|
||||||
|
final isWide = MediaQuery.of(context).size.width > 600;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: isWide ? 600 : double.maxFinite, // Full bredd på mobil
|
||||||
|
// ...
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Långsiktiga förbättringar (Phase 2)
|
||||||
|
|
||||||
|
Se `next_steps_flutter.md` för split-view roadmap:
|
||||||
|
- Horisontell split-view på desktop
|
||||||
|
- Scroll-synkronisering
|
||||||
|
- Tab-fallback på mobil
|
||||||
|
- AI-guiding labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ärendemal
|
||||||
|
|
||||||
|
**Titel**: "Se kvitto"-modal för kvittoimporten
|
||||||
|
**Branch**: `feat/receipt-preview-modal`
|
||||||
|
**Labels**: `enhancement`, `import-ux`, `phase-1-mvp`
|
||||||
|
**Estimate**: 2-3h
|
||||||
@@ -449,6 +449,14 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showReceiptPreview(BuildContext context, List<ParsedReceiptItem> items) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _ReceiptPreviewDialog(items: items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _addSelected() async {
|
Future<void> _addSelected() async {
|
||||||
final items = _items;
|
final items = _items;
|
||||||
if (items == null) return;
|
if (items == null) return;
|
||||||
@@ -742,12 +750,22 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
Text('${items.length} rader — tryck för att redigera', style: theme.textTheme.titleSmall),
|
||||||
TextButton(
|
Row(
|
||||||
onPressed: () => setState(() {
|
children: [
|
||||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
TextButton.icon(
|
||||||
notifier.setSelectedForAll(items.length, _selectedCount < items.length);
|
onPressed: items.isEmpty ? null : () => _showReceiptPreview(context, items),
|
||||||
}),
|
icon: const Icon(Icons.description_outlined),
|
||||||
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
label: const Text('Se kvitto'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||||
|
notifier.setSelectedForAll(items.length, _selectedCount < items.length);
|
||||||
|
}),
|
||||||
|
child: Text(_selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1031,3 +1049,103 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ReceiptPreviewDialog extends StatelessWidget {
|
||||||
|
final List<ParsedReceiptItem> items;
|
||||||
|
|
||||||
|
const _ReceiptPreviewDialog({required this.items});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Kvittotexten i sin helhet'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 600,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Här visas all OCR-parsad text från kvittot. En rad per artikel:',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerLowest,
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: items.isEmpty
|
||||||
|
? [TextSpan(text: '(Inga rader)', style: theme.textTheme.bodySmall)]
|
||||||
|
: items
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) {
|
||||||
|
final item = entry.value;
|
||||||
|
final lineNumber = entry.key + 1;
|
||||||
|
final lineText = _formatReceiptLine(item);
|
||||||
|
return TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$lineNumber. ',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: lineText,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: '\n'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Stäng'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatReceiptLine(ParsedReceiptItem item) {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (item.quantity != null) {
|
||||||
|
parts.add('${item.quantity}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.unit != null) {
|
||||||
|
parts.add(item.unit!);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.add(item.rawName);
|
||||||
|
|
||||||
|
if (item.price != null) {
|
||||||
|
parts.add('— ${item.price} kr');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,23 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
|
|||||||
|
|
||||||
## Prioriterade nasta steg
|
## Prioriterade nasta steg
|
||||||
|
|
||||||
1. Verifiera bildimport och felhantering end-to-end i testmiljo.
|
1. **Kvitto-import UX förbättring (Split-view långsiktigt)**
|
||||||
2. Implementera alias-inlarning vid manuell korrigering i importflodet.
|
- MVP (kort sikt): Lägg till "Se kvitto"-modal som visar full OCR-text från parsade rader
|
||||||
3. Forbattra UI/UX i granskningsfloden for kvittoimport.
|
- Knapp i radlist-header, öppnar dialog med ScrollableText
|
||||||
4. Fortsatt migrering av kvarvarande adminfloden.
|
- Enkelt UI, höga UX-vinster
|
||||||
5. Lokalisera kvarvarande delar i import- och inventarievyer.
|
- Implementering: ~2h
|
||||||
|
- Långsiktigt (Phase 2): Split-view med scroll-synkronisering
|
||||||
|
- Desktop: Horisontell split (kvitto-text vänster, radlista höger)
|
||||||
|
- Tablet/Mobil: Tab-based fallback (radlista standard, "Se kvitto"-tab för kontext)
|
||||||
|
- Scroll-sync mellan text och rader (om rad 3 är synlig, visa motsvarande text)
|
||||||
|
- AI-guiding labels ("Denna rad matchade mejeri automatiskt")
|
||||||
|
- Implementering: ~8h
|
||||||
|
|
||||||
|
2. Verifiera bildimport och felhantering end-to-end i testmiljo.
|
||||||
|
3. Implementera alias-inlarning vid manuell korrigering i importflodet.
|
||||||
|
4. Forbattra UI/UX i granskningsfloden for kvittoimport.
|
||||||
|
5. Fortsatt migrering av kvarvarande adminfloden.
|
||||||
|
6. Lokalisera kvarvarande delar i import- och inventarievyer.
|
||||||
|
|
||||||
## Viktiga beslut
|
## Viktiga beslut
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user