feat: add equality operators, make history screen paginated, random performance updates
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ abstract class BarTabService {
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
});
|
||||
|
||||
Future<void> updateTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int quantity,
|
||||
Future<void> Function()? stockAdjustment,
|
||||
});
|
||||
|
||||
/// Archives the tab's current items into history and clears them.
|
||||
@@ -30,6 +32,16 @@ abstract class BarTabService {
|
||||
Future<void> closeTab(String tabId);
|
||||
|
||||
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 {
|
||||
@@ -144,6 +156,7 @@ class DriftBarTabService implements BarTabService {
|
||||
Future<void> addProductToTab({
|
||||
required String tabId,
|
||||
required Product product,
|
||||
Future<void> 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<void> updateTabItemQuantity({
|
||||
required String tabItemId,
|
||||
required int quantity,
|
||||
Future<void> 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<List<ClosedTab>> 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<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();
|
||||
|
||||
@@ -281,4 +332,30 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const kUpdateServerUrl = 'https://updater.brammie15.dev';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> _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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -254,4 +254,73 @@ class DevMenuViewModel extends ChangeNotifier {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,16 @@ class HistoryViewModel extends ChangeNotifier {
|
||||
|
||||
HistoryViewModel({required this.barTabService});
|
||||
|
||||
static const int _pageSize = 20;
|
||||
|
||||
List<ClosedTab> _closedTabs = [];
|
||||
List<String> _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<String> 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<void> 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<ClosedTab>;
|
||||
_totalCount = results[1] as int;
|
||||
_customerNames = results[2] as List<String>;
|
||||
_offset = _closedTabs.length;
|
||||
} catch (_) {
|
||||
_errorMessage = 'Could not load tab history.';
|
||||
} 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) {
|
||||
_searchQuery = query;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> filterByCustomer(String? customerName) async {
|
||||
_selectedCustomer = customerName;
|
||||
_searchQuery = '';
|
||||
_offset = 0;
|
||||
_closedTabs = [];
|
||||
|
||||
await load();
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
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<DevMenuView> {
|
||||
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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<HistoryScreenView> with RouteAware {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<HistoryViewModel>().load();
|
||||
});
|
||||
@@ -34,16 +35,23 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Called when you come back to this screen
|
||||
context.read<HistoryViewModel>().load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.extentAfter < 300) {
|
||||
context.read<HistoryViewModel>().loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewModel = context.watch<HistoryViewModel>();
|
||||
@@ -53,9 +61,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> 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<HistoryScreenView> 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<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 {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -174,4 +328,4 @@ class _EmptyState extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
|
||||
Product? _existingProduct;
|
||||
String? _imagePath;
|
||||
bool _hasImage = false;
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@@ -72,6 +73,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
_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<ProductFormView> {
|
||||
|
||||
Future<void> _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<ProductFormView> {
|
||||
|
||||
setState(() {
|
||||
_imagePath = copiedImagePath;
|
||||
_hasImage = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,7 +226,6 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
}
|
||||
|
||||
Widget _buildImagePicker(BuildContext context) {
|
||||
final hasImage = _imagePath != null && File(_imagePath!).existsSync();
|
||||
|
||||
return InkWell(
|
||||
onTap: _pickImage,
|
||||
@@ -234,11 +237,22 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
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,
|
||||
|
||||
@@ -73,10 +73,17 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user