feat: add alot
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user