feat(version-check): add in-app update prompt with Remote Config

Adds a production-grade in-app version check that prompts users to update when a new build is available. Soft updates are dismissable. Force updates block the app entirely. Configured via FirebaseRemote Config so rollouts can be triggered without redeploying.

- Sealed result types (NoUpdate / SoftUpdate / ForceUpdate) for type-safe pattern matching
- AppVersionCheckService
- AppUpdateGate widget encapsulates listener, route guard and dialog wireup, isolated from save_family_app
- Serialized notifier operations prevent race between dismiss and refresh, mounted checks blindar disposal edge cases                                                                                                  - Build-aware dismiss persistence via SharedPreferences
This commit is contained in:
2026-04-09 14:52:37 +02:00
parent 506dd5a80f
commit 693f55369c
24 changed files with 887 additions and 31 deletions

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'app_version_check.dart';
Future<void> showAppUpdateDialog(
BuildContext context, {
required AvailableUpdate result,
VoidCallback? onDismiss,
VoidCallback? onUpdateTapped,
}) {
final isForce = result is ForceUpdate;
return showDialog<void>(
context: context,
barrierDismissible: !isForce,
builder: (dialogContext) {
return PopScope(
canPop: !isForce,
child: AlertDialog(
title: Text(
dialogContext.translate(
isForce
? I18n.appUpdateRequiredTitle
: I18n.appUpdateAvailableTitle,
),
),
content: Text(
result.message.isNotEmpty
? result.message
: dialogContext.translate(
isForce
? I18n.appUpdateRequiredMessage
: I18n.appUpdateAvailableMessage,
),
),
actions: [
if (!isForce)
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
onDismiss?.call();
},
child: Text(dialogContext.translate(I18n.appUpdateLater)),
),
TextButton(
onPressed: () => _launchStore(result.storeUrl, onUpdateTapped),
child: Text(dialogContext.translate(I18n.appUpdateNow)),
),
],
),
);
},
);
}
Future<void> _launchStore(String storeUrl, VoidCallback? onTapped) async {
onTapped?.call();
try {
final uri = Uri.tryParse(storeUrl);
if (uri == null) {
debugPrint('[AppUpdateDialog] invalid store URL: $storeUrl');
return;
}
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched) {
debugPrint('[AppUpdateDialog] launchUrl returned false for $storeUrl');
}
} catch (e) {
debugPrint('[AppUpdateDialog] launchUrl failed: $e');
}
}

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_app_platform/navigation/app_router.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'app_update_dialog.dart';
import 'app_version_check.dart';
class AppUpdateGate extends ConsumerStatefulWidget {
const AppUpdateGate({super.key, required this.child});
final Widget child;
@override
ConsumerState<AppUpdateGate> createState() => _AppUpdateGateState();
}
class _AppUpdateGateState extends ConsumerState<AppUpdateGate> {
bool _dialogVisible = false;
VoidCallback? _pendingRouterListener;
@override
void dispose() {
_detachPendingRouterListener();
super.dispose();
}
void _detachPendingRouterListener() {
final listener = _pendingRouterListener;
if (listener != null) {
appRouter.routerDelegate.removeListener(listener);
_pendingRouterListener = null;
}
}
bool _isStableRoute() {
final path = appRouter.routerDelegate.currentConfiguration.uri.path;
return path.startsWith(AppRoutes.dashboard) ||
path.startsWith(AppRoutes.legacyDashboard);
}
void _onResultEmitted(AppVersionCheckResult result) {
if (result is! AvailableUpdate) {
_detachPendingRouterListener();
return;
}
if (_dialogVisible) return;
if (_isStableRoute()) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_show(result);
});
return;
}
_detachPendingRouterListener();
void onChange() {
if (!_isStableRoute()) return;
_detachPendingRouterListener();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_show(result);
});
}
_pendingRouterListener = onChange;
appRouter.routerDelegate.addListener(onChange);
}
void _show(AvailableUpdate result) {
if (!mounted) return;
if (_dialogVisible) return;
final ctx = appRouter.routerDelegate.navigatorKey.currentContext;
if (ctx == null) return;
final tracking = ref.read(sfTrackingProvider);
final kind = _kindLabel(result);
tracking.appUpdateDialogShown(
kind: kind,
latestBuild: result.latestBuild,
currentBuild: result.currentBuild,
);
_dialogVisible = true;
showAppUpdateDialog(
ctx,
result: result,
onDismiss: () {
if (result is SoftUpdate) {
tracking.appUpdateDialogDismissed(latestBuild: result.latestBuild);
ref.read(appVersionCheckProvider.notifier).markSoftDismissed(result);
}
},
onUpdateTapped: () => tracking.appUpdateCtaTapped(
kind: kind,
latestBuild: result.latestBuild,
),
).whenComplete(() {
_dialogVisible = false;
});
}
String _kindLabel(AvailableUpdate result) {
return switch (result) {
SoftUpdate() => 'soft',
ForceUpdate() => 'force',
};
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<AppVersionCheckResult>>(
appVersionCheckProvider,
(previous, next) {
next.whenData(_onResultEmitted);
},
);
return widget.child;
}
}

