diff --git a/lib/app/router.dart b/lib/app/router.dart index 022da7b..b7f2c7e 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -5,6 +5,7 @@ import 'package:kooltab2/views/settings_view.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../views/bar_screen_view.dart'; import '../views/dev_menu_view.dart'; +import '../views/error_screen_view.dart'; import '../views/history_screen_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; @@ -80,6 +81,10 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { path: '/dev', builder: (context, state) => const DevMenuView(), ), + GoRoute( + path: '/error', + builder: (context, state) => const ErrorScreenView(), + ), ], ); } diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index a733848..d2f1fd9 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -126,7 +126,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 4; + int get schemaVersion => 5; @override MigrationStrategy get migration { diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart index 56cec0e..fa98b6e 100644 --- a/lib/models/bar_tab.dart +++ b/lib/models/bar_tab.dart @@ -30,4 +30,35 @@ class BarTab { } bool get isOpen => status == 'open'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BarTab && + id == other.id && + customerName == other.customerName && + status == other.status && + openedAt == other.openedAt && + closedAt == other.closedAt && + _listEquals(items, other.items); + + @override + int get hashCode => Object.hash( + id, + customerName, + status, + openedAt, + closedAt, + Object.hashAll(items), + ); +} + +bool _listEquals(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == b; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; } diff --git a/lib/models/closed_tab.dart b/lib/models/closed_tab.dart index 07ce2dd..b6c14e9 100644 --- a/lib/models/closed_tab.dart +++ b/lib/models/closed_tab.dart @@ -24,4 +24,33 @@ class ClosedTab { String get formattedTotal => NumberFormat.simpleCurrency().format(totalInCents / 100); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClosedTab && + id == other.id && + originalTabId == other.originalTabId && + customerName == other.customerName && + closedAt == other.closedAt && + _listEquals(items, other.items); + + @override + int get hashCode => Object.hash( + id, + originalTabId, + customerName, + closedAt, + Object.hashAll(items), + ); +} + +bool _listEquals(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == b; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; } diff --git a/lib/models/closed_tab_item.dart b/lib/models/closed_tab_item.dart index 86b8851..3446cad 100644 --- a/lib/models/closed_tab_item.dart +++ b/lib/models/closed_tab_item.dart @@ -16,4 +16,25 @@ class ClosedTabItem { }); int get lineTotalInCents => quantity * unitPriceInCents; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClosedTabItem && + id == other.id && + closedTabId == other.closedTabId && + productId == other.productId && + productName == other.productName && + quantity == other.quantity && + unitPriceInCents == other.unitPriceInCents; + + @override + int get hashCode => Object.hash( + id, + closedTabId, + productId, + productName, + quantity, + unitPriceInCents, + ); } diff --git a/lib/models/product.dart b/lib/models/product.dart index 7e09b5d..94a8dfb 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -48,4 +48,29 @@ class Product { active: active ?? this.active, ); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Product && + id == other.id && + name == other.name && + category == other.category && + stockQuantity == other.stockQuantity && + lowStockThreshold == other.lowStockThreshold && + priceInCents == other.priceInCents && + imagePath == other.imagePath && + active == other.active; + + @override + int get hashCode => Object.hash( + id, + name, + category, + stockQuantity, + lowStockThreshold, + priceInCents, + imagePath, + active, + ); } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index e70b85d..965e1a8 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -17,4 +17,14 @@ class AppSettings { pinRequired: false, themeMode: AppThemeMode.system, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppSettings && + pinRequired == other.pinRequired && + themeMode == other.themeMode; + + @override + int get hashCode => Object.hash(pinRequired, themeMode); } diff --git a/lib/models/tab_item.dart b/lib/models/tab_item.dart index 5bc194b..1e4b2e8 100644 --- a/lib/models/tab_item.dart +++ b/lib/models/tab_item.dart @@ -24,4 +24,25 @@ class TabItem { String get formattedUnitPrice { return '€${(unitPriceInCents / 100).toStringAsFixed(2)}'; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TabItem && + id == other.id && + tabId == other.tabId && + productId == other.productId && + productName == other.productName && + quantity == other.quantity && + unitPriceInCents == other.unitPriceInCents; + + @override + int get hashCode => Object.hash( + id, + tabId, + productId, + productName, + quantity, + unitPriceInCents, + ); } diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 78aedb1..eaae987 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -18,11 +18,13 @@ abstract class BarTabService { Future addProductToTab({ required String tabId, required Product product, + Future Function()? stockAdjustment, }); Future updateTabItemQuantity({ required String tabItemId, required int quantity, + Future Function()? stockAdjustment, }); /// Archives the tab's current items into history and clears them. @@ -30,6 +32,16 @@ abstract class BarTabService { Future closeTab(String tabId); Future> getClosedTabs(); + + Future> getClosedTabsPaginated({ + int limit = 20, + int offset = 0, + String? customerName, + }); + + Future getClosedTabCount({String? customerName}); + + Future> getDistinctCustomerNames(); } class DriftBarTabService implements BarTabService { @@ -144,6 +156,7 @@ class DriftBarTabService implements BarTabService { Future addProductToTab({ required String tabId, required Product product, + Future Function()? stockAdjustment, }) async { await database.transaction(() async { final existingItemQuery = database.select(database.tabItems) @@ -162,6 +175,10 @@ class DriftBarTabService implements BarTabService { TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); + if (stockAdjustment != null) { + await stockAdjustment(); + } + return; } @@ -178,6 +195,10 @@ class DriftBarTabService implements BarTabService { createdAt: DateTime.now(), ), ); + + if (stockAdjustment != null) { + await stockAdjustment(); + } }); } @@ -185,19 +206,31 @@ class DriftBarTabService implements BarTabService { Future updateTabItemQuantity({ required String tabItemId, required int quantity, + Future Function()? stockAdjustment, }) async { - if (quantity <= 0) { - final deleteQuery = database.delete(database.tabItems) + await database.transaction(() async { + if (quantity <= 0) { + final deleteQuery = database.delete(database.tabItems) + ..where((item) => item.id.equals(tabItemId)); + + await deleteQuery.go(); + + if (stockAdjustment != null) { + await stockAdjustment(); + } + + return; + } + + final updateQuery = database.update(database.tabItems) ..where((item) => item.id.equals(tabItemId)); - await deleteQuery.go(); - return; - } + await updateQuery.write(TabItemsCompanion(quantity: Value(quantity))); - final updateQuery = database.update(database.tabItems) - ..where((item) => item.id.equals(tabItemId)); - - await updateQuery.write(TabItemsCompanion(quantity: Value(quantity))); + if (stockAdjustment != null) { + await stockAdjustment(); + } + }); } @override @@ -255,8 +288,26 @@ class DriftBarTabService implements BarTabService { @override Future> getClosedTabs() async { - final query = database.select(database.closedTabs) - ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]); + final count = await getClosedTabCount(); + if (count == 0) return []; + + return getClosedTabsPaginated(limit: count, offset: 0); + } + + @override + Future> getClosedTabsPaginated({ + int limit = 20, + int offset = 0, + String? customerName, + }) async { + var query = database.select(database.closedTabs) + ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]) + ..limit(limit, offset: offset); + + if (customerName != null && customerName.isNotEmpty) { + query = query + ..where((tab) => tab.customerName.equals(customerName)); + } final closedTabRows = await query.get(); @@ -281,4 +332,30 @@ class DriftBarTabService implements BarTabService { return closedTabs; } + + @override + Future getClosedTabCount({String? customerName}) async { + final countExpr = database.closedTabs.id.count(); + + var query = database.selectOnly(database.closedTabs) + ..addColumns([countExpr]); + + if (customerName != null && customerName.isNotEmpty) { + query = query + ..where(database.closedTabs.customerName.equals(customerName)); + } + + final row = await query.getSingle(); + return row.read(countExpr) ?? 0; + } + + @override + Future> getDistinctCustomerNames() async { + final rows = await database.select(database.closedTabs).get(); + return rows + .map((row) => row.customerName) + .toSet() + .toList() + ..sort(); + } } diff --git a/lib/utils/app_config.dart b/lib/utils/app_config.dart new file mode 100644 index 0000000..739cd66 --- /dev/null +++ b/lib/utils/app_config.dart @@ -0,0 +1 @@ +const kUpdateServerUrl = 'https://updater.brammie15.dev'; \ No newline at end of file diff --git a/lib/utils/app_update_util.dart b/lib/utils/app_update_util.dart index 9a4dc3a..ba2fe16 100644 --- a/lib/utils/app_update_util.dart +++ b/lib/utils/app_update_util.dart @@ -36,6 +36,21 @@ class UpdateInfo { download: json["download"] ?? "", ); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UpdateInfo && + update == other.update && + version == other.version && + notes == other.notes && + mandatory == other.mandatory && + sha256 == other.sha256 && + download == other.download; + + @override + int get hashCode => + Object.hash(update, version, notes, mandatory, sha256, download); } class AppUpdateUtil { diff --git a/lib/utils/app_updater.dart b/lib/utils/app_updater.dart index 38ae91c..49545e7 100644 --- a/lib/utils/app_updater.dart +++ b/lib/utils/app_updater.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:kooltab2/utils/app_update_util.dart'; import 'package:ota_update/ota_update.dart'; +import 'app_config.dart'; + class UpdateChecker { static bool _hasChecked = false; @@ -13,7 +15,7 @@ class UpdateChecker { _hasChecked = true; - final updater = AppUpdateUtil(serverUrl: "https://updater.brammie15.dev"); + final updater = AppUpdateUtil(serverUrl: kUpdateServerUrl); try { final update = await updater.checkForUpdate(); @@ -72,7 +74,7 @@ Future _downloadAndInstall(UpdateInfo update) async { try { final url = update.download.startsWith("http") ? update.download - : "https://updater.brammie15.dev${update.download}"; + : "$kUpdateServerUrl${update.download}"; OtaUpdate() .execute( url, diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index 3d05758..cc984cf 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -88,9 +88,11 @@ class BarScreenViewModel extends ChangeNotifier { throw Exception('Product is out of stock.'); } - await barTabService.addProductToTab(tabId: tab.id, product: product); - - await inventory.decreaseStock(product.id, 1); + await barTabService.addProductToTab( + tabId: tab.id, + product: product, + stockAdjustment: () => inventory.decreaseStock(product.id, 1), + ); await _reloadTabs(); } @@ -101,14 +103,15 @@ class BarScreenViewModel extends ChangeNotifier { await barTabService.updateTabItemQuantity( tabItemId: item.id, quantity: quantity, + stockAdjustment: () async { + if (difference > 0) { + await inventory.decreaseStock(item.productId, difference); + } else if (difference < 0) { + await inventory.increaseStock(item.productId, -difference); + } + }, ); - if (difference > 0) { - await inventory.decreaseStock(item.productId, difference); - } else if (difference < 0) { - await inventory.increaseStock(item.productId, -difference); - } - await _reloadTabs(); } diff --git a/lib/viewmodels/dev_menu_view_model.dart b/lib/viewmodels/dev_menu_view_model.dart index ef92063..4cb1af8 100644 --- a/lib/viewmodels/dev_menu_view_model.dart +++ b/lib/viewmodels/dev_menu_view_model.dart @@ -254,4 +254,73 @@ class DevMenuViewModel extends ChangeNotifier { notifyListeners(); } } + + Future generateMockOrders({int count = 100}) async { + _isLoading = true; + notifyListeners(); + + try { + final products = await productService.getProducts(); + if (products.isEmpty) { + _lastAction = 'No products available — seed demo data first'; + return; + } + + final uuid = const Uuid(); + final customerNames = [ + 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', + 'Grace', 'Hank', 'Ivy', 'Jack', 'Kate', 'Leo', + 'Mia', 'Noah', 'Olivia', 'Pete', 'Quinn', 'Rose', + 'Sam', 'Tina', 'Umar', 'Vera', 'Wes', 'Xena', + 'Yves', 'Zara', + ]; + + await database.transaction(() async { + for (var i = 0; i < count; i++) { + final customer = customerNames[i % customerNames.length]; + final closedTabId = uuid.v4(); + final itemCount = 1 + (i % 5); + + final closedAt = DateTime.now().subtract( + Duration( + days: i % 90, + hours: i % 24, + minutes: i % 60, + ), + ); + + await database.into(database.closedTabs).insert( + ClosedTabsCompanion.insert( + id: closedTabId, + originalTabId: uuid.v4(), + customerName: customer, + closedAt: closedAt, + ), + ); + + for (var j = 0; j < itemCount; j++) { + final product = products[(i + j) % products.length]; + final quantity = 1 + ((i * 7 + j * 13) % 6); + + await database.into(database.closedTabItems).insert( + ClosedTabItemsCompanion.insert( + id: uuid.v4(), + closedTabId: closedTabId, + productId: product.id, + productName: product.name, + quantity: quantity, + unitPriceInCents: product.priceInCents, + ), + ); + } + } + }); + + _lastAction = 'Generated $count mock orders across ' + '${customerNames.length} customers'; + } finally { + _isLoading = false; + notifyListeners(); + } + } } diff --git a/lib/viewmodels/history_view_model.dart b/lib/viewmodels/history_view_model.dart index 39f8d28..9945525 100644 --- a/lib/viewmodels/history_view_model.dart +++ b/lib/viewmodels/history_view_model.dart @@ -8,9 +8,16 @@ class HistoryViewModel extends ChangeNotifier { HistoryViewModel({required this.barTabService}); + static const int _pageSize = 20; + List _closedTabs = []; + List _customerNames = []; + String? _selectedCustomer; + int _offset = 0; + int _totalCount = 0; bool _isLoading = false; bool _hasLoaded = false; + bool _isLoadingMore = false; String? _errorMessage; String _searchQuery = ''; @@ -26,10 +33,18 @@ class HistoryViewModel extends ChangeNotifier { .toList(); } + List get customerNames => _customerNames; + + String? get selectedCustomer => _selectedCustomer; + bool get isLoading => _isLoading; bool get hasLoaded => _hasLoaded; + bool get isLoadingMore => _isLoadingMore; + + bool get hasMore => _closedTabs.length < _totalCount; + String? get errorMessage => _errorMessage; Future ensureLoaded() async { @@ -46,7 +61,18 @@ class HistoryViewModel extends ChangeNotifier { notifyListeners(); try { - _closedTabs = await barTabService.getClosedTabs(); + final results = await Future.wait([ + _fetchPage(offset: 0), + barTabService.getClosedTabCount( + customerName: _selectedCustomer, + ), + barTabService.getDistinctCustomerNames(), + ]); + + _closedTabs = results[0] as List; + _totalCount = results[1] as int; + _customerNames = results[2] as List; + _offset = _closedTabs.length; } catch (_) { _errorMessage = 'Could not load tab history.'; } finally { @@ -56,8 +82,43 @@ class HistoryViewModel extends ChangeNotifier { } } + Future loadMore() async { + if (_isLoadingMore || !hasMore) return; + + _isLoadingMore = true; + notifyListeners(); + + try { + final more = await _fetchPage(offset: _offset); + _closedTabs = [..._closedTabs, ...more]; + _offset = _closedTabs.length; + } catch (_) { + _errorMessage = 'Could not load more tabs.'; + } finally { + _isLoadingMore = false; + notifyListeners(); + } + } + + Future> _fetchPage({required int offset}) { + return barTabService.getClosedTabsPaginated( + limit: _pageSize, + offset: offset, + customerName: _selectedCustomer, + ); + } + void search(String query) { _searchQuery = query; notifyListeners(); } -} + + Future filterByCustomer(String? customerName) async { + _selectedCustomer = customerName; + _searchQuery = ''; + _offset = 0; + _closedTabs = []; + + await load(); + } +} \ No newline at end of file diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart index 6d3598a..4b9bfdc 100644 --- a/lib/viewmodels/settings_view_model.dart +++ b/lib/viewmodels/settings_view_model.dart @@ -4,6 +4,7 @@ import 'package:ota_update/ota_update.dart'; import '../models/settings.dart'; import '../services/settings_service.dart'; +import '../utils/app_config.dart'; class SettingsViewModel extends ChangeNotifier { final SettingsService settingsService; @@ -15,9 +16,7 @@ class SettingsViewModel extends ChangeNotifier { bool _hasLoaded = false; String? _errorMessage; - final AppUpdateUtil _updater = AppUpdateUtil( - serverUrl: "https://updater.brammie15.dev", - ); + final AppUpdateUtil _updater = AppUpdateUtil(serverUrl: kUpdateServerUrl); bool _checkingForUpdates = false; @@ -74,7 +73,7 @@ class SettingsViewModel extends ChangeNotifier { }) async { final url = update.download.startsWith("http") ? update.download - : "https://updater.brammie15.dev${update.download}"; + : "$kUpdateServerUrl${update.download}"; debugPrint("Download Url: ${url}"); diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index a94796b..9e22b4c 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -107,6 +107,12 @@ class _DevMenuViewState extends State { subtitle: 'Show FPS, memory, widget count', onTap: () => _showDebugOverlayInfo(), ), + _Tile( + icon: Icons.error_outline_rounded, + title: 'Error screen', + subtitle: 'View the error screen UI', + onTap: () => context.push('/error'), + ), ], ), _Section( @@ -141,6 +147,14 @@ class _DevMenuViewState extends State { subtitle: 'Set all products below threshold', onTap: vm.isLoading ? null : () => vm.simulateLowStock(), ), + _Tile( + icon: Icons.history_rounded, + title: 'Generate 100 mock orders', + subtitle: 'Random customers, items, and amounts', + onTap: vm.isLoading + ? null + : () => vm.generateMockOrders(count: 100), + ), ], ), _Section( diff --git a/lib/views/error_screen_view.dart b/lib/views/error_screen_view.dart new file mode 100644 index 0000000..80fca91 --- /dev/null +++ b/lib/views/error_screen_view.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ErrorScreenView extends StatelessWidget { + const ErrorScreenView({super.key}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back), + ), + title: const Text('Error Screen'), + centerTitle: true, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.error.withValues(alpha: 0.12), + ), + child: Icon( + Icons.error_outline_rounded, + size: 64, + color: scheme.error, + ), + ), + const SizedBox(height: 24), + Text( + 'Something went wrong', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: scheme.error, + ), + ), + const SizedBox(height: 12), + Text( + 'An unexpected error occurred.\nPlease try restarting the app.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => context.go('/bar'), + icon: const Icon(Icons.home_rounded), + label: const Text('Go to bar screen'), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/views/history_screen_view.dart b/lib/views/history_screen_view.dart index 39323ca..43aa411 100644 --- a/lib/views/history_screen_view.dart +++ b/lib/views/history_screen_view.dart @@ -1,12 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:kooltab2/views/widgets/closed_tab_card.dart'; import 'package:provider/provider.dart'; import '../app/router.dart'; -import '../models/closed_tab.dart'; -import '../models/closed_tab_item.dart'; import '../viewmodels/history_view_model.dart'; class HistoryScreenView extends StatefulWidget { @@ -17,10 +14,14 @@ class HistoryScreenView extends StatefulWidget { } class _HistoryScreenViewState extends State with RouteAware { + final _scrollController = ScrollController(); + @override void initState() { super.initState(); + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { context.read().load(); }); @@ -34,16 +35,23 @@ class _HistoryScreenViewState extends State with RouteAware { @override void didPopNext() { - // Called when you come back to this screen context.read().load(); } @override void dispose() { routeObserver.unsubscribe(this); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + if (_scrollController.position.extentAfter < 300) { + context.read().loadMore(); + } + } + @override Widget build(BuildContext context) { final viewModel = context.watch(); @@ -53,9 +61,7 @@ class _HistoryScreenViewState extends State with RouteAware { title: Row( children: [ IconButton( - onPressed: () { - context.go('/bar'); - }, + onPressed: () => context.go('/bar'), icon: const Icon(Icons.arrow_back), ), const SizedBox(width: 5), @@ -108,23 +114,51 @@ class _HistoryScreenViewState extends State with RouteAware { children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: TextField( - onChanged: viewModel.search, - decoration: const InputDecoration( - hintText: 'Search by name…', - prefixIcon: Icon(Icons.search_rounded, size: 20), - isDense: true, - ), + child: Row( + children: [ + Expanded( + child: TextField( + onChanged: viewModel.search, + decoration: const InputDecoration( + hintText: 'Search by name…', + prefixIcon: Icon(Icons.search_rounded, size: 20), + isDense: true, + ), + ), + ), + const SizedBox(width: 12), + _CustomerDropdown( + customerNames: viewModel.customerNames, + selectedCustomer: viewModel.selectedCustomer, + onSelected: viewModel.filterByCustomer, + ), + ], ), ), Expanded( child: viewModel.closedTabs.isEmpty ? _EmptyState() : ListView.separated( + controller: _scrollController, padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - itemCount: viewModel.closedTabs.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), + itemCount: viewModel.closedTabs.length + + (viewModel.hasMore || viewModel.isLoadingMore + ? 1 + : 0), + separatorBuilder: (_, _) => + const SizedBox(height: 10), itemBuilder: (context, index) { + if (index == viewModel.closedTabs.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ), + ); + } + final closedTab = viewModel.closedTabs[index]; return ClosedTabCard(closedTab: closedTab); @@ -139,6 +173,126 @@ class _HistoryScreenViewState extends State with RouteAware { } } +class _CustomerDropdown extends StatelessWidget { + static const _allSentinel = r'$__all__$'; + + final List customerNames; + final String? selectedCustomer; + final ValueChanged onSelected; + + const _CustomerDropdown({ + required this.customerNames, + required this.selectedCustomer, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isFiltered = selectedCustomer != null; + + return PopupMenuButton( + onSelected: (value) { + onSelected(value == _allSentinel ? null : value); + }, + offset: const Offset(0, 44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + color: Theme.of(context).cardTheme.color ?? scheme.surface, + itemBuilder: (context) => [ + PopupMenuItem( + value: _allSentinel, + child: Row( + children: [ + Icon( + isFiltered + ? Icons.people_outline + : Icons.people_rounded, + size: 18, + color: isFiltered + ? null + : scheme.primary, + ), + const SizedBox(width: 10), + Text( + 'All customers', + style: TextStyle( + fontWeight: isFiltered ? FontWeight.w400 : FontWeight.w700, + color: isFiltered ? null : scheme.primary, + ), + ), + ], + ), + ), + if (customerNames.isNotEmpty) + const PopupMenuDivider(height: 1), + ...customerNames.map( + (name) => PopupMenuItem( + value: name, + child: Row( + children: [ + Icon( + name == selectedCustomer + ? Icons.person_rounded + : Icons.person_outline_rounded, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text(name, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)), + color: isFiltered + ? scheme.primary.withValues(alpha: 0.08) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_rounded, + size: 18, + color: isFiltered + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + selectedCustomer ?? 'Customer', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: isFiltered + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down_rounded, + size: 18, + color: isFiltered + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + } +} + class _EmptyState extends StatelessWidget { @override Widget build(BuildContext context) { @@ -174,4 +328,4 @@ class _EmptyState extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/views/product_form_view.dart b/lib/views/product_form_view.dart index 9095d6a..14198bd 100644 --- a/lib/views/product_form_view.dart +++ b/lib/views/product_form_view.dart @@ -36,6 +36,7 @@ class _ProductFormViewState extends State { Product? _existingProduct; String? _imagePath; + bool _hasImage = false; bool _isLoading = true; bool _isSaving = false; @@ -72,6 +73,7 @@ class _ProductFormViewState extends State { _stockController.text = product.stockQuantity.toString(); _lowStockController.text = product.lowStockThreshold.toString(); _imagePath = product.imagePath; + _hasImage = product.imagePath != null && product.imagePath!.isNotEmpty; setState(() => _isLoading = false); } @@ -108,10 +110,11 @@ class _ProductFormViewState extends State { Future _pickImage() async { final pickedFile = await _imagePicker.pickImage( - source: ImageSource.gallery, - imageQuality: 85, - maxWidth: 1000, - ); + source: ImageSource.gallery, + imageQuality: 80, + maxWidth: 1000, + maxHeight: 1000, + ); if (pickedFile == null) return; @@ -121,6 +124,7 @@ class _ProductFormViewState extends State { setState(() { _imagePath = copiedImagePath; + _hasImage = true; }); } @@ -222,7 +226,6 @@ class _ProductFormViewState extends State { } Widget _buildImagePicker(BuildContext context) { - final hasImage = _imagePath != null && File(_imagePath!).existsSync(); return InkWell( onTap: _pickImage, @@ -234,11 +237,22 @@ class _ProductFormViewState extends State { border: Border.all(color: Theme.of(context).colorScheme.outline), ), clipBehavior: Clip.antiAlias, - child: hasImage + child: _hasImage ? Stack( fit: StackFit.expand, children: [ - Image.file(File(_imagePath!), fit: BoxFit.fitHeight), + Image.file( + File(_imagePath!), + fit: BoxFit.fitHeight, + cacheWidth: 1000, + errorBuilder: (context, error, stackTrace) => Center( + child: Icon( + Icons.broken_image_outlined, + size: 48, + color: Theme.of(context).colorScheme.outline, + ), + ), + ), Positioned( right: 12, bottom: 12, diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 43aa664..a14044c 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -73,10 +73,17 @@ class _ProductListViewState extends State { width: 56, height: 56, child: Center( - child: Image.file( - File(product.imagePath!), - fit: BoxFit.contain, - ), + child: product.imagePath != null && + product.imagePath!.isNotEmpty + ? Image.file( + File(product.imagePath!), + fit: BoxFit.contain, + cacheWidth: 112, + errorBuilder: + (context, error, stackTrace) => + const Icon(Icons.image_not_supported_outlined), + ) + : const Icon(Icons.image_not_supported_outlined), ), ), title: Text(product.name), diff --git a/lib/views/widgets/product_tile.dart b/lib/views/widgets/product_tile.dart index 19f3f41..0d03cc6 100644 --- a/lib/views/widgets/product_tile.dart +++ b/lib/views/widgets/product_tile.dart @@ -7,6 +7,7 @@ class ProductTile extends StatelessWidget { final Product product; final bool enabled; final VoidCallback onTap; + static const _tileImageSize = 280.0; const ProductTile({ required this.product, @@ -14,16 +15,6 @@ class ProductTile extends StatelessWidget { required this.onTap, }); - bool get _hasImage { - final imagePath = product.imagePath; - - if (imagePath == null || imagePath.isEmpty) { - return false; - } - - return File(imagePath).existsSync(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -32,7 +23,10 @@ class ProductTile extends StatelessWidget { final outOfStock = product.stockQuantity <= 0; final lowStock = product.stockQuantity > 0 && - product.stockQuantity <= product.lowStockThreshold; // adjust name + product.stockQuantity <= product.lowStockThreshold; + + final hasImage = product.imagePath != null && + product.imagePath!.isNotEmpty; final borderColor = outOfStock ? scheme.error.withValues(alpha: 0.7) @@ -59,21 +53,18 @@ class ProductTile extends StatelessWidget { children: [ Opacity( opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4), - child: _hasImage + child: hasImage ? Image.file( File(product.imagePath!), fit: BoxFit.scaleDown, + cacheWidth: _tileImageSize.toInt(), + errorBuilder: + (context, error, stackTrace) => _noImage(scheme), ) - : Center( - child: Icon( - Icons.image_not_supported_outlined, - size: 34, - color: scheme.onSurface.withValues(alpha: 0.3), - ), - ), + : _noImage(scheme), ), - if (_hasImage) + if (hasImage) Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( @@ -126,6 +117,16 @@ class ProductTile extends StatelessWidget { ), ); } + + Widget _noImage(ColorScheme scheme) { + return Center( + child: Icon( + Icons.image_not_supported_outlined, + size: 34, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ); + } } class _StockBadge extends StatelessWidget { @@ -141,8 +142,7 @@ class _StockBadge extends StatelessWidget { return const SizedBox.shrink(); } - final low = - product.stockQuantity <= product.lowStockThreshold; // adjust name + final low = product.stockQuantity <= product.lowStockThreshold; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),