added categories, import export, settings

This commit is contained in:
davmar
2026-08-29 16:45:27 +02:00
parent 2a5a922aab
commit 732a0dc14a
30 changed files with 3700 additions and 167 deletions
+29
View File
@@ -0,0 +1,29 @@
/// Grocery categories a product can be assigned to.
library;
const List<String> kDefaultCategories = [
'Fruit & vegetables',
'Dairy & eggs',
'Meat & fish',
'Bread & bakery',
'Pantry',
'Drinks',
'Frozen',
'Snacks',
'Household',
'Personal care',
];
List<String> mergeCategories(Iterable<String> used) {
final seen = <String>{};
final result = <String>[];
for (final name in [...kDefaultCategories, ...used]) {
final trimmed = name.trim();
if (trimmed.isEmpty) continue;
final key = trimmed.toLowerCase();
if (seen.contains(key)) continue;
seen.add(key);
result.add(trimmed);
}
return result;
}
+20
View File
@@ -0,0 +1,20 @@
/// Date formatting for shop log and product history.
library;
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
String formatDate(DateTime date) =>
'${date.day} ${_months[date.month - 1]} ${date.year}';
+40
View File
@@ -0,0 +1,40 @@
/// Lightweight fuzzy matching for product search.
library;
String _normalize(String value) =>
value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
/// True when [query] fuzzily matches [text].
bool fuzzyMatch(String query, String text) {
final q = _normalize(query);
if (q.isEmpty) return true;
final t = _normalize(text);
if (t.contains(q)) return true;
for (final word in q.split(' ')) {
if (word.isEmpty) continue;
if (!t.contains(word) && !_subsequence(word, t)) return false;
}
if (q.contains(' ')) return true;
return _subsequence(q, t);
}
/// Higher is better. Null when [query] does not match.
int? fuzzyScore(String query, String text) {
final q = _normalize(query);
if (q.isEmpty) return 0;
final t = _normalize(text);
if (t == q) return 1000;
if (t.startsWith(q)) return 800;
if (t.contains(q)) return 600 - t.indexOf(q);
if (!fuzzyMatch(q, t)) return null;
return 200 - (t.length - q.length).abs().clamp(0, 150);
}
bool _subsequence(String query, String text) {
var i = 0;
for (var c = 0; c < text.length && i < query.length; c++) {
if (text.codeUnitAt(c) == query.codeUnitAt(i)) i++;
}
return i == query.length;
}
+99
View File
@@ -0,0 +1,99 @@
/// Known Dutch supermarket names, default colors, and OCR detection.
library;
import '../models/supermarket.dart';
const String kOtherSupermarket = 'Other';
final List<Supermarket> kDefaultSupermarkets = [
const Supermarket(name: 'Albert Heijn', colorValue: 0xFF00A0E2, sortOrder: 0),
const Supermarket(name: 'Jumbo', colorValue: 0xFFEEC21B, sortOrder: 1),
const Supermarket(name: 'Lidl', colorValue: 0xFF0050AA, sortOrder: 2),
const Supermarket(name: 'Aldi', colorValue: 0xFF00205B, sortOrder: 3),
const Supermarket(name: 'Plus', colorValue: 0xFF6EC31E, sortOrder: 4),
const Supermarket(name: 'Dirk', colorValue: 0xFFE30613, sortOrder: 5),
const Supermarket(name: 'Coop', colorValue: 0xFFE30613, sortOrder: 6),
const Supermarket(name: 'SPAR', colorValue: 0xFF009640, sortOrder: 7),
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 Map<String, List<String>> kSupermarketAliases = {
'Albert Heijn': ['ALBERT HEIJN', 'AH TO GO', 'AH XL'],
'Jumbo': ['JUMBO'],
'Lidl': ['LIDL'],
'Aldi': ['ALDI'],
'Plus': ['PLUS'],
'Dirk': ['DIRK'],
'Coop': ['COOP'],
'SPAR': ['SPAR'],
'Nettorama': ['NETTORAMA'],
'Hoogvliet': ['HOOGVLIET'],
'Picnic': ['PICNIC'],
};
/// Names used before settings were user-editable.
const List<String> kSupermarkets = [
'Albert Heijn',
'Jumbo',
'Lidl',
'Aldi',
'Plus',
'Dirk',
'Coop',
'SPAR',
'Nettorama',
'Hoogvliet',
'Picnic',
'Other',
];
/// Returns a supermarket name if it appears in [rawText].
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));
for (final name in extra) {
if (RegExp('\\b${RegExp.escape(name)}\\b', caseSensitive: false)
.hasMatch(rawText)) {
return name;
}
}
for (final entry in kSupermarketAliases.entries) {
for (final alias in entry.value) {
if (RegExp('\\b${RegExp.escape(alias)}\\b').hasMatch(upper)) {
return _canonicalName(entry.key, extra);
}
}
}
if (RegExp(r'\bAH\b').hasMatch(upper)) {
return _canonicalName('Albert Heijn', extra);
}
return null;
}
String _canonicalName(String fallback, List<String> names) {
for (final name in names) {
if (name.toUpperCase() == fallback.toUpperCase()) return name;
}
return fallback;
}
Supermarket? supermarketByName(Iterable<Supermarket> stores, String? name) {
if (name == null || name.isEmpty) return null;
for (final store in stores) {
if (store.name.toLowerCase() == name.toLowerCase()) return store;
}
return null;
}