View File

@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_version_check_result.dart';
import 'app_version_check_service.dart';
export 'app_version_check_result.dart';
export 'app_version_check_service.dart';
export 'dismissed_build_store.dart';
export 'remote_config_reader.dart';
final appVersionCheckServiceProvider = Provider<AppVersionCheckService>((ref) {
return AppVersionCheckService();
});
class AppVersionCheck extends AsyncNotifier<AppVersionCheckResult> {
Future<void>? _inflight;
AppVersionCheckService get _service =>
ref.read(appVersionCheckServiceProvider);
@override
Future<AppVersionCheckResult> build() {
return _service.check();
}
Future<void> refresh() => _runSerialized(() async {
state = AsyncData(await _service.check());
});
Future<void> markSoftDismissed(SoftUpdate result) =>
_runSerialized(() async {
await _service.markSoftDismissed(result.latestBuild);
state = const AsyncData(NoUpdate());
});
Future<void> _runSerialized(Future<void> Function() op) async {
final previous = _inflight;
final completer = Completer<void>();
_inflight = completer.future;
try {
if (previous != null) {
try {
await previous;
} catch (_) {}
}
await op();
} finally {
completer.complete();
if (identical(_inflight, completer.future)) {
_inflight = null;
}
}
}
}
final appVersionCheckProvider =
AsyncNotifierProvider<AppVersionCheck, AppVersionCheckResult>(
AppVersionCheck.new,
);

View File

@@ -0,0 +1,39 @@
sealed class AppVersionCheckResult {
const AppVersionCheckResult();
}
class NoUpdate extends AppVersionCheckResult {
const NoUpdate();
}
sealed class AvailableUpdate extends AppVersionCheckResult {
const AvailableUpdate({
required this.message,
required this.storeUrl,
required this.latestBuild,
required this.currentBuild,
});
final String message;
final String storeUrl;
final int latestBuild;
final int currentBuild;
}
class SoftUpdate extends AvailableUpdate {
const SoftUpdate({
required super.message,
required super.storeUrl,
required super.latestBuild,
required super.currentBuild,
});
}
class ForceUpdate extends AvailableUpdate {
const ForceUpdate({
required super.message,
required super.storeUrl,
required super.latestBuild,
required super.currentBuild,
});
}

View File

