feat: more easter eggs + version checking
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -434,6 +434,9 @@ enum RemoteSyncEventType {
|
||||
syncFailed,
|
||||
conflict,
|
||||
unauthorized,
|
||||
|
||||
/// The backoffice no longer accepts this app version.
|
||||
updateRequired,
|
||||
}
|
||||
|
||||
class RemoteSyncEvent {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user