feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+28 -16
View File
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import '../models/app_error.dart';
import '../services/admin_pin_service.dart';
import '../services/pin_lock_service.dart';
class AdminPinViewModel extends ChangeNotifier {
final AdminPinService adminPinService;
@@ -11,7 +13,7 @@ class AdminPinViewModel extends ChangeNotifier {
bool _isUnlocked = false;
bool _isLoading = false;
bool _hasLoaded = false;
String? _errorMessage;
AppError? _error;
bool get isPinSet => _isPinSet;
@@ -21,7 +23,7 @@ class AdminPinViewModel extends ChangeNotifier {
bool get hasLoaded => _hasLoaded;
String? get errorMessage => _errorMessage;
AppError? get error => _error;
Future<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
@@ -33,14 +35,14 @@ class AdminPinViewModel extends ChangeNotifier {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
_error = null;
notifyListeners();
try {
_isPinSet = await adminPinService.hasPin();
} catch (e) {
debugPrint('AdminPinViewModel: load error: $e');
_errorMessage = 'Could not check PIN status.';
_error = AppError.checkPinStatus;
} finally {
_hasLoaded = true;
_isLoading = false;
@@ -49,7 +51,7 @@ class AdminPinViewModel extends ChangeNotifier {
}
Future<bool> setPin(String pin) async {
_errorMessage = null;
_error = null;
try {
await adminPinService.setPin(pin);
@@ -59,14 +61,14 @@ class AdminPinViewModel extends ChangeNotifier {
return true;
} catch (e) {
debugPrint('AdminPinViewModel: setPin error: $e');
_errorMessage = 'Could not save PIN.';
_error = AppError.savePin;
notifyListeners();
return false;
}
}
Future<bool> verify(String pin) async {
_errorMessage = null;
_error = null;
try {
final matches = await adminPinService.verifyPin(pin);
@@ -77,12 +79,16 @@ class AdminPinViewModel extends ChangeNotifier {
return true;
}
_errorMessage = 'Incorrect PIN.';
_error = AppError.incorrectPin;
notifyListeners();
return false;
} on PinLockedOutException {
_error = AppError.tooManyPinAttempts;
notifyListeners();
return false;
} catch (e) {
debugPrint('AdminPinViewModel: verify error: $e');
_errorMessage = 'Could not verify PIN.';
_error = AppError.verifyPin;
notifyListeners();
return false;
}
@@ -92,17 +98,23 @@ class AdminPinViewModel extends ChangeNotifier {
required String currentPin,
required String newPin,
}) async {
final currentMatches = await adminPinService.verifyPin(currentPin);
if (!currentMatches) {
_errorMessage = 'Current PIN is incorrect.';
notifyListeners();
return false;
}
if (!await _verifyCurrentPin(currentPin)) return false;
return setPin(newPin);
}
Future<bool> _verifyCurrentPin(String pin) async {
try {
if (await adminPinService.verifyPin(pin)) return true;
_error = AppError.currentPinIncorrect;
} on PinLockedOutException {
_error = AppError.tooManyPinAttempts;
}
notifyListeners();
return false;
}
void lock() {
if (!_isUnlocked) return;
+68 -185
View File
@@ -1,114 +1,68 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/payment_method.dart';
import '../models/app_error.dart';
import '../models/bar_tab.dart';
import '../models/payment_method.dart';
import '../models/product.dart';
import '../models/tab_item.dart';
import '../models/remote_command.dart';
import '../services/bar_tab_service.dart';
import '../services/product_service.dart';
import 'inventory_view_model.dart';
import 'remote_sync_view_model.dart';
/// Tabs come from a database stream, so every change (local or remote) shows
/// up without manual reloads. Actions report to Sentry and rethrow; the view
/// decides how to tell the user.
class BarScreenViewModel extends ChangeNotifier {
final BarTabService barTabService;
final InventoryViewModel inventory;
final RemoteSyncViewModel remoteSyncViewModel;
BarScreenViewModel({
required this.barTabService,
required this.inventory,
required this.remoteSyncViewModel,
}) {
_eventSubscription = remoteSyncViewModel.syncService.events.listen(_onRemoteEvent);
}
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
RemoteSyncEvent? _lastRemoteEvent;
RemoteSyncEvent? get lastRemoteEvent => _lastRemoteEvent;
BarScreenViewModel({required this.barTabService});
StreamSubscription<List<BarTab>>? _subscription;
List<BarTab> _tabs = [];
String? _selectedTabId;
bool _isLoading = false;
bool _hasLoaded = false;
String? _errorMessage;
AppError? _loadError;
List<BarTab> get tabs => _tabs;
String? get selectedTabId => _selectedTabId;
bool get isLoading => _isLoading;
bool get isLoading => !_hasLoaded;
bool get hasLoaded => _hasLoaded;
AppError? get loadError => _loadError;
String? get errorMessage => _errorMessage;
BarTab? get selectedTab =>
_tabs.firstWhereOrNull((tab) => tab.id == _selectedTabId);
BarTab? get selectedTab {
if (_selectedTabId == null) return null;
void ensureLoaded() {
if (_subscription != null) return;
try {
return _tabs.firstWhere((tab) => tab.id == _selectedTabId);
} catch (e) {
debugPrint('BarScreenViewModel: error finding selected tab: $e');
return null;
}
_subscription = barTabService.watchOpenTabs().listen(
_onTabs,
onError: _onLoadError,
);
}
void _onRemoteEvent(RemoteSyncEvent event) {
_lastRemoteEvent = event;
void _onTabs(List<BarTab> tabs) {
_tabs = tabs;
_hasLoaded = true;
_loadError = null;
if (selectedTab == null) _selectedTabId = tabs.firstOrNull?.id;
notifyListeners();
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
_reloadTabs();
}
}
void clearRemoteEvent() {
_lastRemoteEvent = null;
}
void _onLoadError(Object error, StackTrace stack) {
debugPrint('BarScreenViewModel: load error: $error');
unawaited(Sentry.captureException(error, stackTrace: stack));
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
Future<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
await load();
}
Future<void> load() async {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
_hasLoaded = true;
_loadError = AppError.loadBar;
notifyListeners();
try {
_tabs = await barTabService.getOpenTabs();
if (_selectedTabId == null ||
!_tabs.any((tab) => tab.id == _selectedTabId)) {
_selectedTabId = _tabs.isEmpty ? null : _tabs.first.id;
}
} catch (e) {
debugPrint('BarScreenViewModel: load error: $e');
_errorMessage = 'Could not load bar screen.';
} finally {
_hasLoaded = true;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAndSync() async {
await load();
await remoteSyncViewModel.syncNow();
}
void selectTab(String tabId) {
@@ -116,143 +70,72 @@ class BarScreenViewModel extends ChangeNotifier {
notifyListeners();
}
Future<void> createTab(String customerName) async {
try {
Future<void> createTab(String customerName) {
return _guard('createTab', () async {
final tab = await barTabService.createTab(customerName: customerName);
_selectedTabId = tab.id;
await _reloadTabs();
} catch (e, stack) {
debugPrint('BarScreenViewModel: createTab error: $e');
_errorMessage = 'Could not create tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
}
selectTab(tab.id);
});
}
Future<void> renameTab(String tabId, String customerName) async {
try {
await barTabService.renameTab(tabId: tabId, customerName: customerName);
await _reloadTabs();
} catch (e, stack) {
debugPrint('BarScreenViewModel: renameTab error: $e');
_errorMessage = 'Could not rename tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
Future<void> renameTab(String tabId, String customerName) {
return _guard(
'renameTab',
() => barTabService.renameTab(tabId: tabId, customerName: customerName),
);
}
Future<void> deleteTab(String tabId) async {
try {
final deletedItems = await barTabService.deleteTab(tabId);
for (final item in deletedItems) {
inventory.applyStockDelta(item.productId, item.quantity);
}
await _reloadTabs();
} catch (e, stack) {
debugPrint('BarScreenViewModel: deleteTab error: $e');
_errorMessage = 'Could not delete tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
Future<void> deleteTab(String tabId) {
return _guard('deleteTab', () => barTabService.deleteTab(tabId));
}
Future<void> addProductToSelectedTab(Product product) async {
final tab = selectedTab;
if (tab == null) return;
try {
await barTabService.addProductToTab(tabId: tab.id, product: product);
inventory.applyStockDelta(product.id, -1);
await _reloadTabs();
} catch (e, stack) {
if (e is InsufficientStockException) rethrow;
debugPrint('BarScreenViewModel: addProductToSelectedTab error: $e');
_errorMessage = 'Could not add product to tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
await _guard(
'addProductToSelectedTab',
() => barTabService.addProductToTab(tabId: tab.id, product: product),
);
}
Future<void> changeItemQuantity(TabItem item, int delta) async {
if (delta == 0) return;
try {
final actualDelta = await barTabService.adjustTabItemQuantity(
tabItemId: item.id,
delta: delta,
);
inventory.applyStockDelta(item.productId, -actualDelta);
await _reloadTabs();
} catch (e, stack) {
if (e is InsufficientStockException) rethrow;
debugPrint('BarScreenViewModel: changeItemQuantity error: $e');
_errorMessage = 'Could not update item quantity.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
await _guard(
'changeItemQuantity',
() =>
barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta),
);
}
Future<void> closeSelectedTab({
PaymentMethod paymentMethod = PaymentMethod.cash,
}) async {
final tab = selectedTab;
if (tab == null) return;
await _guard(
'closeSelectedTab',
() => barTabService.closeTab(tab.id, paymentMethod: paymentMethod),
);
}
/// Running out of stock is an expected outcome, not a bug worth reporting.
Future<T> _guard<T>(String action, Future<T> Function() body) async {
try {
await barTabService.closeTab(tab.id, paymentMethod: paymentMethod);
await _reloadTabs();
return await body();
} on InsufficientStockException {
rethrow;
} catch (e, stack) {
debugPrint('BarScreenViewModel: closeSelectedTab error: $e');
_errorMessage = 'Could not close tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
debugPrint('BarScreenViewModel: $action error: $e');
unawaited(Sentry.captureException(e, stackTrace: stack));
rethrow;
}
}
Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
}) async {
try {
await barTabService.closeTab(tabId, paymentMethod: paymentMethod);
await _reloadTabs();
} catch (e, stack) {
debugPrint('BarScreenViewModel: closeTab error: $e');
_errorMessage = 'Could not close tab.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
}
Future<void> _reloadTabs() async {
try {
_tabs = await barTabService.getOpenTabs();
if (_selectedTabId == null ||
!_tabs.any((tab) => tab.id == _selectedTabId)) {
_selectedTabId = _tabs.isEmpty ? null : _tabs.first.id;
}
notifyListeners();
} catch (e, stack) {
debugPrint('BarScreenViewModel: reloadTabs error: $e');
_errorMessage = 'Could not reload tabs.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
+17 -12
View File
@@ -1,19 +1,21 @@
import 'dart:math';
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:kooltab2/database/app_database.dart';
import 'package:kooltab2/services/bar_tab_service.dart';
import 'package:kooltab2/models/payment_method.dart';
import 'package:kooltab2/services/default_product_seeder.dart';
import 'package:kooltab2/services/pin_lock_service.dart';
import 'package:kooltab2/services/product_service.dart';
import 'package:kooltab2/services/settings_service.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
import '../models/payment_method.dart';
import '../services/bar_tab_service.dart';
import '../services/default_product_seeder.dart';
import '../services/pin_lock_service.dart';
import '../services/product_service.dart';
import '../services/settings_service.dart';
class DevMenuViewModel extends ChangeNotifier {
final AppDatabase database;
final ProductService productService;
@@ -41,6 +43,7 @@ class DevMenuViewModel extends ChangeNotifier {
try {
await database.transaction(() async {
await database.delete(database.tabItemPurchases).go();
await database.delete(database.tabItems).go();
await database.delete(database.barTabs).go();
});
@@ -73,6 +76,7 @@ class DevMenuViewModel extends ChangeNotifier {
try {
await database.transaction(() async {
await database.delete(database.tabItemPurchases).go();
await database.delete(database.tabItems).go();
await database.delete(database.products).go();
});
@@ -103,6 +107,7 @@ class DevMenuViewModel extends ChangeNotifier {
try {
await database.transaction(() async {
await database.delete(database.tabItemPurchases).go();
await database.delete(database.tabItems).go();
await database.delete(database.products).go();
});
@@ -136,7 +141,7 @@ class DevMenuViewModel extends ChangeNotifier {
_lastAction = 'Product images cleared';
} catch (e, stack) {
debugPrint('DevMenuViewModel: clearProductImages error: $e');
Sentry.captureException(e, stackTrace: stack);
unawaited(Sentry.captureException(e, stackTrace: stack));
_lastAction = 'Failed to clear images: $e';
} finally {
_isLoading = false;
@@ -327,7 +332,7 @@ class DevMenuViewModel extends ChangeNotifier {
try {
throw Exception('Test GlitchTip error!');
} catch (exception, stackTrace) {
Sentry.captureException(exception, stackTrace: stackTrace);
unawaited(Sentry.captureException(exception, stackTrace: stackTrace));
}
}
@@ -422,7 +427,7 @@ class DevMenuViewModel extends ChangeNotifier {
customerName: customer,
closedAt: closedAt,
paymentMethod: Value(
paymentMethods[random.nextInt(paymentMethods.length)].value,
paymentMethods[random.nextInt(paymentMethods.length)],
),
),
);
+6 -5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import '../models/app_error.dart';
import '../models/bar_tab.dart';
import '../models/closed_tab.dart';
import '../services/bar_tab_service.dart';
@@ -20,7 +21,7 @@ class HistoryViewModel extends ChangeNotifier {
bool _isLoading = false;
bool _hasLoaded = false;
bool _isLoadingMore = false;
String? _errorMessage;
AppError? _error;
String _searchQuery = '';
List<ClosedTab> get closedTabs {
@@ -47,7 +48,7 @@ class HistoryViewModel extends ChangeNotifier {
bool get hasMore => _closedTabs.length < _totalCount;
String? get errorMessage => _errorMessage;
AppError? get error => _error;
Future<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
@@ -59,7 +60,7 @@ class HistoryViewModel extends ChangeNotifier {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
_error = null;
notifyListeners();
try {
@@ -81,7 +82,7 @@ class HistoryViewModel extends ChangeNotifier {
_offset = _closedTabs.length;
} catch (e) {
debugPrint('HistoryViewModel: load error: $e');
_errorMessage = 'Could not load tab history.';
_error = AppError.loadHistory;
} finally {
_hasLoaded = true;
_isLoading = false;
@@ -101,7 +102,7 @@ class HistoryViewModel extends ChangeNotifier {
_offset = _closedTabs.length;
} catch (e) {
debugPrint('HistoryViewModel: loadMore error: $e');
_errorMessage = 'Could not load more tabs.';
_error = AppError.loadMoreTabs;
} finally {
_isLoadingMore = false;
notifyListeners();
+37 -57
View File
@@ -1,80 +1,60 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/app_error.dart';
import '../models/product.dart';
import '../services/product_service.dart';
/// The app-wide product list, kept in sync with the database. Stock changes
/// from tabs show up here automatically.
class InventoryViewModel extends ChangeNotifier {
final ProductService productService;
InventoryViewModel({required this.productService});
StreamSubscription<List<Product>>? _subscription;
List<Product> _products = [];
bool _hasLoaded = false;
AppError? _loadError;
List<Product> get products => _products;
Future<void> load() async {
try {
_products = await productService.getProducts();
notifyListeners();
} catch (e, stack) {
debugPrint('InventoryViewModel: load error: $e');
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
}
bool get hasLoaded => _hasLoaded;
Future<void> decreaseStock(String productId, int amount) async {
try {
await productService.decreaseStock(productId, amount);
bool get isLoading => !_hasLoaded;
final index = _products.indexWhere((product) => product.id == productId);
AppError? get loadError => _loadError;
if (index != -1) {
_products[index] = _products[index].copyWith(
stockQuantity: _products[index].stockQuantity - amount,
);
}
void ensureLoaded() {
if (_subscription != null) return;
notifyListeners();
} catch (e, stack) {
if (e is InsufficientStockException) rethrow;
debugPrint('InventoryViewModel: decreaseStock error: $e');
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
}
Future<void> increaseStock(String productId, int amount) async {
try {
await productService.increaseStock(productId, amount);
final index = _products.indexWhere((product) => product.id == productId);
if (index != -1) {
_products[index] = _products[index].copyWith(
stockQuantity: _products[index].stockQuantity + amount,
);
}
notifyListeners();
} catch (e, stack) {
debugPrint('InventoryViewModel: increaseStock error: $e');
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
}
void applyStockDelta(String productId, int delta) {
if (delta == 0) return;
final index = _products.indexWhere((product) => product.id == productId);
if (index == -1) return;
_products[index] = _products[index].copyWith(
stockQuantity: _products[index].stockQuantity + delta,
_subscription = productService.watchProducts().listen(
_onProducts,
onError: _onLoadError,
);
}
void _onProducts(List<Product> products) {
_products = products;
_hasLoaded = true;
_loadError = null;
notifyListeners();
}
void _onLoadError(Object error, StackTrace stack) {
debugPrint('InventoryViewModel: load error: $error');
unawaited(Sentry.captureException(error, stackTrace: stack));
_hasLoaded = true;
_loadError = AppError.loadProducts;
notifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
+28 -23
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import '../models/app_error.dart';
import '../services/pin_lock_service.dart';
import '../services/settings_service.dart';
@@ -14,7 +15,7 @@ class PinLockViewModel extends ChangeNotifier {
bool _isLoading = false;
bool _hasLoaded = false;
bool _pinRequired = true;
String? _errorMessage;
AppError? _error;
bool get isPinSet => _isPinSet;
@@ -26,7 +27,7 @@ class PinLockViewModel extends ChangeNotifier {
bool get pinRequired => _pinRequired;
String? get errorMessage => _errorMessage;
AppError? get error => _error;
void setPinRequired(bool value) {
if (_pinRequired == value) return;
@@ -45,7 +46,7 @@ class PinLockViewModel extends ChangeNotifier {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
_error = null;
notifyListeners();
try {
@@ -58,7 +59,7 @@ class PinLockViewModel extends ChangeNotifier {
}
} catch (e) {
debugPrint('PinLockViewModel: load error: $e');
_errorMessage = 'Could not check PIN status.';
_error = AppError.checkPinStatus;
} finally {
_hasLoaded = true;
_isLoading = false;
@@ -67,7 +68,7 @@ class PinLockViewModel extends ChangeNotifier {
}
Future<bool> setPin(String pin) async {
_errorMessage = null;
_error = null;
try {
await pinLockService.setPin(pin);
@@ -85,14 +86,14 @@ class PinLockViewModel extends ChangeNotifier {
return true;
} catch (e) {
debugPrint('PinLockViewModel: setPin error: $e');
_errorMessage = 'Could not save PIN.';
_error = AppError.savePin;
notifyListeners();
return false;
}
}
Future<bool> verify(String pin) async {
_errorMessage = null;
_error = null;
try {
final matches = await pinLockService.verifyPin(pin);
@@ -103,12 +104,16 @@ class PinLockViewModel extends ChangeNotifier {
return true;
}
_errorMessage = 'Incorrect PIN.';
_error = AppError.incorrectPin;
notifyListeners();
return false;
} on PinLockedOutException {
_error = AppError.tooManyPinAttempts;
notifyListeners();
return false;
} catch (e) {
debugPrint('PinLockViewModel: verify error: $e');
_errorMessage = 'Could not verify PIN.';
_error = AppError.verifyPin;
notifyListeners();
return false;
}
@@ -118,25 +123,13 @@ class PinLockViewModel extends ChangeNotifier {
required String currentPin,
required String newPin,
}) async {
final currentMatches = await pinLockService.verifyPin(currentPin);
if (!currentMatches) {
_errorMessage = 'Current PIN is incorrect.';
notifyListeners();
return false;
}
if (!await _verifyCurrentPin(currentPin)) return false;
return setPin(newPin);
}
Future<bool> disablePin(String currentPin) async {
final matches = await pinLockService.verifyPin(currentPin);
if (!matches) {
_errorMessage = 'Current PIN is incorrect.';
notifyListeners();
return false;
}
if (!await _verifyCurrentPin(currentPin)) return false;
await pinLockService.clearPin();
_isPinSet = false;
@@ -152,6 +145,18 @@ class PinLockViewModel extends ChangeNotifier {
return true;
}
Future<bool> _verifyCurrentPin(String pin) async {
try {
if (await pinLockService.verifyPin(pin)) return true;
_error = AppError.currentPinIncorrect;
} on PinLockedOutException {
_error = AppError.tooManyPinAttempts;
}
notifyListeners();
return false;
}
void lock() {
if (!_isUnlocked) return;
+43 -71
View File
@@ -1,15 +1,25 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/app_error.dart';
import '../models/product.dart';
import '../services/product_service.dart';
import 'inventory_view_model.dart';
/// Product management on top of [InventoryViewModel]. The list refreshes by
/// itself through the inventory stream, so actions only have to write.
class ProductListViewModel extends ChangeNotifier {
final ProductService productService;
final InventoryViewModel inventory;
ProductListViewModel({required this.productService, required this.inventory});
ProductListViewModel({
required this.productService,
required this.inventory,
}) {
inventory.addListener(notifyListeners);
}
static const List<String> _defaultCategories = [
'Bier',
@@ -22,42 +32,13 @@ class ProductListViewModel extends ChangeNotifier {
'Other',
];
bool _isLoading = false;
bool _hasLoaded = false;
String? _errorMessage;
List<Product> get products => inventory.products;
bool get isLoading => _isLoading;
bool get isLoading => inventory.isLoading;
bool get hasLoaded => _hasLoaded;
AppError? get loadError => inventory.loadError;
String? get errorMessage => _errorMessage;
Future<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
await loadProducts();
}
Future<void> loadProducts() async {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await inventory.load();
} catch (e) {
debugPrint('ProductListViewModel: load error: $e');
_errorMessage = 'Could not load products.';
} finally {
_hasLoaded = true;
_isLoading = false;
notifyListeners();
}
}
void ensureLoaded() => inventory.ensureLoaded();
Future<void> addProduct({
required String name,
@@ -66,67 +47,58 @@ class ProductListViewModel extends ChangeNotifier {
required int lowStockThreshold,
required int priceInCents,
required String? imagePath,
}) async {
try {
await productService.createProduct(
}) {
return _guard(
'addProduct',
() => productService.createProduct(
name: name,
category: category,
stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold,
priceInCents: priceInCents,
imagePath: imagePath,
);
await loadProducts();
} catch (e, stack) {
debugPrint('ProductListViewModel: addProduct error: $e');
_errorMessage = 'Could not add product.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
),
);
}
Future<void> updateProduct(Product product) async {
try {
await productService.updateProduct(product);
await loadProducts();
} catch (e, stack) {
debugPrint('ProductListViewModel: updateProduct error: $e');
_errorMessage = 'Could not update product.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
Future<void> updateProduct(Product product) {
return _guard('updateProduct', () => productService.updateProduct(product));
}
Future<void> deleteProduct(String id) async {
try {
await productService.deleteProduct(id);
await loadProducts();
} catch (e, stack) {
debugPrint('ProductListViewModel: deleteProduct error: $e');
_errorMessage = 'Could not delete product.';
notifyListeners();
Sentry.captureException(e, stackTrace: stack);
rethrow;
}
Future<void> deleteProduct(String id) {
return _guard('deleteProduct', () => productService.deleteProduct(id));
}
Future<Product?> getProductById(String id) {
return productService.getProductById(id);
return _guard('getProductById', () => productService.getProductById(id));
}
List<String> get categories {
final categories = {
..._defaultCategories,
...inventory.products
.map((p) => p.category.trim())
.where((c) => c.isNotEmpty),
.map((product) => product.category.trim())
.where((category) => category.isNotEmpty),
}.toList();
categories.sort();
return categories;
}
Future<T> _guard<T>(String action, Future<T> Function() body) async {
try {
return await body();
} catch (e, stack) {
debugPrint('ProductListViewModel: $action error: $e');
unawaited(Sentry.captureException(e, stackTrace: stack));
rethrow;
}
}
@override
void dispose() {
inventory.removeListener(notifyListeners);
super.dispose();
}
}
+39 -67
View File
@@ -1,18 +1,23 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/venue.dart';
import '../services/kooltap_api_client.dart';
import '../services/remote_sync_service.dart';
import '../utils/app_config.dart';
enum RegistrationStep { initial, loadingVenues, venuesLoaded, error, registering, registered }
enum RegistrationStep {
initial,
loadingVenues,
venuesLoaded,
error,
registering,
registered,
}
class RegistrationViewModel extends ChangeNotifier {
final KoolTapApiClient apiClient;
final RemoteSyncService syncService;
RegistrationViewModel({required this.syncService});
RegistrationViewModel({required this.apiClient, required this.syncService});
RegistrationStep _step = RegistrationStep.initial;
RegistrationStep get step => _step;
@@ -23,6 +28,7 @@ class RegistrationViewModel extends ChangeNotifier {
Venue? _selectedVenue;
Venue? get selectedVenue => _selectedVenue;
/// Server-provided, so shown as-is.
String? _errorMessage;
String? get errorMessage => _errorMessage;
@@ -37,21 +43,9 @@ class RegistrationViewModel extends ChangeNotifier {
notifyListeners();
try {
final response = await http
.get(Uri.parse('$kRemoteServerUrl/venues/'))
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
_venues = data
.map((e) => Venue.fromJson(e as Map<String, dynamic>))
.toList();
_step = RegistrationStep.venuesLoaded;
} else {
_errorMessage = 'Failed to load venues (HTTP ${response.statusCode})';
_step = RegistrationStep.error;
}
} catch (e) {
_venues = await apiClient.getVenues();
_step = RegistrationStep.venuesLoaded;
} on Exception catch (e) {
debugPrint('RegistrationViewModel: fetchVenues error: $e');
_errorMessage = e.toString();
_step = RegistrationStep.error;
@@ -72,58 +66,36 @@ class RegistrationViewModel extends ChangeNotifier {
notifyListeners();
try {
final response = await http
.post(
Uri.parse('$kRemoteServerUrl/devices/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'venueId': venue.id,
'name': deviceName,
'registrationCode': registrationCode,
}),
)
.timeout(const Duration(seconds: 15));
final credentials = await apiClient.registerDevice(
venueId: venue.id,
deviceName: deviceName,
registrationCode: registrationCode,
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final deviceId = data['deviceId'] as String;
final token = data['token'] as String;
await syncService.setCredentials(
deviceId: credentials.deviceId,
token: credentials.token,
venueId: venue.id,
);
await syncService.setCredentials(
deviceId: deviceId,
token: token,
venueId: venue.id,
serverUrl: kRemoteServerUrl,
);
_step = RegistrationStep.registered;
notifyListeners();
return true;
} else {
String message;
try {
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
message = errorData['message'] as String? ?? 'Registration failed';
} catch (_) {
message = 'Registration failed (HTTP ${response.statusCode})';
}
_errorMessage = message;
_step = RegistrationStep.venuesLoaded;
notifyListeners();
return false;
}
} catch (e) {
_step = RegistrationStep.registered;
notifyListeners();
return true;
} on ApiException catch (e) {
_errorMessage = e.message.isEmpty
? 'Registration failed (HTTP ${e.statusCode})'
: e.message;
} on Exception catch (e) {
debugPrint('RegistrationViewModel: registerDevice error: $e');
_errorMessage = e.toString();
_step = RegistrationStep.venuesLoaded;
notifyListeners();
return false;
}
_step = RegistrationStep.venuesLoaded;
notifyListeners();
return false;
}
Future<void> retry() async {
await fetchVenues();
}
Future<void> retry() => fetchVenues();
void reset() {
_step = RegistrationStep.initial;
@@ -132,4 +104,4 @@ class RegistrationViewModel extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
}
}
}
+43 -107
View File
@@ -1,13 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../database/app_database.dart';
import '../models/remote_command.dart';
import '../services/remote_sync_service.dart';
import '../utils/app_config.dart';
class RemoteSyncViewModel extends ChangeNotifier {
final RemoteSyncService syncService;
@@ -16,11 +13,10 @@ class RemoteSyncViewModel extends ChangeNotifier {
_eventSubscription = syncService.events.listen(_onEvent);
}
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
late final StreamSubscription<RemoteSyncEvent> _eventSubscription;
List<RemoteSyncEvent> _recentEvents = [];
List<RemoteSyncEvent> get recentEvents => _recentEvents;
bool _hasLoaded = false;
bool get hasLoaded => _hasLoaded;
bool _isSyncing = false;
bool get isSyncing => _isSyncing;
@@ -40,114 +36,56 @@ class RemoteSyncViewModel extends ChangeNotifier {
List<ProcessedRemoteCommandRow> _activity = [];
List<ProcessedRemoteCommandRow> get activity => _activity;
bool _isRegistering = false;
bool get isRegistering => _isRegistering;
String? _registrationError;
String? get registrationError => _registrationError;
Stream<RemoteSyncEvent> get tabsClosedRemotely => syncService.events.where(
(event) => event.type == RemoteSyncEventType.tabClosedRemotely,
);
Future<void> init() async {
_isConfigured = await syncService.isConfigured;
_venueId = await syncService.venueId;
await _refreshStatus();
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
await loadActivity();
if (_isConfigured) {
syncService.startPeriodicSync();
}
await _loadActivity();
if (_isConfigured) syncService.startPeriodicSync();
_hasLoaded = true;
notifyListeners();
}
Future<void> refreshStatus() async {
/// Called once registration stored new credentials.
Future<void> activate() async {
await _refreshStatus();
notifyListeners();
if (!_isConfigured) return;
syncService.startPeriodicSync();
await syncNow();
}
Future<void> _refreshStatus() async {
_isConfigured = await syncService.isConfigured;
_venueId = await syncService.venueId;
notifyListeners();
}
Future<void> syncNow() async {
_isSyncing = true;
_lastError = null;
notifyListeners();
await syncService.sync();
_isSyncing = syncService.isSyncing;
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
await loadActivity();
_isSyncing = false;
await _loadActivity();
notifyListeners();
}
Future<void> loadActivity() async {
Future<void> _loadActivity() async {
try {
_activity = await syncService.getRecentActivity();
} catch (e) {
} on Exception catch (e) {
debugPrint('RemoteSyncViewModel: loadActivity error: $e');
}
}
Future<bool> registerDevice({
required String venueId,
required String deviceName,
required String registrationCode,
}) async {
_isRegistering = true;
_registrationError = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$kRemoteServerUrl/devices/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'venueId': venueId,
'name': deviceName,
'registrationCode': registrationCode,
}),
).timeout(const Duration(seconds: 15));
if (response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final deviceId = data['deviceId'] as String;
final token = data['token'] as String;
await syncService.setCredentials(
deviceId: deviceId,
token: token,
venueId: venueId,
serverUrl: kRemoteServerUrl,
);
_isConfigured = true;
_venueId = venueId;
_isRegistering = false;
notifyListeners();
syncService.startPeriodicSync();
await syncNow();
return true;
} else {
final body = response.body;
String message;
try {
final errorData = jsonDecode(body) as Map<String, dynamic>;
message = errorData['message'] as String? ?? 'Registration failed';
} catch (_) {
message = 'Registration failed (HTTP ${response.statusCode})';
}
_registrationError = message;
_isRegistering = false;
notifyListeners();
return false;
}
} catch (e) {
debugPrint('RemoteSyncViewModel: registration error: $e');
_registrationError = e.toString();
_isRegistering = false;
notifyListeners();
return false;
}
}
Future<void> unlink() async {
syncService.stopPeriodicSync();
await syncService.clearCredentials();
@@ -155,24 +93,23 @@ class RemoteSyncViewModel extends ChangeNotifier {
_venueId = null;
_lastSyncAt = null;
_lastError = null;
_recentEvents = [];
_activity = [];
notifyListeners();
}
void startSync() {
syncService.startPeriodicSync();
syncNow();
}
void _onEvent(RemoteSyncEvent event) {
_recentEvents = [event, ..._recentEvents.take(49)];
_lastSyncAt = DateTime.now();
if (event.type == RemoteSyncEventType.syncFailed) {
_lastError = event.message;
} else if (event.type == RemoteSyncEventType.syncCompleted) {
_lastError = null;
switch (event.type) {
case RemoteSyncEventType.syncCompleted:
_lastSyncAt = DateTime.now();
_lastError = null;
case RemoteSyncEventType.syncFailed:
_lastError = event.message;
case RemoteSyncEventType.unauthorized:
// The router sends the device back to registration.
_isConfigured = false;
case RemoteSyncEventType.tabClosedRemotely:
case RemoteSyncEventType.conflict:
break;
}
notifyListeners();
@@ -184,13 +121,12 @@ class RemoteSyncViewModel extends ChangeNotifier {
void resume() {
syncService.resume();
syncNow();
unawaited(syncNow());
}
@override
void dispose() {
_eventSubscription?.cancel();
syncService.dispose();
_eventSubscription.cancel();
super.dispose();
}
}
}
+16 -15
View File
@@ -1,10 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:kooltab2/utils/app_update_util.dart';
import 'package:ota_update/ota_update.dart';
import '../models/app_error.dart';
import '../models/settings.dart';
import '../services/settings_service.dart';
import '../utils/app_config.dart';
import '../utils/app_update_util.dart';
class SettingsViewModel extends ChangeNotifier {
final SettingsService settingsService;
@@ -14,7 +15,7 @@ class SettingsViewModel extends ChangeNotifier {
AppSettings _settings = AppSettings.defaults;
bool _isLoading = false;
bool _hasLoaded = false;
String? _errorMessage;
AppError? _error;
final AppUpdateUtil _updater = AppUpdateUtil(serverUrl: kUpdateServerUrl);
@@ -28,7 +29,7 @@ class SettingsViewModel extends ChangeNotifier {
bool get hasLoaded => _hasLoaded;
String? get errorMessage => _errorMessage;
AppError? get error => _error;
Future<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
@@ -39,14 +40,14 @@ class SettingsViewModel extends ChangeNotifier {
if (_isLoading) return;
_isLoading = true;
_errorMessage = null;
_error = null;
notifyListeners();
try {
_settings = await settingsService.getSettings();
} catch (e) {
debugPrint('SettingsViewModel: load error: $e');
_errorMessage = 'Could not load settings.';
_error = AppError.loadSettings;
} finally {
_hasLoaded = true;
_isLoading = false;
@@ -72,33 +73,33 @@ class SettingsViewModel extends ChangeNotifier {
UpdateInfo update, {
void Function(double progress)? onProgress,
}) async {
final url = update.download.startsWith("http")
final url = update.download.startsWith('http')
? update.download
: "$kUpdateServerUrl${update.download}";
: '$kUpdateServerUrl${update.download}';
debugPrint("Download Url: $url");
debugPrint('Download Url: $url');
final stream = OtaUpdate().execute(
url,
destinationFilename: "update.apk",
destinationFilename: 'update.apk',
sha256checksum: update.sha256,
);
await for (final event in stream) {
debugPrint("OTA: ${event.status} ${event.value}");
debugPrint('OTA: ${event.status} ${event.value}');
if (event.status == OtaStatus.DOWNLOADING) {
final progress = double.tryParse(event.value ?? "0") ?? 0;
final progress = double.tryParse(event.value ?? '0') ?? 0;
onProgress?.call(progress / 100);
}
if (event.status == OtaStatus.DOWNLOAD_ERROR) {
throw Exception("Download failed: ${event.value}");
throw Exception('Download failed: ${event.value}');
}
if (event.status == OtaStatus.INSTALLATION_ERROR) {
throw Exception("Installation failed: ${event.value}");
throw Exception('Installation failed: ${event.value}');
}
}
}
@@ -121,7 +122,7 @@ class SettingsViewModel extends ChangeNotifier {
Future<void> _save(AppSettings updated) async {
final previous = _settings;
_settings = updated;
_errorMessage = null;
_error = null;
notifyListeners();
try {
@@ -129,7 +130,7 @@ class SettingsViewModel extends ChangeNotifier {
} catch (e) {
debugPrint('SettingsViewModel: save error: $e');
_settings = previous;
_errorMessage = 'Could not save settings.';
_error = AppError.saveSettings;
notifyListeners();
}
}
@@ -52,11 +52,7 @@ class UpdateProgressViewModel extends ChangeNotifier {
destinationFilename: 'update.apk',
sha256checksum: update.sha256,
)
.listen(
_onEvent,
onError: _onError,
onDone: _onDone,
);
.listen(_onEvent, onError: _onError, onDone: _onDone);
}
void _runSimulation() {