From 1aee0541c423c8bd418a955d65a0a8d723c667d1 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Tue, 27 Oct 2026 00:17:18 +0100 Subject: [PATCH] feat: more easter eggs + version checking --- lib/l10n/app_en.arb | 2 ++ lib/l10n/app_localizations.dart | 12 +++++++ lib/l10n/app_localizations_en.dart | 7 ++++ lib/l10n/app_localizations_nl.dart | 7 ++++ lib/l10n/app_nl.arb | 2 ++ lib/main.dart | 5 ++- lib/models/remote_command.dart | 3 ++ lib/services/kooltap_api_client.dart | 33 +++++++++++++++--- lib/services/remote_sync_service.dart | 9 +++++ lib/viewmodels/remote_sync_view_model.dart | 9 +++++ lib/views/bar/bar_screen_view.dart | 38 +++++++++++++++++++++ pubspec.yaml | 2 +- test/kooltap_api_client_test.dart | 39 ++++++++++++++++++++++ test/remote_sync_service_test.dart | 16 +++++++++ 14 files changed, 178 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 602b8a9..c46122c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -280,6 +280,8 @@ "offlineDescription": "No connection to the server. The tablet keeps working: everything is sent as soon as the connection is back.", "syncProblem": "Sync problem", "syncProblemDescription": "Changes from the website don't reach this tablet. Error:", + "appUpdateRequired": "App update required", + "appUpdateRequiredDescription": "The website no longer accepts this version of the app. The bar keeps working and everything is sent once the app is updated. Update it in Settings.", "syncNow": "Sync now", "syncing": "Syncing…", "lastSync": "Last sync: {time}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8f00774..ee08895 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1664,6 +1664,18 @@ abstract class AppLocalizations { /// **'Changes from the website don\'t reach this tablet. Error:'** String get syncProblemDescription; + /// No description provided for @appUpdateRequired. + /// + /// In en, this message translates to: + /// **'App update required'** + String get appUpdateRequired; + + /// No description provided for @appUpdateRequiredDescription. + /// + /// In en, this message translates to: + /// **'The website no longer accepts this version of the app. The bar keeps working and everything is sent once the app is updated. Update it in Settings.'** + String get appUpdateRequiredDescription; + /// No description provided for @syncNow. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d969885..3cd253e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -860,6 +860,13 @@ class AppLocalizationsEn extends AppLocalizations { String get syncProblemDescription => 'Changes from the website don\'t reach this tablet. Error:'; + @override + String get appUpdateRequired => 'App update required'; + + @override + String get appUpdateRequiredDescription => + 'The website no longer accepts this version of the app. The bar keeps working and everything is sent once the app is updated. Update it in Settings.'; + @override String get syncNow => 'Sync now'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index bb386c5..c90bdfa 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -873,6 +873,13 @@ class AppLocalizationsNl extends AppLocalizations { String get syncProblemDescription => 'Wijzigingen van de website komen niet op deze tablet aan. Fout:'; + @override + String get appUpdateRequired => 'App-update vereist'; + + @override + String get appUpdateRequiredDescription => + 'De website aanvaardt deze versie van de app niet meer. De bar blijft werken en alles wordt verstuurd zodra de app geüpdatet is. Update hem via Instellingen.'; + @override String get syncNow => 'Nu synchroniseren'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d488638..e28bf8f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -280,6 +280,8 @@ "offlineDescription": "Geen verbinding met de server. De kassa werkt gewoon verder: alles wordt verstuurd zodra er weer verbinding is.", "syncProblem": "Synchronisatieprobleem", "syncProblemDescription": "Wijzigingen van de website komen niet op deze tablet aan. Fout:", + "appUpdateRequired": "App-update vereist", + "appUpdateRequiredDescription": "De website aanvaardt deze versie van de app niet meer. De bar blijft werken en alles wordt verstuurd zodra de app geüpdatet is. Update hem via Instellingen.", "syncNow": "Nu synchroniseren", "syncing": "Bezig met synchroniseren…", "lastSync": "Laatste sync: {time}", diff --git a/lib/main.dart b/lib/main.dart index bab4425..3441cc3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -69,7 +70,9 @@ Future _runApp() async { final barTabService = DriftBarTabService(database: database); final settingsService = DriftSettingsService(database: database); final pinLockService = PinLockService(maxLockout: const Duration(minutes: 1)); - final apiClient = KoolTapApiClient(); + final apiClient = KoolTapApiClient( + appVersion: (await PackageInfo.fromPlatform()).version, + ); final remoteSyncService = RemoteSyncService( database: database, diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index 80ef128..0da8ad8 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -434,6 +434,9 @@ enum RemoteSyncEventType { syncFailed, conflict, unauthorized, + + /// The backoffice no longer accepts this app version. + updateRequired, } class RemoteSyncEvent { diff --git a/lib/services/kooltap_api_client.dart b/lib/services/kooltap_api_client.dart index 03dd337..de97667 100644 --- a/lib/services/kooltap_api_client.dart +++ b/lib/services/kooltap_api_client.dart @@ -26,6 +26,13 @@ class UnauthorizedException extends ApiException { : super(401, message ?? 'Unauthorized'); } +/// This app is older than the backoffice accepts; it has to be updated +/// before it can sync again. The token stays valid. +class UpdateRequiredException extends ApiException { + const UpdateRequiredException({super.code, String? message}) + : super(426, message ?? 'App update required'); +} + class RateLimitedException extends ApiException { final Duration retryAfter; @@ -43,15 +50,26 @@ class KoolTapApiClient { final http.Client _client; final String baseUrl; - KoolTapApiClient({http.Client? client, this.baseUrl = kRemoteServerUrl}) - : _client = client ?? http.Client(); + /// Sent with every request so the backoffice can refuse an app that is too + /// old (`426 app_update_required`). + final String? appVersion; + + KoolTapApiClient({ + http.Client? client, + this.baseUrl = kRemoteServerUrl, + this.appVersion, + }) : _client = client ?? http.Client(); + + Map get _versionHeader => { + 'X-App-Version': ?appVersion, + }; static const _timeout = Duration(seconds: 15); static const _defaultRetryAfter = Duration(seconds: 60); Future> getVenues() async { final response = await _client - .get(Uri.parse('$baseUrl/venues/')) + .get(Uri.parse('$baseUrl/venues/'), headers: _versionHeader) .timeout(_timeout); final data = _decode(response) as List; @@ -117,6 +135,7 @@ class KoolTapApiClient { 'POST', Uri.parse('$baseUrl/devices/$deviceId/images'), ) + ..headers.addAll(_versionHeader) ..headers['Authorization'] = 'Bearer $token' ..files.add( http.MultipartFile.fromBytes('file', bytes, filename: filename), @@ -133,7 +152,7 @@ class KoolTapApiClient { /// A product picture (public: pictures never change at their address). Future> downloadImage(String imageId) async { final response = await _client - .get(Uri.parse('$baseUrl/images/$imageId')) + .get(Uri.parse('$baseUrl/images/$imageId'), headers: _versionHeader) .timeout(_timeout * 2); if (response.statusCode != 200) { throw ApiException(response.statusCode, 'Image $imageId not available'); @@ -151,6 +170,7 @@ class KoolTapApiClient { Uri.parse('$baseUrl$path'), headers: { 'Content-Type': 'application/json', + ..._versionHeader, if (token != null) 'Authorization': 'Bearer $token', }, body: jsonEncode(body), @@ -170,6 +190,11 @@ class KoolTapApiClient { throw UnauthorizedException(code: error.code, message: error.message); } + if (status == 426) { + final error = _error(response); + throw UpdateRequiredException(code: error.code, message: error.message); + } + if (status == 429) { final seconds = int.tryParse(response.headers['retry-after'] ?? ''); throw RateLimitedException( diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 4faf03e..65519ad 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -273,6 +273,13 @@ class RemoteSyncService { stopPeriodicSync(); _emit(RemoteSyncEventType.unauthorized); return; + } on UpdateRequiredException catch (e) { + // Retrying can't help until the app is updated. The token and every + // outbox stay, so nothing is lost; a manual sync or restart tries again + debugPrint('RemoteSyncService: app update required'); + stopPeriodicSync(); + _emit(RemoteSyncEventType.updateRequired, message: e.message); + return; } on RateLimitedException catch (e) { nextDelay = await _recordFailure(e, retryAfter: e.retryAfter); } catch (e, stack) { @@ -328,6 +335,8 @@ class RemoteSyncService { ); } on UnauthorizedException { rethrow; + } on UpdateRequiredException { + rethrow; } catch (e) { debugPrint('RemoteSyncService: picture upload failed: $e'); } diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index 1939c4e..173b967 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -29,6 +29,12 @@ class RemoteSyncViewModel extends ChangeNotifier { /// The server rejected this device's token; it needs to be linked again. bool get wasRevoked => _wasRevoked; + String? _updateRequired; + + /// The backoffice refused this app version, with its message: the app has + /// to be updated before it syncs again. + String? get updateRequired => _updateRequired; + DateTime? _lastSyncAt; DateTime? get lastSyncAt => _lastSyncAt; @@ -130,12 +136,15 @@ class RemoteSyncViewModel extends ChangeNotifier { _lastSyncAt = DateTime.now(); _lastError = null; _isOffline = false; + _updateRequired = null; case RemoteSyncEventType.syncFailed: _lastError = event.message; _isOffline = event.offline; case RemoteSyncEventType.unauthorized: _isConfigured = false; _wasRevoked = true; + case RemoteSyncEventType.updateRequired: + _updateRequired = event.message ?? ''; case RemoteSyncEventType.tabClosedRemotely: case RemoteSyncEventType.conflict: break; diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index 1744044..48cfc09 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -108,6 +108,34 @@ class _BarScreenViewState extends State { ); } + void _showUpdateRequired(String message) { + final l10n = AppLocalizations.of(context); + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.appUpdateRequired), + content: SelectableText( + message.isEmpty + ? l10n.appUpdateRequiredDescription + : '${l10n.appUpdateRequiredDescription}\n\n$message', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + context.push('/settings'); + }, + child: Text(l10n.update), + ), + ], + ), + ); + } + void _logout() { context.read().lock(); context.read().lock(); @@ -167,6 +195,16 @@ class _BarScreenViewState extends State { icon: const Icon(Icons.cloud_off_rounded), ), ], + if (context.watch().updateRequired + case final message?) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.appUpdateRequired, + color: Theme.of(context).colorScheme.error, + onPressed: () => _showUpdateRequired(message), + icon: const Icon(Icons.system_update_rounded), + ), + ], // A failing sync is otherwise invisible: the bar keeps working // offline while nothing from the website arrives if (context.watch().lastError diff --git a/pubspec.yaml b/pubspec.yaml index 00ca35e..6799079 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.15+0 +version: 1.4.0+1 environment: sdk: ^3.12.2 diff --git a/test/kooltap_api_client_test.dart b/test/kooltap_api_client_test.dart index 7dca68c..a288469 100644 --- a/test/kooltap_api_client_test.dart +++ b/test/kooltap_api_client_test.dart @@ -88,5 +88,44 @@ void main() { throwsA(isA()), ); }); + + test('sends the app version', () async { + late http.Request sent; + final client = KoolTapApiClient( + client: MockClient((request) async { + sent = request; + return http.Response('{}', 200); + }), + baseUrl: 'https://api.test', + appVersion: '1.4.0', + ); + + await client.sync( + token: 'token', + request: const SyncRequest(deviceId: 'device', openTabs: []), + ); + + expect(sent.headers['X-App-Version'], '1.4.0'); + }); + + test('treats a 426 as an app that must be updated', () async { + final client = _clientReturning( + _error(426, 'app_update_required', 'Update the app to version 1.4.0'), + ); + + await expectLater( + client.sync( + token: 'token', + request: const SyncRequest(deviceId: 'device', openTabs: []), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Update the app to version 1.4.0', + ), + ), + ); + }); }); } diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index e4569c3..3dd994e 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -243,6 +243,22 @@ void main() { expect(await service.isConfigured, false); }); + test('stops syncing but keeps the token when the app is too old', () async { + server.syncResponse = http.Response( + jsonEncode({ + 'error': {'code': 'app_update_required', 'message': 'Update'}, + }), + 426, + ); + + await service.sync(); + await pumpEventQueue(); + + expect(eventTypes(), [RemoteSyncEventType.updateRequired]); + expect(events.single.message, 'Update'); + expect(await service.isConfigured, true); + }); + test('creates planned tabs that are not open yet, once', () async { await barTabService.createTab(customerName: 'Alice'); server.respondWith({