new plus button
This commit is contained in:
+107
-23
@@ -22,6 +22,7 @@ class HomeShell extends StatefulWidget {
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
static const _addIndex = 2;
|
||||
static const _shopLogIndex = 3;
|
||||
static const _fabGap = 56.0;
|
||||
|
||||
int _index = _addIndex;
|
||||
int _revision = 0;
|
||||
@@ -65,8 +66,15 @@ class _HomeShellState extends State<HomeShell> {
|
||||
});
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
if (index == _index) return;
|
||||
setState(() => _index = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: switch (_index) {
|
||||
0 => ProductsScreen(
|
||||
@@ -86,29 +94,105 @@ class _HomeShellState extends State<HomeShell> {
|
||||
repository: widget.repository,
|
||||
),
|
||||
},
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.inventory_2_outlined),
|
||||
selectedIcon: Icon(Icons.inventory_2),
|
||||
label: 'Products',
|
||||
),
|
||||
NavigationDestination(
|
||||
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),
|
||||
selectedIcon: Icon(Icons.receipt_long),
|
||||
label: 'Shop log',
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
floatingActionButton: FloatingActionButton.small(
|
||||
onPressed: () => _select(_addIndex),
|
||||
tooltip: 'Add',
|
||||
elevation: _index == _addIndex ? 4 : 1,
|
||||
shape: const CircleBorder(),
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
child: const Icon(Icons.add, size: 24),
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
notchMargin: 6,
|
||||
elevation: 2,
|
||||
height: 52,
|
||||
padding: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
selectedIcon: Icons.inventory_2,
|
||||
label: 'Products',
|
||||
selected: _index == 0,
|
||||
onTap: () => _select(0),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.checklist_outlined,
|
||||
selectedIcon: Icons.checklist,
|
||||
label: 'Lists',
|
||||
selected: _index == 1,
|
||||
onTap: () => _select(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _fabGap),
|
||||
Expanded(
|
||||
child: _NavButton(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
selectedIcon: Icons.receipt_long,
|
||||
label: 'Shop log',
|
||||
selected: _index == _shopLogIndex,
|
||||
onTap: () => _select(_shopLogIndex),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(selected ? selectedIcon : icon, color: color, size: 20),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
height: 1.1,
|
||||
fontSize: 10,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+27
-2
@@ -8,22 +8,47 @@ class Shop {
|
||||
required this.shoppedAt,
|
||||
required this.total,
|
||||
required this.itemCount,
|
||||
this.name,
|
||||
this.receiptImagePath,
|
||||
this.tags = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String supermarket;
|
||||
final String? name;
|
||||
final DateTime shoppedAt;
|
||||
final double total;
|
||||
final int itemCount;
|
||||
final String? receiptImagePath;
|
||||
final List<String> tags;
|
||||
|
||||
String get displayName {
|
||||
final tripName = name?.trim();
|
||||
if (tripName != null && tripName.isNotEmpty) return tripName;
|
||||
return supermarket;
|
||||
}
|
||||
|
||||
Shop copyWith({String? name, bool clearName = false, List<String>? tags}) {
|
||||
return Shop(
|
||||
id: id,
|
||||
supermarket: supermarket,
|
||||
shoppedAt: shoppedAt,
|
||||
total: total,
|
||||
itemCount: itemCount,
|
||||
receiptImagePath: receiptImagePath,
|
||||
name: clearName ? null : (name ?? this.name),
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
}
|
||||
|
||||
factory Shop.fromMap(Map<String, Object?> map) {
|
||||
final rawName = (map['name'] as String?)?.trim();
|
||||
return Shop(
|
||||
id: map['id']! as int,
|
||||
supermarket: map['supermarket']! as String,
|
||||
name: (rawName == null || rawName.isEmpty) ? null : rawName,
|
||||
shoppedAt: DateTime.parse(map['shopped_at']! as String),
|
||||
total: (map['total']! as num).toDouble(),
|
||||
total: (map['total'] as num).toDouble(),
|
||||
itemCount: (map['item_count'] as num?)?.toInt() ?? 0,
|
||||
receiptImagePath: map['receipt_image_path'] as String?,
|
||||
);
|
||||
@@ -55,7 +80,7 @@ class ShopItem {
|
||||
shopId: map['shop_id']! as int,
|
||||
productId: map['product_id']! as int,
|
||||
name: map['name']! as String,
|
||||
price: (map['price']! as num).toDouble(),
|
||||
price: (map['price'] as num).toDouble(),
|
||||
quantity: map['quantity'] as int?,
|
||||
unitPrice: (map['unit_price'] as num?)?.toDouble(),
|
||||
);
|
||||
|
||||
+95
-41
@@ -11,6 +11,7 @@ import 'utils/money.dart';
|
||||
import 'utils/supermarkets.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
const ReviewScreen({
|
||||
@@ -42,6 +43,9 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
String? _supermarket;
|
||||
String? _customStore;
|
||||
bool _saving = false;
|
||||
final _tripName = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
List<String> _tagSuggestions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -57,6 +61,18 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
} else if (widget.detectedStore != null) {
|
||||
_supermarket = widget.detectedStore;
|
||||
}
|
||||
widget.repository
|
||||
.getUsedShopTags()
|
||||
.then((tags) {
|
||||
if (mounted) setState(() => _tagSuggestions = tags);
|
||||
})
|
||||
.catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tripName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? get _resolvedSupermarket {
|
||||
@@ -117,6 +133,8 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
shoppedAt: _shoppedAt,
|
||||
items: _items,
|
||||
receiptImagePath: widget.imagePath,
|
||||
name: _tripName.text,
|
||||
tags: _tags,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
@@ -214,52 +232,88 @@ class _ReviewScreenState extends State<ReviewScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _tripName,
|
||||
enabled: !_saving,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Trip name',
|
||||
hintText: 'Optional, e.g. Weekly shop',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TagEditor(
|
||||
tags: _tags,
|
||||
suggestions: _tagSuggestions,
|
||||
enabled: !_saving,
|
||||
onChanged: (tags) => setState(() => _tags = tags),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_items.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No line items yet.\nAdd products for this trip.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
onDismissed: (_) {
|
||||
setState(() {
|
||||
_items.removeWhere((row) => row.id == item.id);
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _saving ? null : () => _edit(existing: item),
|
||||
),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
setState(() {
|
||||
_items.removeWhere((row) => row.id == item.id);
|
||||
});
|
||||
},
|
||||
child: ListTile(
|
||||
title: Text(item.name),
|
||||
subtitle: _itemSubtitle(item),
|
||||
trailing: Text(
|
||||
formatEuro(item.price),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onTap: _saving ? null : () => _edit(existing: item),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.rawText.isNotEmpty)
|
||||
ExpansionTile(
|
||||
|
||||
@@ -3,7 +3,7 @@ library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
const int kBackupVersion = 2;
|
||||
const int kBackupVersion = 3;
|
||||
|
||||
class BackupDocument {
|
||||
const BackupDocument({
|
||||
@@ -17,6 +17,7 @@ class BackupDocument {
|
||||
required this.shoppingLists,
|
||||
required this.shoppingListItems,
|
||||
required this.images,
|
||||
this.shopTags = const [],
|
||||
});
|
||||
|
||||
final int version;
|
||||
@@ -26,6 +27,7 @@ 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?>> shopTags;
|
||||
final List<Map<String, Object?>> shoppingLists;
|
||||
final List<Map<String, Object?>> shoppingListItems;
|
||||
final Map<String, String> images;
|
||||
@@ -39,6 +41,7 @@ class BackupDocument {
|
||||
'products': products,
|
||||
'shops': shops,
|
||||
'shopItems': shopItems,
|
||||
'shopTags': shopTags,
|
||||
'shoppingLists': shoppingLists,
|
||||
'shoppingListItems': shoppingListItems,
|
||||
'images': images,
|
||||
@@ -66,6 +69,7 @@ class BackupDocument {
|
||||
products: _maps(decoded['products']),
|
||||
shops: _maps(decoded['shops']),
|
||||
shopItems: _maps(decoded['shopItems']),
|
||||
shopTags: _maps(decoded['shopTags']),
|
||||
shoppingLists: _maps(decoded['shoppingLists']),
|
||||
shoppingListItems: _maps(decoded['shoppingListItems']),
|
||||
images: _strings(decoded['images']),
|
||||
|
||||
@@ -57,6 +57,7 @@ class BackupService {
|
||||
products: rewrittenProducts,
|
||||
shops: rewrittenShops,
|
||||
shopItems: await repository.dumpTable('shop_items'),
|
||||
shopTags: await repository.dumpTable('shop_tags'),
|
||||
shoppingLists: await repository.dumpTable('shopping_lists'),
|
||||
shoppingListItems: await repository.dumpTable('shopping_list_items'),
|
||||
images: images,
|
||||
@@ -146,6 +147,7 @@ class BackupService {
|
||||
products: products,
|
||||
shops: shops,
|
||||
shopItems: document.shopItems,
|
||||
shopTags: document.shopTags,
|
||||
shoppingLists: document.shoppingLists,
|
||||
shoppingListItems: document.shoppingListItems,
|
||||
darkMode: document.darkMode,
|
||||
|
||||
@@ -33,7 +33,7 @@ class ProductRepository {
|
||||
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
|
||||
return openDatabase(
|
||||
path,
|
||||
version: 5,
|
||||
version: 6,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
@@ -64,6 +64,9 @@ class ProductRepository {
|
||||
if (oldVersion < 5) {
|
||||
await _createShoppingListTables(db);
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
await _upgradeShopsToV6(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -121,6 +124,7 @@ class ProductRepository {
|
||||
CREATE TABLE shops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supermarket TEXT NOT NULL,
|
||||
name TEXT,
|
||||
shopped_at TEXT NOT NULL,
|
||||
receipt_image_path TEXT,
|
||||
total REAL NOT NULL,
|
||||
@@ -140,6 +144,29 @@ class ProductRepository {
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
)
|
||||
''');
|
||||
await _createShopTagTable(db);
|
||||
}
|
||||
|
||||
Future<void> _createShopTagTable(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shop_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
shop_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
normalized_tag TEXT NOT NULL,
|
||||
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
|
||||
UNIQUE (shop_id, normalized_tag)
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _upgradeShopsToV6(DatabaseExecutor db) async {
|
||||
final columns = await db.rawQuery('PRAGMA table_info(shops)');
|
||||
final names = {for (final row in columns) row['name'] as String};
|
||||
if (!names.contains('name')) {
|
||||
await db.execute('ALTER TABLE shops ADD COLUMN name TEXT');
|
||||
}
|
||||
await _createShopTagTable(db);
|
||||
}
|
||||
|
||||
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
|
||||
@@ -185,20 +212,25 @@ class ProductRepository {
|
||||
required DateTime shoppedAt,
|
||||
required List<LineItem> items,
|
||||
String? receiptImagePath,
|
||||
String? name,
|
||||
List<String> tags = const [],
|
||||
}) async {
|
||||
final db = await _database;
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final total = roundMoney(
|
||||
items.fold<double>(0, (sum, item) => sum + item.price),
|
||||
);
|
||||
final tripName = _optionalText(name);
|
||||
|
||||
final shopId = await db.transaction((txn) async {
|
||||
final id = await txn.insert('shops', {
|
||||
'supermarket': supermarket,
|
||||
'name': tripName,
|
||||
'shopped_at': shoppedAt.toIso8601String(),
|
||||
'total': total,
|
||||
'created_at': now,
|
||||
});
|
||||
await _replaceShopTags(txn, shopId: id, tags: tags);
|
||||
|
||||
for (final item in items) {
|
||||
final productId = await _upsertProduct(
|
||||
@@ -440,6 +472,7 @@ class ProductRepository {
|
||||
SELECT
|
||||
shops.id,
|
||||
shops.supermarket,
|
||||
shops.name,
|
||||
shops.shopped_at,
|
||||
shops.receipt_image_path,
|
||||
shops.total,
|
||||
@@ -449,7 +482,7 @@ class ProductRepository {
|
||||
GROUP BY shops.id
|
||||
ORDER BY shops.shopped_at DESC, shops.id DESC
|
||||
''');
|
||||
return rows.map(Shop.fromMap).toList();
|
||||
return _shopsWithTags(rows);
|
||||
}
|
||||
|
||||
Future<Shop?> getShop(int id) async {
|
||||
@@ -459,6 +492,7 @@ class ProductRepository {
|
||||
SELECT
|
||||
shops.id,
|
||||
shops.supermarket,
|
||||
shops.name,
|
||||
shops.shopped_at,
|
||||
shops.receipt_image_path,
|
||||
shops.total,
|
||||
@@ -471,7 +505,103 @@ class ProductRepository {
|
||||
[id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return Shop.fromMap(rows.first);
|
||||
final shops = await _shopsWithTags(rows);
|
||||
return shops.first;
|
||||
}
|
||||
|
||||
Future<List<Shop>> _shopsWithTags(List<Map<String, Object?>> rows) async {
|
||||
final shops = rows.map(Shop.fromMap).toList();
|
||||
if (shops.isEmpty) return shops;
|
||||
final tags = await _tagsByShopIds(shops.map((shop) => shop.id).toList());
|
||||
return [
|
||||
for (final shop in shops) shop.copyWith(tags: tags[shop.id] ?? const []),
|
||||
];
|
||||
}
|
||||
|
||||
Future<Map<int, List<String>>> _tagsByShopIds(List<int> ids) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final db = await _database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final rows = await db.query(
|
||||
'shop_tags',
|
||||
where: 'shop_id IN ($placeholders)',
|
||||
whereArgs: ids,
|
||||
orderBy: 'tag COLLATE NOCASE ASC',
|
||||
);
|
||||
final tags = <int, List<String>>{};
|
||||
for (final row in rows) {
|
||||
final shopId = row['shop_id']! as int;
|
||||
(tags[shopId] ??= []).add(row['tag']! as String);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
Future<Map<int, List<String>>> getShopItemNamesByShop() async {
|
||||
final db = await _database;
|
||||
final rows = await db.query('shop_items', columns: ['shop_id', 'name']);
|
||||
final names = <int, List<String>>{};
|
||||
for (final row in rows) {
|
||||
(names[row['shop_id']! as int] ??= []).add(row['name']! as String);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
Future<List<String>> getUsedShopTags() async {
|
||||
final db = await _database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT tag FROM shop_tags
|
||||
GROUP BY normalized_tag
|
||||
ORDER BY tag COLLATE NOCASE ASC
|
||||
''');
|
||||
return [for (final row in rows) row['tag']! as String];
|
||||
}
|
||||
|
||||
Future<void> updateShop({
|
||||
required int id,
|
||||
String? name,
|
||||
bool clearName = false,
|
||||
List<String>? tags,
|
||||
}) async {
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
if (clearName || name != null) {
|
||||
await txn.update(
|
||||
'shops',
|
||||
{'name': clearName ? null : _optionalText(name)},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
if (tags != null) {
|
||||
await _replaceShopTags(txn, shopId: id, tags: tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _replaceShopTags(
|
||||
DatabaseExecutor db, {
|
||||
required int shopId,
|
||||
required List<String> tags,
|
||||
}) async {
|
||||
await db.delete('shop_tags', where: 'shop_id = ?', whereArgs: [shopId]);
|
||||
final seen = <String>{};
|
||||
for (final raw in tags) {
|
||||
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (label.isEmpty) continue;
|
||||
final key = label.toLowerCase();
|
||||
if (!seen.add(key)) continue;
|
||||
await db.insert('shop_tags', {
|
||||
'shop_id': shopId,
|
||||
'tag': label,
|
||||
'normalized_tag': key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String? _optionalText(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
Future<List<ShopItem>> getShopItems(int shopId) async {
|
||||
@@ -491,6 +621,7 @@ class ProductRepository {
|
||||
final db = await _database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]);
|
||||
await txn.delete('shop_tags', where: 'shop_id = ?', whereArgs: [id]);
|
||||
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
final path = shop?.receiptImagePath;
|
||||
@@ -845,11 +976,13 @@ class ProductRepository {
|
||||
required bool darkMode,
|
||||
List<Map<String, Object?>> shoppingLists = const [],
|
||||
List<Map<String, Object?>> shoppingListItems = const [],
|
||||
List<Map<String, Object?>> shopTags = 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_tags');
|
||||
await txn.delete('shop_items');
|
||||
await txn.delete('shops');
|
||||
await txn.delete('products');
|
||||
@@ -868,6 +1001,9 @@ class ProductRepository {
|
||||
for (final row in shopItems) {
|
||||
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
|
||||
}
|
||||
for (final row in shopTags) {
|
||||
await txn.insert('shop_tags', _sqlRow(row, _shopTagColumns));
|
||||
}
|
||||
for (final row in shoppingLists) {
|
||||
await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns));
|
||||
}
|
||||
@@ -886,6 +1022,7 @@ class ProductRepository {
|
||||
await _resetSequence(txn, 'products');
|
||||
await _resetSequence(txn, 'shops');
|
||||
await _resetSequence(txn, 'shop_items');
|
||||
await _resetSequence(txn, 'shop_tags');
|
||||
await _resetSequence(txn, 'shopping_lists');
|
||||
await _resetSequence(txn, 'shopping_list_items');
|
||||
});
|
||||
@@ -909,6 +1046,7 @@ class ProductRepository {
|
||||
static const _shopColumns = {
|
||||
'id',
|
||||
'supermarket',
|
||||
'name',
|
||||
'shopped_at',
|
||||
'receipt_image_path',
|
||||
'total',
|
||||
@@ -923,6 +1061,7 @@ class ProductRepository {
|
||||
'quantity',
|
||||
'unit_price',
|
||||
};
|
||||
static const _shopTagColumns = {'id', 'shop_id', 'tag', 'normalized_tag'};
|
||||
static const _shoppingListColumns = {
|
||||
'id',
|
||||
'name',
|
||||
|
||||
@@ -13,6 +13,8 @@ import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/item_edit_sheet.dart';
|
||||
import 'widgets/name_dialog.dart';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
class ShopDetailScreen extends StatefulWidget {
|
||||
const ShopDetailScreen({
|
||||
@@ -31,11 +33,18 @@ class ShopDetailScreen extends StatefulWidget {
|
||||
class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
late Future<({Shop shop, List<ShopItem> items})> _future;
|
||||
bool _editing = false;
|
||||
List<String> _tagSuggestions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
widget.repository
|
||||
.getUsedShopTags()
|
||||
.then((tags) {
|
||||
if (mounted) setState(() => _tagSuggestions = tags);
|
||||
})
|
||||
.catchError((_) {});
|
||||
}
|
||||
|
||||
Future<({Shop shop, List<ShopItem> items})> _load() async {
|
||||
@@ -111,6 +120,28 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _editName(Shop shop) async {
|
||||
final name = await showNameDialog(
|
||||
context,
|
||||
title: 'Trip name',
|
||||
label: 'Name',
|
||||
initial: shop.name,
|
||||
allowEmpty: true,
|
||||
);
|
||||
if (name == null || !mounted) return;
|
||||
await widget.repository.updateShop(
|
||||
id: shop.id,
|
||||
name: name,
|
||||
clearName: name.isEmpty,
|
||||
);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Future<void> _setTags(Shop shop, List<String> tags) async {
|
||||
await widget.repository.updateShop(id: shop.id, tags: tags);
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
Widget? _itemSubtitle(ShopItem item) {
|
||||
final parts = [
|
||||
if (item.quantity != null)
|
||||
@@ -160,12 +191,40 @@ class _ShopDetailScreenState extends State<ShopDetailScreen> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
Text(shop.supermarket, style: theme.textTheme.headlineMedium),
|
||||
if (_editing)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
shop.name?.isNotEmpty == true
|
||||
? shop.name!
|
||||
: 'Add a trip name',
|
||||
),
|
||||
subtitle: const Text('Optional name for this shop log'),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: () => _editName(shop),
|
||||
)
|
||||
else
|
||||
Text(shop.displayName, style: theme.textTheme.headlineMedium),
|
||||
if (_editing || (shop.name != null && shop.name!.isNotEmpty)) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(shop.supermarket, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatDate(shop.shoppedAt),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
if (_editing) ...[
|
||||
const SizedBox(height: 16),
|
||||
TagEditor(
|
||||
tags: shop.tags,
|
||||
suggestions: _tagSuggestions,
|
||||
onChanged: (tags) => _setTags(shop, tags),
|
||||
),
|
||||
] else if (shop.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
TagChips(tags: shop.tags),
|
||||
],
|
||||
if (hasReceipt) ...[
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
|
||||
+243
-78
@@ -1,19 +1,22 @@
|
||||
/// Log of saved shopping trips.
|
||||
/// Log of saved shopping trips, with a monthly heatmap and product search.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'models/shop.dart';
|
||||
import 'models/supermarket.dart';
|
||||
import 'services/app_settings.dart';
|
||||
import 'services/product_repository.dart';
|
||||
import 'shop_detail_screen.dart';
|
||||
import 'utils/dates.dart';
|
||||
import 'utils/money.dart';
|
||||
import 'utils/shop_calendar.dart';
|
||||
import 'utils/shop_log_filter.dart';
|
||||
import 'widgets/confirm_dialog.dart';
|
||||
import 'widgets/settings_button.dart';
|
||||
import 'widgets/shop_heatmap.dart';
|
||||
import 'widgets/store_tag.dart';
|
||||
import 'widgets/tag_editor.dart';
|
||||
|
||||
class ShopLogScreen extends StatefulWidget {
|
||||
const ShopLogScreen({super.key, required this.repository});
|
||||
@@ -25,17 +28,37 @@ class ShopLogScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
late Future<List<Shop>> _future;
|
||||
late Future<({List<Shop> shops, Map<int, List<String>> productNames})>
|
||||
_future;
|
||||
final _search = TextEditingController();
|
||||
bool _editing = false;
|
||||
bool _searching = false;
|
||||
DateTime _month = monthStart(DateTime.now());
|
||||
DateTime? _selectedDay;
|
||||
String? _tagFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.getShops();
|
||||
_future = _load();
|
||||
_search.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<({List<Shop> shops, Map<int, List<String>> productNames})>
|
||||
_load() async {
|
||||
final shops = await widget.repository.getShops();
|
||||
final productNames = await widget.repository.getShopItemNamesByShop();
|
||||
return (shops: shops, productNames: productNames);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.repository.getShops());
|
||||
setState(() => _future = _load());
|
||||
}
|
||||
|
||||
Future<void> _openShop(Shop shop) async {
|
||||
@@ -59,15 +82,52 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
if (mounted) _reload();
|
||||
}
|
||||
|
||||
void _selectDay(DateTime? day) {
|
||||
setState(() => _selectedDay = day);
|
||||
}
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
final current = monthStart(DateTime.now());
|
||||
if (delta > 0 && !next.isBefore(current) && next != current) return;
|
||||
setState(() {
|
||||
_month = next.isAfter(current) ? current : next;
|
||||
_selectedDay = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stores = SettingsScope.maybeOf(context)?.supermarkets ?? const [];
|
||||
final nowMonth = monthStart(DateTime.now());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Shop log'),
|
||||
title: _searching
|
||||
? TextField(
|
||||
controller: _search,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search products in trips',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
)
|
||||
: const Text('Shop log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _searching ? 'Close search' : 'Search products',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searching = !_searching;
|
||||
if (!_searching) _search.clear();
|
||||
});
|
||||
},
|
||||
icon: Icon(_searching ? Icons.close : Icons.search),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _editing ? 'Done' : 'Edit',
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
@@ -76,7 +136,7 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
const SettingsButton(),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<Shop>>(
|
||||
body: FutureBuilder(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
@@ -91,49 +151,104 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final shops = snapshot.data!;
|
||||
if (shops.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Scan a receipt or enter a trip to log it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
final shops = snapshot.data!.shops;
|
||||
final productNames = snapshot.data!.productNames;
|
||||
final counts = tripCountsByDay([
|
||||
for (final shop in shops)
|
||||
if (isSameMonth(shop.shoppedAt, _month)) shop.shoppedAt,
|
||||
]);
|
||||
final monthShops = [
|
||||
for (final shop in shops)
|
||||
if (isSameMonth(shop.shoppedAt, _month)) shop,
|
||||
];
|
||||
final monthTotal = monthShops.fold<double>(
|
||||
0,
|
||||
(sum, shop) => sum + shop.total,
|
||||
);
|
||||
final visible = filterShops(
|
||||
shops: shops,
|
||||
productNames: productNames,
|
||||
selectedDay: _selectedDay,
|
||||
tag: _tagFilter,
|
||||
query: _search.text,
|
||||
);
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
|
||||
itemCount: shops.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final shop = shops[index];
|
||||
final count = shop.itemCount;
|
||||
return ListTile(
|
||||
leading: _ReceiptThumb(path: shop.receiptImagePath),
|
||||
title: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: StoreTag(name: shop.supermarket, stores: stores),
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
ShopHeatmap(
|
||||
month: _month,
|
||||
counts: counts,
|
||||
monthTotal: monthTotal,
|
||||
monthTripCount: monthShops.length,
|
||||
canGoForward: _month.isBefore(nowMonth),
|
||||
selectedDay: _selectedDay,
|
||||
onPreviousMonth: () => _goMonth(-1),
|
||||
onNextMonth: () => _goMonth(1),
|
||||
onSelectDay: _selectDay,
|
||||
),
|
||||
if (_selectedDay != null || _tagFilter != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (_selectedDay != null)
|
||||
InputChip(
|
||||
label: Text(formatDate(_selectedDay!)),
|
||||
onDeleted: () => _selectDay(null),
|
||||
),
|
||||
if (_tagFilter != null)
|
||||
InputChip(
|
||||
label: Text(_tagFilter!),
|
||||
onDeleted: () => setState(() => _tagFilter = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
formatDate(shop.shoppedAt),
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · '),
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
if (shops.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Scan a receipt or enter a trip to log it.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
)
|
||||
else if (visible.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
_search.text.trim().isNotEmpty
|
||||
? 'No trips contain that product.'
|
||||
: 'No trips match this filter.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final shop in visible)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
||||
child: _ShopLogTile(
|
||||
shop: shop,
|
||||
stores: stores,
|
||||
editing: _editing,
|
||||
selectedTag: _tagFilter,
|
||||
onOpen: () => _openShop(shop),
|
||||
onRemove: () => _removeShop(shop),
|
||||
onTagTap: (tag) {
|
||||
setState(() {
|
||||
_tagFilter =
|
||||
_tagFilter?.toLowerCase() == tag.toLowerCase()
|
||||
? null
|
||||
: tag;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -141,42 +256,92 @@ class _ShopLogScreenState extends State<ShopLogScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReceiptThumb extends StatelessWidget {
|
||||
const _ReceiptThumb({this.path});
|
||||
class _ShopLogTile extends StatelessWidget {
|
||||
const _ShopLogTile({
|
||||
required this.shop,
|
||||
required this.stores,
|
||||
required this.editing,
|
||||
required this.onOpen,
|
||||
required this.onRemove,
|
||||
required this.onTagTap,
|
||||
this.selectedTag,
|
||||
});
|
||||
|
||||
final String? path;
|
||||
final Shop shop;
|
||||
final List<Supermarket> stores;
|
||||
final bool editing;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onRemove;
|
||||
final ValueChanged<String> onTagTap;
|
||||
final String? selectedTag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = path == null ? null : File(path!);
|
||||
final hasFile = file != null && file.existsSync();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final theme = Theme.of(context);
|
||||
final count = shop.itemCount;
|
||||
final hasName = shop.name != null && shop.name!.isNotEmpty;
|
||||
final meta = [
|
||||
formatDate(shop.shoppedAt),
|
||||
count == 1 ? '1 product' : '$count products',
|
||||
].join(' · ');
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: hasFile
|
||||
? Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 144,
|
||||
errorBuilder: (_, _, _) => ColoredBox(
|
||||
color: scheme.secondaryContainer,
|
||||
child: Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
color: scheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ColoredBox(
|
||||
color: scheme.secondaryContainer,
|
||||
child: Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
color: scheme.onSecondaryContainer,
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: editing ? null : onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasName)
|
||||
Text(shop.name!, style: theme.textTheme.titleMedium)
|
||||
else
|
||||
StoreTag(name: shop.supermarket, stores: stores),
|
||||
const SizedBox(height: 4),
|
||||
if (hasName) ...[
|
||||
StoreTag(
|
||||
name: shop.supermarket,
|
||||
stores: stores,
|
||||
compact: true,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(meta, style: theme.textTheme.bodyMedium),
|
||||
if (shop.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
TagChips(
|
||||
tags: shop.tags,
|
||||
compact: true,
|
||||
selected: selectedTag,
|
||||
onTap: onTagTap,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
editing
|
||||
? IconButton(
|
||||
tooltip: 'Remove',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: Text(
|
||||
formatEuro(shop.total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,23 @@ const _months = [
|
||||
'Dec',
|
||||
];
|
||||
|
||||
const _monthNames = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
String formatDate(DateTime date) =>
|
||||
'${date.day} ${_months[date.month - 1]} ${date.year}';
|
||||
|
||||
String formatMonthYear(DateTime date) =>
|
||||
'${_monthNames[date.month - 1]} ${date.year}';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Month-grid helpers for the shop-log heatmap.
|
||||
library;
|
||||
|
||||
DateTime dateOnly(DateTime date) => DateTime(date.year, date.month, date.day);
|
||||
|
||||
DateTime monthStart(DateTime date) => DateTime(date.year, date.month);
|
||||
|
||||
bool isSameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
bool isSameMonth(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month;
|
||||
|
||||
DateTime addMonths(DateTime month, int delta) =>
|
||||
DateTime(month.year, month.month + delta);
|
||||
|
||||
/// Monday-first calendar cells for [month], padded with nulls to full weeks.
|
||||
List<DateTime?> monthGrid(DateTime month) {
|
||||
final start = DateTime(month.year, month.month, 1);
|
||||
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
|
||||
final leading = start.weekday - DateTime.monday;
|
||||
final cells = <DateTime?>[
|
||||
...List<DateTime?>.filled(leading, null),
|
||||
for (var day = 1; day <= daysInMonth; day++)
|
||||
DateTime(month.year, month.month, day),
|
||||
];
|
||||
while (cells.length % 7 != 0) {
|
||||
cells.add(null);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/// How many trips landed on each calendar day.
|
||||
Map<DateTime, int> tripCountsByDay(Iterable<DateTime> shoppedAt) {
|
||||
final counts = <DateTime, int>{};
|
||||
for (final date in shoppedAt) {
|
||||
final key = dateOnly(date);
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// GitHub-style 0–4 intensity from a trip count.
|
||||
int heatmapLevel(int count) {
|
||||
if (count <= 0) return 0;
|
||||
if (count >= 4) return 4;
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Filters shop-log rows by day, tag, and product search.
|
||||
library;
|
||||
|
||||
import '../models/shop.dart';
|
||||
import 'fuzzy.dart';
|
||||
import 'shop_calendar.dart';
|
||||
|
||||
List<Shop> filterShops({
|
||||
required List<Shop> shops,
|
||||
required Map<int, List<String>> productNames,
|
||||
DateTime? selectedDay,
|
||||
String? tag,
|
||||
String query = '',
|
||||
}) {
|
||||
final needle = query.trim();
|
||||
final tagFilter = tag?.trim();
|
||||
return [
|
||||
for (final shop in shops)
|
||||
if (_matches(
|
||||
shop: shop,
|
||||
names: productNames[shop.id] ?? const [],
|
||||
selectedDay: selectedDay,
|
||||
tag: tagFilter,
|
||||
query: needle,
|
||||
))
|
||||
shop,
|
||||
];
|
||||
}
|
||||
|
||||
bool _matches({
|
||||
required Shop shop,
|
||||
required List<String> names,
|
||||
required DateTime? selectedDay,
|
||||
required String? tag,
|
||||
required String query,
|
||||
}) {
|
||||
if (selectedDay != null && !isSameDay(shop.shoppedAt, selectedDay)) {
|
||||
return false;
|
||||
}
|
||||
if (tag != null && tag.isNotEmpty) {
|
||||
final wanted = tag.toLowerCase();
|
||||
if (!shop.tags.any((item) => item.toLowerCase() == wanted)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (query.isEmpty) return true;
|
||||
return names.any((name) => fuzzyScore(query, name) != null);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ Future<String?> showNameDialog(
|
||||
String? initial,
|
||||
String label = 'Name',
|
||||
String confirmLabel = 'Save',
|
||||
bool allowEmpty = false,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
@@ -17,6 +18,7 @@ Future<String?> showNameDialog(
|
||||
initial: initial ?? '',
|
||||
label: label,
|
||||
confirmLabel: confirmLabel,
|
||||
allowEmpty: allowEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -27,12 +29,14 @@ class _NameDialog extends StatefulWidget {
|
||||
required this.initial,
|
||||
required this.label,
|
||||
required this.confirmLabel,
|
||||
required this.allowEmpty,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String initial;
|
||||
final String label;
|
||||
final String confirmLabel;
|
||||
final bool allowEmpty;
|
||||
|
||||
@override
|
||||
State<_NameDialog> createState() => _NameDialogState();
|
||||
@@ -56,7 +60,7 @@ class _NameDialogState extends State<_NameDialog> {
|
||||
|
||||
void _save() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) {
|
||||
if (name.isEmpty && !widget.allowEmpty) {
|
||||
setState(() => _error = 'Enter a name.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/// GitHub-style contribution heatmap for one calendar month of shopping trips.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/dates.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/shop_calendar.dart';
|
||||
|
||||
class ShopHeatmap extends StatelessWidget {
|
||||
const ShopHeatmap({
|
||||
super.key,
|
||||
required this.month,
|
||||
required this.counts,
|
||||
required this.monthTotal,
|
||||
required this.monthTripCount,
|
||||
required this.canGoForward,
|
||||
this.selectedDay,
|
||||
required this.onPreviousMonth,
|
||||
required this.onNextMonth,
|
||||
required this.onSelectDay,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final Map<DateTime, int> counts;
|
||||
final double monthTotal;
|
||||
final int monthTripCount;
|
||||
final bool canGoForward;
|
||||
final DateTime? selectedDay;
|
||||
final VoidCallback onPreviousMonth;
|
||||
final VoidCallback onNextMonth;
|
||||
final ValueChanged<DateTime?> onSelectDay;
|
||||
|
||||
static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final cells = monthGrid(month);
|
||||
final today = dateOnly(DateTime.now());
|
||||
final weeks = cells.length ~/ 7;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Previous month',
|
||||
onPressed: onPreviousMonth,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
formatMonthYear(month),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Next month',
|
||||
onPressed: canGoForward ? onNextMonth : null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
monthTripCount == 0
|
||||
? 'No trips this month'
|
||||
: '${monthTripCount == 1 ? '1 trip' : '$monthTripCount trips'} · ${formatEuro(monthTotal)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
for (final label in _weekdays)
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (var week = 0; week < weeks; week++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var weekday = 0; weekday < 7; weekday++)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: _HeatCell(
|
||||
date: cells[week * 7 + weekday],
|
||||
count: cells[week * 7 + weekday] == null
|
||||
? 0
|
||||
: counts[cells[week * 7 + weekday]] ?? 0,
|
||||
today: today,
|
||||
selectedDay: selectedDay,
|
||||
onSelect: onSelectDay,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text('Less', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(width: 6),
|
||||
for (var level = 0; level <= 4; level++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 3),
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: _fillForLevel(scheme, level),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('More', style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeatCell extends StatelessWidget {
|
||||
const _HeatCell({
|
||||
required this.date,
|
||||
required this.count,
|
||||
required this.today,
|
||||
required this.selectedDay,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final DateTime? date;
|
||||
final int count;
|
||||
final DateTime today;
|
||||
final DateTime? selectedDay;
|
||||
final ValueChanged<DateTime?> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (date == null) {
|
||||
return const SizedBox(height: 36);
|
||||
}
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final level = heatmapLevel(count);
|
||||
final selected = selectedDay != null && isSameDay(date!, selectedDay!);
|
||||
final isToday = isSameDay(date!, today);
|
||||
final fill = _fillForLevel(scheme, level);
|
||||
final textColor = level >= 3
|
||||
? scheme.onPrimary
|
||||
: scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1);
|
||||
|
||||
return Tooltip(
|
||||
message: count == 0
|
||||
? formatDate(date!)
|
||||
: '${formatDate(date!)} · ${count == 1 ? '1 trip' : '$count trips'}',
|
||||
child: InkWell(
|
||||
onTap: () => onSelect(selected ? null : date),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? scheme.primary
|
||||
: isToday
|
||||
? scheme.outline
|
||||
: Colors.transparent,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${date!.day}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: isToday || selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _fillForLevel(ColorScheme scheme, int level) {
|
||||
if (level <= 0) return scheme.surfaceContainerHighest;
|
||||
final t = (level / 4).clamp(0.28, 1.0);
|
||||
return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/// Chip row and optional editor for shopping-trip tags.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TagChips extends StatelessWidget {
|
||||
const TagChips({
|
||||
super.key,
|
||||
required this.tags,
|
||||
this.onTap,
|
||||
this.selected,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final List<String> tags;
|
||||
final ValueChanged<String>? onTap;
|
||||
final String? selected;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tags.isEmpty) return const SizedBox.shrink();
|
||||
return Wrap(
|
||||
spacing: compact ? 4 : 6,
|
||||
runSpacing: compact ? 4 : 6,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
_TagChip(
|
||||
label: tag,
|
||||
selected:
|
||||
selected != null &&
|
||||
selected!.toLowerCase() == tag.toLowerCase(),
|
||||
compact: compact,
|
||||
onPressed: onTap == null ? null : () => onTap!(tag),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TagEditor extends StatefulWidget {
|
||||
const TagEditor({
|
||||
super.key,
|
||||
required this.tags,
|
||||
required this.onChanged,
|
||||
this.suggestions = const [],
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final List<String> tags;
|
||||
final List<String> suggestions;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<TagEditor> createState() => _TagEditorState();
|
||||
}
|
||||
|
||||
class _TagEditorState extends State<TagEditor> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add(String raw) {
|
||||
final label = raw.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (label.isEmpty) return;
|
||||
final exists = widget.tags.any(
|
||||
(tag) => tag.toLowerCase() == label.toLowerCase(),
|
||||
);
|
||||
if (exists) {
|
||||
_controller.clear();
|
||||
return;
|
||||
}
|
||||
widget.onChanged([...widget.tags, label]);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
void _remove(String tag) {
|
||||
widget.onChanged([
|
||||
for (final item in widget.tags)
|
||||
if (item.toLowerCase() != tag.toLowerCase()) item,
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unused = [
|
||||
for (final tag in widget.suggestions)
|
||||
if (!widget.tags.any((item) => item.toLowerCase() == tag.toLowerCase()))
|
||||
tag,
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.tags.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tag in widget.tags)
|
||||
InputChip(
|
||||
label: Text(tag),
|
||||
onDeleted: widget.enabled ? () => _remove(tag) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Add tag',
|
||||
hintText: 'weekly, bulk, work…',
|
||||
),
|
||||
onSubmitted: widget.enabled ? _add : null,
|
||||
),
|
||||
if (widget.enabled && unused.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tag in unused.take(12))
|
||||
ActionChip(label: Text(tag), onPressed: () => _add(tag)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.compact,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final bg = selected ? scheme.primary : scheme.secondaryContainer;
|
||||
final fg = selected ? scheme.onPrimary : scheme.onSecondaryContainer;
|
||||
return Material(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 8,
|
||||
vertical: compact ? 2 : 4,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: compact ? 11 : 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user