feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+248 -256
View File
@@ -1,33 +1,52 @@
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/bar_tab.dart';
import '../models/payment_method.dart';
import '../models/remote_command.dart';
import '../utils/app_config.dart';
import 'bar_tab_service.dart';
import 'kooltap_api_client.dart';
typedef _Credentials = ({String deviceId, String token});
typedef _CommandOutcome = ({CommandResult result, String? message});
class RemoteSyncService {
final AppDatabase database;
final BarTabService barTabService;
final KoolTapApiClient apiClient;
final FlutterSecureStorage secureStorage;
/// How long tab changes must be quiet before they are synced.
final Duration changeDebounce;
/// The longest a tab change waits for a sync while changes keep coming.
final Duration changeMaxDelay;
RemoteSyncService({
required this.database,
required this.barTabService,
required this.apiClient,
FlutterSecureStorage? secureStorage,
this.changeDebounce = const Duration(milliseconds: kSyncChangeDebounceMs),
this.changeMaxDelay = const Duration(milliseconds: kSyncChangeMaxDelayMs),
}) : 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';
static const _syncStateId = 'default';
static const _pollingInterval = Duration(
seconds: kSyncPollingIntervalSeconds,
);
static const _maxBackoff = Duration(seconds: kSyncMaxBackoffSeconds);
final _eventController = StreamController<RemoteSyncEvent>.broadcast();
@@ -36,38 +55,52 @@ class RemoteSyncService {
bool _isSyncing = false;
bool get isSyncing => _isSyncing;
Timer? _syncTimer;
int _consecutiveFailures = 0;
Timer? _timer;
bool _isRunning = false;
bool _paused = false;
int _consecutiveFailures = 0;
Future<String?> get deviceId async =>
await secureStorage.read(key: _deviceIdKey);
StreamSubscription<List<BarTab>>? _tabChanges;
Timer? _changeTimer;
DateTime? _firstPendingChange;
bool _syncQueued = false;
Future<String?> get authToken async =>
await secureStorage.read(key: _authTokenKey);
Future<String?> get venueId async =>
await secureStorage.read(key: _venueIdKey);
Future<String?> get venueId => 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;
final token = await secureStorage.read(key: _authTokenKey);
final venueId = await this.venueId;
return (token?.isNotEmpty ?? false) && (venueId?.isNotEmpty ?? false);
}
String get _baseUrl => kRemoteServerUrl;
Future<_Credentials?> _credentials() async {
final deviceId = await secureStorage.read(key: _deviceIdKey);
final token = await secureStorage.read(key: _authTokenKey);
if (deviceId == null || token == null) return null;
void startPeriodicSync({int intervalSeconds = 90}) {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(
Duration(seconds: intervalSeconds),
(_) => _onTick(),
);
return (deviceId: deviceId, token: token);
}
void startPeriodicSync() {
_isRunning = true;
// The first emission is the current state, not a change
_tabChanges ??= barTabService
.watchOpenTabs()
.skip(1)
.listen((_) => _onTabsChanged());
_scheduleNext(_pollingInterval);
}
void stopPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = null;
_isRunning = false;
_timer?.cancel();
_timer = null;
_changeTimer?.cancel();
_changeTimer = null;
_firstPendingChange = null;
_syncQueued = false;
unawaited(_tabChanges?.cancel());
_tabChanges = null;
}
void pause() {
@@ -76,321 +109,285 @@ class RemoteSyncService {
void resume() {
_paused = false;
// Catch up right away instead of waiting for the next poll
_onTabsChanged();
}
Future<void> _onTick() async {
if (_isSyncing || _paused) return;
await sync();
/// Pushes tab changes to the server soon after they happen instead of
/// waiting for the next poll. A burst of changes (several drinks rung up in
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
/// but never later than [changeMaxDelay] after the first change.
void _onTabsChanged() {
// While failing, the backoff timer decides when to retry
if (!_isRunning || _paused || _consecutiveFailures > 0) return;
final now = DateTime.now();
_firstPendingChange ??= now;
final untilMaxDelay = changeMaxDelay - now.difference(_firstPendingChange!);
final delay = untilMaxDelay < changeDebounce
? (untilMaxDelay.isNegative ? Duration.zero : untilMaxDelay)
: changeDebounce;
_changeTimer?.cancel();
_changeTimer = Timer(delay, () {
_firstPendingChange = null;
unawaited(sync());
});
}
/// Every sync schedules the next one, so a failure can stretch the delay
/// without stopping the loop.
void _scheduleNext(Duration delay) {
_timer?.cancel();
if (!_isRunning) return;
_timer = Timer(delay, () {
if (_paused) {
_scheduleNext(_pollingInterval);
return;
}
unawaited(sync());
});
}
Duration _backoff() {
final exponent = (_consecutiveFailures - 1).clamp(0, 3);
final delay = _pollingInterval * (1 << exponent);
return delay > _maxBackoff ? _maxBackoff : delay;
}
Future<void> sync() async {
if (_isSyncing) return;
if (_isSyncing) {
// Changes made during a sync must not wait for the next poll
_syncQueued = true;
return;
}
final configured = await isConfigured;
if (!configured) return;
final credentials = await _credentials();
if (credentials == null || !await isConfigured) return;
_isSyncing = true;
Duration nextDelay;
try {
await _doSync();
await _doSync(credentials);
_consecutiveFailures = 0;
_eventController.add(const RemoteSyncEvent(
type: RemoteSyncEventType.syncCompleted,
));
nextDelay = _pollingInterval;
_emit(RemoteSyncEventType.syncCompleted);
} on UnauthorizedException {
debugPrint('RemoteSyncService: token revoked');
await secureStorage.delete(key: _authTokenKey);
stopPeriodicSync();
_emit(RemoteSyncEventType.unauthorized);
return;
} on RateLimitedException catch (e) {
nextDelay = await _recordFailure(e, retryAfter: e.retryAfter);
} catch (e, stack) {
_consecutiveFailures++;
debugPrint('RemoteSyncService: sync failed: $e');
Sentry.captureException(e, stackTrace: stack);
_eventController.add(RemoteSyncEvent(
type: RemoteSyncEventType.syncFailed,
message: e.toString(),
));
_applyBackoff();
unawaited(Sentry.captureException(e, stackTrace: stack));
nextDelay = await _recordFailure(e);
} finally {
_isSyncing = false;
}
_scheduleNext(nextDelay);
if (_syncQueued) {
_syncQueued = false;
if (_consecutiveFailures == 0) unawaited(sync());
}
}
Future<void> _doSync() async {
final token = await authToken;
if (token == null) return;
Future<Duration> _recordFailure(Object error, {Duration? retryAfter}) async {
debugPrint('RemoteSyncService: sync failed: $error');
_consecutiveFailures++;
await _updateSyncState(success: false);
_emit(RemoteSyncEventType.syncFailed, message: error.toString());
final deviceId = await this.deviceId;
if (deviceId == null) return;
return retryAfter ?? _backoff();
}
Future<void> _doSync(_Credentials credentials) async {
final state = await _getSyncState();
final openTabs = await _buildSnapshot();
final openTabs = await barTabService.getOpenTabs();
final request = SyncRequest(
deviceId: deviceId,
cursor: state?.serverCursor,
openTabs: openTabs,
final response = await apiClient.sync(
token: credentials.token,
request: SyncRequest(
deviceId: credentials.deviceId,
cursor: state?.serverCursor,
openTabs: openTabs.map(_toSnapshot).toList(),
),
);
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 command in response.commands) {
await _processCommand(command, credentials);
}
for (final name in syncResponse.plannedTabNames) {
final hasOpenTab = openTabs.any((t) => t.customerName == name);
if (!hasOpenTab) {
await barTabService.createTab(customerName: name);
}
final openNames = openTabs.map((tab) => tab.customerName).toSet();
for (final name in response.plannedTabNames) {
if (name.trim().isEmpty || !openNames.add(name)) continue;
await barTabService.createTab(customerName: name);
}
if (syncResponse.cursor != null) {
await _saveCursor(syncResponse.cursor!);
}
await _updateSyncState(success: true);
await _updateSyncState(success: true, cursor: response.cursor);
}
Future<List<SnapshotTab>> _buildSnapshot() async {
final openTabs = await barTabService.getOpenTabs();
return openTabs.map((tab) => SnapshotTab(
SnapshotTab _toSnapshot(BarTab tab) {
return 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;
Future<void> _processCommand(
RemoteCommand command,
_Credentials credentials,
) async {
final processed = await _getProcessedCommand(command.id);
if (processed != null) {
// The server only returns pending commands, so the earlier ack never arrived: resend it
await _ackCommand(credentials, command.id, processed.result);
return;
}
final outcome = await _executeCommand(command);
// Recorded before acking, so a failed ack never re-executes the command.
await _recordProcessed(command, outcome.result);
await _ackCommand(credentials, command.id, outcome.result, outcome.message);
}
Future<_CommandOutcome> _executeCommand(RemoteCommand command) async {
if (command.isExpired) {
await _ackCommand(command.id, 'expired', 'Command expired');
await _recordProcessed(command.id, command.type, 'expired');
return;
return (result: CommandResult.expired, message: 'Command expired');
}
if (command.type != 'close_tab') {
await _ackCommand(command.id, 'failed', 'Unknown command type: ${command.type}');
await _recordProcessed(command.id, command.type, 'failed');
return;
}
return switch (command.type) {
CommandType.closeTab => _closeTab(command),
null => (
result: CommandResult.failed,
message: 'Unknown command type: ${command.rawType}',
),
};
}
Future<_CommandOutcome> _closeTab(RemoteCommand command) async {
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;
}
await barTabService.closeTab(
command.tabId,
paymentMethod: PaymentMethod.fromValue(command.paymentMethod),
expectedVersion: command.expectedVersion,
);
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;
}
_eventController.add(
RemoteSyncEvent(
type: RemoteSyncEventType.tabClosedRemotely,
tabCustomerName: tab?.customerName,
requestedBy: command.requestedBy,
),
);
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,
));
return (result: CommandResult.completed, message: null);
} on TabNotFoundException {
return _conflict('Tab not found');
} on TabVersionConflictException {
return _conflict('Tab was modified since the request');
} 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');
unawaited(Sentry.captureException(e, stackTrace: stack));
return (result: CommandResult.failed, message: e.toString());
}
}
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));
_CommandOutcome _conflict(String message) {
_emit(RemoteSyncEventType.conflict, message: message);
return (result: CommandResult.conflict, message: message);
}
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>;
void _emit(RemoteSyncEventType type, {String? message}) {
_eventController.add(RemoteSyncEvent(type: type, message: message));
}
Future<void> _ackCommand(
_Credentials credentials,
String commandId,
String status, [
CommandResult 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');
}
]) {
return apiClient.ackCommand(
token: credentials.token,
deviceId: credentials.deviceId,
ack: CommandAck(commandId: commandId, status: status, result: result),
);
}
Future<RemoteSyncStateRow?> _getSyncState() async {
final query = database.select(database.remoteSyncState)
..where((row) => row.deviceId.equals('default'));
return query.getSingleOrNull();
Future<RemoteSyncStateRow?> _getSyncState() {
return (database.select(
database.remoteSyncState,
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
}
Future<void> _saveCursor(String cursor) async {
Future<void> _updateSyncState({required bool success, String? cursor}) async {
final now = DateTime.now();
final existing = await _getSyncState();
await database
.into(database.remoteSyncState)
.insertOnConflictUpdate(
RemoteSyncStateCompanion(
deviceId: const Value('default'),
serverCursor: Value(cursor),
RemoteSyncStateCompanion.insert(
deviceId: _syncStateId,
serverCursor: Value(cursor ?? existing?.serverCursor),
lastSuccessfulSyncAt: Value(
success ? now : existing?.lastSuccessfulSyncAt,
),
lastAttemptedSyncAt: Value(now),
consecutiveFailureCount: Value(
success ? 0 : (existing?.consecutiveFailureCount ?? 0) + 1,
),
),
);
}
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<ProcessedRemoteCommandRow?> _getProcessedCommand(String commandId) {
return (database.select(
database.processedRemoteCommands,
)..where((row) => row.commandId.equals(commandId))).getSingleOrNull();
}
Future<void> _recordProcessed(
String commandId,
String commandType,
String result,
RemoteCommand command,
CommandResult result,
) async {
await database
.into(database.processedRemoteCommands)
.insertOnConflictUpdate(
ProcessedRemoteCommandsCompanion.insert(
commandId: commandId,
commandType: commandType,
commandId: command.id,
commandType: command.rawType,
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);
}
@@ -399,20 +396,15 @@ class RemoteSyncService {
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();
Future<List<ProcessedRemoteCommandRow>> getRecentActivity() {
return (database.select(database.processedRemoteCommands)
..orderBy([(row) => OrderingTerm.desc(row.processedAt)])
..limit(50))
.get();
}
void dispose() {
_syncTimer?.cancel();
stopPeriodicSync();
_eventController.close();
}
}
}