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