@@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'app_version_check_result.dart';
import 'dismissed_build_store.dart';
import 'remote_config_keys.dart';
import 'remote_config_reader.dart';
typedef CurrentBuildLoader = Future<int> Function();
Future<int> defaultCurrentBuildLoader() async {
final info = await PackageInfo.fromPlatform();
return int.tryParse(info.buildNumber) ?? 0;
}
class AppVersionCheckService {
AppVersionCheckService({
RemoteConfigReader? remoteConfig,
DismissedBuildStore? dismissedStore,
CurrentBuildLoader? currentBuildLoader,
bool? isIos,
}) : _remoteConfig = remoteConfig ?? FirebaseRemoteConfigReader(),
_dismissedStore = dismissedStore ?? SharedPrefsDismissedBuildStore(),
_currentBuildLoader = currentBuildLoader ?? defaultCurrentBuildLoader,
_isIos = isIos ?? Platform.isIOS;
final RemoteConfigReader _remoteConfig;
final DismissedBuildStore _dismissedStore;
final CurrentBuildLoader _currentBuildLoader;
final bool _isIos;
Future<AppVersionCheckResult> check() async {
try {
final currentBuild = await _currentBuildLoader();
try {
await _remoteConfig.fetchAndActivate();
} catch (e) {
debugPrint('[AppVersionCheck] RC fetch failed: $e');
}
final minRequired = _remoteConfig.getInt(RemoteConfigKeys.minRequiredBuild);
final latest = _remoteConfig.getInt(RemoteConfigKeys.latestBuild);
final forceFlag = _remoteConfig.getBool(RemoteConfigKeys.updateForce);
final message = _remoteConfig.getString(RemoteConfigKeys.updateMessage);
final storeUrl = _isIos
? _remoteConfig.getString(RemoteConfigKeys.updateUrlIos)
: _remoteConfig.getString(RemoteConfigKeys.updateUrlAndroid);
if (forceFlag || currentBuild < minRequired) {
return ForceUpdate(
message: message,
storeUrl: storeUrl,
latestBuild: latest,
currentBuild: currentBuild,
);
}
if (currentBuild < latest) {
final dismissedFor = await _dismissedStore.read();
if (latest <= dismissedFor) {
return const NoUpdate();
}
return SoftUpdate(
message: message,
storeUrl: storeUrl,
latestBuild: latest,
currentBuild: currentBuild,
);
}
return const NoUpdate();
} catch (e) {
debugPrint('[AppVersionCheck] check failed: $e');
return const NoUpdate();
}
}
Future<void> markSoftDismissed(int latestBuild) async {
try {
await _dismissedStore.write(latestBuild);
} catch (e) {
debugPrint('[AppVersionCheck] markSoftDismissed failed: $e');
}
}
}

View File

@@ -0,0 +1,22 @@
import 'package:shared_preferences/shared_preferences.dart';
abstract class DismissedBuildStore {
Future<int> read();
Future<void> write(int latestBuild);
}
class SharedPrefsDismissedBuildStore implements DismissedBuildStore {
static const _key = 'app_update_dismissed_for_latest_build';
@override
Future<int> read() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_key) ?? 0;
}
@override
Future<void> write(int latestBuild) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_key, latestBuild);
}
}

View File

@@ -0,0 +1,10 @@
class RemoteConfigKeys {
const RemoteConfigKeys._();
static const minRequiredBuild = 'min_required_build';
static const latestBuild = 'latest_build';
static const updateForce = 'update_force';
static const updateMessage = 'update_message';
static const updateUrlIos = 'update_url_ios';
static const updateUrlAndroid = 'update_url_android';
}

View File

@@ -0,0 +1,27 @@
import 'package:firebase_remote_config/firebase_remote_config.dart';
abstract class RemoteConfigReader {
Future<void> fetchAndActivate();
int getInt(String key);
bool getBool(String key);
String getString(String key);
}
class FirebaseRemoteConfigReader implements RemoteConfigReader {
FirebaseRemoteConfigReader([FirebaseRemoteConfig? rc])
: _rc = rc ?? FirebaseRemoteConfig.instance;
final FirebaseRemoteConfig _rc;
@override
Future<void> fetchAndActivate() => _rc.fetchAndActivate();
@override
int getInt(String key) => _rc.getInt(key);
@override
bool getBool(String key) => _rc.getBool(key);
@override
String getString(String key) => _rc.getString(key);
}

View File

