diff --git a/lib/app/app.dart b/lib/app/app.dart index d51ca0a..943dc37 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -6,6 +6,7 @@ import 'app_lifecycle_lock.dart'; import '../models/settings.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart'; +import '../viewmodels/remote_sync_view_model.dart'; import '../viewmodels/settings_view_model.dart'; import '../theme.dart'; import '../l10n/app_localizations.dart'; @@ -31,6 +32,12 @@ class _KoolTabAppState extends State { context.read().lock(); context.read().lock(); }, + onBackground: () { + context.read().pause(); + }, + onForeground: () { + context.read().resume(); + }, shouldLock: () => context.read().settings.autoLockEnabled, ); diff --git a/lib/app/app_bootstrap.dart b/lib/app/app_bootstrap.dart index ddf4ece..46e5a34 100644 --- a/lib/app/app_bootstrap.dart +++ b/lib/app/app_bootstrap.dart @@ -7,6 +7,7 @@ import '../services/default_product_seeder.dart'; import '../services/export_service.dart'; import '../viewmodels/bar_screen_view_model.dart'; import '../viewmodels/product_list_view_model.dart'; +import '../viewmodels/remote_sync_view_model.dart'; class AppBootstrap extends StatefulWidget { final Widget child; @@ -33,6 +34,8 @@ class _AppBootstrapState extends State { DefaultExportService(database: database).uploadToServer(); + context.read().init(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); }); } @@ -41,4 +44,4 @@ class _AppBootstrapState extends State { Widget build(BuildContext context) { return widget.child; } -} +} \ No newline at end of file diff --git a/lib/app/app_lifecycle_lock.dart b/lib/app/app_lifecycle_lock.dart index 0735678..9be20e3 100644 --- a/lib/app/app_lifecycle_lock.dart +++ b/lib/app/app_lifecycle_lock.dart @@ -2,9 +2,16 @@ import 'package:flutter/material.dart'; class AppLifecycleLockObserver extends WidgetsBindingObserver { final VoidCallback onLock; + final VoidCallback? onBackground; + final VoidCallback? onForeground; final bool Function()? shouldLock; - AppLifecycleLockObserver({required this.onLock, this.shouldLock}); + AppLifecycleLockObserver({ + required this.onLock, + this.onBackground, + this.onForeground, + this.shouldLock, + }); @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -14,8 +21,9 @@ class AppLifecycleLockObserver extends WidgetsBindingObserver { case AppLifecycleState.paused: case AppLifecycleState.detached: if (shouldLock?.call() ?? true) onLock(); + onBackground?.call(); case AppLifecycleState.resumed: - break; + onForeground?.call(); } } } diff --git a/lib/app/router.dart b/lib/app/router.dart index eef9bec..53fb840 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -14,6 +14,7 @@ import '../views/import_names_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; import '../views/product_list_view.dart'; +import '../views/registration_view.dart'; import '../views/settings_view.dart'; import '../views/update_progress_view.dart'; import '../utils/app_update_util.dart'; @@ -23,6 +24,7 @@ final RouteObserver routeObserver = RouteObserver(); String? _redirectApp({ required PinLockViewModel pinLockViewModel, required AdminPinViewModel adminPinViewModel, + required ValueNotifier isConfiguredNotifier, required GoRouterState state, }) { if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) { @@ -30,6 +32,18 @@ String? _redirectApp({ } 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 goingToAdminLock = location == '/admin/lock'; final mustBeOnLock = _mustBeOnLock(pinLockViewModel); @@ -83,19 +97,31 @@ bool _isDebugShortcutArea(String location) { GoRouter createAppRouter(PinLockViewModel pinLockViewModel, { required AdminPinViewModel adminPinViewModel, + required ValueNotifier isConfiguredNotifier, }) { return GoRouter( initialLocation: '/bar', observers: [routeObserver, SentryNavigatorObserver()], - refreshListenable: Listenable.merge([pinLockViewModel, adminPinViewModel]), + refreshListenable: Listenable.merge([ + pinLockViewModel, + adminPinViewModel, + isConfiguredNotifier, + ]), redirect: (_, state) => _redirectApp( pinLockViewModel: pinLockViewModel, adminPinViewModel: adminPinViewModel, + isConfiguredNotifier: isConfiguredNotifier, state: state, ), routes: [ GoRoute(path: '/', redirect: (context, state) => '/bar'), + + GoRoute( + path: '/register', + builder: (context, state) => const RegistrationView(), + ), + GoRoute( path: '/lock', builder: (context, state) => @@ -187,4 +213,4 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel, { ), ], ); -} +} \ No newline at end of file diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 9d11194..673c29a 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -38,6 +38,8 @@ class BarTabs extends Table { DateTimeColumn get closedAt => dateTime().nullable()(); + IntColumn get version => integer().withDefault(const Constant(0))(); + @override Set get primaryKey => {id}; } @@ -138,24 +140,51 @@ class AppSettingsTable extends Table { Set 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 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 get primaryKey => {commandId}; +} + @DriftDatabase( tables: [ Products, BarTabs, TabItems, TabItemPurchases, - ClosedTabs, ClosedTabItems, - AppSettingsTable, + RemoteSyncState, + ProcessedRemoteCommands, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 11; + int get schemaVersion => 12; @override MigrationStrategy get migration { @@ -232,6 +261,14 @@ class AppDatabase extends _$AppDatabase { if (from < 11) { 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 { ), ); } -} +} \ No newline at end of file diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 2ec32e9..86962a2 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -585,6 +585,18 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _versionMeta = const VerificationMeta( + 'version', + ); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); @override List get $columns => [ id, @@ -592,6 +604,7 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { status, openedAt, closedAt, + version, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -641,6 +654,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { closedAt.isAcceptableOrUnknown(data['closed_at']!, _closedAtMeta), ); } + if (data.containsKey('version')) { + context.handle( + _versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta), + ); + } return context; } @@ -670,6 +689,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { DriftSqlType.dateTime, data['${effectivePrefix}closed_at'], ), + version: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}version'], + )!, ); } @@ -685,12 +708,14 @@ class BarTabRow extends DataClass implements Insertable { final String status; final DateTime openedAt; final DateTime? closedAt; + final int version; const BarTabRow({ required this.id, required this.customerName, required this.status, required this.openedAt, this.closedAt, + required this.version, }); @override Map toColumns(bool nullToAbsent) { @@ -702,6 +727,7 @@ class BarTabRow extends DataClass implements Insertable { if (!nullToAbsent || closedAt != null) { map['closed_at'] = Variable(closedAt); } + map['version'] = Variable(version); return map; } @@ -714,6 +740,7 @@ class BarTabRow extends DataClass implements Insertable { closedAt: closedAt == null && nullToAbsent ? const Value.absent() : Value(closedAt), + version: Value(version), ); } @@ -728,6 +755,7 @@ class BarTabRow extends DataClass implements Insertable { status: serializer.fromJson(json['status']), openedAt: serializer.fromJson(json['openedAt']), closedAt: serializer.fromJson(json['closedAt']), + version: serializer.fromJson(json['version']), ); } @override @@ -739,6 +767,7 @@ class BarTabRow extends DataClass implements Insertable { 'status': serializer.toJson(status), 'openedAt': serializer.toJson(openedAt), 'closedAt': serializer.toJson(closedAt), + 'version': serializer.toJson(version), }; } @@ -748,12 +777,14 @@ class BarTabRow extends DataClass implements Insertable { String? status, DateTime? openedAt, Value closedAt = const Value.absent(), + int? version, }) => BarTabRow( id: id ?? this.id, customerName: customerName ?? this.customerName, status: status ?? this.status, openedAt: openedAt ?? this.openedAt, closedAt: closedAt.present ? closedAt.value : this.closedAt, + version: version ?? this.version, ); BarTabRow copyWithCompanion(BarTabsCompanion data) { return BarTabRow( @@ -764,6 +795,7 @@ class BarTabRow extends DataClass implements Insertable { status: data.status.present ? data.status.value : this.status, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, closedAt: data.closedAt.present ? data.closedAt.value : this.closedAt, + version: data.version.present ? data.version.value : this.version, ); } @@ -774,13 +806,15 @@ class BarTabRow extends DataClass implements Insertable { ..write('customerName: $customerName, ') ..write('status: $status, ') ..write('openedAt: $openedAt, ') - ..write('closedAt: $closedAt') + ..write('closedAt: $closedAt, ') + ..write('version: $version') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, customerName, status, openedAt, closedAt); + int get hashCode => + Object.hash(id, customerName, status, openedAt, closedAt, version); @override bool operator ==(Object other) => identical(this, other) || @@ -789,7 +823,8 @@ class BarTabRow extends DataClass implements Insertable { other.customerName == this.customerName && other.status == this.status && other.openedAt == this.openedAt && - other.closedAt == this.closedAt); + other.closedAt == this.closedAt && + other.version == this.version); } class BarTabsCompanion extends UpdateCompanion { @@ -798,6 +833,7 @@ class BarTabsCompanion extends UpdateCompanion { final Value status; final Value openedAt; final Value closedAt; + final Value version; final Value rowid; const BarTabsCompanion({ this.id = const Value.absent(), @@ -805,6 +841,7 @@ class BarTabsCompanion extends UpdateCompanion { this.status = const Value.absent(), this.openedAt = const Value.absent(), this.closedAt = const Value.absent(), + this.version = const Value.absent(), this.rowid = const Value.absent(), }); BarTabsCompanion.insert({ @@ -813,6 +850,7 @@ class BarTabsCompanion extends UpdateCompanion { this.status = const Value.absent(), required DateTime openedAt, this.closedAt = const Value.absent(), + this.version = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), customerName = Value(customerName), @@ -823,6 +861,7 @@ class BarTabsCompanion extends UpdateCompanion { Expression? status, Expression? openedAt, Expression? closedAt, + Expression? version, Expression? rowid, }) { return RawValuesInsertable({ @@ -831,6 +870,7 @@ class BarTabsCompanion extends UpdateCompanion { if (status != null) 'status': status, if (openedAt != null) 'opened_at': openedAt, if (closedAt != null) 'closed_at': closedAt, + if (version != null) 'version': version, if (rowid != null) 'rowid': rowid, }); } @@ -841,6 +881,7 @@ class BarTabsCompanion extends UpdateCompanion { Value? status, Value? openedAt, Value? closedAt, + Value? version, Value? rowid, }) { return BarTabsCompanion( @@ -849,6 +890,7 @@ class BarTabsCompanion extends UpdateCompanion { status: status ?? this.status, openedAt: openedAt ?? this.openedAt, closedAt: closedAt ?? this.closedAt, + version: version ?? this.version, rowid: rowid ?? this.rowid, ); } @@ -871,6 +913,9 @@ class BarTabsCompanion extends UpdateCompanion { if (closedAt.present) { map['closed_at'] = Variable(closedAt.value); } + if (version.present) { + map['version'] = Variable(version.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -885,6 +930,7 @@ class BarTabsCompanion extends UpdateCompanion { ..write('status: $status, ') ..write('openedAt: $openedAt, ') ..write('closedAt: $closedAt, ') + ..write('version: $version, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -3027,6 +3073,761 @@ class AppSettingsTableCompanion extends UpdateCompanion { } } +class $RemoteSyncStateTable extends RemoteSyncState + with TableInfo<$RemoteSyncStateTable, RemoteSyncStateRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RemoteSyncStateTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _deviceIdMeta = const VerificationMeta( + 'deviceId', + ); + @override + late final GeneratedColumn deviceId = GeneratedColumn( + 'device_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverCursorMeta = const VerificationMeta( + 'serverCursor', + ); + @override + late final GeneratedColumn serverCursor = GeneratedColumn( + 'server_cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSuccessfulSyncAtMeta = + const VerificationMeta('lastSuccessfulSyncAt'); + @override + late final GeneratedColumn lastSuccessfulSyncAt = + GeneratedColumn( + 'last_successful_sync_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastAttemptedSyncAtMeta = + const VerificationMeta('lastAttemptedSyncAt'); + @override + late final GeneratedColumn lastAttemptedSyncAt = + GeneratedColumn( + 'last_attempted_sync_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _consecutiveFailureCountMeta = + const VerificationMeta('consecutiveFailureCount'); + @override + late final GeneratedColumn consecutiveFailureCount = + GeneratedColumn( + 'consecutive_failure_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + List get $columns => [ + deviceId, + serverCursor, + lastSuccessfulSyncAt, + lastAttemptedSyncAt, + consecutiveFailureCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_sync_state'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('device_id')) { + context.handle( + _deviceIdMeta, + deviceId.isAcceptableOrUnknown(data['device_id']!, _deviceIdMeta), + ); + } else if (isInserting) { + context.missing(_deviceIdMeta); + } + if (data.containsKey('server_cursor')) { + context.handle( + _serverCursorMeta, + serverCursor.isAcceptableOrUnknown( + data['server_cursor']!, + _serverCursorMeta, + ), + ); + } + if (data.containsKey('last_successful_sync_at')) { + context.handle( + _lastSuccessfulSyncAtMeta, + lastSuccessfulSyncAt.isAcceptableOrUnknown( + data['last_successful_sync_at']!, + _lastSuccessfulSyncAtMeta, + ), + ); + } + if (data.containsKey('last_attempted_sync_at')) { + context.handle( + _lastAttemptedSyncAtMeta, + lastAttemptedSyncAt.isAcceptableOrUnknown( + data['last_attempted_sync_at']!, + _lastAttemptedSyncAtMeta, + ), + ); + } + if (data.containsKey('consecutive_failure_count')) { + context.handle( + _consecutiveFailureCountMeta, + consecutiveFailureCount.isAcceptableOrUnknown( + data['consecutive_failure_count']!, + _consecutiveFailureCountMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {deviceId}; + @override + RemoteSyncStateRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteSyncStateRow( + deviceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}device_id'], + )!, + serverCursor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_cursor'], + ), + lastSuccessfulSyncAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_successful_sync_at'], + ), + lastAttemptedSyncAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_attempted_sync_at'], + ), + consecutiveFailureCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}consecutive_failure_count'], + )!, + ); + } + + @override + $RemoteSyncStateTable createAlias(String alias) { + return $RemoteSyncStateTable(attachedDatabase, alias); + } +} + +class RemoteSyncStateRow extends DataClass + implements Insertable { + final String deviceId; + final String? serverCursor; + final DateTime? lastSuccessfulSyncAt; + final DateTime? lastAttemptedSyncAt; + final int consecutiveFailureCount; + const RemoteSyncStateRow({ + required this.deviceId, + this.serverCursor, + this.lastSuccessfulSyncAt, + this.lastAttemptedSyncAt, + required this.consecutiveFailureCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['device_id'] = Variable(deviceId); + if (!nullToAbsent || serverCursor != null) { + map['server_cursor'] = Variable(serverCursor); + } + if (!nullToAbsent || lastSuccessfulSyncAt != null) { + map['last_successful_sync_at'] = Variable(lastSuccessfulSyncAt); + } + if (!nullToAbsent || lastAttemptedSyncAt != null) { + map['last_attempted_sync_at'] = Variable(lastAttemptedSyncAt); + } + map['consecutive_failure_count'] = Variable(consecutiveFailureCount); + return map; + } + + RemoteSyncStateCompanion toCompanion(bool nullToAbsent) { + return RemoteSyncStateCompanion( + deviceId: Value(deviceId), + serverCursor: serverCursor == null && nullToAbsent + ? const Value.absent() + : Value(serverCursor), + lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent + ? const Value.absent() + : Value(lastSuccessfulSyncAt), + lastAttemptedSyncAt: lastAttemptedSyncAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAttemptedSyncAt), + consecutiveFailureCount: Value(consecutiveFailureCount), + ); + } + + factory RemoteSyncStateRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteSyncStateRow( + deviceId: serializer.fromJson(json['deviceId']), + serverCursor: serializer.fromJson(json['serverCursor']), + lastSuccessfulSyncAt: serializer.fromJson( + json['lastSuccessfulSyncAt'], + ), + lastAttemptedSyncAt: serializer.fromJson( + json['lastAttemptedSyncAt'], + ), + consecutiveFailureCount: serializer.fromJson( + json['consecutiveFailureCount'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'deviceId': serializer.toJson(deviceId), + 'serverCursor': serializer.toJson(serverCursor), + 'lastSuccessfulSyncAt': serializer.toJson( + lastSuccessfulSyncAt, + ), + 'lastAttemptedSyncAt': serializer.toJson(lastAttemptedSyncAt), + 'consecutiveFailureCount': serializer.toJson( + consecutiveFailureCount, + ), + }; + } + + RemoteSyncStateRow copyWith({ + String? deviceId, + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptedSyncAt = const Value.absent(), + int? consecutiveFailureCount, + }) => RemoteSyncStateRow( + deviceId: deviceId ?? this.deviceId, + serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt.present + ? lastSuccessfulSyncAt.value + : this.lastSuccessfulSyncAt, + lastAttemptedSyncAt: lastAttemptedSyncAt.present + ? lastAttemptedSyncAt.value + : this.lastAttemptedSyncAt, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + ); + RemoteSyncStateRow copyWithCompanion(RemoteSyncStateCompanion data) { + return RemoteSyncStateRow( + deviceId: data.deviceId.present ? data.deviceId.value : this.deviceId, + serverCursor: data.serverCursor.present + ? data.serverCursor.value + : this.serverCursor, + lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present + ? data.lastSuccessfulSyncAt.value + : this.lastSuccessfulSyncAt, + lastAttemptedSyncAt: data.lastAttemptedSyncAt.present + ? data.lastAttemptedSyncAt.value + : this.lastAttemptedSyncAt, + consecutiveFailureCount: data.consecutiveFailureCount.present + ? data.consecutiveFailureCount.value + : this.consecutiveFailureCount, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteSyncStateRow(') + ..write('deviceId: $deviceId, ') + ..write('serverCursor: $serverCursor, ') + ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') + ..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + deviceId, + serverCursor, + lastSuccessfulSyncAt, + lastAttemptedSyncAt, + consecutiveFailureCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteSyncStateRow && + other.deviceId == this.deviceId && + other.serverCursor == this.serverCursor && + other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt && + other.lastAttemptedSyncAt == this.lastAttemptedSyncAt && + other.consecutiveFailureCount == this.consecutiveFailureCount); +} + +class RemoteSyncStateCompanion extends UpdateCompanion { + final Value deviceId; + final Value serverCursor; + final Value lastSuccessfulSyncAt; + final Value lastAttemptedSyncAt; + final Value consecutiveFailureCount; + final Value rowid; + const RemoteSyncStateCompanion({ + this.deviceId = const Value.absent(), + this.serverCursor = const Value.absent(), + this.lastSuccessfulSyncAt = const Value.absent(), + this.lastAttemptedSyncAt = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + RemoteSyncStateCompanion.insert({ + required String deviceId, + this.serverCursor = const Value.absent(), + this.lastSuccessfulSyncAt = const Value.absent(), + this.lastAttemptedSyncAt = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.rowid = const Value.absent(), + }) : deviceId = Value(deviceId); + static Insertable custom({ + Expression? deviceId, + Expression? serverCursor, + Expression? lastSuccessfulSyncAt, + Expression? lastAttemptedSyncAt, + Expression? consecutiveFailureCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (deviceId != null) 'device_id': deviceId, + if (serverCursor != null) 'server_cursor': serverCursor, + if (lastSuccessfulSyncAt != null) + 'last_successful_sync_at': lastSuccessfulSyncAt, + if (lastAttemptedSyncAt != null) + 'last_attempted_sync_at': lastAttemptedSyncAt, + if (consecutiveFailureCount != null) + 'consecutive_failure_count': consecutiveFailureCount, + if (rowid != null) 'rowid': rowid, + }); + } + + RemoteSyncStateCompanion copyWith({ + Value? deviceId, + Value? serverCursor, + Value? lastSuccessfulSyncAt, + Value? lastAttemptedSyncAt, + Value? consecutiveFailureCount, + Value? rowid, + }) { + return RemoteSyncStateCompanion( + deviceId: deviceId ?? this.deviceId, + serverCursor: serverCursor ?? this.serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt, + lastAttemptedSyncAt: lastAttemptedSyncAt ?? this.lastAttemptedSyncAt, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (deviceId.present) { + map['device_id'] = Variable(deviceId.value); + } + if (serverCursor.present) { + map['server_cursor'] = Variable(serverCursor.value); + } + if (lastSuccessfulSyncAt.present) { + map['last_successful_sync_at'] = Variable( + lastSuccessfulSyncAt.value, + ); + } + if (lastAttemptedSyncAt.present) { + map['last_attempted_sync_at'] = Variable( + lastAttemptedSyncAt.value, + ); + } + if (consecutiveFailureCount.present) { + map['consecutive_failure_count'] = Variable( + consecutiveFailureCount.value, + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteSyncStateCompanion(') + ..write('deviceId: $deviceId, ') + ..write('serverCursor: $serverCursor, ') + ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') + ..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ProcessedRemoteCommandsTable extends ProcessedRemoteCommands + with TableInfo<$ProcessedRemoteCommandsTable, ProcessedRemoteCommandRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProcessedRemoteCommandsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _commandIdMeta = const VerificationMeta( + 'commandId', + ); + @override + late final GeneratedColumn commandId = GeneratedColumn( + 'command_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _commandTypeMeta = const VerificationMeta( + 'commandType', + ); + @override + late final GeneratedColumn commandType = GeneratedColumn( + 'command_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _processedAtMeta = const VerificationMeta( + 'processedAt', + ); + @override + late final GeneratedColumn processedAt = GeneratedColumn( + 'processed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _resultMeta = const VerificationMeta('result'); + @override + late final GeneratedColumn result = GeneratedColumn( + 'result', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + commandId, + commandType, + processedAt, + result, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'processed_remote_commands'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('command_id')) { + context.handle( + _commandIdMeta, + commandId.isAcceptableOrUnknown(data['command_id']!, _commandIdMeta), + ); + } else if (isInserting) { + context.missing(_commandIdMeta); + } + if (data.containsKey('command_type')) { + context.handle( + _commandTypeMeta, + commandType.isAcceptableOrUnknown( + data['command_type']!, + _commandTypeMeta, + ), + ); + } else if (isInserting) { + context.missing(_commandTypeMeta); + } + if (data.containsKey('processed_at')) { + context.handle( + _processedAtMeta, + processedAt.isAcceptableOrUnknown( + data['processed_at']!, + _processedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_processedAtMeta); + } + if (data.containsKey('result')) { + context.handle( + _resultMeta, + result.isAcceptableOrUnknown(data['result']!, _resultMeta), + ); + } else if (isInserting) { + context.missing(_resultMeta); + } + return context; + } + + @override + Set get $primaryKey => {commandId}; + @override + ProcessedRemoteCommandRow map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProcessedRemoteCommandRow( + commandId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}command_id'], + )!, + commandType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}command_type'], + )!, + processedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}processed_at'], + )!, + result: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}result'], + )!, + ); + } + + @override + $ProcessedRemoteCommandsTable createAlias(String alias) { + return $ProcessedRemoteCommandsTable(attachedDatabase, alias); + } +} + +class ProcessedRemoteCommandRow extends DataClass + implements Insertable { + final String commandId; + final String commandType; + final DateTime processedAt; + final String result; + const ProcessedRemoteCommandRow({ + required this.commandId, + required this.commandType, + required this.processedAt, + required this.result, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['command_id'] = Variable(commandId); + map['command_type'] = Variable(commandType); + map['processed_at'] = Variable(processedAt); + map['result'] = Variable(result); + return map; + } + + ProcessedRemoteCommandsCompanion toCompanion(bool nullToAbsent) { + return ProcessedRemoteCommandsCompanion( + commandId: Value(commandId), + commandType: Value(commandType), + processedAt: Value(processedAt), + result: Value(result), + ); + } + + factory ProcessedRemoteCommandRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProcessedRemoteCommandRow( + commandId: serializer.fromJson(json['commandId']), + commandType: serializer.fromJson(json['commandType']), + processedAt: serializer.fromJson(json['processedAt']), + result: serializer.fromJson(json['result']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'commandId': serializer.toJson(commandId), + 'commandType': serializer.toJson(commandType), + 'processedAt': serializer.toJson(processedAt), + 'result': serializer.toJson(result), + }; + } + + ProcessedRemoteCommandRow copyWith({ + String? commandId, + String? commandType, + DateTime? processedAt, + String? result, + }) => ProcessedRemoteCommandRow( + commandId: commandId ?? this.commandId, + commandType: commandType ?? this.commandType, + processedAt: processedAt ?? this.processedAt, + result: result ?? this.result, + ); + ProcessedRemoteCommandRow copyWithCompanion( + ProcessedRemoteCommandsCompanion data, + ) { + return ProcessedRemoteCommandRow( + commandId: data.commandId.present ? data.commandId.value : this.commandId, + commandType: data.commandType.present + ? data.commandType.value + : this.commandType, + processedAt: data.processedAt.present + ? data.processedAt.value + : this.processedAt, + result: data.result.present ? data.result.value : this.result, + ); + } + + @override + String toString() { + return (StringBuffer('ProcessedRemoteCommandRow(') + ..write('commandId: $commandId, ') + ..write('commandType: $commandType, ') + ..write('processedAt: $processedAt, ') + ..write('result: $result') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(commandId, commandType, processedAt, result); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProcessedRemoteCommandRow && + other.commandId == this.commandId && + other.commandType == this.commandType && + other.processedAt == this.processedAt && + other.result == this.result); +} + +class ProcessedRemoteCommandsCompanion + extends UpdateCompanion { + final Value commandId; + final Value commandType; + final Value processedAt; + final Value result; + final Value rowid; + const ProcessedRemoteCommandsCompanion({ + this.commandId = const Value.absent(), + this.commandType = const Value.absent(), + this.processedAt = const Value.absent(), + this.result = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProcessedRemoteCommandsCompanion.insert({ + required String commandId, + required String commandType, + required DateTime processedAt, + required String result, + this.rowid = const Value.absent(), + }) : commandId = Value(commandId), + commandType = Value(commandType), + processedAt = Value(processedAt), + result = Value(result); + static Insertable custom({ + Expression? commandId, + Expression? commandType, + Expression? processedAt, + Expression? result, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (commandId != null) 'command_id': commandId, + if (commandType != null) 'command_type': commandType, + if (processedAt != null) 'processed_at': processedAt, + if (result != null) 'result': result, + if (rowid != null) 'rowid': rowid, + }); + } + + ProcessedRemoteCommandsCompanion copyWith({ + Value? commandId, + Value? commandType, + Value? processedAt, + Value? result, + Value? rowid, + }) { + return ProcessedRemoteCommandsCompanion( + commandId: commandId ?? this.commandId, + commandType: commandType ?? this.commandType, + processedAt: processedAt ?? this.processedAt, + result: result ?? this.result, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (commandId.present) { + map['command_id'] = Variable(commandId.value); + } + if (commandType.present) { + map['command_type'] = Variable(commandType.value); + } + if (processedAt.present) { + map['processed_at'] = Variable(processedAt.value); + } + if (result.present) { + map['result'] = Variable(result.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProcessedRemoteCommandsCompanion(') + ..write('commandId: $commandId, ') + ..write('commandType: $commandType, ') + ..write('processedAt: $processedAt, ') + ..write('result: $result, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -3041,6 +3842,11 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $AppSettingsTableTable appSettingsTable = $AppSettingsTableTable( this, ); + late final $RemoteSyncStateTable remoteSyncState = $RemoteSyncStateTable( + this, + ); + late final $ProcessedRemoteCommandsTable processedRemoteCommands = + $ProcessedRemoteCommandsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3053,6 +3859,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { closedTabs, closedTabItems, appSettingsTable, + remoteSyncState, + processedRemoteCommands, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -3440,6 +4248,7 @@ typedef $$BarTabsTableCreateCompanionBuilder = Value status, required DateTime openedAt, Value closedAt, + Value version, Value rowid, }); typedef $$BarTabsTableUpdateCompanionBuilder = @@ -3449,6 +4258,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder = Value status, Value openedAt, Value closedAt, + Value version, Value rowid, }); @@ -3509,6 +4319,11 @@ class $$BarTabsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnFilters(column), + ); + Expression tabItemsRefs( Expression Function($$TabItemsTableFilterComposer f) f, ) { @@ -3568,6 +4383,11 @@ class $$BarTabsTableOrderingComposer column: $table.closedAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnOrderings(column), + ); } class $$BarTabsTableAnnotationComposer @@ -3596,6 +4416,9 @@ class $$BarTabsTableAnnotationComposer GeneratedColumn get closedAt => $composableBuilder(column: $table.closedAt, builder: (column) => column); + GeneratedColumn get version => + $composableBuilder(column: $table.version, builder: (column) => column); + Expression tabItemsRefs( Expression Function($$TabItemsTableAnnotationComposer a) f, ) { @@ -3655,6 +4478,7 @@ class $$BarTabsTableTableManager Value status = const Value.absent(), Value openedAt = const Value.absent(), Value closedAt = const Value.absent(), + Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion( id: id, @@ -3662,6 +4486,7 @@ class $$BarTabsTableTableManager status: status, openedAt: openedAt, closedAt: closedAt, + version: version, rowid: rowid, ), createCompanionCallback: @@ -3671,6 +4496,7 @@ class $$BarTabsTableTableManager Value status = const Value.absent(), required DateTime openedAt, Value closedAt = const Value.absent(), + Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion.insert( id: id, @@ -3678,6 +4504,7 @@ class $$BarTabsTableTableManager status: status, openedAt: openedAt, closedAt: closedAt, + version: version, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -5305,6 +6132,428 @@ typedef $$AppSettingsTableTableProcessedTableManager = AppSettingsRow, PrefetchHooks Function() >; +typedef $$RemoteSyncStateTableCreateCompanionBuilder = + RemoteSyncStateCompanion Function({ + required String deviceId, + Value serverCursor, + Value lastSuccessfulSyncAt, + Value lastAttemptedSyncAt, + Value consecutiveFailureCount, + Value rowid, + }); +typedef $$RemoteSyncStateTableUpdateCompanionBuilder = + RemoteSyncStateCompanion Function({ + Value deviceId, + Value serverCursor, + Value lastSuccessfulSyncAt, + Value lastAttemptedSyncAt, + Value consecutiveFailureCount, + Value rowid, + }); + +class $$RemoteSyncStateTableFilterComposer + extends Composer<_$AppDatabase, $RemoteSyncStateTable> { + $$RemoteSyncStateTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get deviceId => $composableBuilder( + column: $table.deviceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAttemptedSyncAt => $composableBuilder( + column: $table.lastAttemptedSyncAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => ColumnFilters(column), + ); +} + +class $$RemoteSyncStateTableOrderingComposer + extends Composer<_$AppDatabase, $RemoteSyncStateTable> { + $$RemoteSyncStateTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get deviceId => $composableBuilder( + column: $table.deviceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAttemptedSyncAt => $composableBuilder( + column: $table.lastAttemptedSyncAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RemoteSyncStateTableAnnotationComposer + extends Composer<_$AppDatabase, $RemoteSyncStateTable> { + $$RemoteSyncStateTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get deviceId => + $composableBuilder(column: $table.deviceId, builder: (column) => column); + + GeneratedColumn get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => column, + ); + + GeneratedColumn get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => column, + ); + + GeneratedColumn get lastAttemptedSyncAt => $composableBuilder( + column: $table.lastAttemptedSyncAt, + builder: (column) => column, + ); + + GeneratedColumn get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => column, + ); +} + +class $$RemoteSyncStateTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RemoteSyncStateTable, + RemoteSyncStateRow, + $$RemoteSyncStateTableFilterComposer, + $$RemoteSyncStateTableOrderingComposer, + $$RemoteSyncStateTableAnnotationComposer, + $$RemoteSyncStateTableCreateCompanionBuilder, + $$RemoteSyncStateTableUpdateCompanionBuilder, + ( + RemoteSyncStateRow, + BaseReferences< + _$AppDatabase, + $RemoteSyncStateTable, + RemoteSyncStateRow + >, + ), + RemoteSyncStateRow, + PrefetchHooks Function() + > { + $$RemoteSyncStateTableTableManager( + _$AppDatabase db, + $RemoteSyncStateTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RemoteSyncStateTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$RemoteSyncStateTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$RemoteSyncStateTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value deviceId = const Value.absent(), + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptedSyncAt = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value rowid = const Value.absent(), + }) => RemoteSyncStateCompanion( + deviceId: deviceId, + serverCursor: serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt, + lastAttemptedSyncAt: lastAttemptedSyncAt, + consecutiveFailureCount: consecutiveFailureCount, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String deviceId, + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptedSyncAt = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value rowid = const Value.absent(), + }) => RemoteSyncStateCompanion.insert( + deviceId: deviceId, + serverCursor: serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt, + lastAttemptedSyncAt: lastAttemptedSyncAt, + consecutiveFailureCount: consecutiveFailureCount, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$RemoteSyncStateTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RemoteSyncStateTable, + RemoteSyncStateRow, + $$RemoteSyncStateTableFilterComposer, + $$RemoteSyncStateTableOrderingComposer, + $$RemoteSyncStateTableAnnotationComposer, + $$RemoteSyncStateTableCreateCompanionBuilder, + $$RemoteSyncStateTableUpdateCompanionBuilder, + ( + RemoteSyncStateRow, + BaseReferences< + _$AppDatabase, + $RemoteSyncStateTable, + RemoteSyncStateRow + >, + ), + RemoteSyncStateRow, + PrefetchHooks Function() + >; +typedef $$ProcessedRemoteCommandsTableCreateCompanionBuilder = + ProcessedRemoteCommandsCompanion Function({ + required String commandId, + required String commandType, + required DateTime processedAt, + required String result, + Value rowid, + }); +typedef $$ProcessedRemoteCommandsTableUpdateCompanionBuilder = + ProcessedRemoteCommandsCompanion Function({ + Value commandId, + Value commandType, + Value processedAt, + Value result, + Value rowid, + }); + +class $$ProcessedRemoteCommandsTableFilterComposer + extends Composer<_$AppDatabase, $ProcessedRemoteCommandsTable> { + $$ProcessedRemoteCommandsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get commandId => $composableBuilder( + column: $table.commandId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get commandType => $composableBuilder( + column: $table.commandType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get processedAt => $composableBuilder( + column: $table.processedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get result => $composableBuilder( + column: $table.result, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ProcessedRemoteCommandsTableOrderingComposer + extends Composer<_$AppDatabase, $ProcessedRemoteCommandsTable> { + $$ProcessedRemoteCommandsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get commandId => $composableBuilder( + column: $table.commandId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get commandType => $composableBuilder( + column: $table.commandType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get processedAt => $composableBuilder( + column: $table.processedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get result => $composableBuilder( + column: $table.result, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProcessedRemoteCommandsTableAnnotationComposer + extends Composer<_$AppDatabase, $ProcessedRemoteCommandsTable> { + $$ProcessedRemoteCommandsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get commandId => + $composableBuilder(column: $table.commandId, builder: (column) => column); + + GeneratedColumn get commandType => $composableBuilder( + column: $table.commandType, + builder: (column) => column, + ); + + GeneratedColumn get processedAt => $composableBuilder( + column: $table.processedAt, + builder: (column) => column, + ); + + GeneratedColumn get result => + $composableBuilder(column: $table.result, builder: (column) => column); +} + +class $$ProcessedRemoteCommandsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ProcessedRemoteCommandsTable, + ProcessedRemoteCommandRow, + $$ProcessedRemoteCommandsTableFilterComposer, + $$ProcessedRemoteCommandsTableOrderingComposer, + $$ProcessedRemoteCommandsTableAnnotationComposer, + $$ProcessedRemoteCommandsTableCreateCompanionBuilder, + $$ProcessedRemoteCommandsTableUpdateCompanionBuilder, + ( + ProcessedRemoteCommandRow, + BaseReferences< + _$AppDatabase, + $ProcessedRemoteCommandsTable, + ProcessedRemoteCommandRow + >, + ), + ProcessedRemoteCommandRow, + PrefetchHooks Function() + > { + $$ProcessedRemoteCommandsTableTableManager( + _$AppDatabase db, + $ProcessedRemoteCommandsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProcessedRemoteCommandsTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$ProcessedRemoteCommandsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ProcessedRemoteCommandsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value commandId = const Value.absent(), + Value commandType = const Value.absent(), + Value processedAt = const Value.absent(), + Value result = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProcessedRemoteCommandsCompanion( + commandId: commandId, + commandType: commandType, + processedAt: processedAt, + result: result, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String commandId, + required String commandType, + required DateTime processedAt, + required String result, + Value rowid = const Value.absent(), + }) => ProcessedRemoteCommandsCompanion.insert( + commandId: commandId, + commandType: commandType, + processedAt: processedAt, + result: result, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ProcessedRemoteCommandsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ProcessedRemoteCommandsTable, + ProcessedRemoteCommandRow, + $$ProcessedRemoteCommandsTableFilterComposer, + $$ProcessedRemoteCommandsTableOrderingComposer, + $$ProcessedRemoteCommandsTableAnnotationComposer, + $$ProcessedRemoteCommandsTableCreateCompanionBuilder, + $$ProcessedRemoteCommandsTableUpdateCompanionBuilder, + ( + ProcessedRemoteCommandRow, + BaseReferences< + _$AppDatabase, + $ProcessedRemoteCommandsTable, + ProcessedRemoteCommandRow + >, + ), + ProcessedRemoteCommandRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -5323,4 +6572,11 @@ class $AppDatabaseManager { $$ClosedTabItemsTableTableManager(_db, _db.closedTabItems); $$AppSettingsTableTableTableManager get appSettingsTable => $$AppSettingsTableTableTableManager(_db, _db.appSettingsTable); + $$RemoteSyncStateTableTableManager get remoteSyncState => + $$RemoteSyncStateTableTableManager(_db, _db.remoteSyncState); + $$ProcessedRemoteCommandsTableTableManager get processedRemoteCommands => + $$ProcessedRemoteCommandsTableTableManager( + _db, + _db.processedRemoteCommands, + ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ed22793..bb6b247 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -264,5 +264,15 @@ "@devActionResetStock": {"placeholders": {"count": {"type": "int"}}}, "paymentCash": "Cash", "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"}}} } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5e6cda0..5b4d460 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1580,11 +1580,59 @@ abstract class AppLocalizations { /// **'Payconiq'** String get paymentPayconiq; + /// No description provided for @paymentPending. + /// + /// In en, this message translates to: + /// **'Leave unpaid'** + String get paymentPending; + /// No description provided for @simulatedUpdateNotes. /// /// In en, this message translates to: /// **'Bug fixes and performance improvements.'** 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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 87b8aa8..30b1b17 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -808,6 +808,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get paymentPayconiq => 'Payconiq'; + @override + String get paymentPending => 'Leave unpaid'; + @override 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'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 0e1eefb..ecb2463 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -819,6 +819,34 @@ class AppLocalizationsNl extends AppLocalizations { @override String get paymentPayconiq => 'Payconiq'; + @override + String get paymentPending => 'Onbetaald laten'; + @override 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'; + } } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d3e4bfe..611c888 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -264,5 +264,15 @@ "@devActionResetStock": {"placeholders": {"count": {"type": "int"}}}, "paymentCash": "Contant", "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"}}} } diff --git a/lib/main.dart b/lib/main.dart index 617122d..364ef3f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,11 +4,13 @@ import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.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/viewmodels/dev_menu_view_model.dart'; import 'package:kooltab2/viewmodels/history_view_model.dart'; import 'package:kooltab2/viewmodels/inventory_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:provider/provider.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/admin_pin_view_model.dart'; import 'viewmodels/product_list_view_model.dart'; +import 'viewmodels/remote_sync_view_model.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -48,9 +51,29 @@ Future main() async { ); await adminPinViewModel.ensureLoaded(); + final remoteSyncService = RemoteSyncService( + database: database, + barTabService: DriftBarTabService(database: database), + ); + + final remoteSyncViewModel = RemoteSyncViewModel( + syncService: remoteSyncService, + ); + + final isConfiguredNotifier = ValueNotifier(await remoteSyncService.isConfigured); + + remoteSyncViewModel.addListener(() { + isConfiguredNotifier.value = remoteSyncViewModel.isConfigured; + }); + + final registrationViewModel = RegistrationViewModel( + syncService: remoteSyncService, + ); + final appRouter = createAppRouter( pinLockViewModel, adminPinViewModel: adminPinViewModel, + isConfiguredNotifier: isConfiguredNotifier, ); await SentryFlutter.init( @@ -83,6 +106,13 @@ Future main() async { create: (context) => DriftSettingsService(database: context.read()), ), + Provider.value(value: remoteSyncService), + ChangeNotifierProvider.value( + value: remoteSyncViewModel, + ), + ChangeNotifierProvider.value( + value: registrationViewModel, + ), ChangeNotifierProvider( create: (context) => InventoryViewModel( productService: context.read(), @@ -98,6 +128,7 @@ Future main() async { create: (context) => BarScreenViewModel( barTabService: context.read(), inventory: context.read(), + remoteSyncViewModel: context.read(), ), ), ChangeNotifierProvider( diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart index 7350b62..544784b 100644 --- a/lib/models/bar_tab.dart +++ b/lib/models/bar_tab.dart @@ -7,6 +7,7 @@ class BarTab { final String status; final DateTime openedAt; final DateTime? closedAt; + final int version; final List items; const BarTab({ @@ -14,6 +15,7 @@ class BarTab { required this.customerName, required this.status, required this.openedAt, + this.version = 0, required this.items, this.closedAt, }); @@ -41,6 +43,7 @@ class BarTab { status == other.status && openedAt == other.openedAt && closedAt == other.closedAt && + version == other.version && listEquals(items, other.items); @override @@ -50,6 +53,7 @@ class BarTab { status, openedAt, closedAt, + version, Object.hashAll(items), ); } diff --git a/lib/models/payment_method.dart b/lib/models/payment_method.dart index 9015387..660d6bc 100644 --- a/lib/models/payment_method.dart +++ b/lib/models/payment_method.dart @@ -1,6 +1,7 @@ enum PaymentMethod { 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 label; diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart new file mode 100644 index 0000000..df8da26 --- /dev/null +++ b/lib/models/remote_command.dart @@ -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 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 commands; + final String? cursor; + final List plannedTabNames; + + SyncResponse({ + required this.commands, + this.cursor, + this.plannedTabNames = const [], + }); + + factory SyncResponse.fromJson(Map json) { + final commandsList = (json['commands'] as List?) + ?.map((e) => RemoteCommand.fromJson(e as Map)) + .toList() ?? + []; + return SyncResponse( + commands: commandsList, + cursor: json['cursor'] as String?, + plannedTabNames: (json['plannedTabNames'] as List?) + ?.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 toJson() => { + 'id': id, + 'customerName': customerName, + 'version': version, + 'totalInCents': totalInCents, + 'itemCount': itemCount, + }; +} + +class SyncRequest { + final String deviceId; + final String? cursor; + final List openTabs; + + const SyncRequest({ + required this.deviceId, + this.cursor, + required this.openTabs, + }); + + Map 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 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, + }); +} \ No newline at end of file diff --git a/lib/models/venue.dart b/lib/models/venue.dart new file mode 100644 index 0000000..bfdc2dd --- /dev/null +++ b/lib/models/venue.dart @@ -0,0 +1,11 @@ +class Venue { + final String id; + final String name; + + const Venue({required this.id, required this.name}); + + factory Venue.fromJson(Map json) => Venue( + id: json['id'] as String, + name: json['name'] as String, + ); +} \ No newline at end of file diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index d7ca62f..377d836 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -87,6 +87,7 @@ class DriftBarTabService implements BarTabService { status: row.status, openedAt: row.openedAt, closedAt: row.closedAt, + version: row.version, items: items, ); } @@ -292,6 +293,7 @@ class DriftBarTabService implements BarTabService { @override Future createTab({required String customerName}) async { final id = _uuid.v4(); + final now = DateTime.now(); await database .into(database.barTabs) @@ -300,7 +302,8 @@ class DriftBarTabService implements BarTabService { id: id, customerName: customerName, status: const Value('open'), - openedAt: DateTime.now(), + openedAt: now, + version: const Value(1), ), ); @@ -335,6 +338,8 @@ class DriftBarTabService implements BarTabService { if (updatedRows != 1) { throw StateError('Tab not found.'); } + + await _incrementTabVersion(tabId); } @override @@ -368,6 +373,14 @@ class DriftBarTabService implements BarTabService { }); } + Future _incrementTabVersion(String tabId) async { + await database.customUpdate( + 'UPDATE bar_tabs SET version = version + 1 WHERE id = ?', + variables: [Variable.withString(tabId)], + updates: {database.barTabs}, + ); + } + @override Future addProductToTab({ required String tabId, @@ -414,6 +427,8 @@ class DriftBarTabService implements BarTabService { await _recordPurchase(tabItemId: tabItemId); }); + + await _incrementTabVersion(tabId); } @override @@ -421,7 +436,9 @@ class DriftBarTabService implements BarTabService { required String tabItemId, required int delta, }) async { - return database.transaction(() async { + String? tabId; + + final actualDelta = await database.transaction(() async { final item = await (database.select( database.tabItems, )..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); @@ -430,6 +447,8 @@ class DriftBarTabService implements BarTabService { return 0; } + tabId = item.tabId; + final requestedQuantity = item.quantity + delta; final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; @@ -483,6 +502,12 @@ class DriftBarTabService implements BarTabService { return actualDelta; }); + + if (tabId != null && actualDelta != 0) { + await _incrementTabVersion(tabId!); + } + + return actualDelta; } @override @@ -544,6 +569,8 @@ class DriftBarTabService implements BarTabService { await deleteQuery.go(); }); + + await _incrementTabVersion(tabId); } @override diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart new file mode 100644 index 0000000..fc4ec3d --- /dev/null +++ b/lib/services/remote_sync_service.dart @@ -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.broadcast(); + + Stream get events => _eventController.stream; + + bool _isSyncing = false; + bool get isSyncing => _isSyncing; + + Timer? _syncTimer; + int _consecutiveFailures = 0; + bool _paused = false; + + Future get deviceId async => + await secureStorage.read(key: _deviceIdKey); + + Future get authToken async => + await secureStorage.read(key: _authTokenKey); + + Future get venueId async => + await secureStorage.read(key: _venueIdKey); + + Future 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 _onTick() async { + if (_isSyncing || _paused) return; + await sync(); + } + + Future 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 _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> _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 _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?> _httpPost( + String url, { + required Map 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; + } + + Future _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 _getSyncState() async { + final query = database.select(database.remoteSyncState) + ..where((row) => row.deviceId.equals('default')); + return query.getSingleOrNull(); + } + + Future _saveCursor(String cursor) async { + await database + .into(database.remoteSyncState) + .insertOnConflictUpdate( + RemoteSyncStateCompanion( + deviceId: const Value('default'), + serverCursor: Value(cursor), + ), + ); + } + + Future _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 _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 _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 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 clearCredentials() async { + await secureStorage.delete(key: _deviceIdKey); + await secureStorage.delete(key: _authTokenKey); + await secureStorage.delete(key: _serverUrlKey); + await secureStorage.delete(key: _venueIdKey); + } + + Future get lastSuccessfulSyncAt async { + final state = await _getSyncState(); + return state?.lastSuccessfulSyncAt; + } + + Future get lastFailureCount async { + final state = await _getSyncState(); + return state?.consecutiveFailureCount ?? 0; + } + + Future> 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(); + } +} \ No newline at end of file diff --git a/lib/utils/app_config.dart b/lib/utils/app_config.dart index 4f4d80b..bc427a7 100644 --- a/lib/utils/app_config.dart +++ b/lib/utils/app_config.dart @@ -1,2 +1,6 @@ const kUpdateServerUrl = 'https://updater.brammie15.dev'; -const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup'; \ No newline at end of file +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; \ No newline at end of file diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index c35416c..b9f9976 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -5,15 +7,29 @@ import '../models/payment_method.dart'; import '../models/bar_tab.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import '../models/remote_command.dart'; import '../services/bar_tab_service.dart'; import '../services/product_service.dart'; import 'inventory_view_model.dart'; +import 'remote_sync_view_model.dart'; class BarScreenViewModel extends ChangeNotifier { final BarTabService barTabService; 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? _eventSubscription; + + RemoteSyncEvent? _lastRemoteEvent; + RemoteSyncEvent? get lastRemoteEvent => _lastRemoteEvent; List _tabs = []; 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 ensureLoaded() async { if (_hasLoaded || _isLoading) return; @@ -72,6 +106,11 @@ class BarScreenViewModel extends ChangeNotifier { } } + Future loadAndSync() async { + await load(); + await remoteSyncViewModel.syncNow(); + } + void selectTab(String tabId) { _selectedTabId = tabId; notifyListeners(); diff --git a/lib/viewmodels/registration_view_model.dart b/lib/viewmodels/registration_view_model.dart new file mode 100644 index 0000000..7e7c65e --- /dev/null +++ b/lib/viewmodels/registration_view_model.dart @@ -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 _venues = []; + List get venues => _venues; + + Venue? _selectedVenue; + Venue? get selectedVenue => _selectedVenue; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + void selectVenue(Venue venue) { + _selectedVenue = venue; + notifyListeners(); + } + + Future 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 data = jsonDecode(response.body) as List; + _venues = data + .map((e) => Venue.fromJson(e as Map)) + .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 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; + 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; + 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 retry() async { + await fetchVenues(); + } + + void reset() { + _step = RegistrationStep.initial; + _venues = []; + _selectedVenue = null; + _errorMessage = null; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart new file mode 100644 index 0000000..d54f0ac --- /dev/null +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -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? _eventSubscription; + + List _recentEvents = []; + + List 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 _activity = []; + List get activity => _activity; + + bool _isRegistering = false; + bool get isRegistering => _isRegistering; + + String? _registrationError; + String? get registrationError => _registrationError; + + Future init() async { + _isConfigured = await syncService.isConfigured; + _venueId = await syncService.venueId; + _lastSyncAt = await syncService.lastSuccessfulSyncAt; + await loadActivity(); + if (_isConfigured) { + syncService.startPeriodicSync(); + } + notifyListeners(); + } + + Future refreshStatus() async { + _isConfigured = await syncService.isConfigured; + _venueId = await syncService.venueId; + notifyListeners(); + } + + Future syncNow() async { + _isSyncing = true; + _lastError = null; + notifyListeners(); + + await syncService.sync(); + + _isSyncing = syncService.isSyncing; + _lastSyncAt = await syncService.lastSuccessfulSyncAt; + await loadActivity(); + notifyListeners(); + } + + Future loadActivity() async { + try { + _activity = await syncService.getRecentActivity(); + } catch (e) { + debugPrint('RemoteSyncViewModel: loadActivity error: $e'); + } + } + + Future 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; + 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; + 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 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(); + } +} \ No newline at end of file diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index 4561751..a3ce553 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; +import '../viewmodels/remote_sync_view_model.dart'; class AdminScreenView extends StatelessWidget { const AdminScreenView({super.key}); @@ -15,9 +16,39 @@ class AdminScreenView extends StatelessWidget { adminPinViewModel.lock(); } + Future _confirmUnlink(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + 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().unlink(); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final remoteSync = context.watch(); return PopScope( 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 String title; final String? subtitle; + final Widget? trailing; final VoidCallback? onTap; const _AdminTile({ required this.icon, required this.title, this.subtitle, + this.trailing, required this.onTap, }); @@ -169,6 +277,10 @@ class _AdminTile extends StatelessWidget { ], ), ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], if (onTap != null) Icon( Icons.chevron_right_rounded, @@ -180,4 +292,4 @@ class _AdminTile extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 195a9c9..75f0c9d 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -15,6 +15,7 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../models/bar_tab.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import '../models/remote_command.dart'; import '../services/product_service.dart'; import '../utils/app_updater.dart'; import '../viewmodels/bar_screen_view_model.dart'; @@ -38,6 +39,43 @@ class _BarScreenViewState extends State { }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkRemoteEvent(); + } + + @override + void didUpdateWidget(covariant BarScreenView oldWidget) { + super.didUpdateWidget(oldWidget); + _checkRemoteEvent(); + } + + void _checkRemoteEvent() { + final viewModel = context.read(); + 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 Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -83,7 +121,7 @@ class _BarScreenViewState extends State { const SizedBox(width: 6), IconButton( tooltip: l10n.refresh, - onPressed: () => viewModel.load(), + onPressed: () => viewModel.loadAndSync(), icon: const Icon(Icons.refresh_rounded), ), const SizedBox(width: 6), diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart index dab4a9d..5ff075e 100644 --- a/lib/views/dialogs/close_tab_dialog.dart +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -83,7 +83,11 @@ class _PaymentPicker extends StatelessWidget { 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 Widget build(BuildContext context) { @@ -93,7 +97,11 @@ class _PaymentPicker extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: _options.map((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; return Padding( @@ -111,7 +119,7 @@ class _PaymentPicker extends StatelessWidget { size: 16, color: Colors.lightGreen, ) - else + else if (value == PaymentMethod.payconiq) SvgPicture.asset( 'assets/icons/payconic.svg', width: 16, @@ -120,6 +128,12 @@ class _PaymentPicker extends StatelessWidget { Colors.pinkAccent, BlendMode.srcIn, ), + ) + else + const Icon( + Icons.hourglass_empty, + size: 16, + color: Colors.orange, ), const SizedBox(width: 6), Text(label), diff --git a/lib/views/registration_view.dart b/lib/views/registration_view.dart new file mode 100644 index 0000000..4f2e7da --- /dev/null +++ b/lib/views/registration_view.dart @@ -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 createState() => _RegistrationViewState(); +} + +class _RegistrationViewState extends State { + final _deviceNameController = TextEditingController(text: 'Bar terminal 1'); + final _regCodeController = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchVenues(); + }); + } + + @override + void dispose() { + _deviceNameController.dispose(); + _regCodeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + 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().startSync(); + context.go('/bar'); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('Start using KoolTab'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index 05481cf..b0b3335 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -23,6 +23,7 @@ class _ClosedTabCardState extends State { return switch (method) { PaymentMethod.payconiq => Icons.qr_code_rounded, PaymentMethod.cash => Icons.money_rounded, + PaymentMethod.paymentPending => Icons.hourglass_empty, }; } @@ -102,6 +103,7 @@ class _ClosedTabCardState extends State { switch (closedTab.paymentMethod) { PaymentMethod.cash => l10n.cash, PaymentMethod.payconiq => l10n.payconiq, + PaymentMethod.paymentPending => l10n.paymentPending, }, style: TextStyle( fontSize: 11, diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index 4f2ec1b..7a24a9d 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -3,27 +3,36 @@ import 'package:kooltab2/models/product.dart'; import 'package:kooltab2/models/tab_item.dart'; import 'package:kooltab2/services/bar_tab_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/inventory_view_model.dart'; +import 'package:kooltab2/viewmodels/remote_sync_view_model.dart'; import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} class MockProductService extends Mock implements ProductService {} +class MockRemoteSyncService extends Mock implements RemoteSyncService {} + void main() { late MockBarTabService barTabService; late MockProductService productService; late InventoryViewModel inventory; late BarScreenViewModel viewModel; + late RemoteSyncViewModel remoteSyncViewModel; setUp(() { barTabService = MockBarTabService(); productService = MockProductService(); inventory = InventoryViewModel(productService: productService); + remoteSyncViewModel = RemoteSyncViewModel( + syncService: MockRemoteSyncService(), + ); viewModel = BarScreenViewModel( barTabService: barTabService, inventory: inventory, + remoteSyncViewModel: remoteSyncViewModel, ); }); diff --git a/test/edit_tab_dialog_test.dart b/test/edit_tab_dialog_test.dart index f45b6d3..f8d6275 100644 --- a/test/edit_tab_dialog_test.dart +++ b/test/edit_tab_dialog_test.dart @@ -4,8 +4,10 @@ import 'package:kooltab2/l10n/app_localizations.dart'; import 'package:kooltab2/models/bar_tab.dart'; import 'package:kooltab2/services/bar_tab_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/inventory_view_model.dart'; +import 'package:kooltab2/viewmodels/remote_sync_view_model.dart'; import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart'; import 'package:mocktail/mocktail.dart'; import 'package:provider/provider.dart'; @@ -14,15 +16,21 @@ class MockBarTabService extends Mock implements BarTabService {} class MockProductService extends Mock implements ProductService {} +class MockRemoteSyncService extends Mock implements RemoteSyncService {} + void main() { testWidgets('edit dialog disposes its controller after closing', ( tester, ) async { final barTabService = MockBarTabService(); final productService = MockProductService(); + final remoteSyncViewModel = RemoteSyncViewModel( + syncService: MockRemoteSyncService(), + ); final viewModel = BarScreenViewModel( barTabService: barTabService, inventory: InventoryViewModel(productService: productService), + remoteSyncViewModel: remoteSyncViewModel, ); final tab = BarTab( id: 'tab-1',