feat: add equality operators, make history screen paginated, random performance updates

This commit is contained in:
2026-07-29 18:10:46 +02:00
parent da5588cd1c
commit 031a618220
22 changed files with 702 additions and 79 deletions
+5
View File
@@ -5,6 +5,7 @@ import 'package:kooltab2/views/settings_view.dart';
import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart';
import '../views/bar_screen_view.dart'; import '../views/bar_screen_view.dart';
import '../views/dev_menu_view.dart'; import '../views/dev_menu_view.dart';
import '../views/error_screen_view.dart';
import '../views/history_screen_view.dart'; import '../views/history_screen_view.dart';
import '../views/pin_lock_view.dart'; import '../views/pin_lock_view.dart';
import '../views/product_form_view.dart'; import '../views/product_form_view.dart';
@@ -80,6 +81,10 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
path: '/dev', path: '/dev',
builder: (context, state) => const DevMenuView(), builder: (context, state) => const DevMenuView(),
), ),
GoRoute(
path: '/error',
builder: (context, state) => const ErrorScreenView(),
),
], ],
); );
} }
+1 -1
View File
@@ -126,7 +126,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 4; int get schemaVersion => 5;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
+31
View File
@@ -30,4 +30,35 @@ class BarTab {
} }
bool get isOpen => status == 'open'; 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<T>(List<T>? a, List<T>? 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;
} }
+29
View File
@@ -24,4 +24,33 @@ class ClosedTab {
String get formattedTotal => String get formattedTotal =>
NumberFormat.simpleCurrency().format(totalInCents / 100); 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<T>(List<T>? a, List<T>? 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;
} }
+21
View File
@@ -16,4 +16,25 @@ class ClosedTabItem {
}); });
int get lineTotalInCents => quantity * unitPriceInCents; 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,
);
} }
+25
View File
@@ -48,4 +48,29 @@ class Product {
active: active ?? this.active, 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,
);
} }
+10
View File
@@ -17,4 +17,14 @@ class AppSettings {
pinRequired: false, pinRequired: false,
themeMode: AppThemeMode.system, 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);
} }
+21
View File
@@ -24,4 +24,25 @@ class TabItem {
String get formattedUnitPrice { String get formattedUnitPrice {
return '${(unitPriceInCents / 100).toStringAsFixed(2)}'; 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,
);
} }
+88 -11
View File
@@ -18,11 +18,13 @@ abstract class BarTabService {
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
Future<void> Function()? stockAdjustment,
}); });
Future<void> updateTabItemQuantity({ Future<void> updateTabItemQuantity({
required String tabItemId, required String tabItemId,
required int quantity, required int quantity,
Future<void> Function()? stockAdjustment,
}); });
/// Archives the tab's current items into history and clears them. /// Archives the tab's current items into history and clears them.
@@ -30,6 +32,16 @@ abstract class BarTabService {
Future<void> closeTab(String tabId); Future<void> closeTab(String tabId);
Future<List<ClosedTab>> getClosedTabs(); Future<List<ClosedTab>> getClosedTabs();
Future<List<ClosedTab>> getClosedTabsPaginated({
int limit = 20,
int offset = 0,
String? customerName,
});
Future<int> getClosedTabCount({String? customerName});
Future<List<String>> getDistinctCustomerNames();
} }
class DriftBarTabService implements BarTabService { class DriftBarTabService implements BarTabService {
@@ -144,6 +156,7 @@ class DriftBarTabService implements BarTabService {
Future<void> addProductToTab({ Future<void> addProductToTab({
required String tabId, required String tabId,
required Product product, required Product product,
Future<void> Function()? stockAdjustment,
}) async { }) async {
await database.transaction(() async { await database.transaction(() async {
final existingItemQuery = database.select(database.tabItems) final existingItemQuery = database.select(database.tabItems)
@@ -162,6 +175,10 @@ class DriftBarTabService implements BarTabService {
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
); );
if (stockAdjustment != null) {
await stockAdjustment();
}
return; return;
} }
@@ -178,6 +195,10 @@ class DriftBarTabService implements BarTabService {
createdAt: DateTime.now(), createdAt: DateTime.now(),
), ),
); );
if (stockAdjustment != null) {
await stockAdjustment();
}
}); });
} }
@@ -185,19 +206,31 @@ class DriftBarTabService implements BarTabService {
Future<void> updateTabItemQuantity({ Future<void> updateTabItemQuantity({
required String tabItemId, required String tabItemId,
required int quantity, required int quantity,
Future<void> Function()? stockAdjustment,
}) async { }) async {
if (quantity <= 0) { await database.transaction(() async {
final deleteQuery = database.delete(database.tabItems) 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)); ..where((item) => item.id.equals(tabItemId));
await deleteQuery.go(); await updateQuery.write(TabItemsCompanion(quantity: Value(quantity)));
return;
}
final updateQuery = database.update(database.tabItems) if (stockAdjustment != null) {
..where((item) => item.id.equals(tabItemId)); await stockAdjustment();
}
await updateQuery.write(TabItemsCompanion(quantity: Value(quantity))); });
} }
@override @override
@@ -255,8 +288,26 @@ class DriftBarTabService implements BarTabService {
@override @override
Future<List<ClosedTab>> getClosedTabs() async { Future<List<ClosedTab>> getClosedTabs() async {
final query = database.select(database.closedTabs) final count = await getClosedTabCount();
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]); if (count == 0) return [];
return getClosedTabsPaginated(limit: count, offset: 0);
}
@override
Future<List<ClosedTab>> 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(); final closedTabRows = await query.get();
@@ -281,4 +332,30 @@ class DriftBarTabService implements BarTabService {
return closedTabs; return closedTabs;
} }
@override
Future<int> 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<List<String>> getDistinctCustomerNames() async {
final rows = await database.select(database.closedTabs).get();
return rows
.map((row) => row.customerName)
.toSet()
.toList()
..sort();
}
} }
+1
View File
@@ -0,0 +1 @@
const kUpdateServerUrl = 'https://updater.brammie15.dev';
+15
View File
@@ -36,6 +36,21 @@ class UpdateInfo {
download: json["download"] ?? "", 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 { class AppUpdateUtil {
+4 -2
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:kooltab2/utils/app_update_util.dart'; import 'package:kooltab2/utils/app_update_util.dart';
import 'package:ota_update/ota_update.dart'; import 'package:ota_update/ota_update.dart';
import 'app_config.dart';
class UpdateChecker { class UpdateChecker {
static bool _hasChecked = false; static bool _hasChecked = false;
@@ -13,7 +15,7 @@ class UpdateChecker {
_hasChecked = true; _hasChecked = true;
final updater = AppUpdateUtil(serverUrl: "https://updater.brammie15.dev"); final updater = AppUpdateUtil(serverUrl: kUpdateServerUrl);
try { try {
final update = await updater.checkForUpdate(); final update = await updater.checkForUpdate();
@@ -72,7 +74,7 @@ Future<void> _downloadAndInstall(UpdateInfo update) async {
try { try {
final url = update.download.startsWith("http") final url = update.download.startsWith("http")
? update.download ? update.download
: "https://updater.brammie15.dev${update.download}"; : "$kUpdateServerUrl${update.download}";
OtaUpdate() OtaUpdate()
.execute( .execute(
url, url,
+12 -9
View File
@@ -88,9 +88,11 @@ class BarScreenViewModel extends ChangeNotifier {
throw Exception('Product is out of stock.'); throw Exception('Product is out of stock.');
} }
await barTabService.addProductToTab(tabId: tab.id, product: product); await barTabService.addProductToTab(
tabId: tab.id,
await inventory.decreaseStock(product.id, 1); product: product,
stockAdjustment: () => inventory.decreaseStock(product.id, 1),
);
await _reloadTabs(); await _reloadTabs();
} }
@@ -101,14 +103,15 @@ class BarScreenViewModel extends ChangeNotifier {
await barTabService.updateTabItemQuantity( await barTabService.updateTabItemQuantity(
tabItemId: item.id, tabItemId: item.id,
quantity: quantity, 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(); await _reloadTabs();
} }
+69
View File
@@ -254,4 +254,73 @@ class DevMenuViewModel extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
Future<void> 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();
}
}
} }
+63 -2
View File
@@ -8,9 +8,16 @@ class HistoryViewModel extends ChangeNotifier {
HistoryViewModel({required this.barTabService}); HistoryViewModel({required this.barTabService});
static const int _pageSize = 20;
List<ClosedTab> _closedTabs = []; List<ClosedTab> _closedTabs = [];
List<String> _customerNames = [];
String? _selectedCustomer;
int _offset = 0;
int _totalCount = 0;
bool _isLoading = false; bool _isLoading = false;
bool _hasLoaded = false; bool _hasLoaded = false;
bool _isLoadingMore = false;
String? _errorMessage; String? _errorMessage;
String _searchQuery = ''; String _searchQuery = '';
@@ -26,10 +33,18 @@ class HistoryViewModel extends ChangeNotifier {
.toList(); .toList();
} }
List<String> get customerNames => _customerNames;
String? get selectedCustomer => _selectedCustomer;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get hasLoaded => _hasLoaded; bool get hasLoaded => _hasLoaded;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _closedTabs.length < _totalCount;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
Future<void> ensureLoaded() async { Future<void> ensureLoaded() async {
@@ -46,7 +61,18 @@ class HistoryViewModel extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
_closedTabs = await barTabService.getClosedTabs(); final results = await Future.wait([
_fetchPage(offset: 0),
barTabService.getClosedTabCount(
customerName: _selectedCustomer,
),
barTabService.getDistinctCustomerNames(),
]);
_closedTabs = results[0] as List<ClosedTab>;
_totalCount = results[1] as int;
_customerNames = results[2] as List<String>;
_offset = _closedTabs.length;
} catch (_) { } catch (_) {
_errorMessage = 'Could not load tab history.'; _errorMessage = 'Could not load tab history.';
} finally { } finally {
@@ -56,8 +82,43 @@ class HistoryViewModel extends ChangeNotifier {
} }
} }
Future<void> 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<List<ClosedTab>> _fetchPage({required int offset}) {
return barTabService.getClosedTabsPaginated(
limit: _pageSize,
offset: offset,
customerName: _selectedCustomer,
);
}
void search(String query) { void search(String query) {
_searchQuery = query; _searchQuery = query;
notifyListeners(); notifyListeners();
} }
}
Future<void> filterByCustomer(String? customerName) async {
_selectedCustomer = customerName;
_searchQuery = '';
_offset = 0;
_closedTabs = [];
await load();
}
}
+3 -4
View File
@@ -4,6 +4,7 @@ import 'package:ota_update/ota_update.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/app_config.dart';
class SettingsViewModel extends ChangeNotifier { class SettingsViewModel extends ChangeNotifier {
final SettingsService settingsService; final SettingsService settingsService;
@@ -15,9 +16,7 @@ class SettingsViewModel extends ChangeNotifier {
bool _hasLoaded = false; bool _hasLoaded = false;
String? _errorMessage; String? _errorMessage;
final AppUpdateUtil _updater = AppUpdateUtil( final AppUpdateUtil _updater = AppUpdateUtil(serverUrl: kUpdateServerUrl);
serverUrl: "https://updater.brammie15.dev",
);
bool _checkingForUpdates = false; bool _checkingForUpdates = false;
@@ -74,7 +73,7 @@ class SettingsViewModel extends ChangeNotifier {
}) async { }) async {
final url = update.download.startsWith("http") final url = update.download.startsWith("http")
? update.download ? update.download
: "https://updater.brammie15.dev${update.download}"; : "$kUpdateServerUrl${update.download}";
debugPrint("Download Url: ${url}"); debugPrint("Download Url: ${url}");
+14
View File
@@ -107,6 +107,12 @@ class _DevMenuViewState extends State<DevMenuView> {
subtitle: 'Show FPS, memory, widget count', subtitle: 'Show FPS, memory, widget count',
onTap: () => _showDebugOverlayInfo(), onTap: () => _showDebugOverlayInfo(),
), ),
_Tile(
icon: Icons.error_outline_rounded,
title: 'Error screen',
subtitle: 'View the error screen UI',
onTap: () => context.push('/error'),
),
], ],
), ),
_Section( _Section(
@@ -141,6 +147,14 @@ class _DevMenuViewState extends State<DevMenuView> {
subtitle: 'Set all products below threshold', subtitle: 'Set all products below threshold',
onTap: vm.isLoading ? null : () => vm.simulateLowStock(), 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( _Section(
+65
View File
@@ -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'),
),
],
),
),
),
);
}
}
+171 -17
View File
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:kooltab2/views/widgets/closed_tab_card.dart'; import 'package:kooltab2/views/widgets/closed_tab_card.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../app/router.dart'; import '../app/router.dart';
import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../viewmodels/history_view_model.dart'; import '../viewmodels/history_view_model.dart';
class HistoryScreenView extends StatefulWidget { class HistoryScreenView extends StatefulWidget {
@@ -17,10 +14,14 @@ class HistoryScreenView extends StatefulWidget {
} }
class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware { class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
final _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HistoryViewModel>().load(); context.read<HistoryViewModel>().load();
}); });
@@ -34,16 +35,23 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
@override @override
void didPopNext() { void didPopNext() {
// Called when you come back to this screen
context.read<HistoryViewModel>().load(); context.read<HistoryViewModel>().load();
} }
@override @override
void dispose() { void dispose() {
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (_scrollController.position.extentAfter < 300) {
context.read<HistoryViewModel>().loadMore();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewModel = context.watch<HistoryViewModel>(); final viewModel = context.watch<HistoryViewModel>();
@@ -53,9 +61,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
title: Row( title: Row(
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () => context.go('/bar'),
context.go('/bar');
},
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
@@ -108,23 +114,51 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: TextField( child: Row(
onChanged: viewModel.search, children: [
decoration: const InputDecoration( Expanded(
hintText: 'Search by name…', child: TextField(
prefixIcon: Icon(Icons.search_rounded, size: 20), onChanged: viewModel.search,
isDense: true, 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( Expanded(
child: viewModel.closedTabs.isEmpty child: viewModel.closedTabs.isEmpty
? _EmptyState() ? _EmptyState()
: ListView.separated( : ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
itemCount: viewModel.closedTabs.length, itemCount: viewModel.closedTabs.length +
separatorBuilder: (_, _) => const SizedBox(height: 10), (viewModel.hasMore || viewModel.isLoadingMore
? 1
: 0),
separatorBuilder: (_, _) =>
const SizedBox(height: 10),
itemBuilder: (context, index) { 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]; final closedTab = viewModel.closedTabs[index];
return ClosedTabCard(closedTab: closedTab); return ClosedTabCard(closedTab: closedTab);
@@ -139,6 +173,126 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
} }
} }
class _CustomerDropdown extends StatelessWidget {
static const _allSentinel = r'$__all__$';
final List<String> customerNames;
final String? selectedCustomer;
final ValueChanged<String?> 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<String>(
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<String>(
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<String>(
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 { class _EmptyState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -174,4 +328,4 @@ class _EmptyState extends StatelessWidget {
), ),
); );
} }
} }
+21 -7
View File
@@ -36,6 +36,7 @@ class _ProductFormViewState extends State<ProductFormView> {
Product? _existingProduct; Product? _existingProduct;
String? _imagePath; String? _imagePath;
bool _hasImage = false;
bool _isLoading = true; bool _isLoading = true;
bool _isSaving = false; bool _isSaving = false;
@@ -72,6 +73,7 @@ class _ProductFormViewState extends State<ProductFormView> {
_stockController.text = product.stockQuantity.toString(); _stockController.text = product.stockQuantity.toString();
_lowStockController.text = product.lowStockThreshold.toString(); _lowStockController.text = product.lowStockThreshold.toString();
_imagePath = product.imagePath; _imagePath = product.imagePath;
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -108,10 +110,11 @@ class _ProductFormViewState extends State<ProductFormView> {
Future<void> _pickImage() async { Future<void> _pickImage() async {
final pickedFile = await _imagePicker.pickImage( final pickedFile = await _imagePicker.pickImage(
source: ImageSource.gallery, source: ImageSource.gallery,
imageQuality: 85, imageQuality: 80,
maxWidth: 1000, maxWidth: 1000,
); maxHeight: 1000,
);
if (pickedFile == null) return; if (pickedFile == null) return;
@@ -121,6 +124,7 @@ class _ProductFormViewState extends State<ProductFormView> {
setState(() { setState(() {
_imagePath = copiedImagePath; _imagePath = copiedImagePath;
_hasImage = true;
}); });
} }
@@ -222,7 +226,6 @@ class _ProductFormViewState extends State<ProductFormView> {
} }
Widget _buildImagePicker(BuildContext context) { Widget _buildImagePicker(BuildContext context) {
final hasImage = _imagePath != null && File(_imagePath!).existsSync();
return InkWell( return InkWell(
onTap: _pickImage, onTap: _pickImage,
@@ -234,11 +237,22 @@ class _ProductFormViewState extends State<ProductFormView> {
border: Border.all(color: Theme.of(context).colorScheme.outline), border: Border.all(color: Theme.of(context).colorScheme.outline),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: hasImage child: _hasImage
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ 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( Positioned(
right: 12, right: 12,
bottom: 12, bottom: 12,
+11 -4
View File
@@ -73,10 +73,17 @@ class _ProductListViewState extends State<ProductListView> {
width: 56, width: 56,
height: 56, height: 56,
child: Center( child: Center(
child: Image.file( child: product.imagePath != null &&
File(product.imagePath!), product.imagePath!.isNotEmpty
fit: BoxFit.contain, ? 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), title: Text(product.name),
+22 -22
View File
@@ -7,6 +7,7 @@ class ProductTile extends StatelessWidget {
final Product product; final Product product;
final bool enabled; final bool enabled;
final VoidCallback onTap; final VoidCallback onTap;
static const _tileImageSize = 280.0;
const ProductTile({ const ProductTile({
required this.product, required this.product,
@@ -14,16 +15,6 @@ class ProductTile extends StatelessWidget {
required this.onTap, required this.onTap,
}); });
bool get _hasImage {
final imagePath = product.imagePath;
if (imagePath == null || imagePath.isEmpty) {
return false;
}
return File(imagePath).existsSync();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -32,7 +23,10 @@ class ProductTile extends StatelessWidget {
final outOfStock = product.stockQuantity <= 0; final outOfStock = product.stockQuantity <= 0;
final lowStock = final lowStock =
product.stockQuantity > 0 && product.stockQuantity > 0 &&
product.stockQuantity <= product.lowStockThreshold; // adjust name product.stockQuantity <= product.lowStockThreshold;
final hasImage = product.imagePath != null &&
product.imagePath!.isNotEmpty;
final borderColor = outOfStock final borderColor = outOfStock
? scheme.error.withValues(alpha: 0.7) ? scheme.error.withValues(alpha: 0.7)
@@ -59,21 +53,18 @@ class ProductTile extends StatelessWidget {
children: [ children: [
Opacity( Opacity(
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4), opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
child: _hasImage child: hasImage
? Image.file( ? Image.file(
File(product.imagePath!), File(product.imagePath!),
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
cacheWidth: _tileImageSize.toInt(),
errorBuilder:
(context, error, stackTrace) => _noImage(scheme),
) )
: Center( : _noImage(scheme),
child: Icon(
Icons.image_not_supported_outlined,
size: 34,
color: scheme.onSurface.withValues(alpha: 0.3),
),
),
), ),
if (_hasImage) if (hasImage)
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( 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 { class _StockBadge extends StatelessWidget {
@@ -141,8 +142,7 @@ class _StockBadge extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final low = final low = product.stockQuantity <= product.lowStockThreshold;
product.stockQuantity <= product.lowStockThreshold; // adjust name
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),