@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import '../config/env/environment_enum.dart';
import '../firebase_options_dev.dart' as dev_options;
import '../firebase_options_staging.dart' as staging_options;
import 'app_version_check/remote_config_keys.dart';
Future<void> setupFirebase(EnvironmentEnum env) async {
final FirebaseOptions options;
@@ -48,6 +49,15 @@ Future<void> setupFirebase(EnvironmentEnum env) async {
: const Duration(hours: 12),
),
);
await remoteConfig.setDefaults(<String, Object>{
RemoteConfigKeys.minRequiredBuild: 0,
RemoteConfigKeys.latestBuild: 0,
RemoteConfigKeys.updateForce: false,
RemoteConfigKeys.updateMessage: '',
RemoteConfigKeys.updateUrlIos: 'https://apps.apple.com/app/id6759873957',
RemoteConfigKeys.updateUrlAndroid:
'https://play.google.com/store/apps/details?id=com.savefamily.app',
});
try {
await remoteConfig.fetchAndActivate();
} catch (e) {

View File

@@ -2,6 +2,8 @@ import 'package:auth/auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_app_platform/core/app_version_check/app_update_gate.dart';
import 'package:sf_app_platform/core/app_version_check/app_version_check.dart';
import 'package:sf_app_platform/core/config/app_mode.dart';
import 'package:sf_app_platform/navigation/app_router.dart';
import 'package:navigation/navigation.dart';
@@ -104,6 +106,7 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
_onRouteChanged();
}
ref.read(permissionsProvider.notifier).checkPermissions();
ref.read(appVersionCheckProvider.notifier).refresh();
} else if (state == AppLifecycleState.paused) {
_walletHeartbeat?.stop();
_legacyHeartbeat?.stop();
@@ -128,31 +131,32 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
});
});
return MaterialApp.router(
title: 'SaveFamily',
theme: ThemeData(
fontFamily: AppFonts.stolzl,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF329E95)),
),
routerConfig: appRouter,
debugShowCheckedModeBanner: false,
localizationsDelegates: [
// CountryLocalizations.delegate,
SFLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [for (final lang in supportedLanguages) Locale(lang)],
localeResolutionCallback: (locale, supportedLocales) {
if (locale == null) return supportedLocales.first;
for (var supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale.languageCode) {
return supportedLocale;
return AppUpdateGate(
child: MaterialApp.router(
title: 'SaveFamily',
theme: ThemeData(
fontFamily: AppFonts.stolzl,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF329E95)),
),
routerConfig: appRouter,
debugShowCheckedModeBanner: false,
localizationsDelegates: [
SFLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: [for (final lang in supportedLanguages) Locale(lang)],
localeResolutionCallback: (locale, supportedLocales) {
if (locale == null) return supportedLocales.first;
for (var supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale.languageCode) {
return supportedLocale;
}
}
}
return supportedLocales.first;
},
return supportedLocales.first;
},
),
);
}
}

View File

@@ -106,6 +106,9 @@ dependencies:
firebase_crashlytics: ^5.1.0
firebase_analytics: ^12.2.0
firebase_remote_config: ^6.3.0
package_info_plus: ^8.3.1
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
firebase_messaging: ^16.1.3
firebase_performance: ^0.11.2

View File

