new plus button

This commit is contained in:
davmar
2026-08-31 09:40:38 +02:00
parent 3b698a3667
commit 92581e6d55
17 changed files with 1336 additions and 152 deletions
+107 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+5 -1
View File
@@ -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']),
+2
View File
@@ -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,
+142 -3
View File
@@ -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',
+60 -1
View File
@@ -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
View File
@@ -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,
),
),
],
),
),
),
);
}
+18
View File
@@ -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}';
+48
View File
@@ -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;
}
+48
View File
@@ -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);
}
+5 -1
View File
@@ -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;
}
+216
View File
@@ -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)!;
}
+181
View File
@@ -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,
),
),
),
),
);
}
}