feat: add remote pull and push

This commit is contained in:
2026-09-23 00:19:58 +02:00
parent 9aaba69e3c
commit ccf5165417
29 changed files with 3042 additions and 24 deletions
+7
View File
@@ -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<KoolTabApp> {
context.read<PinLockViewModel>().lock();
context.read<AdminPinViewModel>().lock();
},
onBackground: () {
context.read<RemoteSyncViewModel>().pause();
},
onForeground: () {
context.read<RemoteSyncViewModel>().resume();
},
shouldLock: () =>
context.read<SettingsViewModel>().settings.autoLockEnabled,
);
+4 -1
View File
@@ -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<AppBootstrap> {
DefaultExportService(database: database).uploadToServer();
context.read<RemoteSyncViewModel>().init();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
});
}
@@ -41,4 +44,4 @@ class _AppBootstrapState extends State<AppBootstrap> {
Widget build(BuildContext context) {
return widget.child;
}
}
}
+10 -2
View File
@@ -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();
}
}
}
+28 -2
View File
@@ -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<PageRoute> routeObserver = RouteObserver<PageRoute>();
String? _redirectApp({
required PinLockViewModel pinLockViewModel,
required AdminPinViewModel adminPinViewModel,
required ValueNotifier<bool> 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<bool> 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, {
),
],
);
}
}
+41 -4
View File
@@ -38,6 +38,8 @@ class BarTabs extends Table {
DateTimeColumn get closedAt => dateTime().nullable()();
IntColumn get version => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
@@ -138,24 +140,51 @@ class AppSettingsTable extends Table {
Set<Column> get primaryKey => {id};
}
@DataClassName('RemoteSyncStateRow')
class RemoteSyncState extends Table {
TextColumn get deviceId => text()();
TextColumn get serverCursor => text().nullable()();
DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()();
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
IntColumn get consecutiveFailureCount => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {deviceId};
}
@DataClassName('ProcessedRemoteCommandRow')
class ProcessedRemoteCommands extends Table {
TextColumn get commandId => text()();
TextColumn get commandType => text()();
DateTimeColumn get processedAt => dateTime()();
TextColumn get result => text()();
@override
Set<Column> get primaryKey => {commandId};
}
@DriftDatabase(
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 {
),
);
}
}
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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"}}}
}
+48
View File
@@ -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
+28
View File
@@ -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';
}
}
+28
View File
@@ -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';
}
}
+11 -1
View File
@@ -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"}}}
}
+31
View File
@@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -48,9 +51,29 @@ Future<void> main() async {
);
await adminPinViewModel.ensureLoaded();
final remoteSyncService = RemoteSyncService(
database: database,
barTabService: DriftBarTabService(database: database),
);
final remoteSyncViewModel = RemoteSyncViewModel(
syncService: remoteSyncService,
);
final isConfiguredNotifier = ValueNotifier<bool>(await remoteSyncService.isConfigured);
remoteSyncViewModel.addListener(() {
isConfiguredNotifier.value = remoteSyncViewModel.isConfigured;
});
final registrationViewModel = RegistrationViewModel(
syncService: remoteSyncService,
);
final appRouter = createAppRouter(
pinLockViewModel,
adminPinViewModel: adminPinViewModel,
isConfiguredNotifier: isConfiguredNotifier,
);
await SentryFlutter.init(
@@ -83,6 +106,13 @@ Future<void> main() async {
create: (context) =>
DriftSettingsService(database: context.read<AppDatabase>()),
),
Provider<RemoteSyncService>.value(value: remoteSyncService),
ChangeNotifierProvider<RemoteSyncViewModel>.value(
value: remoteSyncViewModel,
),
ChangeNotifierProvider<RegistrationViewModel>.value(
value: registrationViewModel,
),
ChangeNotifierProvider<InventoryViewModel>(
create: (context) => InventoryViewModel(
productService: context.read<ProductService>(),
@@ -98,6 +128,7 @@ Future<void> main() async {
create: (context) => BarScreenViewModel(
barTabService: context.read<BarTabService>(),
inventory: context.read<InventoryViewModel>(),
remoteSyncViewModel: context.read<RemoteSyncViewModel>(),
),
),
ChangeNotifierProvider<HistoryViewModel>(
+4
View File
@@ -7,6 +7,7 @@ class BarTab {
final String status;
final DateTime openedAt;
final DateTime? closedAt;
final int version;
final List<TabItem> 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),
);
}
+2 -1
View File
@@ -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;
+144
View File
@@ -0,0 +1,144 @@
class RemoteCommand {
final String id;
final String type;
final String tabId;
final int expectedVersion;
final String paymentMethod;
final String requestedBy;
final DateTime createdAt;
const RemoteCommand({
required this.id,
required this.type,
required this.tabId,
required this.expectedVersion,
required this.paymentMethod,
required this.requestedBy,
required this.createdAt,
});
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
return RemoteCommand(
id: json['id'] as String,
type: json['type'] as String,
tabId: json['tabId'] as String,
expectedVersion: json['expectedVersion'] as int,
paymentMethod: json['paymentMethod'] as String? ?? 'cash',
requestedBy: json['requestedBy'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
bool get isExpired {
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
return createdAt.isBefore(cutoff);
}
}
class SyncResponse {
final List<RemoteCommand> commands;
final String? cursor;
final List<String> plannedTabNames;
SyncResponse({
required this.commands,
this.cursor,
this.plannedTabNames = const [],
});
factory SyncResponse.fromJson(Map<String, dynamic> json) {
final commandsList = (json['commands'] as List<dynamic>?)
?.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
return SyncResponse(
commands: commandsList,
cursor: json['cursor'] as String?,
plannedTabNames: (json['plannedTabNames'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
);
}
}
class SnapshotTab {
final String id;
final String customerName;
final int version;
final int totalInCents;
final int itemCount;
const SnapshotTab({
required this.id,
required this.customerName,
required this.version,
required this.totalInCents,
required this.itemCount,
});
Map<String, dynamic> toJson() => {
'id': id,
'customerName': customerName,
'version': version,
'totalInCents': totalInCents,
'itemCount': itemCount,
};
}
class SyncRequest {
final String deviceId;
final String? cursor;
final List<SnapshotTab> openTabs;
const SyncRequest({
required this.deviceId,
this.cursor,
required this.openTabs,
});
Map<String, dynamic> toJson() => {
'deviceId': deviceId,
if (cursor != null) 'cursor': cursor,
'openTabs': openTabs.map((t) => t.toJson()).toList(),
};
}
class CommandAck {
final String commandId;
final String status;
final String? result;
const CommandAck({
required this.commandId,
required this.status,
this.result,
});
Map<String, dynamic> toJson() => {
'commandId': commandId,
'status': status,
if (result != null) 'result': result,
};
}
enum RemoteSyncEventType {
tabClosedRemotely,
syncCompleted,
syncFailed,
conflict,
}
class RemoteSyncEvent {
final RemoteSyncEventType type;
final String? tabCustomerName;
final String? requestedBy;
final String? message;
const RemoteSyncEvent({
required this.type,
this.tabCustomerName,
this.requestedBy,
this.message,
});
}
+11
View File
@@ -0,0 +1,11 @@
class Venue {
final String id;
final String name;
const Venue({required this.id, required this.name});
factory Venue.fromJson(Map<String, dynamic> json) => Venue(
id: json['id'] as String,
name: json['name'] as String,
);
}
+29 -2
View File
@@ -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<BarTab> 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<void> _incrementTabVersion(String tabId) async {
await database.customUpdate(
'UPDATE bar_tabs SET version = version + 1 WHERE id = ?',
variables: [Variable.withString(tabId)],
updates: {database.barTabs},
);
}
@override
Future<void> 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
+418
View File
@@ -0,0 +1,418 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import '../database/app_database.dart';
import '../models/payment_method.dart';
import '../models/remote_command.dart';
import '../utils/app_config.dart';
import 'bar_tab_service.dart';
class RemoteSyncService {
final AppDatabase database;
final BarTabService barTabService;
final FlutterSecureStorage secureStorage;
RemoteSyncService({
required this.database,
required this.barTabService,
FlutterSecureStorage? secureStorage,
}) : secureStorage = secureStorage ?? const FlutterSecureStorage();
static const _deviceIdKey = 'remote_device_id';
static const _authTokenKey = 'remote_auth_token';
static const _serverUrlKey = 'remote_server_url';
static const _venueIdKey = 'remote_venue_id';
final _eventController = StreamController<RemoteSyncEvent>.broadcast();
Stream<RemoteSyncEvent> get events => _eventController.stream;
bool _isSyncing = false;
bool get isSyncing => _isSyncing;
Timer? _syncTimer;
int _consecutiveFailures = 0;
bool _paused = false;
Future<String?> get deviceId async =>
await secureStorage.read(key: _deviceIdKey);
Future<String?> get authToken async =>
await secureStorage.read(key: _authTokenKey);
Future<String?> get venueId async =>
await secureStorage.read(key: _venueIdKey);
Future<bool> get isConfigured async {
final token = await authToken;
final vid = await venueId;
return token != null && token.isNotEmpty && vid != null && vid.isNotEmpty;
}
String get _baseUrl => kRemoteServerUrl;
void startPeriodicSync({int intervalSeconds = 90}) {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(
Duration(seconds: intervalSeconds),
(_) => _onTick(),
);
}
void stopPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = null;
}
void pause() {
_paused = true;
}
void resume() {
_paused = false;
}
Future<void> _onTick() async {
if (_isSyncing || _paused) return;
await sync();
}
Future<void> sync() async {
if (_isSyncing) return;
final configured = await isConfigured;
if (!configured) return;
_isSyncing = true;
try {
await _doSync();
_consecutiveFailures = 0;
_eventController.add(const RemoteSyncEvent(
type: RemoteSyncEventType.syncCompleted,
));
} catch (e, stack) {
_consecutiveFailures++;
debugPrint('RemoteSyncService: sync failed: $e');
Sentry.captureException(e, stackTrace: stack);
_eventController.add(RemoteSyncEvent(
type: RemoteSyncEventType.syncFailed,
message: e.toString(),
));
_applyBackoff();
} finally {
_isSyncing = false;
}
}
Future<void> _doSync() async {
final token = await authToken;
if (token == null) return;
final deviceId = await this.deviceId;
if (deviceId == null) return;
final state = await _getSyncState();
final openTabs = await _buildSnapshot();
final request = SyncRequest(
deviceId: deviceId,
cursor: state?.serverCursor,
openTabs: openTabs,
);
final response = await _httpPost(
'$_baseUrl/devices/$deviceId/sync',
body: request.toJson(),
token: token,
);
if (response == null) return;
final syncResponse = SyncResponse.fromJson(response);
for (final command in syncResponse.commands) {
await _processCommand(command);
}
for (final name in syncResponse.plannedTabNames) {
final hasOpenTab = openTabs.any((t) => t.customerName == name);
if (!hasOpenTab) {
await barTabService.createTab(customerName: name);
}
}
if (syncResponse.cursor != null) {
await _saveCursor(syncResponse.cursor!);
}
await _updateSyncState(success: true);
}
Future<List<SnapshotTab>> _buildSnapshot() async {
final openTabs = await barTabService.getOpenTabs();
return openTabs.map((tab) => SnapshotTab(
id: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
)).toList();
}
Future<void> _processCommand(RemoteCommand command) async {
final alreadyProcessed = await _isCommandProcessed(command.id);
if (alreadyProcessed) return;
if (command.isExpired) {
await _ackCommand(command.id, 'expired', 'Command expired');
await _recordProcessed(command.id, command.type, 'expired');
return;
}
if (command.type != 'close_tab') {
await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}');
await _recordProcessed(command.id, command.type, 'failed');
return;
}
try {
final tab = await barTabService.getTabById(command.tabId);
if (tab == null) {
await _ackCommand(command.id, 'conflict', 'Tab not found');
await _recordProcessed(command.id, command.type, 'conflict');
_eventController.add(const RemoteSyncEvent(
type: RemoteSyncEventType.conflict,
message: 'Tab not found',
));
return;
}
if (!tab.isOpen) {
await _ackCommand(command.id, 'conflict', 'Tab already closed');
await _recordProcessed(command.id, command.type, 'conflict');
_eventController.add(const RemoteSyncEvent(
type: RemoteSyncEventType.conflict,
message: 'Tab already closed',
));
return;
}
if (tab.version != command.expectedVersion) {
await _ackCommand(command.id, 'conflict', 'Version mismatch');
await _recordProcessed(command.id, command.type, 'conflict');
_eventController.add(const RemoteSyncEvent(
type: RemoteSyncEventType.conflict,
message: 'Tab was modified since the request',
));
return;
}
final method = PaymentMethod.fromValue(command.paymentMethod);
await barTabService.closeTab(command.tabId, paymentMethod: method);
await _ackCommand(command.id, 'completed');
await _recordProcessed(command.id, command.type, 'completed');
_eventController.add(RemoteSyncEvent(
type: RemoteSyncEventType.tabClosedRemotely,
tabCustomerName: tab.customerName,
requestedBy: command.requestedBy,
));
} catch (e, stack) {
debugPrint('RemoteSyncService: command processing failed: $e');
Sentry.captureException(e, stackTrace: stack);
await _ackCommand(command.id, 'failed', e.toString());
await _recordProcessed(command.id, command.type, 'failed');
}
}
Future<Map<String, dynamic>?> _httpPost(
String url, {
required Map<String, dynamic> body,
required String token,
}) async {
final response = await http.post(
Uri.parse(url),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode(body),
).timeout(const Duration(seconds: 15));
if (response.statusCode == 401) {
await secureStorage.delete(key: _authTokenKey);
debugPrint('RemoteSyncService: token revoked');
return null;
}
if (response.statusCode == 429) {
final retryAfter = response.headers['retry-after'];
if (retryAfter != null) {
final seconds = int.tryParse(retryAfter) ?? 60;
_applyBackoff(forcedDelay: seconds);
}
return null;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
debugPrint('RemoteSyncService: HTTP ${response.statusCode}: ${response.body}');
return null;
}
if (response.body.isEmpty) return {};
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<void> _ackCommand(
String commandId,
String status, [
String? result,
]) async {
final token = await authToken;
final deviceId = await this.deviceId;
if (token == null || deviceId == null) return;
try {
final ack = CommandAck(commandId: commandId, status: status, result: result);
await _httpPost(
'$_baseUrl/devices/$deviceId/commands/$commandId/ack',
body: ack.toJson(),
token: token,
);
} catch (e) {
debugPrint('RemoteSyncService: ack failed: $e');
}
}
Future<RemoteSyncStateRow?> _getSyncState() async {
final query = database.select(database.remoteSyncState)
..where((row) => row.deviceId.equals('default'));
return query.getSingleOrNull();
}
Future<void> _saveCursor(String cursor) async {
await database
.into(database.remoteSyncState)
.insertOnConflictUpdate(
RemoteSyncStateCompanion(
deviceId: const Value('default'),
serverCursor: Value(cursor),
),
);
}
Future<void> _updateSyncState({required bool success}) async {
final now = DateTime.now();
final existing = await _getSyncState();
if (existing == null) {
await database.into(database.remoteSyncState).insert(
RemoteSyncStateCompanion.insert(
deviceId: 'default',
lastSuccessfulSyncAt: Value(success ? now : null),
lastAttemptedSyncAt: Value(now),
consecutiveFailureCount: Value(success ? 0 : 1),
),
);
} else {
await (database.update(database.remoteSyncState)
..where((row) => row.deviceId.equals('default')))
.write(RemoteSyncStateCompanion(
lastSuccessfulSyncAt: Value(success ? now : existing.lastSuccessfulSyncAt),
lastAttemptedSyncAt: Value(now),
consecutiveFailureCount: Value(
success ? 0 : existing.consecutiveFailureCount + 1,
),
));
}
}
Future<bool> _isCommandProcessed(String commandId) async {
final query = database.select(database.processedRemoteCommands)
..where((row) => row.commandId.equals(commandId));
final result = await query.getSingleOrNull();
return result != null;
}
Future<void> _recordProcessed(
String commandId,
String commandType,
String result,
) async {
await database
.into(database.processedRemoteCommands)
.insertOnConflictUpdate(
ProcessedRemoteCommandsCompanion.insert(
commandId: commandId,
commandType: commandType,
processedAt: DateTime.now(),
result: result,
),
);
}
void _applyBackoff({int? forcedDelay}) {
final delay = forcedDelay ?? _calculateBackoff();
_syncTimer?.cancel();
_syncTimer = Timer(Duration(seconds: delay), () => _onTick());
}
int _calculateBackoff() {
if (_consecutiveFailures <= 1) return 90;
final backoff = 90 * (1 << (_consecutiveFailures - 1).clamp(0, 3));
return backoff.clamp(90, 600);
}
Future<void> setCredentials({
required String deviceId,
required String token,
required String venueId,
String? serverUrl,
}) async {
await secureStorage.write(key: _deviceIdKey, value: deviceId);
await secureStorage.write(key: _authTokenKey, value: token);
await secureStorage.write(key: _venueIdKey, value: venueId);
if (serverUrl != null) {
await secureStorage.write(key: _serverUrlKey, value: serverUrl);
}
}
Future<void> clearCredentials() async {
await secureStorage.delete(key: _deviceIdKey);
await secureStorage.delete(key: _authTokenKey);
await secureStorage.delete(key: _serverUrlKey);
await secureStorage.delete(key: _venueIdKey);
}
Future<DateTime?> get lastSuccessfulSyncAt async {
final state = await _getSyncState();
return state?.lastSuccessfulSyncAt;
}
Future<int> get lastFailureCount async {
final state = await _getSyncState();
return state?.consecutiveFailureCount ?? 0;
}
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() async {
final query = database.select(database.processedRemoteCommands)
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
..limit(50);
return query.get();
}
void dispose() {
_syncTimer?.cancel();
_eventController.close();
}
}
+5 -1
View File
@@ -1,2 +1,6 @@
const kUpdateServerUrl = 'https://updater.brammie15.dev';
const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup';
const kExportServerUrl = 'https://kooltab.brammie15.dev/api/backup';
const kRemoteServerUrl = 'https://api.kooltap.com/api/v1';
const kSyncPollingIntervalSeconds = 90;
const kSyncMaxBackoffSeconds = 600;
const kCommandExpiryHours = 24;
+40 -1
View File
@@ -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<RemoteSyncEvent>? _eventSubscription;
RemoteSyncEvent? _lastRemoteEvent;
RemoteSyncEvent? get lastRemoteEvent => _lastRemoteEvent;
List<BarTab> _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<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
@@ -72,6 +106,11 @@ class BarScreenViewModel extends ChangeNotifier {
}
}
Future<void> loadAndSync() async {
await load();
await remoteSyncViewModel.syncNow();
}
void selectTab(String tabId) {
_selectedTabId = tabId;
notifyListeners();
+135
View File
@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/venue.dart';
import '../services/remote_sync_service.dart';
import '../utils/app_config.dart';
enum RegistrationStep { initial, loadingVenues, venuesLoaded, error, registering, registered }
class RegistrationViewModel extends ChangeNotifier {
final RemoteSyncService syncService;
RegistrationViewModel({required this.syncService});
RegistrationStep _step = RegistrationStep.initial;
RegistrationStep get step => _step;
List<Venue> _venues = [];
List<Venue> get venues => _venues;
Venue? _selectedVenue;
Venue? get selectedVenue => _selectedVenue;
String? _errorMessage;
String? get errorMessage => _errorMessage;
void selectVenue(Venue venue) {
_selectedVenue = venue;
notifyListeners();
}
Future<void> fetchVenues() async {
_step = RegistrationStep.loadingVenues;
_errorMessage = null;
notifyListeners();
try {
final response = await http
.get(Uri.parse('$kRemoteServerUrl/venues/'))
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body) as List<dynamic>;
_venues = data
.map((e) => Venue.fromJson(e as Map<String, dynamic>))
.toList();
_step = RegistrationStep.venuesLoaded;
} else {
_errorMessage = 'Failed to load venues (HTTP ${response.statusCode})';
_step = RegistrationStep.error;
}
} catch (e) {
debugPrint('RegistrationViewModel: fetchVenues error: $e');
_errorMessage = e.toString();
_step = RegistrationStep.error;
}
notifyListeners();
}
Future<bool> registerDevice({
required String deviceName,
required String registrationCode,
}) async {
final venue = _selectedVenue;
if (venue == null) return false;
_step = RegistrationStep.registering;
_errorMessage = null;
notifyListeners();
try {
final response = await http
.post(
Uri.parse('$kRemoteServerUrl/devices/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'venueId': venue.id,
'name': deviceName,
'registrationCode': registrationCode,
}),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final deviceId = data['deviceId'] as String;
final token = data['token'] as String;
await syncService.setCredentials(
deviceId: deviceId,
token: token,
venueId: venue.id,
serverUrl: kRemoteServerUrl,
);
_step = RegistrationStep.registered;
notifyListeners();
return true;
} else {
String message;
try {
final errorData = jsonDecode(response.body) as Map<String, dynamic>;
message = errorData['message'] as String? ?? 'Registration failed';
} catch (_) {
message = 'Registration failed (HTTP ${response.statusCode})';
}
_errorMessage = message;
_step = RegistrationStep.venuesLoaded;
notifyListeners();
return false;
}
} catch (e) {
debugPrint('RegistrationViewModel: registerDevice error: $e');
_errorMessage = e.toString();
_step = RegistrationStep.venuesLoaded;
notifyListeners();
return false;
}
}
Future<void> retry() async {
await fetchVenues();
}
void reset() {
_step = RegistrationStep.initial;
_venues = [];
_selectedVenue = null;
_errorMessage = null;
notifyListeners();
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../database/app_database.dart';
import '../models/remote_command.dart';
import '../services/remote_sync_service.dart';
import '../utils/app_config.dart';
class RemoteSyncViewModel extends ChangeNotifier {
final RemoteSyncService syncService;
RemoteSyncViewModel({required this.syncService}) {
_eventSubscription = syncService.events.listen(_onEvent);
}
StreamSubscription<RemoteSyncEvent>? _eventSubscription;
List<RemoteSyncEvent> _recentEvents = [];
List<RemoteSyncEvent> get recentEvents => _recentEvents;
bool _isSyncing = false;
bool get isSyncing => _isSyncing;
bool _isConfigured = false;
bool get isConfigured => _isConfigured;
DateTime? _lastSyncAt;
DateTime? get lastSyncAt => _lastSyncAt;
String? _lastError;
String? get lastError => _lastError;
String? _venueId;
String? get venueId => _venueId;
List<ProcessedRemoteCommandRow> _activity = [];
List<ProcessedRemoteCommandRow> get activity => _activity;
bool _isRegistering = false;
bool get isRegistering => _isRegistering;
String? _registrationError;
String? get registrationError => _registrationError;
Future<void> init() async {
_isConfigured = await syncService.isConfigured;
_venueId = await syncService.venueId;
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
await loadActivity();
if (_isConfigured) {
syncService.startPeriodicSync();
}
notifyListeners();
}
Future<void> refreshStatus() async {
_isConfigured = await syncService.isConfigured;
_venueId = await syncService.venueId;
notifyListeners();
}
Future<void> syncNow() async {
_isSyncing = true;
_lastError = null;
notifyListeners();
await syncService.sync();
_isSyncing = syncService.isSyncing;
_lastSyncAt = await syncService.lastSuccessfulSyncAt;
await loadActivity();
notifyListeners();
}
Future<void> loadActivity() async {
try {
_activity = await syncService.getRecentActivity();
} catch (e) {
debugPrint('RemoteSyncViewModel: loadActivity error: $e');
}
}
Future<bool> registerDevice({
required String venueId,
required String deviceName,
required String registrationCode,
}) async {
_isRegistering = true;
_registrationError = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$kRemoteServerUrl/devices/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'venueId': venueId,
'name': deviceName,
'registrationCode': registrationCode,
}),
).timeout(const Duration(seconds: 15));
if (response.statusCode == 201) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final deviceId = data['deviceId'] as String;
final token = data['token'] as String;
await syncService.setCredentials(
deviceId: deviceId,
token: token,
venueId: venueId,
serverUrl: kRemoteServerUrl,
);
_isConfigured = true;
_venueId = venueId;
_isRegistering = false;
notifyListeners();
syncService.startPeriodicSync();
await syncNow();
return true;
} else {
final body = response.body;
String message;
try {
final errorData = jsonDecode(body) as Map<String, dynamic>;
message = errorData['message'] as String? ?? 'Registration failed';
} catch (_) {
message = 'Registration failed (HTTP ${response.statusCode})';
}
_registrationError = message;
_isRegistering = false;
notifyListeners();
return false;
}
} catch (e) {
debugPrint('RemoteSyncViewModel: registration error: $e');
_registrationError = e.toString();
_isRegistering = false;
notifyListeners();
return false;
}
}
Future<void> unlink() async {
syncService.stopPeriodicSync();
await syncService.clearCredentials();
_isConfigured = false;
_venueId = null;
_lastSyncAt = null;
_lastError = null;
_recentEvents = [];
_activity = [];
notifyListeners();
}
void startSync() {
syncService.startPeriodicSync();
syncNow();
}
void _onEvent(RemoteSyncEvent event) {
_recentEvents = [event, ..._recentEvents.take(49)];
_lastSyncAt = DateTime.now();
if (event.type == RemoteSyncEventType.syncFailed) {
_lastError = event.message;
} else if (event.type == RemoteSyncEventType.syncCompleted) {
_lastError = null;
}
notifyListeners();
}
void pause() {
syncService.pause();
}
void resume() {
syncService.resume();
syncNow();
}
@override
void dispose() {
_eventSubscription?.cancel();
syncService.dispose();
super.dispose();
}
}
+113 -1
View File
@@ -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<void> _confirmUnlink(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect from server'),
content: const Text(
'This will unlink the device from the remote server. '
'Open tabs will no longer be synced. Continue?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true && context.mounted) {
context.read<RemoteSyncViewModel>().unlink();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final remoteSync = context.watch<RemoteSyncViewModel>();
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 {
),
);
}
}
}
+39 -1
View File
@@ -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<BarScreenView> {
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkRemoteEvent();
}
@override
void didUpdateWidget(covariant BarScreenView oldWidget) {
super.didUpdateWidget(oldWidget);
_checkRemoteEvent();
}
void _checkRemoteEvent() {
final viewModel = context.read<BarScreenViewModel>();
final event = viewModel.lastRemoteEvent;
if (event == null) return;
viewModel.clearRemoteEvent();
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
final message = l10n.tabClosedRemotely(
event.tabCustomerName ?? '',
event.requestedBy ?? '',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -83,7 +121,7 @@ class _BarScreenViewState extends State<BarScreenView> {
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
onPressed: () => viewModel.load(),
onPressed: () => viewModel.loadAndSync(),
icon: const Icon(Icons.refresh_rounded),
),
const SizedBox(width: 6),
+17 -3
View File
@@ -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),
+364
View File
@@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../viewmodels/registration_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
import '../l10n/app_localizations.dart';
class RegistrationView extends StatefulWidget {
const RegistrationView({super.key});
@override
State<RegistrationView> createState() => _RegistrationViewState();
}
class _RegistrationViewState extends State<RegistrationView> {
final _deviceNameController = TextEditingController(text: 'Bar terminal 1');
final _regCodeController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<RegistrationViewModel>().fetchVenues();
});
}
@override
void dispose() {
_deviceNameController.dispose();
_regCodeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vm = context.watch<RegistrationViewModel>();
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: _buildStep(vm, l10n),
),
),
),
),
);
}
Widget _buildStep(RegistrationViewModel vm, AppLocalizations l10n) {
switch (vm.step) {
case RegistrationStep.loadingVenues:
return _buildLoading();
case RegistrationStep.error:
return _buildError(vm);
case RegistrationStep.venuesLoaded:
if (vm.selectedVenue == null) {
return _buildVenueSelection(vm, l10n);
}
return _buildDeviceDetails(vm, l10n);
case RegistrationStep.registering:
return _buildRegistering();
case RegistrationStep.registered:
return _buildSuccess(vm, l10n);
case RegistrationStep.initial:
return _buildLoading();
}
}
Widget _buildLoading() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
Icon(
Icons.cloud_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 16),
Text(
'Connecting to server…',
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildError(RegistrationViewModel vm) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
Icon(
Icons.cloud_off_rounded,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Failed to connect',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
vm.errorMessage ?? 'Unknown error',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => vm.retry(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
Widget _buildVenueSelection(RegistrationViewModel vm, AppLocalizations l10n) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
Icon(
Icons.cloud_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Connect to remote server',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Select your venue to link this device',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
Text(
'Select venue',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15),
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
for (final venue in vm.venues)
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => vm.selectVenue(venue),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(
Icons.store_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
venue.name,
style: Theme.of(context).textTheme.bodyLarge,
),
),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
),
],
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () => vm.retry(),
child: const Text('Refresh venues'),
),
],
);
}
Widget _buildDeviceDetails(RegistrationViewModel vm, AppLocalizations l10n) {
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
Icon(
Icons.store_outlined,
size: 48,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
vm.selectedVenue!.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Enter device details to complete pairing',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
TextFormField(
controller: _deviceNameController,
decoration: const InputDecoration(
labelText: 'Device name',
hintText: 'e.g. Bar terminal 1',
border: OutlineInputBorder(),
),
validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _regCodeController,
decoration: const InputDecoration(
labelText: 'Registration code',
hintText: '6-character code from dashboard',
border: OutlineInputBorder(),
counterText: '',
),
maxLength: 6,
textCapitalization: TextCapitalization.characters,
validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : null,
),
const SizedBox(height: 8),
if (vm.errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
vm.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () {
vm.reset();
},
child: const Text('Back'),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () async {
if (!_formKey.currentState!.validate()) return;
await vm.registerDevice(
deviceName: _deviceNameController.text.trim(),
registrationCode: _regCodeController.text.trim().toUpperCase(),
);
},
child: const Text('Register'),
),
),
],
),
],
),
);
}
Widget _buildRegistering() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 60),
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 24),
Text(
'Registering device…',
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 40),
Icon(
Icons.check_circle_rounded,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Device registered!',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Text(
'${vm.selectedVenue?.name ?? "Venue"} is now linked.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
'Open tabs will sync to the dashboard.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
context.read<RemoteSyncViewModel>().startSync();
context.go('/bar');
},
icon: const Icon(Icons.arrow_forward),
label: const Text('Start using KoolTab'),
),
],
);
}
}
+2
View File
@@ -23,6 +23,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
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<ClosedTabCard> {
switch (closedTab.paymentMethod) {
PaymentMethod.cash => l10n.cash,
PaymentMethod.payconiq => l10n.payconiq,
PaymentMethod.paymentPending => l10n.paymentPending,
},
style: TextStyle(
fontSize: 11,