@@ -0,0 +1,308 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sf_app_platform/core/app_version_check/app_version_check.dart';
import 'package:sf_app_platform/core/app_version_check/remote_config_keys.dart';
class _FakeRemoteConfig implements RemoteConfigReader {
_FakeRemoteConfig({
this.minRequired = 0,
this.latest = 0,
this.force = false,
this.message = '',
this.iosUrl = 'ios-url',
this.androidUrl = 'android-url',
this.fetchThrows = false,
});
int minRequired;
int latest;
bool force;
String message;
String iosUrl;
String androidUrl;
bool fetchThrows;
int fetchCallCount = 0;
@override
Future<void> fetchAndActivate() async {
fetchCallCount++;
if (fetchThrows) throw Exception('boom');
}
@override
int getInt(String key) => switch (key) {
RemoteConfigKeys.minRequiredBuild => minRequired,
RemoteConfigKeys.latestBuild => latest,
_ => 0,
};
@override
bool getBool(String key) =>
key == RemoteConfigKeys.updateForce ? force : false;
@override
String getString(String key) => switch (key) {
RemoteConfigKeys.updateMessage => message,
RemoteConfigKeys.updateUrlIos => iosUrl,
RemoteConfigKeys.updateUrlAndroid => androidUrl,
_ => '',
};
}
class _FakeDismissedStore implements DismissedBuildStore {
_FakeDismissedStore({this.value = 0, this.writeThrows = false});
int value;
bool writeThrows;
int writeCallCount = 0;
@override
Future<int> read() async => value;
@override
Future<void> write(int latestBuild) async {
writeCallCount++;
if (writeThrows) throw Exception('write boom');
value = latestBuild;
}
}
AppVersionCheckService _buildService({
required _FakeRemoteConfig rc,
required _FakeDismissedStore store,
required int currentBuild,
bool isIos = false,
}) {
return AppVersionCheckService(
remoteConfig: rc,
dismissedStore: store,
currentBuildLoader: () async => currentBuild,
isIos: isIos,
);
}
void main() {
group('AppVersionCheckService.check', () {
test('returns NoUpdate when current build matches latest and no force',
() async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 7),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<NoUpdate>());
});
test('returns NoUpdate when nothing requires attention', () async {
final service = _buildService(
rc: _FakeRemoteConfig(),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<NoUpdate>());
});
test('returns SoftUpdate when current build is behind latest', () async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 8, message: 'New stuff'),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<SoftUpdate>());
final soft = result as SoftUpdate;
expect(soft.latestBuild, 8);
expect(soft.currentBuild, 7);
expect(soft.message, 'New stuff');
});
test('returns ForceUpdate when current build is below min required',
() async {
final service = _buildService(
rc: _FakeRemoteConfig(minRequired: 8, latest: 8),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<ForceUpdate>());
final force = result as ForceUpdate;
expect(force.latestBuild, 8);
expect(force.currentBuild, 7);
});
test('returns ForceUpdate when force flag is true even with current build matching',
() async {
final service = _buildService(
rc: _FakeRemoteConfig(force: true, latest: 7),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<ForceUpdate>());
});
test('force flag wins over soft when both conditions are met', () async {
final service = _buildService(
rc: _FakeRemoteConfig(force: true, latest: 8),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<ForceUpdate>());
});
test('returns NoUpdate when latest is dismissed', () async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 8),
store: _FakeDismissedStore(value: 8),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<NoUpdate>());
});
test('returns NoUpdate when dismissed value is greater than latest',
() async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 8),
store: _FakeDismissedStore(value: 10),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<NoUpdate>());
});
test('returns SoftUpdate when latest is greater than dismissed', () async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 9),
store: _FakeDismissedStore(value: 8),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<SoftUpdate>());
});
test('dismiss persistence does NOT block force update', () async {
final service = _buildService(
rc: _FakeRemoteConfig(force: true),
store: _FakeDismissedStore(value: 9999),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<ForceUpdate>());
});
test('uses iOS store URL when isIos is true', () async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 8, iosUrl: 'apple', androidUrl: 'play'),
store: _FakeDismissedStore(),
currentBuild: 7,
isIos: true,
);
final result = await service.check() as SoftUpdate;
expect(result.storeUrl, 'apple');
});
test('uses Android store URL when isIos is false', () async {
final service = _buildService(
rc: _FakeRemoteConfig(latest: 8, iosUrl: 'apple', androidUrl: 'play'),
store: _FakeDismissedStore(),
currentBuild: 7,
isIos: false,
);
final result = await service.check() as SoftUpdate;
expect(result.storeUrl, 'play');
});
test('returns NoUpdate when fetchAndActivate throws', () async {
final service = _buildService(
rc: _FakeRemoteConfig(fetchThrows: true, latest: 8),
store: _FakeDismissedStore(),
currentBuild: 7,
);
final result = await service.check();
expect(result, isA<SoftUpdate>(),
reason:
'fetch failure should fall back to cached values, not return none');
});
test('check still calls fetchAndActivate', () async {
final rc = _FakeRemoteConfig();
final service = _buildService(
rc: rc,
store: _FakeDismissedStore(),
currentBuild: 7,
);
await service.check();
expect(rc.fetchCallCount, 1);
});
test('returns NoUpdate when current build loader throws', () async {
final service = AppVersionCheckService(
remoteConfig: _FakeRemoteConfig(),
dismissedStore: _FakeDismissedStore(),
currentBuildLoader: () async => throw Exception('package_info crash'),
);
final result = await service.check();
expect(result, isA<NoUpdate>());
});
});
group('AppVersionCheckService.markSoftDismissed', () {
test('writes the latest build to the dismiss store', () async {
final store = _FakeDismissedStore();
final service = _buildService(
rc: _FakeRemoteConfig(),
store: store,
currentBuild: 7,
);
await service.markSoftDismissed(8);
expect(store.value, 8);
expect(store.writeCallCount, 1);
});
test('swallows write errors silently', () async {
final store = _FakeDismissedStore(writeThrows: true);
final service = _buildService(
rc: _FakeRemoteConfig(),
store: store,
currentBuild: 7,
);
await expectLater(service.markSoftDismissed(8), completes);
});
});
}

