scan to list working fine
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/// Local SQLite store of confirmed products.
|
||||
library;
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../models/product.dart';
|
||||
|
||||
class ProductRepository {
|
||||
Database? _db;
|
||||
|
||||
Future<Database> get _database async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<Database> _open() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return openDatabase(
|
||||
p.join(dir.path, 'receipity.db'),
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL UNIQUE,
|
||||
last_price REAL NOT NULL,
|
||||
times_seen INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String normalizeName(String name) =>
|
||||
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
Future<void> upsertItems(List<LineItem> items) async {
|
||||
final db = await _database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
|
||||
await db.transaction((txn) async {
|
||||
for (final item in items) {
|
||||
final key = normalizeName(item.name);
|
||||
if (key.isEmpty) continue;
|
||||
|
||||
final existing = await txn.query(
|
||||
'products',
|
||||
where: 'normalized_name = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (existing.isEmpty) {
|
||||
await txn.insert('products', {
|
||||
'name': item.name.trim(),
|
||||
'normalized_name': key,
|
||||
'last_price': item.price,
|
||||
'times_seen': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
});
|
||||
} else {
|
||||
await txn.update(
|
||||
'products',
|
||||
{
|
||||
'name': item.name.trim(),
|
||||
'last_price': item.price,
|
||||
'times_seen': (existing.first['times_seen']! as int) + 1,
|
||||
'updated_at': now,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [existing.first['id']],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Product>> getAll() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query(
|
||||
'products',
|
||||
orderBy: 'name COLLATE NOCASE ASC',
|
||||
);
|
||||
return rows.map(Product.fromMap).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter.
|
||||
library;
|
||||
|
||||
import '../models/line_item.dart';
|
||||
import '../utils/money.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',
|
||||
};
|
||||
|
||||
/// Discount / promo rows. Paired with a price, then dropped.
|
||||
const Set<String> kDropKeywords = {
|
||||
'DISCOUNT',
|
||||
'KORTING',
|
||||
'ACTIE',
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
class _QtyLine {
|
||||
const _QtyLine({required this.count, required this.unitPrice, this.total});
|
||||
|
||||
final int count;
|
||||
final double unitPrice;
|
||||
final double? total;
|
||||
|
||||
double get lineTotal => total ?? roundMoney(count * unitPrice);
|
||||
}
|
||||
|
||||
class ReceiptParser {
|
||||
ReceiptParser._();
|
||||
|
||||
static int _nextId = 0;
|
||||
|
||||
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) {
|
||||
final lines = rawText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.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();
|
||||
}
|
||||
|
||||
static List<LineItem> _parseInline(List<String> lines) {
|
||||
final items = <LineItem>[];
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
static List<LineItem> _parseColumns(List<String> lines) {
|
||||
final names = <({String name, int? quantity, double? unitPrice})>[];
|
||||
final prices = <double>[];
|
||||
|
||||
for (final line in lines) {
|
||||
if (isPriceOnly(line)) {
|
||||
final price = tryParsePrice(line);
|
||||
if (price != null) prices.add(price);
|
||||
continue;
|
||||
}
|
||||
if (_shouldSkipLine(line)) continue;
|
||||
|
||||
final qty = _parseQty(line);
|
||||
if (qty != null) {
|
||||
if (names.isNotEmpty) {
|
||||
final last = names.removeLast();
|
||||
names.add((
|
||||
name: last.name,
|
||||
quantity: qty.count,
|
||||
unitPrice: qty.unitPrice,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
names.add((name: line, quantity: null, unitPrice: null));
|
||||
}
|
||||
|
||||
final count = names.length < prices.length ? names.length : prices.length;
|
||||
return [
|
||||
for (var i = 0; i < count; i++)
|
||||
LineItem(
|
||||
id: _id(),
|
||||
name: names[i].name,
|
||||
price: prices[i],
|
||||
quantity: names[i].quantity,
|
||||
unitPrice: names[i].unitPrice,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static bool _keepItem(LineItem item) {
|
||||
if (item.price < 0) return false;
|
||||
if (item.name.trim().isEmpty) return false;
|
||||
return !_containsKeyword(item.name, kDropKeywords);
|
||||
}
|
||||
|
||||
static bool _shouldSkipLine(String line) {
|
||||
if (_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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static _QtyLine? _parseQty(String line) {
|
||||
final match = quantityPattern.firstMatch(line.trim());
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user