parser logic added
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/// Drag a crop box over the receipt photo before OCR runs.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crop_your_image/crop_your_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CropReceiptScreen extends StatefulWidget {
|
||||
const CropReceiptScreen({super.key, required this.imagePath});
|
||||
|
||||
final String imagePath;
|
||||
|
||||
@override
|
||||
State<CropReceiptScreen> createState() => _CropReceiptScreenState();
|
||||
}
|
||||
|
||||
class _CropReceiptScreenState extends State<CropReceiptScreen> {
|
||||
final CropController _controller = CropController();
|
||||
|
||||
Uint8List? _image;
|
||||
String? _error;
|
||||
bool _ready = false;
|
||||
bool _cropping = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final bytes = await File(widget.imagePath).readAsBytes();
|
||||
if (!mounted) return;
|
||||
setState(() => _image = bytes);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = 'Could not open this photo.');
|
||||
}
|
||||
}
|
||||
|
||||
void _onCropped(CropResult result) {
|
||||
switch (result) {
|
||||
case CropSuccess(:final croppedImage):
|
||||
_saveAndPop(croppedImage);
|
||||
case CropFailure():
|
||||
if (!mounted) return;
|
||||
setState(() => _cropping = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not crop this photo.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAndPop(Uint8List bytes) async {
|
||||
try {
|
||||
final file = File(
|
||||
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(file.path);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _cropping = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not save the cropped photo.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
if (!_ready || _cropping) return;
|
||||
setState(() => _cropping = true);
|
||||
_controller.crop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_cropping,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Crop receipt')),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: _editor(theme)),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Drag the corners over the text you want to read. Pinch to zoom.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _ready && !_cropping ? _confirm : null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(_cropping ? 'Cropping…' : 'Use this area'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _editor(ThemeData theme) {
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final image = _image;
|
||||
if (image == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Crop(
|
||||
image: image,
|
||||
controller: _controller,
|
||||
onCropped: _onCropped,
|
||||
interactive: true,
|
||||
filterQuality: FilterQuality.medium,
|
||||
baseColor: theme.colorScheme.surface,
|
||||
maskColor: Colors.black.withValues(alpha: 0.55),
|
||||
radius: 8,
|
||||
progressIndicator: const Center(child: CircularProgressIndicator()),
|
||||
initialRectBuilder: InitialRectBuilder.withBuilder((
|
||||
viewportRect,
|
||||
imageRect,
|
||||
) {
|
||||
final dx = imageRect.width * 0.06;
|
||||
final dy = imageRect.height * 0.06;
|
||||
return Rect.fromLTRB(
|
||||
imageRect.left + dx,
|
||||
imageRect.top + dy,
|
||||
imageRect.right - dx,
|
||||
imageRect.bottom - dy,
|
||||
);
|
||||
}),
|
||||
cornerDotBuilder: (size, _) =>
|
||||
DotControl(color: theme.colorScheme.primary),
|
||||
onStatusChanged: (status) {
|
||||
final ready = status == CropStatus.ready;
|
||||
if (ready == _ready) return;
|
||||
setState(() => _ready = ready);
|
||||
},
|
||||
),
|
||||
if (_cropping)
|
||||
const ColoredBox(
|
||||
color: Color(0x66000000),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
-20
@@ -1,4 +1,4 @@
|
||||
/// Bottom navigation: products, scan, and shop log.
|
||||
/// Bottom navigation: products, lists, add, and shop log.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,6 +8,7 @@ import 'scan_screen.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'shop_log_screen.dart';
|
||||
import 'shopping_lists_screen.dart';
|
||||
|
||||
class HomeShell extends StatefulWidget {
|
||||
const HomeShell({super.key, required this.repository});
|
||||
@@ -19,10 +20,10 @@ class HomeShell extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
static const _scanIndex = 1;
|
||||
static const _shopLogIndex = 2;
|
||||
static const _addIndex = 2;
|
||||
static const _shopLogIndex = 3;
|
||||
|
||||
int _index = _scanIndex;
|
||||
int _index = _addIndex;
|
||||
int _revision = 0;
|
||||
AppSettings? _settings;
|
||||
int _seenGeneration = 0;
|
||||
@@ -59,9 +60,8 @@ class _HomeShellState extends State<HomeShell> {
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Shopping trip saved.')),
|
||||
);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Shopping trip saved.')));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,17 +70,21 @@ class _HomeShellState extends State<HomeShell> {
|
||||
return Scaffold(
|
||||
body: switch (_index) {
|
||||
0 => ProductsScreen(
|
||||
key: ValueKey('products-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
1 => ScanScreen(
|
||||
repository: widget.repository,
|
||||
onTripSaved: _onTripSaved,
|
||||
),
|
||||
key: ValueKey('products-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
1 => ShoppingListsScreen(
|
||||
key: ValueKey('lists-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
2 => ScanScreen(
|
||||
repository: widget.repository,
|
||||
onTripSaved: _onTripSaved,
|
||||
),
|
||||
_ => ShopLogScreen(
|
||||
key: ValueKey('shops-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
key: ValueKey('shops-$_revision'),
|
||||
repository: widget.repository,
|
||||
),
|
||||
},
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
@@ -92,9 +96,14 @@ class _HomeShellState extends State<HomeShell> {
|
||||
label: 'Products',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.photo_camera_outlined),
|
||||
selectedIcon: Icon(Icons.photo_camera),
|
||||
label: 'Scan',
|
||||
icon: Icon(Icons.checklist_outlined),
|
||||
selectedIcon: Icon(Icons.checklist),
|
||||
label: 'Lists',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.add),
|
||||
selectedIcon: Icon(Icons.add_circle),
|
||||
label: 'Add',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
|
||||
+4
-2
@@ -6,17 +6,19 @@ import 'package:flutter/material.dart';
|
||||
import 'home_shell.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'services/receipt_parser.dart';
|
||||
import 'utils/app_theme.dart';
|
||||
import 'utils/constants.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await ReceiptParser.loadConfig();
|
||||
runApp(ReceipityApp());
|
||||
}
|
||||
|
||||
class ReceipityApp extends StatefulWidget {
|
||||
ReceipityApp({super.key, ProductRepository? repository})
|
||||
: repository = repository ?? ProductRepository();
|
||||
: repository = repository ?? ProductRepository();
|
||||
|
||||
final ProductRepository repository;
|
||||
|
||||
|
||||
@@ -8,20 +8,27 @@ class LineItem {
|
||||
required this.price,
|
||||
this.quantity,
|
||||
this.unitPrice,
|
||||
this.discount = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// Line total after any discount.
|
||||
final double price;
|
||||
final int? quantity;
|
||||
final double? unitPrice;
|
||||
|
||||
/// Negative amount when a discount was folded into [price].
|
||||
final double discount;
|
||||
|
||||
LineItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? price,
|
||||
int? quantity,
|
||||
double? unitPrice,
|
||||
double? discount,
|
||||
bool clearQuantity = false,
|
||||
}) {
|
||||
return LineItem(
|
||||
@@ -30,6 +37,7 @@ class LineItem {
|
||||
price: price ?? this.price,
|
||||
quantity: clearQuantity ? null : (quantity ?? this.quantity),
|
||||
unitPrice: clearQuantity ? null : (unitPrice ?? this.unitPrice),
|
||||
discount: discount ?? this.discount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// A named list of things to buy.
|
||||
library;
|
||||
|
||||
class ShoppingList {
|
||||
const ShoppingList({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.itemCount,
|
||||
required this.checkedCount,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final int itemCount;
|
||||
final int checkedCount;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory ShoppingList.fromMap(Map<String, Object?> map) {
|
||||
return ShoppingList(
|
||||
id: map['id']! as int,
|
||||
name: map['name']! as String,
|
||||
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
|
||||
checkedCount: (map['checked_count'] as num?)?.toInt() ?? 0,
|
||||
updatedAt: DateTime.parse(map['updated_at']! as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShoppingListItem {
|
||||
const ShoppingListItem({
|
||||
required this.id,
|
||||
required this.listId,
|
||||
required this.name,
|
||||
required this.checked,
|
||||
required this.sortOrder,
|
||||
this.productId,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int listId;
|
||||
final String name;
|
||||
final bool checked;
|
||||
final int sortOrder;
|
||||
final int? productId;
|
||||
|
||||
factory ShoppingListItem.fromMap(Map<String, Object?> map) {
|
||||
return ShoppingListItem(
|
||||
id: map['id']! as int,
|
||||
listId: map['list_id']! as int,
|
||||
name: map['name']! as String,
|
||||
checked: (map['checked'] as int? ?? 0) == 1,
|
||||
sortOrder: (map['sort_order'] as num?)?.toInt() ?? 0,
|
||||
productId: map['product_id'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
-117
@@ -9,6 +9,7 @@ import 'services/product_repository.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'utils/supermarkets.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
@@ -19,6 +20,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
this.imagePath,
|
||||
this.supermarketNames = kSupermarkets,
|
||||
this.needsReview = false,
|
||||
this.detectedStore,
|
||||
});
|
||||
|
||||
final List<LineItem> items;
|
||||
@@ -26,6 +29,8 @@ class ReviewScreen extends StatefulWidget {
|
||||
final ProductRepository repository;
|
||||
final String? imagePath;
|
||||
final List<String> supermarketNames;
|
||||
final bool needsReview;
|
||||
final String? detectedStore;
|
||||
|
||||
@override
|
||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||
@@ -49,6 +54,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
);
|
||||
if (detected != null) {
|
||||
_supermarket = detected;
|
||||
} else if (widget.detectedStore != null) {
|
||||
_supermarket = widget.detectedStore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +70,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
}
|
||||
|
||||
Future<void> _edit({LineItem? existing}) async {
|
||||
final result = await showModalBottomSheet<LineItem>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _ItemEditSheet(item: existing),
|
||||
);
|
||||
final result = await showItemEditSheet(context, item: existing);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
@@ -97,9 +99,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
Future<void> _confirm() async {
|
||||
final supermarket = _resolvedSupermarket;
|
||||
if (supermarket == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Select a supermarket.')),
|
||||
);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Select a supermarket.')));
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
@@ -142,7 +143,13 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Review items')),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.rawText.isEmpty && widget.imagePath == null
|
||||
? 'New trip'
|
||||
: 'Review items',
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -150,13 +157,27 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.needsReview) ...[
|
||||
Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Parsed totals do not match the receipt. Check the items before saving.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(_supermarket),
|
||||
initialValue: _supermarket,
|
||||
hint: const Text('Select supermarket'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Supermarket',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Supermarket'),
|
||||
items: [
|
||||
for (final store in names)
|
||||
DropdownMenuItem(
|
||||
@@ -177,9 +198,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
TextField(
|
||||
enabled: !_saving,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Store name',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Store name'),
|
||||
onChanged: (value) => _customStore = value,
|
||||
),
|
||||
],
|
||||
@@ -198,7 +217,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
child: _items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No line items found.\nAdd anything the parser missed.',
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
@@ -231,11 +250,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: item.quantity == null
|
||||
? null
|
||||
: Text(
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
@@ -274,9 +289,7 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _confirm,
|
||||
child: Text(
|
||||
_saving
|
||||
? 'Saving…'
|
||||
: 'Save trip · ${formatEuro(total)}',
|
||||
_saving ? 'Saving…' : 'Save trip · ${formatEuro(total)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -287,98 +300,14 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemEditSheet extends StatefulWidget {
|
||||
const _ItemEditSheet({this.item});
|
||||
|
||||
final LineItem? item;
|
||||
|
||||
@override
|
||||
State<_ItemEditSheet> createState() => _ItemEditSheetState();
|
||||
}
|
||||
|
||||
class _ItemEditSheetState extends State<_ItemEditSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _priceController;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final item = widget.item;
|
||||
_nameController = TextEditingController(text: item?.name ?? '');
|
||||
_priceController = TextEditingController(
|
||||
text: item == null ? '' : item.price.toStringAsFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_priceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _nameController.text.trim();
|
||||
final price = tryParsePrice(_priceController.text);
|
||||
if (name.isEmpty || price == null) {
|
||||
setState(() => _error = 'Enter a product name and a price.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
LineItem(
|
||||
id: widget.item?.id ?? 'manual-${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name,
|
||||
price: roundMoney(price),
|
||||
quantity: widget.item?.quantity,
|
||||
unitPrice: widget.item?.unitPrice,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.item == null ? 'Add item' : 'Edit item',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(labelText: 'Product name'),
|
||||
autofocus: widget.item == null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _priceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Price'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Widget? _itemSubtitle(LineItem item) {
|
||||
final parts = [
|
||||
if (item.quantity != null)
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
if (item.discount != 0) 'Discount ${formatEuro(item.discount.abs())}',
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return Text(parts.join(' · '));
|
||||
}
|
||||
}
|
||||
|
||||
+47
-22
@@ -8,6 +8,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'crop_receipt_screen.dart';
|
||||
import 'models/line_item.dart';
|
||||
import 'review_screen.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
@@ -17,11 +19,7 @@ import 'utils/supermarkets.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
|
||||
class ScanScreen extends StatefulWidget {
|
||||
const ScanScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.onTripSaved,
|
||||
});
|
||||
const ScanScreen({super.key, required this.repository, this.onTripSaved});
|
||||
|
||||
final ProductRepository repository;
|
||||
final VoidCallback? onTripSaved;
|
||||
@@ -51,7 +49,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
if (!mounted || response.isEmpty) return;
|
||||
final file = response.file;
|
||||
if (file != null) {
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
}
|
||||
} on UnimplementedError {
|
||||
// Desktop / platforms that do not support lost-data recovery.
|
||||
@@ -59,12 +57,9 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
|
||||
Future<void> _capture(ImageSource source) async {
|
||||
final file = await _picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 95,
|
||||
);
|
||||
final file = await _picker.pickImage(source: source, imageQuality: 95);
|
||||
if (file == null || !mounted) return;
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
}
|
||||
|
||||
Future<void> _scanSampleReceipt() async {
|
||||
@@ -75,7 +70,7 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
);
|
||||
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
|
||||
if (!mounted) return;
|
||||
await _runOcr(file.path);
|
||||
await _cropThenOcr(file.path);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -84,6 +79,17 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cropThenOcr(String imagePath) async {
|
||||
final croppedPath = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => CropReceiptScreen(imagePath: imagePath),
|
||||
),
|
||||
);
|
||||
if (croppedPath == null || !mounted) return;
|
||||
await _runOcr(croppedPath);
|
||||
}
|
||||
|
||||
Future<void> _runOcr(String imagePath) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
@@ -127,14 +133,26 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enterManually() async {
|
||||
await _openReview('', items: const []);
|
||||
}
|
||||
|
||||
String _ocrFailureMessage(String? detail) {
|
||||
const fallback = 'Could not read text from this image.';
|
||||
if (detail == null || detail.trim().isEmpty) return fallback;
|
||||
return '$fallback\n$detail';
|
||||
}
|
||||
|
||||
Future<void> _openReview(String rawText) async {
|
||||
final items = ReceiptParser.parse(rawText);
|
||||
Future<void> _openReview(
|
||||
String rawText, {
|
||||
List<LineItem>? items,
|
||||
String? imagePath,
|
||||
}) async {
|
||||
final parsedItems = items;
|
||||
ReceiptParseResult? parsed;
|
||||
if (parsedItems == null) {
|
||||
parsed = ReceiptParser.parseReceipt(rawText);
|
||||
}
|
||||
if (!mounted) return;
|
||||
final names =
|
||||
SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ??
|
||||
@@ -142,11 +160,13 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
final saved = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReviewScreen(
|
||||
items: items,
|
||||
items: parsedItems ?? parsed!.items,
|
||||
rawText: rawText,
|
||||
repository: widget.repository,
|
||||
imagePath: _imagePath,
|
||||
imagePath: imagePath ?? (items == null ? _imagePath : null),
|
||||
supermarketNames: names.toList(),
|
||||
needsReview: parsed != null && !parsed.validationPassed,
|
||||
detectedStore: parsed?.store,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -182,6 +202,12 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
label: const Text('Pick from gallery'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _enterManually,
|
||||
icon: const Icon(Icons.edit_note_outlined),
|
||||
label: const Text('Enter manually'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _scanSampleReceipt,
|
||||
icon: const Icon(Icons.receipt_long_outlined),
|
||||
@@ -198,13 +224,12 @@ class _ScanScreenState extends State<ScanScreen> {
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Reading text…',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text('Reading text…', style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: _ResultPane(text: _extractedText, error: _error)),
|
||||
Expanded(
|
||||
child: _ResultPane(text: _extractedText, error: _error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -236,7 +261,7 @@ class _ResultPane extends StatelessWidget {
|
||||
if (text.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Take a photo of a shopping receipt, or pick one from the gallery.\n\nConfirmed trips show up in Shop log.',
|
||||
'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
const int kBackupVersion = 1;
|
||||
const int kBackupVersion = 2;
|
||||
|
||||
class BackupDocument {
|
||||
const BackupDocument({
|
||||
@@ -14,6 +14,8 @@ class BackupDocument {
|
||||
required this.products,
|
||||
required this.shops,
|
||||
required this.shopItems,
|
||||
required this.shoppingLists,
|
||||
required this.shoppingListItems,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@@ -24,6 +26,8 @@ class BackupDocument {
|
||||
final List<Map<String, Object?>> products;
|
||||
final List<Map<String, Object?>> shops;
|
||||
final List<Map<String, Object?>> shopItems;
|
||||
final List<Map<String, Object?>> shoppingLists;
|
||||
final List<Map<String, Object?>> shoppingListItems;
|
||||
final Map<String, String> images;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -35,6 +39,8 @@ class BackupDocument {
|
||||
'products': products,
|
||||
'shops': shops,
|
||||
'shopItems': shopItems,
|
||||
'shoppingLists': shoppingLists,
|
||||
'shoppingListItems': shoppingListItems,
|
||||
'images': images,
|
||||
};
|
||||
|
||||
@@ -60,6 +66,8 @@ class BackupDocument {
|
||||
products: _maps(decoded['products']),
|
||||
shops: _maps(decoded['shops']),
|
||||
shopItems: _maps(decoded['shopItems']),
|
||||
shoppingLists: _maps(decoded['shoppingLists']),
|
||||
shoppingListItems: _maps(decoded['shoppingListItems']),
|
||||
images: _strings(decoded['images']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ class BackupService {
|
||||
products: rewrittenProducts,
|
||||
shops: rewrittenShops,
|
||||
shopItems: await repository.dumpTable('shop_items'),
|
||||
shoppingLists: await repository.dumpTable('shopping_lists'),
|
||||
shoppingListItems: await repository.dumpTable('shopping_list_items'),
|
||||
images: images,
|
||||
);
|
||||
}
|
||||
@@ -114,7 +116,10 @@ class BackupService {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
for (final row in document.products) {
|
||||
final copy = Map<String, Object?>.from(row);
|
||||
copy['image_path'] = await _restoreImage(copy['image_path'], document.images);
|
||||
copy['image_path'] = await _restoreImage(
|
||||
copy['image_path'],
|
||||
document.images,
|
||||
);
|
||||
copy['created_at'] ??= now;
|
||||
copy['updated_at'] ??= copy['created_at'];
|
||||
if (copy['normalized_name'] == null && copy['name'] is String) {
|
||||
@@ -141,14 +146,13 @@ class BackupService {
|
||||
products: products,
|
||||
shops: shops,
|
||||
shopItems: document.shopItems,
|
||||
shoppingLists: document.shoppingLists,
|
||||
shoppingListItems: document.shoppingListItems,
|
||||
darkMode: document.darkMode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _restoreImage(
|
||||
Object? key,
|
||||
Map<String, String> images,
|
||||
) async {
|
||||
Future<String?> _restoreImage(Object? key, Map<String, String> images) async {
|
||||
if (key is! String || key.isEmpty) return null;
|
||||
final encoded = images[key];
|
||||
if (encoded == null) return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../models/line_item.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/purchase.dart';
|
||||
import '../models/shop.dart';
|
||||
import '../models/shopping_list.dart';
|
||||
import '../models/supermarket.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/supermarkets.dart';
|
||||
@@ -27,15 +28,20 @@ class ProductRepository {
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final path = databasePath ??
|
||||
final path =
|
||||
databasePath ??
|
||||
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||
return openDatabase(
|
||||
path,
|
||||
version: 4,
|
||||
version: 5,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onCreate: (db, version) async {
|
||||
await _createProducts(db);
|
||||
await _createShopTables(db);
|
||||
await _createSettingsTables(db);
|
||||
await _createShoppingListTables(db);
|
||||
await _seedSupermarkets(db);
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -55,6 +61,9 @@ class ProductRepository {
|
||||
if (oldVersion < 4) {
|
||||
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
await _createShoppingListTables(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -133,6 +142,29 @@ class ProductRepository {
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shopping_list_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
product_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
static String normalizeName(String name) =>
|
||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
@@ -315,13 +347,11 @@ class ProductRepository {
|
||||
|
||||
Future<List<String>> getUsedCategories() async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery(
|
||||
'''
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT DISTINCT category FROM products
|
||||
WHERE category IS NOT NULL AND TRIM(category) != ''
|
||||
ORDER BY category COLLATE NOCASE ASC
|
||||
''',
|
||||
);
|
||||
''');
|
||||
return [
|
||||
for (final row in rows)
|
||||
if (row['category'] is String) row['category']! as String,
|
||||
@@ -455,6 +485,238 @@ class ProductRepository {
|
||||
return rows.map(ShopItem.fromMap).toList();
|
||||
}
|
||||
|
||||
/// Removes a trip and its line items. Catalog products are left unchanged.
|
||||
Future<void> deleteShop(int id) async {
|
||||
final shop = await getShop(id);
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]);
|
||||
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
final path = shop?.receiptImagePath;
|
||||
if (path == null || path.isEmpty) return;
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes one trip line. Catalog products are left unchanged.
|
||||
Future<void> deleteShopItem(int id) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'shop_items',
|
||||
columns: ['shop_id'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return;
|
||||
final shopId = rows.first['shop_id']! as int;
|
||||
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
|
||||
await _recalculateShopTotal(shopId);
|
||||
}
|
||||
|
||||
Future<void> updateShopItem({
|
||||
required int id,
|
||||
String? name,
|
||||
double? price,
|
||||
int? quantity,
|
||||
double? unitPrice,
|
||||
}) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'shop_items',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return;
|
||||
final shopId = rows.first['shop_id']! as int;
|
||||
final values = <String, Object?>{};
|
||||
if (name != null) values['name'] = name.trim();
|
||||
if (price != null) values['price'] = price;
|
||||
if (quantity != null) values['quantity'] = quantity;
|
||||
if (unitPrice != null) values['unit_price'] = unitPrice;
|
||||
if (values.isEmpty) return;
|
||||
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
|
||||
await _recalculateShopTotal(shopId);
|
||||
}
|
||||
|
||||
Future<void> _recalculateShopTotal(int shopId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?',
|
||||
[shopId],
|
||||
);
|
||||
final total = roundMoney((rows.first['total'] as num).toDouble());
|
||||
await db.update(
|
||||
'shops',
|
||||
{'total': total},
|
||||
where: 'id = ?',
|
||||
whereArgs: [shopId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ShoppingList>> getShoppingLists() async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT
|
||||
shopping_lists.id,
|
||||
shopping_lists.name,
|
||||
shopping_lists.updated_at,
|
||||
COUNT(shopping_list_items.id) AS item_count,
|
||||
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
|
||||
FROM shopping_lists
|
||||
LEFT JOIN shopping_list_items
|
||||
ON shopping_list_items.list_id = shopping_lists.id
|
||||
GROUP BY shopping_lists.id
|
||||
ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC
|
||||
''');
|
||||
return rows.map(ShoppingList.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<ShoppingList?> getShoppingList(int id) async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
shopping_lists.id,
|
||||
shopping_lists.name,
|
||||
shopping_lists.updated_at,
|
||||
COUNT(shopping_list_items.id) AS item_count,
|
||||
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
|
||||
FROM shopping_lists
|
||||
LEFT JOIN shopping_list_items
|
||||
ON shopping_list_items.list_id = shopping_lists.id
|
||||
WHERE shopping_lists.id = ?
|
||||
GROUP BY shopping_lists.id
|
||||
''',
|
||||
[id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return ShoppingList.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<int> createShoppingList(String name) async {
|
||||
final db = await _database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
return db.insert('shopping_lists', {
|
||||
'name': name.trim(),
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> renameShoppingList(int id, String name) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'shopping_lists',
|
||||
{'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteShoppingList(int id) async {
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'shopping_list_items',
|
||||
where: 'list_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'shopping_list_items',
|
||||
where: 'list_id = ?',
|
||||
whereArgs: [listId],
|
||||
orderBy: 'checked ASC, sort_order ASC, id ASC',
|
||||
);
|
||||
return rows.map(ShoppingListItem.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<int> addShoppingListItem({
|
||||
required int listId,
|
||||
required String name,
|
||||
int? productId,
|
||||
}) async {
|
||||
final db = await _database;
|
||||
final max = Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
|
||||
[listId],
|
||||
),
|
||||
);
|
||||
final id = await db.insert('shopping_list_items', {
|
||||
'list_id': listId,
|
||||
'product_id': productId,
|
||||
'name': name.trim(),
|
||||
'checked': 0,
|
||||
'sort_order': (max ?? -1) + 1,
|
||||
});
|
||||
await _touchShoppingList(listId);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> updateShoppingListItem({
|
||||
required int id,
|
||||
String? name,
|
||||
bool? checked,
|
||||
int? productId,
|
||||
}) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'shopping_list_items',
|
||||
columns: ['list_id'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return;
|
||||
final values = <String, Object?>{};
|
||||
if (name != null) values['name'] = name.trim();
|
||||
if (checked != null) values['checked'] = checked ? 1 : 0;
|
||||
if (productId != null) values['product_id'] = productId;
|
||||
if (values.isEmpty) return;
|
||||
await db.update(
|
||||
'shopping_list_items',
|
||||
values,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await _touchShoppingList(rows.first['list_id']! as int);
|
||||
}
|
||||
|
||||
Future<void> deleteShoppingListItem(int id) async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'shopping_list_items',
|
||||
columns: ['list_id'],
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return;
|
||||
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
|
||||
await _touchShoppingList(rows.first['list_id']! as int);
|
||||
}
|
||||
|
||||
Future<void> _touchShoppingList(int id) async {
|
||||
final db = await _database;
|
||||
await db.update(
|
||||
'shopping_lists',
|
||||
{'updated_at': DateTime.now().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> getDarkMode() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
@@ -469,16 +731,18 @@ class ProductRepository {
|
||||
|
||||
Future<void> setDarkMode(bool enabled) async {
|
||||
final db = await _database;
|
||||
await db.insert(
|
||||
'app_settings',
|
||||
{'key': 'dark_mode', 'value': enabled ? '1' : '0'},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await db.insert('app_settings', {
|
||||
'key': 'dark_mode',
|
||||
'value': enabled ? '1' : '0',
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<List<Supermarket>> getSupermarkets() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('supermarkets', orderBy: 'sort_order ASC, id ASC');
|
||||
final rows = await db.query(
|
||||
'supermarkets',
|
||||
orderBy: 'sort_order ASC, id ASC',
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _seedSupermarkets(db);
|
||||
return getSupermarkets();
|
||||
@@ -579,9 +843,13 @@ class ProductRepository {
|
||||
required List<Map<String, Object?>> shops,
|
||||
required List<Map<String, Object?>> shopItems,
|
||||
required bool darkMode,
|
||||
List<Map<String, Object?>> shoppingLists = const [],
|
||||
List<Map<String, Object?>> shoppingListItems = const [],
|
||||
}) async {
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('shopping_list_items');
|
||||
await txn.delete('shopping_lists');
|
||||
await txn.delete('shop_items');
|
||||
await txn.delete('shops');
|
||||
await txn.delete('products');
|
||||
@@ -600,6 +868,15 @@ class ProductRepository {
|
||||
for (final row in shopItems) {
|
||||
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
|
||||
}
|
||||
for (final row in shoppingLists) {
|
||||
await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns));
|
||||
}
|
||||
for (final row in shoppingListItems) {
|
||||
await txn.insert(
|
||||
'shopping_list_items',
|
||||
_sqlRow(row, _shoppingListItemColumns),
|
||||
);
|
||||
}
|
||||
await txn.insert('app_settings', {
|
||||
'key': 'dark_mode',
|
||||
'value': darkMode ? '1' : '0',
|
||||
@@ -609,15 +886,12 @@ class ProductRepository {
|
||||
await _resetSequence(txn, 'products');
|
||||
await _resetSequence(txn, 'shops');
|
||||
await _resetSequence(txn, 'shop_items');
|
||||
await _resetSequence(txn, 'shopping_lists');
|
||||
await _resetSequence(txn, 'shopping_list_items');
|
||||
});
|
||||
}
|
||||
|
||||
static const _supermarketColumns = {
|
||||
'id',
|
||||
'name',
|
||||
'color',
|
||||
'sort_order',
|
||||
};
|
||||
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
|
||||
static const _productColumns = {
|
||||
'id',
|
||||
'name',
|
||||
@@ -649,6 +923,20 @@ class ProductRepository {
|
||||
'quantity',
|
||||
'unit_price',
|
||||
};
|
||||
static const _shoppingListColumns = {
|
||||
'id',
|
||||
'name',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
};
|
||||
static const _shoppingListItemColumns = {
|
||||
'id',
|
||||
'list_id',
|
||||
'product_id',
|
||||
'name',
|
||||
'checked',
|
||||
'sort_order',
|
||||
};
|
||||
|
||||
Map<String, Object?> _sqlRow(
|
||||
Map<String, Object?> source,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/// Keyword and regex config loaded from receipt-parsing-keywords.json.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ReceiptParseConfig {
|
||||
ReceiptParseConfig({
|
||||
required this.knownStores,
|
||||
required this.columnHeaderKeywords,
|
||||
required this.separatorPattern,
|
||||
required this.quantityStandalone,
|
||||
required this.quantityInline,
|
||||
required this.discountKeywords,
|
||||
required this.negativeAmount,
|
||||
required this.totalDiscountKeywords,
|
||||
required this.subtotalKeywords,
|
||||
required this.grandTotalKeywords,
|
||||
required this.taxInclusiveTotalKeywords,
|
||||
required this.paymentKeywords,
|
||||
required this.taxKeywords,
|
||||
required this.categoryRow,
|
||||
required this.trailingCategoryLetter,
|
||||
required this.noiseKeywords,
|
||||
required this.itemCountKeywords,
|
||||
});
|
||||
|
||||
final List<String> knownStores;
|
||||
final List<String> columnHeaderKeywords;
|
||||
final RegExp separatorPattern;
|
||||
final RegExp quantityStandalone;
|
||||
final RegExp quantityInline;
|
||||
final List<String> discountKeywords;
|
||||
final RegExp negativeAmount;
|
||||
final List<String> totalDiscountKeywords;
|
||||
final List<String> subtotalKeywords;
|
||||
final List<String> grandTotalKeywords;
|
||||
final List<String> taxInclusiveTotalKeywords;
|
||||
final List<String> paymentKeywords;
|
||||
final List<String> taxKeywords;
|
||||
final RegExp categoryRow;
|
||||
final RegExp trailingCategoryLetter;
|
||||
final List<String> noiseKeywords;
|
||||
final List<String> itemCountKeywords;
|
||||
|
||||
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
|
||||
List<String> list(String section, [String key = 'list']) {
|
||||
final block = json[section];
|
||||
if (block is! Map) return const [];
|
||||
final value = block[key];
|
||||
if (value is! List) return const [];
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String) item,
|
||||
];
|
||||
}
|
||||
|
||||
RegExp rx(String section, String key, {bool caseInsensitive = true}) {
|
||||
final block = json[section];
|
||||
final value = block is Map ? block[key] : null;
|
||||
final source = value is String ? value : '^\$';
|
||||
return RegExp(source, caseSensitive: !caseInsensitive);
|
||||
}
|
||||
|
||||
return ReceiptParseConfig(
|
||||
knownStores: list('known_stores'),
|
||||
columnHeaderKeywords: list('column_header_keywords'),
|
||||
separatorPattern: rx('column_header_keywords', 'separator_pattern'),
|
||||
quantityStandalone: rx(
|
||||
'quantity_modifier_pattern',
|
||||
'regex',
|
||||
caseInsensitive: true,
|
||||
),
|
||||
quantityInline: rx(
|
||||
'quantity_modifier_pattern',
|
||||
'inline_regex',
|
||||
caseInsensitive: true,
|
||||
),
|
||||
discountKeywords: list('discount_keywords'),
|
||||
negativeAmount: rx('discount_keywords', 'negative_amount_pattern'),
|
||||
totalDiscountKeywords: list(
|
||||
'discount_keywords',
|
||||
'receipt_level_total_discount_keywords',
|
||||
),
|
||||
subtotalKeywords: list('total_keywords', 'subtotal_keywords'),
|
||||
grandTotalKeywords: list('total_keywords', 'grand_total_keywords'),
|
||||
taxInclusiveTotalKeywords: list(
|
||||
'total_keywords',
|
||||
'tax_inclusive_total_keywords',
|
||||
),
|
||||
paymentKeywords: list('payment_keywords'),
|
||||
taxKeywords: list('tax_breakdown_keywords'),
|
||||
categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'),
|
||||
trailingCategoryLetter: rx(
|
||||
'tax_breakdown_keywords',
|
||||
'trailing_category_letter_pattern',
|
||||
),
|
||||
noiseKeywords: list('noise_footer_keywords'),
|
||||
itemCountKeywords: list('line_item_count_keywords'),
|
||||
);
|
||||
}
|
||||
|
||||
static const assetPath = 'receipt-parsing-keywords.json';
|
||||
|
||||
/// True if [line] matches a config keyword.
|
||||
///
|
||||
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
|
||||
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
|
||||
/// and a letter-only compact form so OCR splits like `Betaal d:` still
|
||||
/// match `BETAALD`.
|
||||
bool matchesKeyword(String line, String keyword) {
|
||||
final upper = line.toUpperCase();
|
||||
final key = keyword.toUpperCase();
|
||||
if (key.length <= 3) {
|
||||
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
|
||||
}
|
||||
if (upper.contains(key)) return true;
|
||||
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
return compactKey.length >= 4 && compactLine.contains(compactKey);
|
||||
}
|
||||
|
||||
bool isStandaloneKeywordLine(String line, List<String> keywords) {
|
||||
final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
for (final keyword in keywords) {
|
||||
final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
if (key.isEmpty) continue;
|
||||
if (compact == key) return true;
|
||||
if (compact.startsWith(key) && compact.length <= key.length + 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesAny(String line, List<String> keywords) {
|
||||
for (final keyword in keywords) {
|
||||
if (matchesKeyword(line, keyword)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ReceiptParseConfig? _loadedConfig;
|
||||
|
||||
ReceiptParseConfig get receiptParseConfig {
|
||||
if (_loadedConfig != null) return _loadedConfig!;
|
||||
final file = File(ReceiptParseConfig.assetPath);
|
||||
if (file.existsSync()) {
|
||||
_loadedConfig = ReceiptParseConfig.fromJson(
|
||||
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
|
||||
);
|
||||
return _loadedConfig!;
|
||||
}
|
||||
throw StateError(
|
||||
'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().',
|
||||
);
|
||||
}
|
||||
|
||||
void setReceiptParseConfig(ReceiptParseConfig config) {
|
||||
_loadedConfig = config;
|
||||
}
|
||||
|
||||
Future<void> loadReceiptParseConfig() async {
|
||||
try {
|
||||
final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath);
|
||||
_loadedConfig = ReceiptParseConfig.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
final file = File(ReceiptParseConfig.assetPath);
|
||||
if (!file.existsSync()) rethrow;
|
||||
_loadedConfig = ReceiptParseConfig.fromJson(
|
||||
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
}
|
||||
+499
-113
@@ -1,57 +1,31 @@
|
||||
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter.
|
||||
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
|
||||
library;
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.dart';
|
||||
import 'receipt_parse_config.dart';
|
||||
|
||||
/// Headers, payments, and totals — never treated as products.
|
||||
const Set<String> kHeaderKeywords = {
|
||||
'TOTAL',
|
||||
'SUBTOTAL',
|
||||
'TOTAAL',
|
||||
'TAX',
|
||||
'VAT',
|
||||
'BTW',
|
||||
'CASH',
|
||||
'CARD',
|
||||
'CHANGE',
|
||||
'VISA',
|
||||
'MASTERCARD',
|
||||
'MAESTRO',
|
||||
'PIN',
|
||||
'DEBIT',
|
||||
'CREDIT',
|
||||
'OMSCHRIJVING',
|
||||
'BEDRAG',
|
||||
'AMOUNT',
|
||||
'DESCRIPTION',
|
||||
'THANK',
|
||||
'BEDANKT',
|
||||
'WISSELGELD',
|
||||
'BETAALD',
|
||||
'BETAAL',
|
||||
'CONTANT',
|
||||
'KASSA',
|
||||
'RECEIPT',
|
||||
'WWW',
|
||||
'HTTP',
|
||||
};
|
||||
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
|
||||
|
||||
/// Discount / promo rows. Paired with a price, then dropped.
|
||||
const Set<String> kDropKeywords = {
|
||||
'DISCOUNT',
|
||||
'KORTING',
|
||||
'ACTIE',
|
||||
};
|
||||
/// Result of a full receipt parse, including totals used for review.
|
||||
class ReceiptParseResult {
|
||||
const ReceiptParseResult({
|
||||
required this.items,
|
||||
this.store,
|
||||
this.receiptTotal,
|
||||
this.totalDiscount = 0,
|
||||
this.validationPassed = true,
|
||||
});
|
||||
|
||||
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
|
||||
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
|
||||
final _phonePattern = RegExp(r'^\d{8,}$');
|
||||
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
|
||||
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
|
||||
final List<LineItem> items;
|
||||
final String? store;
|
||||
final double? receiptTotal;
|
||||
final double totalDiscount;
|
||||
final bool validationPassed;
|
||||
}
|
||||
|
||||
class _QtyLine {
|
||||
const _QtyLine({required this.count, required this.unitPrice, this.total});
|
||||
class _Qty {
|
||||
const _Qty({required this.count, required this.unitPrice, this.total});
|
||||
|
||||
final int count;
|
||||
final double unitPrice;
|
||||
@@ -60,6 +34,59 @@ class _QtyLine {
|
||||
double get lineTotal => total ?? roundMoney(count * unitPrice);
|
||||
}
|
||||
|
||||
class _Pending {
|
||||
_Pending({
|
||||
required this.name,
|
||||
this.unitPrice,
|
||||
this.quantity = 1,
|
||||
this.lineTotal,
|
||||
});
|
||||
|
||||
String name;
|
||||
double? unitPrice;
|
||||
int quantity;
|
||||
double? lineTotal;
|
||||
double discount = 0;
|
||||
}
|
||||
|
||||
enum _Kind {
|
||||
skip,
|
||||
item,
|
||||
nameOnly,
|
||||
quantity,
|
||||
discount,
|
||||
grandTotal,
|
||||
totalDiscount,
|
||||
priceOnly,
|
||||
}
|
||||
|
||||
class _Line {
|
||||
const _Line({
|
||||
required this.kind,
|
||||
required this.raw,
|
||||
this.name,
|
||||
this.amount,
|
||||
this.qty,
|
||||
});
|
||||
|
||||
final _Kind kind;
|
||||
final String raw;
|
||||
final String? name;
|
||||
final double? amount;
|
||||
final _Qty? qty;
|
||||
}
|
||||
|
||||
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
|
||||
final _phonePattern = RegExp(r'^\d{8,}$');
|
||||
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
|
||||
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
|
||||
|
||||
/// Jumbo-style OCR often adds a line total after `N X unit`.
|
||||
final _qtyWithTotal = RegExp(
|
||||
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
class ReceiptParser {
|
||||
ReceiptParser._();
|
||||
|
||||
@@ -67,74 +94,198 @@ class ReceiptParser {
|
||||
|
||||
static String _id() => 'item-${++_nextId}';
|
||||
|
||||
/// Parses [rawText] into product rows.
|
||||
///
|
||||
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
|
||||
/// the name has no price). If OCR split the receipt into a name column and a
|
||||
/// price column, falls back to pairing those columns.
|
||||
static List<LineItem> parse(String rawText) {
|
||||
static Future<void> loadConfig() => loadReceiptParseConfig();
|
||||
|
||||
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
|
||||
|
||||
static ReceiptParseResult parseReceipt(String rawText) {
|
||||
final config = receiptParseConfig;
|
||||
final lines = rawText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.map(_preprocess)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final inline = _parseInline(lines);
|
||||
final columns = _parseColumns(lines);
|
||||
final chosen = columns.length > inline.length ? columns : inline;
|
||||
return chosen.where(_keepItem).toList();
|
||||
if (_looksLikeColumnOcr(lines, config)) {
|
||||
return _parseColumns(lines, config, rawText);
|
||||
}
|
||||
return _parseSequential(lines, config, rawText);
|
||||
}
|
||||
|
||||
static List<LineItem> _parseInline(List<String> lines) {
|
||||
final items = <LineItem>[];
|
||||
static String _preprocess(String line) =>
|
||||
line.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
|
||||
static String _stripTax(String line, ReceiptParseConfig config) {
|
||||
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
|
||||
}
|
||||
|
||||
final qty = _parseQty(line);
|
||||
if (qty != null) continue;
|
||||
|
||||
final trailing = trailingPricePattern.firstMatch(line);
|
||||
if (trailing != null) {
|
||||
final name = trailing.group(1)!.trim();
|
||||
final price = tryParsePrice(trailing.group(2)!);
|
||||
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
|
||||
items.add(LineItem(id: _id(), name: name, price: price));
|
||||
continue;
|
||||
static bool _looksLikeColumnOcr(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
) {
|
||||
var run = 0;
|
||||
var maxRun = 0;
|
||||
var prices = 0;
|
||||
var names = 0;
|
||||
for (final line in lines) {
|
||||
final stripped = _stripTax(line, config);
|
||||
if (isPriceOnly(stripped)) {
|
||||
prices++;
|
||||
run++;
|
||||
if (run > maxRun) maxRun = run;
|
||||
} else {
|
||||
run = 0;
|
||||
if (_parseQty(stripped, config) == null &&
|
||||
!_isIgnorable(stripped, config)) {
|
||||
names++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxRun >= 4 && prices >= 4 && names >= 4;
|
||||
}
|
||||
|
||||
if (i + 1 >= lines.length) continue;
|
||||
final nextQty = _parseQty(lines[i + 1]);
|
||||
if (nextQty == null) continue;
|
||||
items.add(
|
||||
LineItem(
|
||||
id: _id(),
|
||||
name: line,
|
||||
price: nextQty.lineTotal,
|
||||
quantity: nextQty.count,
|
||||
unitPrice: nextQty.unitPrice,
|
||||
),
|
||||
);
|
||||
i++;
|
||||
static ReceiptParseResult _parseSequential(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
String rawText,
|
||||
) {
|
||||
final items = <LineItem>[];
|
||||
_Pending? pending;
|
||||
var receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
config.grandTotalKeywords,
|
||||
skipIf: (line) => config.matchesAny(line, [
|
||||
...config.totalDiscountKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
]),
|
||||
);
|
||||
var totalDiscount = 0.0;
|
||||
var stop = false;
|
||||
|
||||
void flush() {
|
||||
final current = pending;
|
||||
pending = null;
|
||||
if (current == null) return;
|
||||
final total = current.lineTotal ?? current.unitPrice;
|
||||
if (total == null) return;
|
||||
items.add(_toItem(current, total));
|
||||
}
|
||||
|
||||
return items;
|
||||
for (var i = 0; i < lines.length && !stop; i++) {
|
||||
final classified = _classify(lines[i], config);
|
||||
switch (classified.kind) {
|
||||
case _Kind.skip:
|
||||
continue;
|
||||
case _Kind.item:
|
||||
flush();
|
||||
pending = _Pending(
|
||||
name: classified.name!,
|
||||
unitPrice: classified.amount,
|
||||
quantity: classified.qty?.count ?? 1,
|
||||
lineTotal: classified.qty == null
|
||||
? classified.amount
|
||||
: classified.qty!.lineTotal,
|
||||
);
|
||||
if (classified.qty != null) {
|
||||
pending!.unitPrice = classified.qty!.unitPrice;
|
||||
}
|
||||
case _Kind.nameOnly:
|
||||
flush();
|
||||
pending = _Pending(name: classified.name ?? classified.raw);
|
||||
case _Kind.quantity:
|
||||
final qty = classified.qty!;
|
||||
if (pending != null) {
|
||||
pending!
|
||||
..quantity = qty.count
|
||||
..unitPrice = qty.unitPrice
|
||||
..lineTotal = qty.lineTotal;
|
||||
}
|
||||
case _Kind.discount:
|
||||
var amount = classified.amount;
|
||||
if (amount == null && i + 1 < lines.length) {
|
||||
amount = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (amount != null) i++;
|
||||
}
|
||||
if (amount == null) continue;
|
||||
if (amount > 0) amount = -amount;
|
||||
if (pending != null) {
|
||||
pending!.discount += amount;
|
||||
pending!.lineTotal = roundMoney(
|
||||
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
|
||||
);
|
||||
} else {
|
||||
totalDiscount += amount;
|
||||
}
|
||||
case _Kind.totalDiscount:
|
||||
var amount = classified.amount;
|
||||
if (amount == null && i + 1 < lines.length) {
|
||||
amount = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (amount != null) i++;
|
||||
}
|
||||
if (amount != null) {
|
||||
totalDiscount += amount > 0 ? -amount : amount;
|
||||
}
|
||||
case _Kind.grandTotal:
|
||||
flush();
|
||||
if (classified.amount != null) {
|
||||
receiptTotal = classified.amount;
|
||||
stop = true;
|
||||
}
|
||||
case _Kind.priceOnly:
|
||||
final amount = classified.amount!;
|
||||
if (amount < 0 && pending != null) {
|
||||
pending!.discount += amount;
|
||||
pending!.lineTotal = roundMoney(
|
||||
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return _result(
|
||||
items: items,
|
||||
rawText: rawText,
|
||||
receiptTotal: receiptTotal,
|
||||
totalDiscount: totalDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
static List<LineItem> _parseColumns(List<String> lines) {
|
||||
static ReceiptParseResult _parseColumns(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
String rawText,
|
||||
) {
|
||||
final names = <({String name, int? quantity, double? unitPrice})>[];
|
||||
final prices = <double>[];
|
||||
final discountSlots = <int>[];
|
||||
final discounts = <double>[];
|
||||
|
||||
for (final line in lines) {
|
||||
for (final raw in lines) {
|
||||
final line = _stripTax(raw, config);
|
||||
if (isPriceOnly(line)) {
|
||||
final price = tryParsePrice(line);
|
||||
if (price != null) prices.add(price);
|
||||
if (price == null) continue;
|
||||
if (price < 0) {
|
||||
discounts.add(price);
|
||||
} else {
|
||||
prices.add(price);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (_isIgnorable(line, config)) continue;
|
||||
if (config.matchesAny(line, [
|
||||
...config.grandTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.totalDiscountKeywords,
|
||||
])) {
|
||||
continue;
|
||||
}
|
||||
if (_shouldSkipLine(line)) continue;
|
||||
|
||||
final qty = _parseQty(line);
|
||||
final qty = _parseQty(line, config);
|
||||
if (qty != null) {
|
||||
if (names.isNotEmpty) {
|
||||
final last = names.removeLast();
|
||||
@@ -147,11 +298,16 @@ class ReceiptParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.matchesAny(line, config.discountKeywords)) {
|
||||
if (names.isNotEmpty) discountSlots.add(names.length - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add((name: line, quantity: null, unitPrice: null));
|
||||
}
|
||||
|
||||
final count = names.length < prices.length ? names.length : prices.length;
|
||||
return [
|
||||
final items = <LineItem>[
|
||||
for (var i = 0; i < count; i++)
|
||||
LineItem(
|
||||
id: _id(),
|
||||
@@ -161,44 +317,274 @@ class ReceiptParser {
|
||||
unitPrice: names[i].unitPrice,
|
||||
),
|
||||
];
|
||||
|
||||
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
|
||||
final index = discountSlots[i];
|
||||
if (index < 0 || index >= items.length) continue;
|
||||
final item = items[index];
|
||||
final discount = discounts[i];
|
||||
items[index] = item.copyWith(
|
||||
price: roundMoney(item.price + discount),
|
||||
discount: roundMoney(item.discount + discount),
|
||||
);
|
||||
}
|
||||
|
||||
final receiptTotal = _readAmountAfterKeyword(
|
||||
lines,
|
||||
config,
|
||||
config.grandTotalKeywords,
|
||||
skipIf: (line) => config.matchesAny(line, [
|
||||
...config.totalDiscountKeywords,
|
||||
...config.taxInclusiveTotalKeywords,
|
||||
...config.subtotalKeywords,
|
||||
]),
|
||||
);
|
||||
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
|
||||
totalDiscount +=
|
||||
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
|
||||
0;
|
||||
|
||||
return _result(
|
||||
items: items,
|
||||
rawText: rawText,
|
||||
receiptTotal: receiptTotal,
|
||||
totalDiscount: totalDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _keepItem(LineItem item) {
|
||||
if (item.price < 0) return false;
|
||||
if (item.name.trim().isEmpty) return false;
|
||||
return !_containsKeyword(item.name, kDropKeywords);
|
||||
static LineItem _toItem(_Pending pending, double total) {
|
||||
return LineItem(
|
||||
id: _id(),
|
||||
name: pending.name,
|
||||
price: roundMoney(total),
|
||||
quantity: pending.quantity == 1 ? null : pending.quantity,
|
||||
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
|
||||
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _shouldSkipLine(String line) {
|
||||
if (_separatorPattern.hasMatch(line)) return true;
|
||||
static ReceiptParseResult _result({
|
||||
required List<LineItem> items,
|
||||
required String rawText,
|
||||
double? receiptTotal,
|
||||
double totalDiscount = 0,
|
||||
}) {
|
||||
final kept = items
|
||||
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
|
||||
.toList();
|
||||
final sum = roundMoney(
|
||||
kept.fold<double>(0, (total, item) => total + item.price),
|
||||
);
|
||||
final passed =
|
||||
receiptTotal == null ||
|
||||
(sum - receiptTotal).abs() <= 0.01 ||
|
||||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
|
||||
return ReceiptParseResult(
|
||||
items: kept,
|
||||
store: _detectStore(rawText),
|
||||
receiptTotal: receiptTotal,
|
||||
totalDiscount: totalDiscount,
|
||||
validationPassed: passed,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _detectStore(String rawText) {
|
||||
final config = receiptParseConfig;
|
||||
final header = rawText
|
||||
.split('\n')
|
||||
.map(_preprocess)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.take(8)
|
||||
.join('\n')
|
||||
.toUpperCase();
|
||||
final stores = [...config.knownStores]
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
for (final store in stores) {
|
||||
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
|
||||
return _canonicalStore(store);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _canonicalStore(String raw) {
|
||||
const aliases = {
|
||||
'AH': 'Albert Heijn',
|
||||
'ALBERT HEIJN': 'Albert Heijn',
|
||||
'JUMBO': 'Jumbo',
|
||||
'LIDL': 'Lidl',
|
||||
'KRUIDVAT': 'Kruidvat',
|
||||
'ALDI': 'Aldi',
|
||||
'PLUS': 'Plus',
|
||||
'DIRK': 'Dirk',
|
||||
'COOP': 'Coop',
|
||||
'SPAR': 'SPAR',
|
||||
'VOMAR': 'Vomar',
|
||||
'EKOPLAZA': 'Ekoplaza',
|
||||
'HOOGVLIET': 'Hoogvliet',
|
||||
'ACTION': 'Action',
|
||||
'ETOS': 'Etos',
|
||||
'WORLD TOKO': 'World Toko',
|
||||
};
|
||||
return aliases[raw.toUpperCase()] ?? raw;
|
||||
}
|
||||
|
||||
static double? _readAmountAfterKeyword(
|
||||
List<String> lines,
|
||||
ReceiptParseConfig config,
|
||||
List<String> keywords, {
|
||||
bool Function(String line)? skipIf,
|
||||
}) {
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = _stripTax(lines[i], config);
|
||||
if (!config.matchesAny(line, keywords)) continue;
|
||||
if (skipIf != null && skipIf(line)) continue;
|
||||
final trailing = _trailingPrice(line, config);
|
||||
if (trailing != null) return trailing;
|
||||
if (i + 1 < lines.length) {
|
||||
final next = tryParsePrice(_stripTax(lines[i + 1], config));
|
||||
if (next != null) return next;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static _Line _classify(String raw, ReceiptParseConfig config) {
|
||||
final line = _stripTax(raw, config);
|
||||
if (_isIgnorable(line, config)) {
|
||||
return _Line(kind: _Kind.skip, raw: line);
|
||||
}
|
||||
if (config.matchesAny(line, config.totalDiscountKeywords)) {
|
||||
return _Line(
|
||||
kind: _Kind.totalDiscount,
|
||||
raw: line,
|
||||
amount: _trailingPrice(line, config),
|
||||
);
|
||||
}
|
||||
if (config.matchesAny(line, config.taxInclusiveTotalKeywords) ||
|
||||
config.matchesAny(line, config.subtotalKeywords)) {
|
||||
return _Line(kind: _Kind.skip, raw: line);
|
||||
}
|
||||
if (config.matchesAny(line, config.grandTotalKeywords)) {
|
||||
return _Line(
|
||||
kind: _Kind.grandTotal,
|
||||
raw: line,
|
||||
amount: _trailingPrice(line, config),
|
||||
);
|
||||
}
|
||||
|
||||
final qty = _parseQty(line, config);
|
||||
if (qty != null) {
|
||||
return _Line(kind: _Kind.quantity, raw: line, qty: qty);
|
||||
}
|
||||
|
||||
final isDiscount =
|
||||
config.matchesAny(line, config.discountKeywords) ||
|
||||
config.negativeAmount.hasMatch(line);
|
||||
if (isDiscount &&
|
||||
(config.matchesAny(line, config.discountKeywords) ||
|
||||
isPriceOnly(line))) {
|
||||
return _Line(
|
||||
kind: _Kind.discount,
|
||||
raw: line,
|
||||
name: line,
|
||||
amount: _trailingPrice(line, config) ?? tryParsePrice(line),
|
||||
);
|
||||
}
|
||||
|
||||
if (isPriceOnly(line)) {
|
||||
return _Line(
|
||||
kind: _Kind.priceOnly,
|
||||
raw: line,
|
||||
amount: tryParsePrice(line),
|
||||
);
|
||||
}
|
||||
|
||||
final inline = config.quantityInline.firstMatch(line);
|
||||
final trailing = _trailingPrice(line, config);
|
||||
if (trailing != null) {
|
||||
var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim();
|
||||
_Qty? inlineQty;
|
||||
if (inline != null) {
|
||||
name = line.substring(0, inline.start).trim();
|
||||
final count = int.tryParse(inline.group(1)!);
|
||||
final unit = tryParsePrice(inline.group(2)!);
|
||||
if (count != null && unit != null) {
|
||||
inlineQty = _Qty(count: count, unitPrice: unit, total: trailing);
|
||||
}
|
||||
}
|
||||
if (name.isEmpty || _isIgnorable(name, config)) {
|
||||
return _Line(kind: _Kind.skip, raw: line);
|
||||
}
|
||||
return _Line(
|
||||
kind: _Kind.item,
|
||||
raw: line,
|
||||
name: name,
|
||||
amount: trailing,
|
||||
qty: inlineQty,
|
||||
);
|
||||
}
|
||||
|
||||
if (inline != null) {
|
||||
final name = line.substring(0, inline.start).trim();
|
||||
final count = int.tryParse(inline.group(1)!);
|
||||
final unit = tryParsePrice(inline.group(2)!);
|
||||
if (name.isNotEmpty && count != null && unit != null) {
|
||||
return _Line(
|
||||
kind: _Kind.item,
|
||||
raw: line,
|
||||
name: name,
|
||||
amount: roundMoney(count * unit),
|
||||
qty: _Qty(count: count, unitPrice: unit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _Line(kind: _Kind.nameOnly, raw: line, name: line);
|
||||
}
|
||||
|
||||
static bool _isIgnorable(String line, ReceiptParseConfig config) {
|
||||
if (config.separatorPattern.hasMatch(line)) return true;
|
||||
if (_urlPattern.hasMatch(line)) return true;
|
||||
if (_postalPattern.hasMatch(line)) return true;
|
||||
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
|
||||
return true;
|
||||
}
|
||||
if (_streetPattern.hasMatch(line)) return true;
|
||||
if (_containsKeyword(line, kHeaderKeywords)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _containsKeyword(String line, Set<String> keywords) {
|
||||
final upper = line.toUpperCase();
|
||||
for (final keyword in keywords) {
|
||||
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
|
||||
return true;
|
||||
}
|
||||
if (config.categoryRow.hasMatch(line)) return true;
|
||||
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
|
||||
return true;
|
||||
}
|
||||
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
|
||||
return true;
|
||||
}
|
||||
if (config.matchesAny(line, config.taxKeywords)) return true;
|
||||
if (config.matchesAny(line, config.paymentKeywords)) return true;
|
||||
if (config.matchesAny(line, config.noiseKeywords)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static _QtyLine? _parseQty(String line) {
|
||||
final match = quantityPattern.firstMatch(line.trim());
|
||||
static double? _trailingPrice(String line, ReceiptParseConfig config) {
|
||||
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
|
||||
if (match == null) return null;
|
||||
return tryParsePrice(match.group(1)!);
|
||||
}
|
||||
|
||||
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
|
||||
final match =
|
||||
_qtyWithTotal.firstMatch(line) ??
|
||||
config.quantityStandalone.firstMatch(line);
|
||||
if (match == null) return null;
|
||||
final count = int.tryParse(match.group(1)!);
|
||||
final unit = tryParsePrice(match.group(2)!);
|
||||
if (count == null || count < 1 || unit == null) return null;
|
||||
final totalRaw = match.group(3);
|
||||
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
|
||||
return _QtyLine(count: count, unitPrice: unit, total: total);
|
||||
double? total;
|
||||
if (match.groupCount >= 3) {
|
||||
final raw = match.group(3);
|
||||
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
|
||||
total = tryParsePrice(raw);
|
||||
}
|
||||
}
|
||||
return _Qty(count: count, unitPrice: unit, total: total);
|
||||
}
|
||||
}
|
||||
|
||||
+92
-13
@@ -5,11 +5,14 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/line_item.dart';
|
||||
import 'models/shop.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
|
||||
class ShopDetailScreen extends StatefulWidget {
|
||||
const ShopDetailScreen({
|
||||
@@ -27,6 +30,7 @@ class ShopDetailScreen extends StatefulWidget {
|
||||
|
||||
class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
late Future<({Shop shop, List<ShopItem> items})> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -43,6 +47,10 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
return (shop: shop, items: items);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _load());
|
||||
}
|
||||
|
||||
Future<void> _openReceipt(File file) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
@@ -68,12 +76,66 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editItem(ShopItem item) async {
|
||||
final result = await showItemEditSheet(
|
||||
context,
|
||||
item: LineItem(
|
||||
id: '${item.id}',
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
await widget.repository.updateShopItem(
|
||||
id: item.id,
|
||||
name: result.name,
|
||||
price: result.price,
|
||||
quantity: result.quantity,
|
||||
unitPrice: result.unitPrice,
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _removeItem(ShopItem item) async {
|
||||
final confirmed = await confirmAction(
|
||||
context,
|
||||
title: 'Remove item?',
|
||||
message:
|
||||
'"${item.name}" will be removed from this trip. The product stays in your catalog.',
|
||||
confirmLabel: 'Remove',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await widget.repository.deleteShopItem(item.id);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Widget? _itemSubtitle(ShopItem item) {
|
||||
final parts = [
|
||||
if (item.quantity != null)
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
if (_editing) formatEuro(item.price),
|
||||
];
|
||||
if (parts.isEmpty) return null;
|
||||
return Text(parts.join(' · '));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Shopping trip')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Shopping trip'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
@@ -92,10 +154,8 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
final shop = snapshot.data!.shop;
|
||||
final items = snapshot.data!.items;
|
||||
final receipt = shop.receiptImagePath;
|
||||
final receiptFile =
|
||||
receipt == null ? null : File(receipt);
|
||||
final hasReceipt =
|
||||
receiptFile != null && receiptFile.existsSync();
|
||||
final receiptFile = receipt == null ? null : File(receipt);
|
||||
final hasReceipt = receiptFile != null && receiptFile.existsSync();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
@@ -125,20 +185,39 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
const SizedBox(height: 24),
|
||||
Text('Products', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Text(
|
||||
'No items on this trip.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
for (final item in items)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(item.name),
|
||||
subtitle: item.quantity == null
|
||||
? null
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: _editing
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _editItem(item),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _removeItem(item),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} × ${formatEuro(item.unitPrice ?? 0)}',
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: () => _openProduct(item.productId),
|
||||
onTap: _editing ? null : () => _openProduct(item.productId),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
||||
+35
-11
@@ -11,6 +11,7 @@ import 'services/product_repository.dart';
|
||||
import 'shop_detail_screen.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class ShopLogScreen extends StatefulWidget {
|
||||
|
||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
late Future<List<Shop>> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,15 +41,24 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
Future<void> _openShop(Shop shop) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ShopDetailScreen(
|
||||
repository: widget.repository,
|
||||
shopId: shop.id,
|
||||
),
|
||||
builder: (_) =>
|
||||
ShopDetailScreen(repository: widget.repository, shopId: shop.id),
|
||||
),
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _removeShop(Shop shop) async {
|
||||
final confirmed = await confirmAction(
|
||||
context,
|
||||
title: 'Remove shopping trip?',
|
||||
message: 'This trip will be removed from the shop log. Products from this trip stay in your catalog.',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await widget.repository.deleteShop(shop.id);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -56,7 +67,14 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Shop log'),
|
||||
actions: const [SettingsButton()],
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
),
|
||||
const SettingsButton(),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<Shop>>(
|
||||
future: _future,
|
||||
@@ -77,7 +95,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
if (shops.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Scan a receipt to log a shopping trip.',
|
||||
'Scan a receipt or enter a trip to log it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
@@ -103,11 +121,17 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: () => _openShop(shop),
|
||||
trailing: _editing
|
||||
? IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeShop(shop),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
)
|
||||
: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _editing ? null : () => _openShop(shop),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/// Items on one shopping list, with check-off and edit/remove.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/product.dart';
|
||||
import 'models/shopping_list.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/name_dialog.dart';
|
||||
|
||||
class ShoppingListDetailScreen extends StatefulWidget {
|
||||
const ShoppingListDetailScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.listId,
|
||||
});
|
||||
|
||||
final ProductRepository repository;
|
||||
final int listId;
|
||||
|
||||
@override
|
||||
State<ShoppingListDetailScreen> createState() =>
|
||||
_ShoppingListDetailScreenState();
|
||||
}
|
||||
|
||||
class _ShoppingListDetailScreenState extends State<ShoppingListDetailScreen> {
|
||||
late Future<({ShoppingList list, List<ShoppingListItem> items})> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<({ShoppingList list, List<ShoppingListItem> items})> _load() async {
|
||||
final list = await widget.repository.getShoppingList(widget.listId);
|
||||
if (list == null) {
|
||||
throw StateError('List not found');
|
||||
}
|
||||
final items = await widget.repository.getShoppingListItems(widget.listId);
|
||||
return (list: list, items: items);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = _load());
|
||||
}
|
||||
|
||||
Future<void> _rename(ShoppingList list) async {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'Rename list',
|
||||
initial: list.name,
|
||||
label: 'List name',
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
await widget.repository.renameShoppingList(widget.listId, name);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _openAddSheet() async {
|
||||
final products = await widget.repository.getAll();
|
||||
if (!mounted) return;
|
||||
final result = await showModalBottomSheet<({String name, int? productId})>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _AddListItemSheet(products: products),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
await widget.repository.addShoppingListItem(
|
||||
listId: widget.listId,
|
||||
name: result.name,
|
||||
productId: result.productId,
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _editItem(ShoppingListItem item) async {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'Edit item',
|
||||
initial: item.name,
|
||||
label: 'Item',
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
await widget.repository.updateShoppingListItem(id: item.id, name: name);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _removeItem(ShoppingListItem item) async {
|
||||
final confirmed = await confirmAction(
|
||||
context,
|
||||
title: 'Remove item?',
|
||||
message: '"${item.name}" will be removed from this list.',
|
||||
confirmLabel: 'Remove',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await widget.repository.deleteShoppingListItem(item.id);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _toggle(ShoppingListItem item) async {
|
||||
await widget.repository.updateShoppingListItem(
|
||||
id: item.id,
|
||||
checked: !item.checked,
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _openProduct(int productId) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProductDetailScreen(
|
||||
repository: widget.repository,
|
||||
productId: productId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Could not load this list.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final list = snapshot.data!.list;
|
||||
final items = snapshot.data!.items;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(list.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Rename',
|
||||
onPressed: () => _rename(list),
|
||||
icon: const Icon(Icons.drive_file_rename_outline),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _openAddSheet,
|
||||
tooltip: 'Add item',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Add things to buy.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
leading: Checkbox(
|
||||
value: item.checked,
|
||||
onChanged: _editing ? null : (_) => _toggle(item),
|
||||
),
|
||||
title: Text(
|
||||
item.name,
|
||||
style: item.checked
|
||||
? theme.textTheme.bodyLarge?.copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
trailing: _editing
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
onPressed: () => _editItem(item),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeItem(item),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
)
|
||||
: item.productId == null
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: 'Product',
|
||||
onPressed: () => _openProduct(item.productId!),
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
onTap: _editing ? null : () => _toggle(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddListItemSheet extends StatefulWidget {
|
||||
const _AddListItemSheet({required this.products});
|
||||
|
||||
final List<Product> products;
|
||||
|
||||
@override
|
||||
State<_AddListItemSheet> createState() => _AddListItemSheetState();
|
||||
}
|
||||
|
||||
class _AddListItemSheetState extends State<_AddListItemSheet> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Product> get _matches {
|
||||
final query = _controller.text.trim().toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
return widget.products.take(8).toList();
|
||||
}
|
||||
return widget.products
|
||||
.where((product) => product.name.toLowerCase().contains(query))
|
||||
.take(8)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _add({String? name, int? productId}) {
|
||||
final value = (name ?? _controller.text).trim();
|
||||
if (value.isEmpty) {
|
||||
setState(() => _error = 'Enter an item name.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop((name: value, productId: productId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final matches = _matches;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Add item', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(labelText: 'Item name'),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
onSubmitted: (_) => _add(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
],
|
||||
if (matches.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('From products', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
for (final product in matches)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(product.name),
|
||||
onTap: () => _add(name: product.name, productId: product.id),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(onPressed: () => _add(), child: const Text('Add')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/// Named shopping lists of things to buy.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/shopping_list.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'shopping_list_detail_screen.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/name_dialog.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
|
||||
class ShoppingListsScreen extends StatefulWidget {
|
||||
const ShoppingListsScreen({super.key, required this.repository});
|
||||
|
||||
final ProductRepository repository;
|
||||
|
||||
@override
|
||||
State<ShoppingListsScreen> createState() => _ShoppingListsScreenState();
|
||||
}
|
||||
|
||||
class _ShoppingListsScreenState extends State<ShoppingListsScreen> {
|
||||
late Future<List<ShoppingList>> _future;
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.getShoppingLists();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.repository.getShoppingLists());
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'New shopping list',
|
||||
label: 'List name',
|
||||
confirmLabel: 'Create',
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
final id = await widget.repository.createShoppingList(name);
|
||||
if (!mounted) return;
|
||||
await _openList(id);
|
||||
}
|
||||
|
||||
Future<void> _openList(int id) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
ShoppingListDetailScreen(repository: widget.repository, listId: id),
|
||||
),
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _removeList(ShoppingList list) async {
|
||||
final confirmed = await confirmAction(
|
||||
context,
|
||||
title: 'Delete shopping list?',
|
||||
message: '"${list.name}" and its items will be deleted.',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await widget.repository.deleteShoppingList(list.id);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Shopping lists'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
icon: Icon(_editing ? Icons.check : Icons.edit_outlined),
|
||||
),
|
||||
const SettingsButton(),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _create,
|
||||
tooltip: 'New list',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: FutureBuilder<List<ShoppingList>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Could not load shopping lists.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final lists = snapshot.data!;
|
||||
if (lists.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Create a list for your next shop.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 88),
|
||||
itemCount: lists.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final list = lists[index];
|
||||
final remaining = list.itemCount - list.checkedCount;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(child: Text('${list.itemCount}')),
|
||||
title: Text(list.name),
|
||||
subtitle: Text(
|
||||
list.itemCount == 0
|
||||
? 'Empty'
|
||||
: remaining == 0
|
||||
? 'All done'
|
||||
: remaining == 1
|
||||
? '1 item left'
|
||||
: '$remaining items left',
|
||||
),
|
||||
trailing: _editing
|
||||
? IconButton(
|
||||
tooltip: 'Delete',
|
||||
onPressed: () => _removeList(list),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
)
|
||||
: const Icon(Icons.chevron_right),
|
||||
onTap: _editing ? null : () => _openList(list.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-12
@@ -17,13 +17,18 @@ final List<Supermarket> kDefaultSupermarkets = [
|
||||
const Supermarket(name: 'Nettorama', colorValue: 0xFFE87722, sortOrder: 8),
|
||||
const Supermarket(name: 'Hoogvliet', colorValue: 0xFFD32F2F, sortOrder: 9),
|
||||
const Supermarket(name: 'Picnic', colorValue: 0xFFE31C5F, sortOrder: 10),
|
||||
const Supermarket(name: kOtherSupermarket, colorValue: 0xFF78909C, sortOrder: 11),
|
||||
const Supermarket(
|
||||
name: kOtherSupermarket,
|
||||
colorValue: 0xFF78909C,
|
||||
sortOrder: 11,
|
||||
),
|
||||
];
|
||||
|
||||
const Map<String, List<String>> kSupermarketAliases = {
|
||||
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
|
||||
'Jumbo': ['JUMBO'],
|
||||
'Lidl': ['LIDL'],
|
||||
'Kruidvat': ['KRUIDVAT'],
|
||||
'Aldi': ['ALDI'],
|
||||
'Plus': ['PLUS'],
|
||||
'Dirk': ['DIRK'],
|
||||
@@ -31,6 +36,10 @@ const Map<String, List<String>> kSupermarketAliases = {
|
||||
'SPAR': ['SPAR'],
|
||||
'Nettorama': ['NETTORAMA'],
|
||||
'Hoogvliet': ['HOOGVLIET'],
|
||||
'Ekoplaza': ['EKOPLAZA'],
|
||||
'Action': ['ACTION'],
|
||||
'Etos': ['ETOS'],
|
||||
'World Toko': ['WORLD TOKO'],
|
||||
'Picnic': ['PICNIC'],
|
||||
};
|
||||
|
||||
@@ -51,20 +60,20 @@ const List<String> kSupermarkets = [
|
||||
];
|
||||
|
||||
/// Returns a supermarket name if it appears in [rawText].
|
||||
String? detectSupermarket(
|
||||
String rawText, {
|
||||
Iterable<String> names = const [],
|
||||
}) {
|
||||
String? detectSupermarket(String rawText, {Iterable<String> names = const []}) {
|
||||
final upper = rawText.toUpperCase();
|
||||
final extra = names
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
final extra =
|
||||
names
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty && name != kOtherSupermarket)
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
for (final name in extra) {
|
||||
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
|
||||
.hasMatch(rawText)) {
|
||||
if (RegExp(
|
||||
'\\b${RegExp.escape(name)}\\b',
|
||||
caseSensitive: false,
|
||||
).hasMatch(rawText)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Confirm a destructive action before it runs.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<bool> confirmAction(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = 'Delete',
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/// Bottom sheet to add or edit a priced line item.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.dart';
|
||||
|
||||
Future<LineItem?> showItemEditSheet(BuildContext context, {LineItem? item}) {
|
||||
return showModalBottomSheet<LineItem>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => ItemEditSheet(item: item),
|
||||
);
|
||||
}
|
||||
|
||||
class ItemEditSheet extends StatefulWidget {
|
||||
const ItemEditSheet({super.key, this.item});
|
||||
|
||||
final LineItem? item;
|
||||
|
||||
@override
|
||||
State<ItemEditSheet> createState() => _ItemEditSheetState();
|
||||
}
|
||||
|
||||
class _ItemEditSheetState extends State<ItemEditSheet> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _priceController;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final item = widget.item;
|
||||
_nameController = TextEditingController(text: item?.name ?? '');
|
||||
_priceController = TextEditingController(
|
||||
text: item == null ? '' : item.price.toStringAsFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_priceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _nameController.text.trim();
|
||||
final price = tryParsePrice(_priceController.text);
|
||||
if (name.isEmpty || price == null) {
|
||||
setState(() => _error = 'Enter a product name and a price.');
|
||||
return;
|
||||
}
|
||||
final rounded = roundMoney(price);
|
||||
final quantity = widget.item?.quantity;
|
||||
Navigator.of(context).pop(
|
||||
LineItem(
|
||||
id:
|
||||
widget.item?.id ??
|
||||
'manual-${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name,
|
||||
price: rounded,
|
||||
quantity: quantity,
|
||||
unitPrice: quantity != null && quantity > 0
|
||||
? roundMoney(rounded / quantity)
|
||||
: widget.item?.unitPrice,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.item == null ? 'Add item' : 'Edit item',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(labelText: 'Product name'),
|
||||
autofocus: widget.item == null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _priceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Price'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/// Name prompt that owns its text controller for the dialog lifetime.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<String?> showNameDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? initial,
|
||||
String label = 'Name',
|
||||
String confirmLabel = 'Save',
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _NameDialog(
|
||||
title: title,
|
||||
initial: initial ?? '',
|
||||
label: label,
|
||||
confirmLabel: confirmLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _NameDialog extends StatefulWidget {
|
||||
const _NameDialog({
|
||||
required this.title,
|
||||
required this.initial,
|
||||
required this.label,
|
||||
required this.confirmLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String initial;
|
||||
final String label;
|
||||
final String confirmLabel;
|
||||
|
||||
@override
|
||||
State<_NameDialog> createState() => _NameDialogState();
|
||||
}
|
||||
|
||||
class _NameDialogState extends State<_NameDialog> {
|
||||
late final TextEditingController _controller;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initial);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = 'Enter a name.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(name);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(labelText: widget.label),
|
||||
onSubmitted: (_) => _save(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(onPressed: _save, child: Text(widget.confirmLabel)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user