feat: more easter eggs + version checking

This commit is contained in:
2026-10-27 00:17:18 +01:00
parent 4d9ba67eb1
commit 1aee0541c4
14 changed files with 178 additions and 6 deletions
+2
View File
@@ -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}",
+12
View File
@@ -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:
+7
View File
@@ -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';
+7
View File
@@ -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';
+2
View File
@@ -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}",
+4 -1
View File
@@ -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<void> _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,
+3
View File
@@ -434,6 +434,9 @@ enum RemoteSyncEventType {
syncFailed,
conflict,
unauthorized,
/// The backoffice no longer accepts this app version.
updateRequired,
}
class RemoteSyncEvent {
+29 -4
View File
@@ -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<String, String> get _versionHeader => {
'X-App-Version': ?appVersion,
};
static const _timeout = Duration(seconds: 15);
static const _defaultRetryAfter = Duration(seconds: 60);
Future<List<Venue>> 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<dynamic>;
@@ -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<List<int>> 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(
+9
View File
@@ -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');
}
@@ -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;
+38
View File
@@ -108,6 +108,34 @@ class _BarScreenViewState extends State<BarScreenView> {
);
}
void _showUpdateRequired(String message) {
final l10n = AppLocalizations.of(context);
showDialog<void>(
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<PinLockViewModel>().lock();
context.read<AdminPinViewModel>().lock();
@@ -167,6 +195,16 @@ class _BarScreenViewState extends State<BarScreenView> {
icon: const Icon(Icons.cloud_off_rounded),
),
],
if (context.watch<RemoteSyncViewModel>().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<RemoteSyncViewModel>().lastError
+1 -1
View File
@@ -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
+39
View File
@@ -88,5 +88,44 @@ void main() {
throwsA(isA<UnauthorizedException>()),
);
});
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<UpdateRequiredException>().having(
(e) => e.message,
'message',
'Update the app to version 1.4.0',
),
),
);
});
});
}
+16
View File
@@ -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({