Flutter forced updates, the why and how.
TL;DR (Too Lazy for Details)
For the brave souls who'd rather embark on a code quest than read lengthy explanations, your magical portal to GitHub awaits: GitHub Repository.
And for the rest of us who prefer words over wizardry, kindly proceed to the article below. Enjoy, code wizards and word warriors! ๐ฎ๐
Intro
In today's digital era, mobile applications are an integral part of our lives. However, getting users to update their apps can be a bit like convincing someone to embrace change. In this article, we'll explore the reasons behind app update reluctance and delve into effective strategies for managing updates, ensuring a seamless user experience. ๐๐ฒ
The Why: Understanding App Update Reluctance
Why do users hesitate to update their apps? Several factors come into play:
Bandwidth Constraints: Some users may have limited data or a slow internet connection, making updates a burdensome task. ๐๐ข
Personal Preference: Others might simply prefer the current app version, fearing that updates could disrupt their familiar user experience. ๐คทโโ๏ธ๐ด
Types of updates
Forced Updates: These updates leave users with no choice. They must update to continue using the app. It's akin to the "no entry without the latest ticket" policy. ๐๐ซ
Optional Updates: On the other hand, optional updates provide users with the freedom to decide. They can choose whether to embrace new features or stick with the current version. It's like offering a menu with today's specials โ some will order, others won't.
Strategy
Retrieve Version Data: We start by fetching two essential pieces of information from a remote service:
The minimum required version.
The currently available version.
Determine User's App Version: Next, we identify the version of the app that the user currently has installed.
Version Comparison and User Interaction:
If the user's app version is below the minimum required version, we immediately display a dialog. This dialog informs the user that they must update the app before proceeding further.
If the user's app version is lower than the currently available version but higher than the minimum required version, we present a dialog. In this dialog, we inform the user about the availability of a new version. Users have the option to update immediately or at a later time.
How I went about it
Remote Version Tracking: I went with Firebase remote config because it's fast, reliable and I didn't need to spin up my own backend.
Native Updater Package: I used the native_updater package. It enables iOS and Android-specific dialogs (Cupertino and Material) and utilizes the in_app_update package to enable in-app updates on Android through official APIs. If you prefer custom designs, you have that option too.
The data model
To represent the relevant information that I need to track
class AppVersionModel with _$AppVersionModel {
factory AppVersionModel({
@JsonKey(name: 'min_ios_ver') String? minIosVer,
@JsonKey(name: 'current_ios_ver') String? currentIosVer,
@JsonKey(name: 'min_android_ver') String? minAndroidVer,
@JsonKey(name: 'current_android_ver') String? currentAndroidVer,
@JsonKey(name: 'update_message') String? updateMessage,
}) = _AppVersionModel;
factory AppVersionModel.fromJson(Map<String, dynamic> json) =>
_$AppVersionModelFromJson(json);
}
iOS Versions:
Minimum iOS Version: This is the lowest iOS version that users are allowed to have. If their current iOS version is lower than this value, they will be required to update the app. Think of it as the app's entry ticket.
Current iOS Version: This represents the latest iOS version available. If a user's installed version is lower than this but higher than the minimum iOS version, they will be presented with an optional update. They can choose whether to update immediately or at a later time.
Android Versions:
Minimum Android Version: Similar to its iOS counterpart, this is the lowest Android version that users are allowed to have. If their current Android version is lower than this value, they will be compelled to update the app.
Current Android Version: This denotes the latest Android version available. If a user's installed version falls lower than this but higher than the minimum iOS version, they will be given the option to update. They can decide whether to update immediately or defer the process.
Update Message:
- The update message is the communication channel with users when encouraging them to update. It serves as a prompt, conveying the importance and benefits of the update. This message is pivotal in conveying the value of the update and encouraging users to take action.
We manage separate iOS and Android versions as a precaution against potential version code discrepancies that could arise when publishing on one platform but not the other.
Data provider
This class is responsible for getting the version data from a remote data source, for this case, I used Firebase remote config but you can switch out and use any other data source such as a REST API.
abstract class RemoteAppSettingsProvider {
Future<AppVersionModel> getAppVersion();
}
@Injectable(as: RemoteAppSettingsProvider)
class RemoteAppSettingsProviderImpl implements RemoteAppSettingsProvider {
@override
Future<AppVersionModel> getAppVersion() async {
final firebaseRemoteConfig = FirebaseRemoteConfig.instance;
await firebaseRemoteConfig.setConfigSettings(
RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 60),
minimumFetchInterval: Duration.zero,
),
);
await firebaseRemoteConfig.fetchAndActivate();
final appVersionStr = firebaseRemoteConfig.getString('app_versions');
return AppVersionModel.fromJson(
jsonDecode(appVersionStr) as Map<String, dynamic>,
);
}
}
Repository
The repository layer is an implementation of the domain interface and calls the remote data source.
@Injectable(as: AppSettingsInterface)
class AppSettingsRepository implements AppSettingsInterface {
final RemoteAppSettingsProvider remoteAppSettingsProvider;
AppSettingsRepository(this.remoteAppSettingsProvider);
@override
Future<Either<AppSettingsFailure, AppVersion>> getAppVersion() async {
try {
final appVersion = await remoteAppSettingsProvider.getAppVersion();
return right(appVersion.toDomain());
} catch (e) {
return left(const AppSettingsFailure.serverError());
}
}
}
The interface
abstract class AppSettingsInterface {
Future<Either<AppSettingsFailure, AppVersion>> getAppVersion();
}
The use case
class GetAppVersionUseCase {
final AppSettingsInterface appSettingsInterface;
GetAppVersionUseCase(this.appSettingsInterface);
Future<Either<AppSettingsFailure, AppVersion>> call() async {
return appSettingsInterface.getAppVersion();
}
}
To manage state
To manage the state I made use of the flutter_bloc package.
@injectable
class AppVersionCubit extends Cubit<AppVersionState> {
final GetAppVersionUseCase _getAppVersionUseCase;
AppVersionCubit(this._getAppVersionUseCase)
: super(const AppVersionState.initial());
Future<void> fetchAppVersion() async {
emit(const AppVersionState.busy());
final appVersion = await _getAppVersionUseCase.call();
final userInstalledVersion = await _getUserAppVersion();
emit(
appVersion.fold(
(failure) => AppVersionState.error(failure: failure),
(result) => _createLoadedState(result, userInstalledVersion),
),
);
}
Future<int> _getUserAppVersion() async {
final info = await PackageInfo.fromPlatform();
return int.parse(info.version.trim().replaceAll('.', ''));
}
AppVersionState _createLoadedState(
AppVersion result, int userInstalledVersion) {
final isAndroid = defaultTargetPlatform == TargetPlatform.android;
final currentVersion =
isAndroid ? result.currentAndroidVer : result.currentIosVer;
final minVersion = isAndroid ? result.minAndroidVer : result.minIosVer;
return AppVersionState.loaded(
optionalUpdate:
_hasOptionalUpdate(userInstalledVersion, currentVersion ?? 0),
forceUpdate: _hasForcedUpdates(userInstalledVersion, minVersion ?? 0),
message: result.updateMessage ?? '',
);
}
bool _hasOptionalUpdate(int installedVersion, int currentAppVersion) {
return currentAppVersion > installedVersion;
}
bool _hasForcedUpdates(int installedVersion, int minimumVersion) {
return minimumVersion > installedVersion;
}
}
The state
@freezed
class AppVersionState with _$AppVersionState {
const factory AppVersionState.initial() = _Initial;
const factory AppVersionState.busy() = Busy;
const factory AppVersionState.error({required AppSettingsFailure failure}) =
Error;
const factory AppVersionState.loaded({
required bool optionalUpdate,
required bool forceUpdate,
required String message,
}) = Loaded;
}
The user interface
BlocProvider(
create: (context) => getIt.get<MealsCubit>()..getMeals(),
child: BlocListener<AppVersionCubit, AppVersionState>(
listener: (context, state) {
state.whenOrNull(loaded: (
bool optionalUpdate,
bool forceUpdate,
String message,
) {
if (forceUpdate || optionalUpdate) {
NativeUpdater.displayUpdateAlert(
context,
forceUpdate: forceUpdate,
appStoreUrl:
'https://apps.apple.com/us/app/chick-fil-a/id488818252',
iOSDescription: message,
);
}
});
},
child: const MealsPageBody(),
),
)
Here is the source code on GitHub that you can take a look at.
Demo
Optional update
Forced update
Considerations
- Ensure that updates have fully propagated on the Google Play Store and the Apple App Store before prompting users to update. Premature updates can lead to frustration and potential lockout scenarios.
Possible Improvements
Automated Version Tracking: Consider implementing a CI/CD pipeline to automate version tracking. This reduces manual effort and enhances accuracy.
Dedicated Update Dashboard: Create a dedicated dashboard for updating the latest app version. This dashboard can include validation checks to prevent mistakes during the update process.
Subscribe to my newsletter
Read articles from Kelvin kosgei directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by