feat: add remote pull and push
This commit is contained in:
@@ -6,6 +6,7 @@ import 'app_lifecycle_lock.dart';
|
|||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../viewmodels/admin_pin_view_model.dart';
|
import '../viewmodels/admin_pin_view_model.dart';
|
||||||
import '../viewmodels/pin_lock_view_model.dart';
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
|
import '../viewmodels/remote_sync_view_model.dart';
|
||||||
import '../viewmodels/settings_view_model.dart';
|
import '../viewmodels/settings_view_model.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -31,6 +32,12 @@ class _KoolTabAppState extends State<KoolTabApp> {
|
|||||||
context.read<PinLockViewModel>().lock();
|
context.read<PinLockViewModel>().lock();
|
||||||
context.read<AdminPinViewModel>().lock();
|
context.read<AdminPinViewModel>().lock();
|
||||||
},
|
},
|
||||||
|
onBackground: () {
|
||||||
|
context.read<RemoteSyncViewModel>().pause();
|
||||||
|
},
|
||||||
|
onForeground: () {
|
||||||
|
context.read<RemoteSyncViewModel>().resume();
|
||||||
|
},
|
||||||
shouldLock: () =>
|
shouldLock: () =>
|
||||||
context.read<SettingsViewModel>().settings.autoLockEnabled,
|
context.read<SettingsViewModel>().settings.autoLockEnabled,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../services/default_product_seeder.dart';
|
|||||||
import '../services/export_service.dart';
|
import '../services/export_service.dart';
|
||||||
import '../viewmodels/bar_screen_view_model.dart';
|
import '../viewmodels/bar_screen_view_model.dart';
|
||||||
import '../viewmodels/product_list_view_model.dart';
|
import '../viewmodels/product_list_view_model.dart';
|
||||||
|
import '../viewmodels/remote_sync_view_model.dart';
|
||||||
|
|
||||||
class AppBootstrap extends StatefulWidget {
|
class AppBootstrap extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -33,6 +34,8 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
|
|
||||||
DefaultExportService(database: database).uploadToServer();
|
DefaultExportService(database: database).uploadToServer();
|
||||||
|
|
||||||
|
context.read<RemoteSyncViewModel>().init();
|
||||||
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,4 +44,4 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return widget.child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,16 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
||||||
final VoidCallback onLock;
|
final VoidCallback onLock;
|
||||||
|
final VoidCallback? onBackground;
|
||||||
|
final VoidCallback? onForeground;
|
||||||
final bool Function()? shouldLock;
|
final bool Function()? shouldLock;
|
||||||
|
|
||||||
AppLifecycleLockObserver({required this.onLock, this.shouldLock});
|
AppLifecycleLockObserver({
|
||||||
|
required this.onLock,
|
||||||
|
this.onBackground,
|
||||||
|
this.onForeground,
|
||||||
|
this.shouldLock,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
@@ -14,8 +21,9 @@ class AppLifecycleLockObserver extends WidgetsBindingObserver {
|
|||||||
case AppLifecycleState.paused:
|
case AppLifecycleState.paused:
|
||||||
case AppLifecycleState.detached:
|
case AppLifecycleState.detached:
|
||||||
if (shouldLock?.call() ?? true) onLock();
|
if (shouldLock?.call() ?? true) onLock();
|
||||||
|
onBackground?.call();
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
break;
|
onForeground?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -14,6 +14,7 @@ import '../views/import_names_view.dart';
|
|||||||
import '../views/pin_lock_view.dart';
|
import '../views/pin_lock_view.dart';
|
||||||
import '../views/product_form_view.dart';
|
import '../views/product_form_view.dart';
|
||||||
import '../views/product_list_view.dart';
|
import '../views/product_list_view.dart';
|
||||||
|
import '../views/registration_view.dart';
|
||||||
import '../views/settings_view.dart';
|
import '../views/settings_view.dart';
|
||||||
import '../views/update_progress_view.dart';
|
import '../views/update_progress_view.dart';
|
||||||
import '../utils/app_update_util.dart';
|
import '../utils/app_update_util.dart';
|
||||||
@@ -23,6 +24,7 @@ final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
|||||||
String? _redirectApp({
|
String? _redirectApp({
|
||||||
required PinLockViewModel pinLockViewModel,
|
required PinLockViewModel pinLockViewModel,
|
||||||
required AdminPinViewModel adminPinViewModel,
|
required AdminPinViewModel adminPinViewModel,
|
||||||
|
required ValueNotifier<bool> isConfiguredNotifier,
|
||||||
required GoRouterState state,
|
required GoRouterState state,
|
||||||
}) {
|
}) {
|
||||||
if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) {
|
if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) {
|
||||||
@@ -30,6 +32,18 @@ String? _redirectApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
final location = state.uri.path;
|
final location = state.uri.path;
|
||||||
|
final isConfigured = isConfiguredNotifier.value;
|
||||||
|
|
||||||
|
if (!isConfigured && location != '/register') {
|
||||||
|
return '/register';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConfigured && location == '/register') {
|
||||||
|
return '/bar';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfigured) return null;
|
||||||
|
|
||||||
final goingToLock = location == '/lock';
|
final goingToLock = location == '/lock';
|
||||||
final goingToAdminLock = location == '/admin/lock';
|
final goingToAdminLock = location == '/admin/lock';
|
||||||
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
final mustBeOnLock = _mustBeOnLock(pinLockViewModel);
|
||||||
@@ -83,19 +97,31 @@ bool _isDebugShortcutArea(String location) {
|
|||||||
|
|
||||||
GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
|
GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
|
||||||
required AdminPinViewModel adminPinViewModel,
|
required AdminPinViewModel adminPinViewModel,
|
||||||
|
required ValueNotifier<bool> isConfiguredNotifier,
|
||||||
}) {
|
}) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/bar',
|
initialLocation: '/bar',
|
||||||
observers: [routeObserver, SentryNavigatorObserver()],
|
observers: [routeObserver, SentryNavigatorObserver()],
|
||||||
refreshListenable: Listenable.merge([pinLockViewModel, adminPinViewModel]),
|
refreshListenable: Listenable.merge([
|
||||||
|
pinLockViewModel,
|
||||||
|
adminPinViewModel,
|
||||||
|
isConfiguredNotifier,
|
||||||
|
]),
|
||||||
redirect: (_, state) =>
|
redirect: (_, state) =>
|
||||||
_redirectApp(
|
_redirectApp(
|
||||||
pinLockViewModel: pinLockViewModel,
|
pinLockViewModel: pinLockViewModel,
|
||||||
adminPinViewModel: adminPinViewModel,
|
adminPinViewModel: adminPinViewModel,
|
||||||
|
isConfiguredNotifier: isConfiguredNotifier,
|
||||||
state: state,
|
state: state,
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', redirect: (context, state) => '/bar'),
|
GoRoute(path: '/', redirect: (context, state) => '/bar'),
|
||||||
|
|
||||||
|
GoRoute(
|
||||||
|
path: '/register',
|
||||||
|
builder: (context, state) => const RegistrationView(),
|
||||||
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/lock',
|
path: '/lock',
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
@@ -187,4 +213,4 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel, {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,8 @@ class BarTabs extends Table {
|
|||||||
|
|
||||||
DateTimeColumn get closedAt => dateTime().nullable()();
|
DateTimeColumn get closedAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
IntColumn get version => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -138,24 +140,51 @@ class AppSettingsTable extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DataClassName('RemoteSyncStateRow')
|
||||||
|
class RemoteSyncState extends Table {
|
||||||
|
TextColumn get deviceId => text()();
|
||||||
|
|
||||||
|
TextColumn get serverCursor => text().nullable()();
|
||||||
|
|
||||||
|
DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
IntColumn get consecutiveFailureCount => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {deviceId};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DataClassName('ProcessedRemoteCommandRow')
|
||||||
|
class ProcessedRemoteCommands extends Table {
|
||||||
|
TextColumn get commandId => text()();
|
||||||
|
TextColumn get commandType => text()();
|
||||||
|
DateTimeColumn get processedAt => dateTime()();
|
||||||
|
TextColumn get result => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {commandId};
|
||||||
|
}
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
tables: [
|
tables: [
|
||||||
Products,
|
Products,
|
||||||
BarTabs,
|
BarTabs,
|
||||||
TabItems,
|
TabItems,
|
||||||
TabItemPurchases,
|
TabItemPurchases,
|
||||||
|
|
||||||
ClosedTabs,
|
ClosedTabs,
|
||||||
ClosedTabItems,
|
ClosedTabItems,
|
||||||
|
|
||||||
AppSettingsTable,
|
AppSettingsTable,
|
||||||
|
RemoteSyncState,
|
||||||
|
ProcessedRemoteCommands,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 11;
|
int get schemaVersion => 12;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -232,6 +261,14 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 11) {
|
if (from < 11) {
|
||||||
await migrator.createTable(tabItemPurchases);
|
await migrator.createTable(tabItemPurchases);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (from < 12) {
|
||||||
|
if (!await hasTableColumn('bar_tabs', 'version')) {
|
||||||
|
await migrator.addColumn(barTabs, barTabs.version);
|
||||||
|
}
|
||||||
|
await migrator.createTable(remoteSyncState);
|
||||||
|
await migrator.createTable(processedRemoteCommands);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -244,4 +281,4 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -264,5 +264,15 @@
|
|||||||
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"paymentCash": "Cash",
|
"paymentCash": "Cash",
|
||||||
"paymentPayconiq": "Payconiq",
|
"paymentPayconiq": "Payconiq",
|
||||||
"simulatedUpdateNotes": "Bug fixes and performance improvements."
|
"paymentPending": "Leave unpaid",
|
||||||
|
"simulatedUpdateNotes": "Bug fixes and performance improvements.",
|
||||||
|
"remoteActivity": "Remote activity",
|
||||||
|
"remoteActivityHistory": "Recent remote activity",
|
||||||
|
"syncNow": "Sync now",
|
||||||
|
"syncing": "Syncing…",
|
||||||
|
"lastSync": "Last sync: {time}",
|
||||||
|
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||||
|
"notSyncedYet": "Not synced yet",
|
||||||
|
"tabClosedRemotely": "Tab {customer} closed remotely by {requester}",
|
||||||
|
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1580,11 +1580,59 @@ abstract class AppLocalizations {
|
|||||||
/// **'Payconiq'**
|
/// **'Payconiq'**
|
||||||
String get paymentPayconiq;
|
String get paymentPayconiq;
|
||||||
|
|
||||||
|
/// No description provided for @paymentPending.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Leave unpaid'**
|
||||||
|
String get paymentPending;
|
||||||
|
|
||||||
/// No description provided for @simulatedUpdateNotes.
|
/// No description provided for @simulatedUpdateNotes.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Bug fixes and performance improvements.'**
|
/// **'Bug fixes and performance improvements.'**
|
||||||
String get simulatedUpdateNotes;
|
String get simulatedUpdateNotes;
|
||||||
|
|
||||||
|
/// No description provided for @remoteActivity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remote activity'**
|
||||||
|
String get remoteActivity;
|
||||||
|
|
||||||
|
/// No description provided for @remoteActivityHistory.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recent remote activity'**
|
||||||
|
String get remoteActivityHistory;
|
||||||
|
|
||||||
|
/// No description provided for @syncNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync now'**
|
||||||
|
String get syncNow;
|
||||||
|
|
||||||
|
/// No description provided for @syncing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Syncing…'**
|
||||||
|
String get syncing;
|
||||||
|
|
||||||
|
/// No description provided for @lastSync.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last sync: {time}'**
|
||||||
|
String lastSync(String time);
|
||||||
|
|
||||||
|
/// No description provided for @notSyncedYet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not synced yet'**
|
||||||
|
String get notSyncedYet;
|
||||||
|
|
||||||
|
/// No description provided for @tabClosedRemotely.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tab {customer} closed remotely by {requester}'**
|
||||||
|
String tabClosedRemotely(String customer, String requester);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -808,6 +808,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get paymentPayconiq => 'Payconiq';
|
String get paymentPayconiq => 'Payconiq';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paymentPending => 'Leave unpaid';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get simulatedUpdateNotes => 'Bug fixes and performance improvements.';
|
String get simulatedUpdateNotes => 'Bug fixes and performance improvements.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteActivity => 'Remote activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteActivityHistory => 'Recent remote activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => 'Sync now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncing => 'Syncing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lastSync(String time) {
|
||||||
|
return 'Last sync: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notSyncedYet => 'Not synced yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tabClosedRemotely(String customer, String requester) {
|
||||||
|
return 'Tab $customer closed remotely by $requester';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -819,6 +819,34 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get paymentPayconiq => 'Payconiq';
|
String get paymentPayconiq => 'Payconiq';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paymentPending => 'Onbetaald laten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get simulatedUpdateNotes => 'Bugfixes en prestatieverbeteringen.';
|
String get simulatedUpdateNotes => 'Bugfixes en prestatieverbeteringen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteActivity => 'Externe activiteit';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remoteActivityHistory => 'Recente externe activiteit';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => 'Nu synchroniseren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncing => 'Bezig met synchroniseren…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lastSync(String time) {
|
||||||
|
return 'Laatste sync: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notSyncedYet => 'Nog niet gesynchroniseerd';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String tabClosedRemotely(String customer, String requester) {
|
||||||
|
return 'Poef $customer extern gesloten door $requester';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -264,5 +264,15 @@
|
|||||||
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
"@devActionResetStock": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"paymentCash": "Contant",
|
"paymentCash": "Contant",
|
||||||
"paymentPayconiq": "Payconiq",
|
"paymentPayconiq": "Payconiq",
|
||||||
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen."
|
"paymentPending": "Onbetaald laten",
|
||||||
|
"simulatedUpdateNotes": "Bugfixes en prestatieverbeteringen.",
|
||||||
|
"remoteActivity": "Externe activiteit",
|
||||||
|
"remoteActivityHistory": "Recente externe activiteit",
|
||||||
|
"syncNow": "Nu synchroniseren",
|
||||||
|
"syncing": "Bezig met synchroniseren…",
|
||||||
|
"lastSync": "Laatste sync: {time}",
|
||||||
|
"@lastSync": {"placeholders": {"time": {"type": "String"}}},
|
||||||
|
"notSyncedYet": "Nog niet gesynchroniseerd",
|
||||||
|
"tabClosedRemotely": "Poef {customer} extern gesloten door {requester}",
|
||||||
|
"@tabClosedRemotely": {"placeholders": {"customer": {"type": "String"}, "requester": {"type": "String"}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:kooltab2/services/pin_lock_service.dart';
|
import 'package:kooltab2/services/pin_lock_service.dart';
|
||||||
|
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||||
import 'package:kooltab2/services/settings_service.dart';
|
import 'package:kooltab2/services/settings_service.dart';
|
||||||
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
|
import 'package:kooltab2/viewmodels/dev_menu_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/history_view_model.dart';
|
import 'package:kooltab2/viewmodels/history_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/pin_lock_view_model.dart';
|
import 'package:kooltab2/viewmodels/pin_lock_view_model.dart';
|
||||||
|
import 'package:kooltab2/viewmodels/registration_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/settings_view_model.dart';
|
import 'package:kooltab2/viewmodels/settings_view_model.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
@@ -23,6 +25,7 @@ import 'services/product_service.dart';
|
|||||||
import 'viewmodels/bar_screen_view_model.dart';
|
import 'viewmodels/bar_screen_view_model.dart';
|
||||||
import 'viewmodels/admin_pin_view_model.dart';
|
import 'viewmodels/admin_pin_view_model.dart';
|
||||||
import 'viewmodels/product_list_view_model.dart';
|
import 'viewmodels/product_list_view_model.dart';
|
||||||
|
import 'viewmodels/remote_sync_view_model.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -48,9 +51,29 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
await adminPinViewModel.ensureLoaded();
|
await adminPinViewModel.ensureLoaded();
|
||||||
|
|
||||||
|
final remoteSyncService = RemoteSyncService(
|
||||||
|
database: database,
|
||||||
|
barTabService: DriftBarTabService(database: database),
|
||||||
|
);
|
||||||
|
|
||||||
|
final remoteSyncViewModel = RemoteSyncViewModel(
|
||||||
|
syncService: remoteSyncService,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isConfiguredNotifier = ValueNotifier<bool>(await remoteSyncService.isConfigured);
|
||||||
|
|
||||||
|
remoteSyncViewModel.addListener(() {
|
||||||
|
isConfiguredNotifier.value = remoteSyncViewModel.isConfigured;
|
||||||
|
});
|
||||||
|
|
||||||
|
final registrationViewModel = RegistrationViewModel(
|
||||||
|
syncService: remoteSyncService,
|
||||||
|
);
|
||||||
|
|
||||||
final appRouter = createAppRouter(
|
final appRouter = createAppRouter(
|
||||||
pinLockViewModel,
|
pinLockViewModel,
|
||||||
adminPinViewModel: adminPinViewModel,
|
adminPinViewModel: adminPinViewModel,
|
||||||
|
isConfiguredNotifier: isConfiguredNotifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
await SentryFlutter.init(
|
await SentryFlutter.init(
|
||||||
@@ -83,6 +106,13 @@ Future<void> main() async {
|
|||||||
create: (context) =>
|
create: (context) =>
|
||||||
DriftSettingsService(database: context.read<AppDatabase>()),
|
DriftSettingsService(database: context.read<AppDatabase>()),
|
||||||
),
|
),
|
||||||
|
Provider<RemoteSyncService>.value(value: remoteSyncService),
|
||||||
|
ChangeNotifierProvider<RemoteSyncViewModel>.value(
|
||||||
|
value: remoteSyncViewModel,
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<RegistrationViewModel>.value(
|
||||||
|
value: registrationViewModel,
|
||||||
|
),
|
||||||
ChangeNotifierProvider<InventoryViewModel>(
|
ChangeNotifierProvider<InventoryViewModel>(
|
||||||
create: (context) => InventoryViewModel(
|
create: (context) => InventoryViewModel(
|
||||||
productService: context.read<ProductService>(),
|
productService: context.read<ProductService>(),
|
||||||
@@ -98,6 +128,7 @@ Future<void> main() async {
|
|||||||
create: (context) => BarScreenViewModel(
|
create: (context) => BarScreenViewModel(
|
||||||
barTabService: context.read<BarTabService>(),
|
barTabService: context.read<BarTabService>(),
|
||||||
inventory: context.read<InventoryViewModel>(),
|
inventory: context.read<InventoryViewModel>(),
|
||||||
|
remoteSyncViewModel: context.read<RemoteSyncViewModel>(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<HistoryViewModel>(
|
ChangeNotifierProvider<HistoryViewModel>(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class BarTab {
|
|||||||
final String status;
|
final String status;
|
||||||
final DateTime openedAt;
|
final DateTime openedAt;
|
||||||
final DateTime? closedAt;
|
final DateTime? closedAt;
|
||||||
|
final int version;
|
||||||
final List<TabItem> items;
|
final List<TabItem> items;
|
||||||
|
|
||||||
const BarTab({
|
const BarTab({
|
||||||
@@ -14,6 +15,7 @@ class BarTab {
|
|||||||
required this.customerName,
|
required this.customerName,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.openedAt,
|
required this.openedAt,
|
||||||
|
this.version = 0,
|
||||||
required this.items,
|
required this.items,
|
||||||
this.closedAt,
|
this.closedAt,
|
||||||
});
|
});
|
||||||
@@ -41,6 +43,7 @@ class BarTab {
|
|||||||
status == other.status &&
|
status == other.status &&
|
||||||
openedAt == other.openedAt &&
|
openedAt == other.openedAt &&
|
||||||
closedAt == other.closedAt &&
|
closedAt == other.closedAt &&
|
||||||
|
version == other.version &&
|
||||||
listEquals(items, other.items);
|
listEquals(items, other.items);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,6 +53,7 @@ class BarTab {
|
|||||||
status,
|
status,
|
||||||
openedAt,
|
openedAt,
|
||||||
closedAt,
|
closedAt,
|
||||||
|
version,
|
||||||
Object.hashAll(items),
|
Object.hashAll(items),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
enum PaymentMethod {
|
enum PaymentMethod {
|
||||||
cash('cash', 'Cash', 'Cash payment'),
|
cash('cash', 'Cash', 'Cash payment'),
|
||||||
payconiq('payconiq', 'Payconiq', 'Payconiq QR payment');
|
payconiq('payconiq', 'Payconiq', 'Payconiq QR payment'),
|
||||||
|
paymentPending('payment_pending', 'Payment pending', 'Unpaid remote close');
|
||||||
|
|
||||||
final String value;
|
final String value;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
class RemoteCommand {
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
final String tabId;
|
||||||
|
final int expectedVersion;
|
||||||
|
final String paymentMethod;
|
||||||
|
final String requestedBy;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const RemoteCommand({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.tabId,
|
||||||
|
required this.expectedVersion,
|
||||||
|
required this.paymentMethod,
|
||||||
|
required this.requestedBy,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RemoteCommand(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
tabId: json['tabId'] as String,
|
||||||
|
expectedVersion: json['expectedVersion'] as int,
|
||||||
|
paymentMethod: json['paymentMethod'] as String? ?? 'cash',
|
||||||
|
requestedBy: json['requestedBy'] as String? ?? '',
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isExpired {
|
||||||
|
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
|
||||||
|
return createdAt.isBefore(cutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncResponse {
|
||||||
|
final List<RemoteCommand> commands;
|
||||||
|
final String? cursor;
|
||||||
|
final List<String> plannedTabNames;
|
||||||
|
|
||||||
|
SyncResponse({
|
||||||
|
required this.commands,
|
||||||
|
this.cursor,
|
||||||
|
this.plannedTabNames = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final commandsList = (json['commands'] as List<dynamic>?)
|
||||||
|
?.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return SyncResponse(
|
||||||
|
commands: commandsList,
|
||||||
|
cursor: json['cursor'] as String?,
|
||||||
|
plannedTabNames: (json['plannedTabNames'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnapshotTab {
|
||||||
|
final String id;
|
||||||
|
final String customerName;
|
||||||
|
final int version;
|
||||||
|
final int totalInCents;
|
||||||
|
final int itemCount;
|
||||||
|
|
||||||
|
const SnapshotTab({
|
||||||
|
required this.id,
|
||||||
|
required this.customerName,
|
||||||
|
required this.version,
|
||||||
|
required this.totalInCents,
|
||||||
|
required this.itemCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'customerName': customerName,
|
||||||
|
'version': version,
|
||||||
|
'totalInCents': totalInCents,
|
||||||
|
'itemCount': itemCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncRequest {
|
||||||
|
final String deviceId;
|
||||||
|
final String? cursor;
|
||||||
|
final List<SnapshotTab> openTabs;
|
||||||
|
|
||||||
|
const SyncRequest({
|
||||||
|
required this.deviceId,
|
||||||
|
this.cursor,
|
||||||
|
required this.openTabs,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'deviceId': deviceId,
|
||||||
|
if (cursor != null) 'cursor': cursor,
|
||||||
|
'openTabs': openTabs.map((t) => t.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandAck {
|
||||||
|
final String commandId;
|
||||||
|
final String status;
|
||||||
|
final String? result;
|
||||||
|
|
||||||
|
const CommandAck({
|
||||||
|
required this.commandId,
|
||||||
|
required this.status,
|
||||||
|
this.result,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'commandId': commandId,
|
||||||
|
'status': status,
|
||||||
|
if (result != null) 'result': result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RemoteSyncEventType {
|
||||||
|
tabClosedRemotely,
|
||||||
|
syncCompleted,
|
||||||
|
syncFailed,
|
||||||
|
conflict,
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteSyncEvent {
|
||||||
|
final RemoteSyncEventType type;
|
||||||
|
final String? tabCustomerName;
|
||||||
|
final String? requestedBy;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const RemoteSyncEvent({
|
||||||
|
required this.type,
|
||||||
|
this.tabCustomerName,
|
||||||
|
this.requestedBy,
|
||||||
|
this.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
class Venue {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const Venue({required this.id, required this.name});
|
||||||
|
|
||||||
|
factory Venue.fromJson(Map<String, dynamic> json) => Venue(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ class DriftBarTabService implements BarTabService {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
openedAt: row.openedAt,
|
openedAt: row.openedAt,
|
||||||
closedAt: row.closedAt,
|
closedAt: row.closedAt,
|
||||||
|
version: row.version,
|
||||||
items: items,
|
items: items,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,6 +293,7 @@ class DriftBarTabService implements BarTabService {
|
|||||||
@override
|
@override
|
||||||
Future<BarTab> createTab({required String customerName}) async {
|
Future<BarTab> createTab({required String customerName}) async {
|
||||||
final id = _uuid.v4();
|
final id = _uuid.v4();
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
await database
|
await database
|
||||||
.into(database.barTabs)
|
.into(database.barTabs)
|
||||||
@@ -300,7 +302,8 @@ class DriftBarTabService implements BarTabService {
|
|||||||
id: id,
|
id: id,
|
||||||
customerName: customerName,
|
customerName: customerName,
|
||||||
status: const Value('open'),
|
status: const Value('open'),
|
||||||
openedAt: DateTime.now(),
|
openedAt: now,
|
||||||
|
version: const Value(1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -335,6 +338,8 @@ class DriftBarTabService implements BarTabService {
|
|||||||
if (updatedRows != 1) {
|
if (updatedRows != 1) {
|
||||||
throw StateError('Tab not found.');
|
throw StateError('Tab not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _incrementTabVersion(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -368,6 +373,14 @@ class DriftBarTabService implements BarTabService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _incrementTabVersion(String tabId) async {
|
||||||
|
await database.customUpdate(
|
||||||
|
'UPDATE bar_tabs SET version = version + 1 WHERE id = ?',
|
||||||
|
variables: [Variable.withString(tabId)],
|
||||||
|
updates: {database.barTabs},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> addProductToTab({
|
Future<void> addProductToTab({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
@@ -414,6 +427,8 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
await _recordPurchase(tabItemId: tabItemId);
|
await _recordPurchase(tabItemId: tabItemId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await _incrementTabVersion(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -421,7 +436,9 @@ class DriftBarTabService implements BarTabService {
|
|||||||
required String tabItemId,
|
required String tabItemId,
|
||||||
required int delta,
|
required int delta,
|
||||||
}) async {
|
}) async {
|
||||||
return database.transaction(() async {
|
String? tabId;
|
||||||
|
|
||||||
|
final actualDelta = await database.transaction(() async {
|
||||||
final item = await (database.select(
|
final item = await (database.select(
|
||||||
database.tabItems,
|
database.tabItems,
|
||||||
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
||||||
@@ -430,6 +447,8 @@ class DriftBarTabService implements BarTabService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tabId = item.tabId;
|
||||||
|
|
||||||
final requestedQuantity = item.quantity + delta;
|
final requestedQuantity = item.quantity + delta;
|
||||||
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta;
|
||||||
|
|
||||||
@@ -483,6 +502,12 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
return actualDelta;
|
return actualDelta;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (tabId != null && actualDelta != 0) {
|
||||||
|
await _incrementTabVersion(tabId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -544,6 +569,8 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
await deleteQuery.go();
|
await deleteQuery.go();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await _incrementTabVersion(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
import '../database/app_database.dart';
|
||||||
|
import '../models/payment_method.dart';
|
||||||
|
import '../models/remote_command.dart';
|
||||||
|
import '../utils/app_config.dart';
|
||||||
|
import 'bar_tab_service.dart';
|
||||||
|
|
||||||
|
class RemoteSyncService {
|
||||||
|
final AppDatabase database;
|
||||||
|
final BarTabService barTabService;
|
||||||
|
final FlutterSecureStorage secureStorage;
|
||||||
|
|
||||||
|
RemoteSyncService({
|
||||||
|
required this.database,
|
||||||
|
required this.barTabService,
|
||||||
|
FlutterSecureStorage? secureStorage,
|
||||||
|
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
static const _deviceIdKey = 'remote_device_id';
|
||||||
|
static const _authTokenKey = 'remote_auth_token';
|
||||||
|
static const _serverUrlKey = 'remote_server_url';
|
||||||
|
static const _venueIdKey = 'remote_venue_id';
|
||||||
|
|
||||||
|
final _eventController = StreamController<RemoteSyncEvent>.broadcast();
|
||||||
|
|
||||||
|
Stream<RemoteSyncEvent> get events => _eventController.stream;
|
||||||
|
|
||||||
|
bool _isSyncing = false;
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
|
||||||
|
Timer? _syncTimer;
|
||||||
|
int _consecutiveFailures = 0;
|
||||||
|
bool _paused = false;
|
||||||
|
|
||||||
|
Future<String?> get deviceId async =>
|
||||||
|
await secureStorage.read(key: _deviceIdKey);
|
||||||
|
|
||||||
|
Future<String?> get authToken async =>
|
||||||
|
await secureStorage.read(key: _authTokenKey);
|
||||||
|
|
||||||
|
Future<String?> get venueId async =>
|
||||||
|
await secureStorage.read(key: _venueIdKey);
|
||||||
|
|
||||||
|
Future<bool> get isConfigured async {
|
||||||
|
final token = await authToken;
|
||||||
|
final vid = await venueId;
|
||||||
|
return token != null && token.isNotEmpty && vid != null && vid.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _baseUrl => kRemoteServerUrl;
|
||||||
|
|
||||||
|
void startPeriodicSync({int intervalSeconds = 90}) {
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_syncTimer = Timer.periodic(
|
||||||
|
Duration(seconds: intervalSeconds),
|
||||||
|
(_) => _onTick(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopPeriodicSync() {
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_syncTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
_paused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void resume() {
|
||||||
|
_paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onTick() async {
|
||||||
|
if (_isSyncing || _paused) return;
|
||||||
|
await sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sync() async {
|
||||||
|
if (_isSyncing) return;
|
||||||
|
|
||||||
|
final configured = await isConfigured;
|
||||||
|
if (!configured) return;
|
||||||
|
|
||||||
|
_isSyncing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _doSync();
|
||||||
|
_consecutiveFailures = 0;
|
||||||
|
_eventController.add(const RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.syncCompleted,
|
||||||
|
));
|
||||||
|
} catch (e, stack) {
|
||||||
|
_consecutiveFailures++;
|
||||||
|
debugPrint('RemoteSyncService: sync failed: $e');
|
||||||
|
Sentry.captureException(e, stackTrace: stack);
|
||||||
|
_eventController.add(RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.syncFailed,
|
||||||
|
message: e.toString(),
|
||||||
|
));
|
||||||
|
_applyBackoff();
|
||||||
|
} finally {
|
||||||
|
_isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doSync() async {
|
||||||
|
final token = await authToken;
|
||||||
|
if (token == null) return;
|
||||||
|
|
||||||
|
final deviceId = await this.deviceId;
|
||||||
|
if (deviceId == null) return;
|
||||||
|
|
||||||
|
final state = await _getSyncState();
|
||||||
|
final openTabs = await _buildSnapshot();
|
||||||
|
|
||||||
|
final request = SyncRequest(
|
||||||
|
deviceId: deviceId,
|
||||||
|
cursor: state?.serverCursor,
|
||||||
|
openTabs: openTabs,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await _httpPost(
|
||||||
|
'$_baseUrl/devices/$deviceId/sync',
|
||||||
|
body: request.toJson(),
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) return;
|
||||||
|
|
||||||
|
final syncResponse = SyncResponse.fromJson(response);
|
||||||
|
|
||||||
|
for (final command in syncResponse.commands) {
|
||||||
|
await _processCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final name in syncResponse.plannedTabNames) {
|
||||||
|
final hasOpenTab = openTabs.any((t) => t.customerName == name);
|
||||||
|
if (!hasOpenTab) {
|
||||||
|
await barTabService.createTab(customerName: name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncResponse.cursor != null) {
|
||||||
|
await _saveCursor(syncResponse.cursor!);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _updateSyncState(success: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SnapshotTab>> _buildSnapshot() async {
|
||||||
|
final openTabs = await barTabService.getOpenTabs();
|
||||||
|
return openTabs.map((tab) => SnapshotTab(
|
||||||
|
id: tab.id,
|
||||||
|
customerName: tab.customerName,
|
||||||
|
version: tab.version,
|
||||||
|
totalInCents: tab.totalInCents,
|
||||||
|
itemCount: tab.itemCount,
|
||||||
|
)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processCommand(RemoteCommand command) async {
|
||||||
|
final alreadyProcessed = await _isCommandProcessed(command.id);
|
||||||
|
if (alreadyProcessed) return;
|
||||||
|
|
||||||
|
if (command.isExpired) {
|
||||||
|
await _ackCommand(command.id, 'expired', 'Command expired');
|
||||||
|
await _recordProcessed(command.id, command.type, 'expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.type != 'close_tab') {
|
||||||
|
await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}');
|
||||||
|
await _recordProcessed(command.id, command.type, 'failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tab = await barTabService.getTabById(command.tabId);
|
||||||
|
|
||||||
|
if (tab == null) {
|
||||||
|
await _ackCommand(command.id, 'conflict', 'Tab not found');
|
||||||
|
await _recordProcessed(command.id, command.type, 'conflict');
|
||||||
|
_eventController.add(const RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.conflict,
|
||||||
|
message: 'Tab not found',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tab.isOpen) {
|
||||||
|
await _ackCommand(command.id, 'conflict', 'Tab already closed');
|
||||||
|
await _recordProcessed(command.id, command.type, 'conflict');
|
||||||
|
_eventController.add(const RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.conflict,
|
||||||
|
message: 'Tab already closed',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.version != command.expectedVersion) {
|
||||||
|
await _ackCommand(command.id, 'conflict', 'Version mismatch');
|
||||||
|
await _recordProcessed(command.id, command.type, 'conflict');
|
||||||
|
_eventController.add(const RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.conflict,
|
||||||
|
message: 'Tab was modified since the request',
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final method = PaymentMethod.fromValue(command.paymentMethod);
|
||||||
|
await barTabService.closeTab(command.tabId, paymentMethod: method);
|
||||||
|
|
||||||
|
await _ackCommand(command.id, 'completed');
|
||||||
|
await _recordProcessed(command.id, command.type, 'completed');
|
||||||
|
|
||||||
|
_eventController.add(RemoteSyncEvent(
|
||||||
|
type: RemoteSyncEventType.tabClosedRemotely,
|
||||||
|
tabCustomerName: tab.customerName,
|
||||||
|
requestedBy: command.requestedBy,
|
||||||
|
));
|
||||||
|
} catch (e, stack) {
|
||||||
|
debugPrint('RemoteSyncService: command processing failed: $e');
|
||||||
|
Sentry.captureException(e, stackTrace: stack);
|
||||||
|
await _ackCommand(command.id, 'failed', e.toString());
|
||||||
|
await _recordProcessed(command.id, command.type, 'failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _httpPost(
|
||||||
|
String url, {
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
).timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await secureStorage.delete(key: _authTokenKey);
|
||||||
|
debugPrint('RemoteSyncService: token revoked');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 429) {
|
||||||
|
final retryAfter = response.headers['retry-after'];
|
||||||
|
if (retryAfter != null) {
|
||||||
|
final seconds = int.tryParse(retryAfter) ?? 60;
|
||||||
|
_applyBackoff(forcedDelay: seconds);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
debugPrint('RemoteSyncService: HTTP ${response.statusCode}: ${response.body}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.body.isEmpty) return {};
|
||||||
|
|
||||||
|
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ackCommand(
|
||||||
|
String commandId,
|
||||||
|
String status, [
|
||||||
|
String? result,
|
||||||
|
]) async {
|
||||||
|
final token = await authToken;
|
||||||
|
final deviceId = await this.deviceId;
|
||||||
|
if (token == null || deviceId == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ack = CommandAck(commandId: commandId, status: status, result: result);
|
||||||
|
await _httpPost(
|
||||||
|
'$_baseUrl/devices/$deviceId/commands/$commandId/ack',
|
||||||
|
body: ack.toJson(),
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('RemoteSyncService: ack failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RemoteSyncStateRow?> _getSyncState() async {
|
||||||
|
final query = database.select(database.remoteSyncState)
|
||||||
|
..where((row) => row.deviceId.equals('default'));
|
||||||
|
return query.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveCursor(String cursor) async {
|
||||||
|
await database
|
||||||
|
.into(database.remoteSyncState)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
RemoteSyncStateCompanion(
|
||||||
|
deviceId: const Value('default'),
|
||||||
|
serverCursor: Value(cursor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateSyncState({required bool success}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final existing = await _getSyncState();
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
await database.into(database.remoteSyncState).insert(
|
||||||
|
RemoteSyncStateCompanion.insert(
|
||||||
|
deviceId: 'default',
|
||||||
|
lastSuccessfulSyncAt: Value(success ? now : null),
|
||||||
|
lastAttemptedSyncAt: Value(now),
|
||||||
|
consecutiveFailureCount: Value(success ? 0 : 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await (database.update(database.remoteSyncState)
|
||||||
|
..where((row) => row.deviceId.equals('default')))
|
||||||
|
.write(RemoteSyncStateCompanion(
|
||||||
|
lastSuccessfulSyncAt: Value(success ? now : existing.lastSuccessfulSyncAt),
|
||||||
|
lastAttemptedSyncAt: Value(now),
|
||||||
|
consecutiveFailureCount: Value(
|
||||||
|
success ? 0 : existing.consecutiveFailureCount + 1,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isCommandProcessed(String commandId) async {
|
||||||
|
final query = database.select(database.processedRemoteCommands)
|
||||||
|
..where((row) => row.commandId.equals(commandId));
|
||||||
|
final result = await query.getSingleOrNull();
|
||||||
|
return result != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _recordProcessed(
|
||||||
|
String commandId,
|
||||||
|
String commandType,
|
||||||
|
String result,
|
||||||
|
) async {
|
||||||
|
await database
|
||||||
|
.into(database.processedRemoteCommands)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
ProcessedRemoteCommandsCompanion.insert(
|
||||||
|
commandId: commandId,
|
||||||
|
commandType: commandType,
|
||||||
|
processedAt: DateTime.now(),
|
||||||
|
result: result,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyBackoff({int? forcedDelay}) {
|
||||||
|
final delay = forcedDelay ?? _calculateBackoff();
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_syncTimer = Timer(Duration(seconds: delay), () => _onTick());
|
||||||
|
}
|
||||||
|
|
||||||
|
int _calculateBackoff() {
|
||||||
|
if (_consecutiveFailures <= 1) return 90;
|
||||||
|
final backoff = 90 * (1 << (_consecutiveFailures - 1).clamp(0, 3));
|
||||||
|
return backoff.clamp(90, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setCredentials({
|
||||||
|
required String deviceId,
|
||||||
|
required String token,
|
||||||
|
required String venueId,
|
||||||
|
String? serverUrl,
|
||||||
|
}) async {
|
||||||
|
await secureStorage.write(key: _deviceIdKey, value: deviceId);
|
||||||
|
await secureStorage.write(key: _authTokenKey, value: token);
|
||||||
|
await secureStorage.write(key: _venueIdKey, value: venueId);
|
||||||
|
if (serverUrl != null) {
|
||||||
|
await secureStorage.write(key: _serverUrlKey, value: serverUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCredentials() async {
|
||||||
|
await secureStorage.delete(key: _deviceIdKey);
|
||||||
|
await secureStorage.delete(key: _authTokenKey);
|
||||||
|
await secureStorage.delete(key: _serverUrlKey);
|
||||||
|
await secureStorage.delete(key: _venueIdKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> get lastSuccessfulSyncAt async {
|
||||||
|
final state = await _getSyncState();
|
||||||
|
return state?.lastSuccessfulSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> get lastFailureCount async {
|
||||||
|
final state = await _getSyncState();
|
||||||
|
return state?.consecutiveFailureCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() async {
|
||||||
|
final query = database.select(database.processedRemoteCommands)
|
||||||
|
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
|
||||||
|
..limit(50);
|
||||||
|
return query.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_eventController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
const kUpdateServerUrl = 'https://updater.brammie15.dev';
|
const kUpdateServerUrl = 'https://updater.brammie15.dev';
|
||||||
const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup';
|
const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup';
|
||||||
|
const kRemoteServerUrl = 'https://api.kooltap.com/api/v1';
|
||||||
|
const kSyncPollingIntervalSeconds = 90;
|
||||||
|
const kSyncMaxBackoffSeconds = 600;
|
||||||
|
const kCommandExpiryHours = 24;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
@@ -5,15 +7,29 @@ import '../models/payment_method.dart';
|
|||||||
import '../models/bar_tab.dart';
|
import '../models/bar_tab.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/tab_item.dart';
|
import '../models/tab_item.dart';
|
||||||
|
import '../models/remote_command.dart';
|
||||||
import '../services/bar_tab_service.dart';
|
import '../services/bar_tab_service.dart';
|
||||||
import '../services/product_service.dart';
|
import '../services/product_service.dart';
|
||||||
import 'inventory_view_model.dart';
|
import 'inventory_view_model.dart';
|
||||||
|
import 'remote_sync_view_model.dart';
|
||||||
|
|
||||||
class BarScreenViewModel extends ChangeNotifier {
|
class BarScreenViewModel extends ChangeNotifier {
|
||||||
final BarTabService barTabService;
|
final BarTabService barTabService;
|
||||||
final InventoryViewModel inventory;
|
final InventoryViewModel inventory;
|
||||||
|
final RemoteSyncViewModel remoteSyncViewModel;
|
||||||
|
|
||||||
BarScreenViewModel({required this.barTabService, required this.inventory});
|
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;
|
||||||
|
|
||||||
List<BarTab> _tabs = [];
|
List<BarTab> _tabs = [];
|
||||||
String? _selectedTabId;
|
String? _selectedTabId;
|
||||||
@@ -42,6 +58,24 @@ class BarScreenViewModel extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onRemoteEvent(RemoteSyncEvent event) {
|
||||||
|
_lastRemoteEvent = event;
|
||||||
|
notifyListeners();
|
||||||
|
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
|
||||||
|
_reloadTabs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearRemoteEvent() {
|
||||||
|
_lastRemoteEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> ensureLoaded() async {
|
Future<void> ensureLoaded() async {
|
||||||
if (_hasLoaded || _isLoading) return;
|
if (_hasLoaded || _isLoading) return;
|
||||||
|
|
||||||
@@ -72,6 +106,11 @@ class BarScreenViewModel extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> loadAndSync() async {
|
||||||
|
await load();
|
||||||
|
await remoteSyncViewModel.syncNow();
|
||||||
|
}
|
||||||
|
|
||||||
void selectTab(String tabId) {
|
void selectTab(String tabId) {
|
||||||
_selectedTabId = tabId;
|
_selectedTabId = tabId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/venue.dart';
|
||||||
|
import '../services/remote_sync_service.dart';
|
||||||
|
import '../utils/app_config.dart';
|
||||||
|
|
||||||
|
enum RegistrationStep { initial, loadingVenues, venuesLoaded, error, registering, registered }
|
||||||
|
|
||||||
|
class RegistrationViewModel extends ChangeNotifier {
|
||||||
|
final RemoteSyncService syncService;
|
||||||
|
|
||||||
|
RegistrationViewModel({required this.syncService});
|
||||||
|
|
||||||
|
RegistrationStep _step = RegistrationStep.initial;
|
||||||
|
RegistrationStep get step => _step;
|
||||||
|
|
||||||
|
List<Venue> _venues = [];
|
||||||
|
List<Venue> get venues => _venues;
|
||||||
|
|
||||||
|
Venue? _selectedVenue;
|
||||||
|
Venue? get selectedVenue => _selectedVenue;
|
||||||
|
|
||||||
|
String? _errorMessage;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
|
void selectVenue(Venue venue) {
|
||||||
|
_selectedVenue = venue;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchVenues() async {
|
||||||
|
_step = RegistrationStep.loadingVenues;
|
||||||
|
_errorMessage = null;
|
||||||
|
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) {
|
||||||
|
debugPrint('RegistrationViewModel: fetchVenues error: $e');
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_step = RegistrationStep.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> registerDevice({
|
||||||
|
required String deviceName,
|
||||||
|
required String registrationCode,
|
||||||
|
}) async {
|
||||||
|
final venue = _selectedVenue;
|
||||||
|
if (venue == null) return false;
|
||||||
|
|
||||||
|
_step = RegistrationStep.registering;
|
||||||
|
_errorMessage = null;
|
||||||
|
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));
|
||||||
|
|
||||||
|
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: 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) {
|
||||||
|
debugPrint('RegistrationViewModel: registerDevice error: $e');
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_step = RegistrationStep.venuesLoaded;
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> retry() async {
|
||||||
|
await fetchVenues();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_step = RegistrationStep.initial;
|
||||||
|
_venues = [];
|
||||||
|
_selectedVenue = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
RemoteSyncViewModel({required this.syncService}) {
|
||||||
|
_eventSubscription = syncService.events.listen(_onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
|
||||||
|
|
||||||
|
List<RemoteSyncEvent> _recentEvents = [];
|
||||||
|
|
||||||
|
List<RemoteSyncEvent> get recentEvents => _recentEvents;
|
||||||
|
|
||||||
|
bool _isSyncing = false;
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
|
||||||
|
bool _isConfigured = false;
|
||||||
|
bool get isConfigured => _isConfigured;
|
||||||
|
|
||||||
|
DateTime? _lastSyncAt;
|
||||||
|
DateTime? get lastSyncAt => _lastSyncAt;
|
||||||
|
|
||||||
|
String? _lastError;
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
|
||||||
|
String? _venueId;
|
||||||
|
String? get venueId => _venueId;
|
||||||
|
|
||||||
|
List<ProcessedRemoteCommandRow> _activity = [];
|
||||||
|
List<ProcessedRemoteCommandRow> get activity => _activity;
|
||||||
|
|
||||||
|
bool _isRegistering = false;
|
||||||
|
bool get isRegistering => _isRegistering;
|
||||||
|
|
||||||
|
String? _registrationError;
|
||||||
|
String? get registrationError => _registrationError;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
_isConfigured = await syncService.isConfigured;
|
||||||
|
_venueId = await syncService.venueId;
|
||||||
|
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
|
||||||
|
await loadActivity();
|
||||||
|
if (_isConfigured) {
|
||||||
|
syncService.startPeriodicSync();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadActivity() async {
|
||||||
|
try {
|
||||||
|
_activity = await syncService.getRecentActivity();
|
||||||
|
} 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();
|
||||||
|
_isConfigured = false;
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
syncService.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resume() {
|
||||||
|
syncService.resume();
|
||||||
|
syncNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventSubscription?.cancel();
|
||||||
|
syncService.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/navigation.dart';
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/admin_pin_view_model.dart';
|
import '../viewmodels/admin_pin_view_model.dart';
|
||||||
|
import '../viewmodels/remote_sync_view_model.dart';
|
||||||
|
|
||||||
class AdminScreenView extends StatelessWidget {
|
class AdminScreenView extends StatelessWidget {
|
||||||
const AdminScreenView({super.key});
|
const AdminScreenView({super.key});
|
||||||
@@ -15,9 +16,39 @@ class AdminScreenView extends StatelessWidget {
|
|||||||
adminPinViewModel.lock();
|
adminPinViewModel.lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmUnlink(BuildContext context) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Disconnect from server'),
|
||||||
|
content: const Text(
|
||||||
|
'This will unlink the device from the remote server. '
|
||||||
|
'Open tabs will no longer be synced. Continue?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
context.read<RemoteSyncViewModel>().unlink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final remoteSync = context.watch<RemoteSyncViewModel>();
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
@@ -68,6 +99,81 @@ class AdminScreenView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_AdminSection(
|
||||||
|
title: 'Remote Server',
|
||||||
|
children: [
|
||||||
|
if (remoteSync.isConfigured) ...[
|
||||||
|
_AdminTile(
|
||||||
|
icon: Icons.cloud_done_outlined,
|
||||||
|
title: 'Connected',
|
||||||
|
subtitle: 'Device linked to remote server',
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => _confirmUnlink(context),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Disconnect'),
|
||||||
|
),
|
||||||
|
onTap: null,
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
_AdminTile(
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
title: 'Not connected',
|
||||||
|
subtitle: 'Link to remote dashboard',
|
||||||
|
onTap: () => context.push('/register'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (remoteSync.isConfigured) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_AdminSection(
|
||||||
|
title: l10n.remoteActivity,
|
||||||
|
children: [
|
||||||
|
_AdminTile(
|
||||||
|
icon: Icons.sync,
|
||||||
|
title: l10n.syncNow,
|
||||||
|
subtitle: remoteSync.isSyncing
|
||||||
|
? l10n.syncing
|
||||||
|
: remoteSync.lastSyncAt != null
|
||||||
|
? l10n.lastSync(remoteSync.lastSyncAt!.toLocal().toString().substring(0, 19))
|
||||||
|
: l10n.notSyncedYet,
|
||||||
|
trailing: remoteSync.isSyncing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => remoteSync.syncNow(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (remoteSync.activity.isNotEmpty)
|
||||||
|
_AdminSection(
|
||||||
|
title: l10n.remoteActivityHistory,
|
||||||
|
children: remoteSync.activity.map((entry) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(
|
||||||
|
entry.result == 'completed'
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: entry.result == 'conflict'
|
||||||
|
? Icons.warning_amber_outlined
|
||||||
|
: Icons.error_outline,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'${entry.commandType} (${entry.commandId.substring(0, 8)}...)',
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${entry.processedAt.toLocal()} - ${entry.result}',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -121,12 +227,14 @@ class _AdminTile extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _AdminTile({
|
const _AdminTile({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +277,10 @@ class _AdminTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
trailing!,
|
||||||
|
],
|
||||||
if (onTap != null)
|
if (onTap != null)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.chevron_right_rounded,
|
Icons.chevron_right_rounded,
|
||||||
@@ -180,4 +292,4 @@ class _AdminTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
|||||||
import '../models/bar_tab.dart';
|
import '../models/bar_tab.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/tab_item.dart';
|
import '../models/tab_item.dart';
|
||||||
|
import '../models/remote_command.dart';
|
||||||
import '../services/product_service.dart';
|
import '../services/product_service.dart';
|
||||||
import '../utils/app_updater.dart';
|
import '../utils/app_updater.dart';
|
||||||
import '../viewmodels/bar_screen_view_model.dart';
|
import '../viewmodels/bar_screen_view_model.dart';
|
||||||
@@ -38,6 +39,43 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_checkRemoteEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BarScreenView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_checkRemoteEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkRemoteEvent() {
|
||||||
|
final viewModel = context.read<BarScreenViewModel>();
|
||||||
|
final event = viewModel.lastRemoteEvent;
|
||||||
|
if (event == null) return;
|
||||||
|
|
||||||
|
viewModel.clearRemoteEvent();
|
||||||
|
|
||||||
|
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final message = l10n.tabClosedRemotely(
|
||||||
|
event.tabCustomerName ?? '',
|
||||||
|
event.requestedBy ?? '',
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
@@ -83,7 +121,7 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.refresh,
|
tooltip: l10n.refresh,
|
||||||
onPressed: () => viewModel.load(),
|
onPressed: () => viewModel.loadAndSync(),
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ class _PaymentPicker extends StatelessWidget {
|
|||||||
|
|
||||||
const _PaymentPicker({required this.selected, required this.onChanged});
|
const _PaymentPicker({required this.selected, required this.onChanged});
|
||||||
|
|
||||||
static const _options = [PaymentMethod.cash, PaymentMethod.payconiq];
|
static const _options = [
|
||||||
|
PaymentMethod.cash,
|
||||||
|
PaymentMethod.payconiq,
|
||||||
|
PaymentMethod.paymentPending,
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -93,7 +97,11 @@ class _PaymentPicker extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: _options.map((option) {
|
children: _options.map((option) {
|
||||||
final value = option;
|
final value = option;
|
||||||
final label = value == PaymentMethod.cash ? l10n.cash : l10n.payconiq;
|
final label = switch (value) {
|
||||||
|
PaymentMethod.cash => l10n.cash,
|
||||||
|
PaymentMethod.payconiq => l10n.payconiq,
|
||||||
|
PaymentMethod.paymentPending => l10n.paymentPending,
|
||||||
|
};
|
||||||
final isSelected = selected == value;
|
final isSelected = selected == value;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -111,7 +119,7 @@ class _PaymentPicker extends StatelessWidget {
|
|||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.lightGreen,
|
color: Colors.lightGreen,
|
||||||
)
|
)
|
||||||
else
|
else if (value == PaymentMethod.payconiq)
|
||||||
SvgPicture.asset(
|
SvgPicture.asset(
|
||||||
'assets/icons/payconic.svg',
|
'assets/icons/payconic.svg',
|
||||||
width: 16,
|
width: 16,
|
||||||
@@ -120,6 +128,12 @@ class _PaymentPicker extends StatelessWidget {
|
|||||||
Colors.pinkAccent,
|
Colors.pinkAccent,
|
||||||
BlendMode.srcIn,
|
BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(label),
|
Text(label),
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../viewmodels/registration_view_model.dart';
|
||||||
|
import '../viewmodels/remote_sync_view_model.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class RegistrationView extends StatefulWidget {
|
||||||
|
const RegistrationView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegistrationView> createState() => _RegistrationViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegistrationViewState extends State<RegistrationView> {
|
||||||
|
final _deviceNameController = TextEditingController(text: 'Bar terminal 1');
|
||||||
|
final _regCodeController = TextEditingController();
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<RegistrationViewModel>().fetchVenues();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_deviceNameController.dispose();
|
||||||
|
_regCodeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final vm = context.watch<RegistrationViewModel>();
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: _buildStep(vm, l10n),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep(RegistrationViewModel vm, AppLocalizations l10n) {
|
||||||
|
switch (vm.step) {
|
||||||
|
case RegistrationStep.loadingVenues:
|
||||||
|
return _buildLoading();
|
||||||
|
case RegistrationStep.error:
|
||||||
|
return _buildError(vm);
|
||||||
|
case RegistrationStep.venuesLoaded:
|
||||||
|
if (vm.selectedVenue == null) {
|
||||||
|
return _buildVenueSelection(vm, l10n);
|
||||||
|
}
|
||||||
|
return _buildDeviceDetails(vm, l10n);
|
||||||
|
case RegistrationStep.registering:
|
||||||
|
return _buildRegistering();
|
||||||
|
case RegistrationStep.registered:
|
||||||
|
return _buildSuccess(vm, l10n);
|
||||||
|
case RegistrationStep.initial:
|
||||||
|
return _buildLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoading() {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const CircularProgressIndicator(strokeWidth: 3),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Connecting to server…',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildError(RegistrationViewModel vm) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Failed to connect',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
vm.errorMessage ?? 'Unknown error',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => vm.retry(),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVenueSelection(RegistrationViewModel vm, AppLocalizations l10n) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Connect to remote server',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Select your venue to link this device',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(
|
||||||
|
'Select venue',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final venue in vm.venues)
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => vm.selectVenue(venue),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.store_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
venue.name,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => vm.retry(),
|
||||||
|
child: const Text('Refresh venues'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDeviceDetails(RegistrationViewModel vm, AppLocalizations l10n) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Icon(
|
||||||
|
Icons.store_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
vm.selectedVenue!.name,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Enter device details to complete pairing',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
TextFormField(
|
||||||
|
controller: _deviceNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Device name',
|
||||||
|
hintText: 'e.g. Bar terminal 1',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _regCodeController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Registration code',
|
||||||
|
hintText: '6-character code from dashboard',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
counterText: '',
|
||||||
|
),
|
||||||
|
maxLength: 6,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (vm.errorMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
vm.errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
vm.reset();
|
||||||
|
},
|
||||||
|
child: const Text('Back'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
await vm.registerDevice(
|
||||||
|
deviceName: _deviceNameController.text.trim(),
|
||||||
|
registrationCode: _regCodeController.text.trim().toUpperCase(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Register'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRegistering() {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
const CircularProgressIndicator(strokeWidth: 3),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Registering device…',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Device registered!',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'${vm.selectedVenue?.name ?? "Venue"} is now linked.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Open tabs will sync to the dashboard.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<RemoteSyncViewModel>().startSync();
|
||||||
|
context.go('/bar');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.arrow_forward),
|
||||||
|
label: const Text('Start using KoolTab'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
|||||||
return switch (method) {
|
return switch (method) {
|
||||||
PaymentMethod.payconiq => Icons.qr_code_rounded,
|
PaymentMethod.payconiq => Icons.qr_code_rounded,
|
||||||
PaymentMethod.cash => Icons.money_rounded,
|
PaymentMethod.cash => Icons.money_rounded,
|
||||||
|
PaymentMethod.paymentPending => Icons.hourglass_empty,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
|||||||
switch (closedTab.paymentMethod) {
|
switch (closedTab.paymentMethod) {
|
||||||
PaymentMethod.cash => l10n.cash,
|
PaymentMethod.cash => l10n.cash,
|
||||||
PaymentMethod.payconiq => l10n.payconiq,
|
PaymentMethod.payconiq => l10n.payconiq,
|
||||||
|
PaymentMethod.paymentPending => l10n.paymentPending,
|
||||||
},
|
},
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|||||||
@@ -3,27 +3,36 @@ import 'package:kooltab2/models/product.dart';
|
|||||||
import 'package:kooltab2/models/tab_item.dart';
|
import 'package:kooltab2/models/tab_item.dart';
|
||||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||||
import 'package:kooltab2/services/product_service.dart';
|
import 'package:kooltab2/services/product_service.dart';
|
||||||
|
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||||
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||||
|
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockBarTabService extends Mock implements BarTabService {}
|
class MockBarTabService extends Mock implements BarTabService {}
|
||||||
|
|
||||||
class MockProductService extends Mock implements ProductService {}
|
class MockProductService extends Mock implements ProductService {}
|
||||||
|
|
||||||
|
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MockBarTabService barTabService;
|
late MockBarTabService barTabService;
|
||||||
late MockProductService productService;
|
late MockProductService productService;
|
||||||
late InventoryViewModel inventory;
|
late InventoryViewModel inventory;
|
||||||
late BarScreenViewModel viewModel;
|
late BarScreenViewModel viewModel;
|
||||||
|
late RemoteSyncViewModel remoteSyncViewModel;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
barTabService = MockBarTabService();
|
barTabService = MockBarTabService();
|
||||||
productService = MockProductService();
|
productService = MockProductService();
|
||||||
inventory = InventoryViewModel(productService: productService);
|
inventory = InventoryViewModel(productService: productService);
|
||||||
|
remoteSyncViewModel = RemoteSyncViewModel(
|
||||||
|
syncService: MockRemoteSyncService(),
|
||||||
|
);
|
||||||
viewModel = BarScreenViewModel(
|
viewModel = BarScreenViewModel(
|
||||||
barTabService: barTabService,
|
barTabService: barTabService,
|
||||||
inventory: inventory,
|
inventory: inventory,
|
||||||
|
remoteSyncViewModel: remoteSyncViewModel,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import 'package:kooltab2/l10n/app_localizations.dart';
|
|||||||
import 'package:kooltab2/models/bar_tab.dart';
|
import 'package:kooltab2/models/bar_tab.dart';
|
||||||
import 'package:kooltab2/services/bar_tab_service.dart';
|
import 'package:kooltab2/services/bar_tab_service.dart';
|
||||||
import 'package:kooltab2/services/product_service.dart';
|
import 'package:kooltab2/services/product_service.dart';
|
||||||
|
import 'package:kooltab2/services/remote_sync_service.dart';
|
||||||
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
import 'package:kooltab2/viewmodels/inventory_view_model.dart';
|
||||||
|
import 'package:kooltab2/viewmodels/remote_sync_view_model.dart';
|
||||||
import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart';
|
import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -14,15 +16,21 @@ class MockBarTabService extends Mock implements BarTabService {}
|
|||||||
|
|
||||||
class MockProductService extends Mock implements ProductService {}
|
class MockProductService extends Mock implements ProductService {}
|
||||||
|
|
||||||
|
class MockRemoteSyncService extends Mock implements RemoteSyncService {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('edit dialog disposes its controller after closing', (
|
testWidgets('edit dialog disposes its controller after closing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final barTabService = MockBarTabService();
|
final barTabService = MockBarTabService();
|
||||||
final productService = MockProductService();
|
final productService = MockProductService();
|
||||||
|
final remoteSyncViewModel = RemoteSyncViewModel(
|
||||||
|
syncService: MockRemoteSyncService(),
|
||||||
|
);
|
||||||
final viewModel = BarScreenViewModel(
|
final viewModel = BarScreenViewModel(
|
||||||
barTabService: barTabService,
|
barTabService: barTabService,
|
||||||
inventory: InventoryViewModel(productService: productService),
|
inventory: InventoryViewModel(productService: productService),
|
||||||
|
remoteSyncViewModel: remoteSyncViewModel,
|
||||||
);
|
);
|
||||||
final tab = BarTab(
|
final tab = BarTab(
|
||||||
id: 'tab-1',
|
id: 'tab-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user