View File

@@ -75,6 +75,7 @@ dependencies:
# ---------------- Utilities ----------------
uuid: ^4.5.3
plugin_platform_interface: ^2.0.2
package_info_plus: ^8.3.1
dev_dependencies:
# ---------------- Linting ----------------

View File

@@ -857,5 +857,11 @@
"videoCall": "Videoanruf",
"watchesOnMap": "Smartwatches auf der Karte:",
"wifiSettings": "WLAN-Einstellungen",
"yesterday": "Gestern"
"yesterday": "Gestern",
"appUpdateAvailableTitle": "Update verfügbar",
"appUpdateAvailableMessage": "Eine neue Version von SaveFamily ist verfügbar.",
"appUpdateRequiredTitle": "Update erforderlich",
"appUpdateRequiredMessage": "Du musst SaveFamily aktualisieren, um die App weiterhin zu nutzen.",
"appUpdateLater": "Später",
"appUpdateNow": "Jetzt aktualisieren"
}

View File

@@ -857,5 +857,11 @@
"renewCardPinTitle": "Enter your security PIN to renew the card",
"renewCardSuccess": "Card renewed successfully",
"renewCardError": "Error renewing card",
"passwordLabel": "Password (6 to 12 characters)"
"passwordLabel": "Password (6 to 12 characters)",
"appUpdateAvailableTitle": "Update available",
"appUpdateAvailableMessage": "A new version of SaveFamily is available.",
"appUpdateRequiredTitle": "Update required",
"appUpdateRequiredMessage": "You need to update SaveFamily to keep using the app.",
"appUpdateLater": "Later",
"appUpdateNow": "Update now"
}

View File

@@ -857,5 +857,11 @@
"renewCardTokenHint": "Introduce el código de la nueva tarjeta",
"renewCardPinTitle": "Introduce tu PIN de seguridad para renovar la tarjeta",
"renewCardSuccess": "Tarjeta renovada correctamente",
"renewCardError": "Error al renovar la tarjeta"
"renewCardError": "Error al renovar la tarjeta",
"appUpdateAvailableTitle": "Actualización disponible",
"appUpdateAvailableMessage": "Hay una nueva versión de SaveFamily disponible.",
"appUpdateRequiredTitle": "Actualización requerida",
"appUpdateRequiredMessage": "Necesitas actualizar SaveFamily para seguir usando la app.",
"appUpdateLater": "Más tarde",
"appUpdateNow": "Actualizar ahora"
}

View File

@@ -857,5 +857,11 @@
"videoCall": "Appel vidéo",
"watchesOnMap": "Montres connectées sur la carte :",
"wifiSettings": "Paramètres WiFi",
"yesterday": "Hier"
"yesterday": "Hier",
"appUpdateAvailableTitle": "Mise à jour disponible",
"appUpdateAvailableMessage": "Une nouvelle version de SaveFamily est disponible.",
"appUpdateRequiredTitle": "Mise à jour requise",
"appUpdateRequiredMessage": "Vous devez mettre à jour SaveFamily pour continuer à utiliser l'application.",
"appUpdateLater": "Plus tard",
"appUpdateNow": "Mettre à jour maintenant"
}

