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:
@@ -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');
|
||||
}
|
||||
}
|
||||
123
apps/mobile_app/lib/core/app_version_check/app_update_gate.dart
Normal file
123
apps/mobile_app/lib/core/app_version_check/app_update_gate.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 ----------------
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
26
packages/sf_tracking/lib/src/mixins/app_update_tracking.dart
Normal file
26
packages/sf_tracking/lib/src/mixins/app_update_tracking.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user