View File

@@ -857,5 +857,11 @@
"videoCall": "Videochiamata",
"watchesOnMap": "Smartwatch sulla mappa:",
"wifiSettings": "Impostazioni WiFi",
"yesterday": "Ieri"
"yesterday": "Ieri",
"appUpdateAvailableTitle": "Aggiornamento disponibile",
"appUpdateAvailableMessage": "È disponibile una nuova versione di SaveFamily.",
"appUpdateRequiredTitle": "Aggiornamento richiesto",
"appUpdateRequiredMessage": "Devi aggiornare SaveFamily per continuare a usare l'app.",
"appUpdateLater": "Più tardi",
"appUpdateNow": "Aggiorna ora"
}

View File

@@ -857,5 +857,11 @@
"videoCall": "Videochamada",
"watchesOnMap": "Relógios inteligentes no mapa:",
"wifiSettings": "Configurações WiFi",
"yesterday": "Ontem"
"yesterday": "Ontem",
"appUpdateAvailableTitle": "Atualização disponível",
"appUpdateAvailableMessage": "Há uma nova versão do SaveFamily disponível.",
"appUpdateRequiredTitle": "Atualização necessária",
"appUpdateRequiredMessage": "Precisas atualizar o SaveFamily para continuar a usar a app.",
"appUpdateLater": "Mais tarde",
"appUpdateNow": "Atualizar agora"
}

View File

@@ -86,6 +86,12 @@ class I18n {
static const String appsSurveillance = 'appsSurveillance';
static const String appStore = 'appStore';
static const String appsUse = 'appsUse';
static const String appUpdateAvailableMessage = 'appUpdateAvailableMessage';
static const String appUpdateAvailableTitle = 'appUpdateAvailableTitle';
static const String appUpdateLater = 'appUpdateLater';
static const String appUpdateNow = 'appUpdateNow';
static const String appUpdateRequiredMessage = 'appUpdateRequiredMessage';
static const String appUpdateRequiredTitle = 'appUpdateRequiredTitle';
static const String appUsers = 'appUsers';
static const String average = 'average';
static const String back = 'back';

View File

@@ -4,6 +4,7 @@ library;
export 'src/clients/debug_tracking_client.dart';
export 'src/clients/firebase_tracking_client.dart';
export 'src/mixins/account_tracking.dart';
export 'src/mixins/app_update_tracking.dart';
export 'src/mixins/auth_tracking.dart';
export 'src/mixins/contacts_tracking.dart';
export 'src/mixins/control_panel_tracking.dart';

View File

@@ -0,0 +1,26 @@
import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'app_update';
mixin AppUpdateTracking on Tracking {
Future<void> appUpdateDialogShown({
required String kind,
required int latestBuild,
required int currentBuild,
}) => trackEvent('${_prefix}_dialog_shown', {
'kind': kind,
'latest_build': latestBuild,
'current_build': currentBuild,
});
Future<void> appUpdateDialogDismissed({required int latestBuild}) =>
trackEvent('${_prefix}_dialog_dismissed', {'latest_build': latestBuild});
Future<void> appUpdateCtaTapped({
required String kind,
required int latestBuild,
}) => trackEvent('${_prefix}_cta_tapped', {
'kind': kind,
'latest_build': latestBuild,
});
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sf_tracking/src/mixins/account_tracking.dart';
import 'package:sf_tracking/src/mixins/app_update_tracking.dart';
import 'package:sf_tracking/src/mixins/auth_tracking.dart';
import 'package:sf_tracking/src/mixins/contacts_tracking.dart';
import 'package:sf_tracking/src/mixins/control_panel_tracking.dart';
@@ -29,7 +30,8 @@ class SfTrackingRepository extends Tracking
SupportTracking,
OnboardingTracking,
LocationTracking,
ControlPanelTracking
ControlPanelTracking,
AppUpdateTracking
implements NavigationTracking {
SfTrackingRepository({required List<TrackingClient> clients})
: _clients = clients;

View File

@@ -1197,6 +1197,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description: