Skip to content

Commit 361bdf8

Browse files
authored
Merge pull request #134 from flutter-news-app-full-source-code/feat/app-version-enforcement
Feat/app version enforcement
2 parents 47379f3 + 16794d5 commit 361bdf8

15 files changed

+486
-93
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.1.0
4+
5+
- **Feature:** Implemented app version enforcement based on remote configuration, including a dedicated `UpdateRequiredPage` to guide users to update.
6+
- **Enhancement:** Integrated `package_info_plus` and `pub_semver` for accurate version retrieval and comparison across platforms (Android, iOS, Web).
7+
38
## 1.0.1
49

510
- **Version Control:** Transitioned from date-based versioning to semantic versioning. This release marks the first version following the semantic versioning standard.

lib/app/bloc/app_bloc.dart

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/config/confi
1111
as local_config;
1212
import 'package:flutter_news_app_mobile_client_full_source_code/app/services/demo_data_initializer_service.dart';
1313
import 'package:flutter_news_app_mobile_client_full_source_code/app/services/demo_data_migration_service.dart';
14+
import 'package:flutter_news_app_mobile_client_full_source_code/app/services/package_info_service.dart';
1415
import 'package:logging/logging.dart';
16+
import 'package:pub_semver/pub_semver.dart';
1517

1618
part 'app_event.dart';
1719
part 'app_state.dart';
@@ -41,6 +43,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
4143
required GlobalKey<NavigatorState> navigatorKey,
4244
required RemoteConfig? initialRemoteConfig,
4345
required HttpException? initialRemoteConfigError,
46+
required PackageInfoService packageInfoService,
4447
this.demoDataMigrationService,
4548
this.demoDataInitializerService,
4649
this.initialUser,
@@ -51,6 +54,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
5154
_userRepository = userRepository,
5255
_environment = environment,
5356
_navigatorKey = navigatorKey,
57+
_packageInfoService = packageInfoService,
5458
_logger = Logger('AppBloc'),
5559
super(
5660
AppState(
@@ -71,6 +75,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
7175
on<AppUserContentPreferencesRefreshed>(_onUserContentPreferencesRefreshed);
7276
on<AppSettingsChanged>(_onAppSettingsChanged);
7377
on<AppPeriodicConfigFetchRequested>(_onAppPeriodicConfigFetchRequested);
78+
on<AppVersionCheckRequested>(_onAppVersionCheckRequested);
7479
on<AppUserFeedDecoratorShown>(_onAppUserFeedDecoratorShown);
7580
on<AppUserContentPreferencesChanged>(_onAppUserContentPreferencesChanged);
7681
on<AppLogoutRequested>(_onLogoutRequested);
@@ -91,6 +96,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
9196
final DataRepository<User> _userRepository;
9297
final local_config.AppEnvironment _environment;
9398
final GlobalKey<NavigatorState> _navigatorKey;
99+
final PackageInfoService _packageInfoService;
94100
final Logger _logger;
95101
final DemoDataMigrationService? demoDataMigrationService;
96102
final DemoDataInitializerService? demoDataInitializerService;
@@ -259,14 +265,14 @@ class AppBloc extends Bloc<AppEvent, AppState> {
259265
return;
260266
}
261267

262-
if (state.remoteConfig!.appStatus.isLatestVersionOnly) {
263-
// TODO(fulleni): Compare with actual app version.
264-
_logger.info(
265-
'[AppBloc] App update required. Transitioning to updateRequired state.',
266-
);
267-
emit(state.copyWith(status: AppLifeCycleStatus.updateRequired));
268-
return;
269-
}
268+
// Dispatch AppVersionCheckRequested to handle version enforcement.
269+
add(
270+
AppVersionCheckRequested(
271+
remoteConfig: state.remoteConfig!,
272+
// Not a background check during startup
273+
isBackgroundCheck: false,
274+
),
275+
);
270276

271277
// If we reach here, the app is not under maintenance or requires update.
272278
// Now, handle user-specific data loading.
@@ -547,17 +553,13 @@ class AppBloc extends Bloc<AppEvent, AppState> {
547553
return;
548554
}
549555

550-
if (remoteConfig.appStatus.isLatestVersionOnly) {
551-
// TODO(fulleni): Compare with actual app version.
552-
emit(
553-
state.copyWith(
554-
status: AppLifeCycleStatus.updateRequired,
555-
remoteConfig: remoteConfig,
556-
initialRemoteConfigError: null,
557-
),
558-
);
559-
return;
560-
}
556+
// Dispatch AppVersionCheckRequested to handle version enforcement.
557+
add(
558+
AppVersionCheckRequested(
559+
remoteConfig: remoteConfig,
560+
isBackgroundCheck: event.isBackgroundCheck,
561+
),
562+
);
561563

562564
final finalStatus = state.user!.appRole == AppUserRole.standardUser
563565
? AppLifeCycleStatus.authenticated
@@ -598,6 +600,105 @@ class AppBloc extends Bloc<AppEvent, AppState> {
598600
}
599601
}
600602

603+
/// Handles the [AppVersionCheckRequested] event to enforce app version updates.
604+
Future<void> _onAppVersionCheckRequested(
605+
AppVersionCheckRequested event,
606+
Emitter<AppState> emit,
607+
) async {
608+
final remoteConfig = event.remoteConfig;
609+
final isBackgroundCheck = event.isBackgroundCheck;
610+
611+
if (!remoteConfig.appStatus.isLatestVersionOnly) {
612+
_logger.info(
613+
'[AppBloc] Version enforcement not enabled. Skipping version check.',
614+
);
615+
return;
616+
}
617+
618+
final currentAppVersionString = await _packageInfoService.getAppVersion();
619+
620+
if (currentAppVersionString == null) {
621+
_logger.warning(
622+
'[AppBloc] Could not determine current app version. '
623+
'Skipping version comparison.',
624+
);
625+
// If we can't get the current version, we can't enforce.
626+
// Do not block the app, but log a warning.
627+
return;
628+
}
629+
630+
try {
631+
final currentVersion = Version.parse(currentAppVersionString);
632+
final latestRequiredVersion = Version.parse(
633+
remoteConfig.appStatus.latestAppVersion,
634+
);
635+
636+
if (currentVersion >= latestRequiredVersion) {
637+
_logger.info(
638+
'[AppBloc] App version ($currentVersion) is up to date '
639+
'or newer than required ($latestRequiredVersion).',
640+
);
641+
// If the app is up to date, and it was previously in an updateRequired
642+
// state (e.g., after an update), transition it back to a normal state.
643+
if (state.status == AppLifeCycleStatus.updateRequired) {
644+
final finalStatus = state.user!.appRole == AppUserRole.standardUser
645+
? AppLifeCycleStatus.authenticated
646+
: AppLifeCycleStatus.anonymous;
647+
emit(
648+
state.copyWith(
649+
status: finalStatus,
650+
currentAppVersion: currentAppVersionString,
651+
),
652+
);
653+
} else {
654+
emit(state.copyWith(currentAppVersion: currentAppVersionString));
655+
}
656+
} else {
657+
_logger.info(
658+
'[AppBloc] App version ($currentVersion) is older than '
659+
'required ($latestRequiredVersion). Transitioning to updateRequired state.',
660+
);
661+
emit(
662+
state.copyWith(
663+
status: AppLifeCycleStatus.updateRequired,
664+
currentAppVersion: currentAppVersionString,
665+
),
666+
);
667+
}
668+
} on FormatException catch (e, s) {
669+
_logger.severe(
670+
'[AppBloc] Failed to parse app version string: $currentAppVersionString '
671+
'or latest required version: ${remoteConfig.appStatus.latestAppVersion}.',
672+
e,
673+
s,
674+
);
675+
if (!isBackgroundCheck) {
676+
emit(
677+
state.copyWith(
678+
status: AppLifeCycleStatus.criticalError,
679+
initialRemoteConfigError: UnknownException(
680+
'Failed to parse app version: ${e.message}',
681+
),
682+
),
683+
);
684+
}
685+
} catch (e, s) {
686+
_logger.severe(
687+
'[AppBloc] Unexpected error during app version check.',
688+
e,
689+
s,
690+
);
691+
if (!isBackgroundCheck) {
692+
emit(
693+
state.copyWith(
694+
status: AppLifeCycleStatus.criticalError,
695+
initialRemoteConfigError: UnknownException(e.toString()),
696+
),
697+
);
698+
}
699+
}
700+
}
701+
601702
/// Handles updating the user's feed decorator status.
602703
Future<void> _onAppUserFeedDecoratorShown(
603704
AppUserFeedDecoratorShown event,

lib/app/bloc/app_event.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ class AppPeriodicConfigFetchRequested extends AppEvent {
8989
List<Object> get props => [isBackgroundCheck];
9090
}
9191

92+
/// Dispatched to request a check of the application's version against
93+
/// the remote configuration.
94+
///
95+
/// This event is used to determine if a mandatory update is required.
96+
class AppVersionCheckRequested extends AppEvent {
97+
const AppVersionCheckRequested({
98+
required this.remoteConfig,
99+
this.isBackgroundCheck = true,
100+
});
101+
102+
/// The latest remote configuration.
103+
final RemoteConfig remoteConfig;
104+
105+
/// Whether this check is a silent background check.
106+
///
107+
/// If `true`, the BLoC will not enter a visible loading state.
108+
final bool isBackgroundCheck;
109+
110+
@override
111+
List<Object> get props => [remoteConfig, isBackgroundCheck];
112+
}
113+
92114
/// Dispatched when the user logs out.
93115
///
94116
/// This event triggers the sign-out process, clearing authentication tokens

lib/app/bloc/app_state.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class AppState extends Equatable {
4848
this.userContentPreferences,
4949
this.settings,
5050
this.selectedBottomNavigationIndex = 0,
51+
this.currentAppVersion,
5152
});
5253

5354
/// The current status of the application, indicating its lifecycle stage.
@@ -84,6 +85,14 @@ class AppState extends Equatable {
8485
/// The current application environment (e.g., demo, development, production).
8586
final local_config.AppEnvironment environment;
8687

88+
/// The current version of the application, fetched from `package_info_plus`.
89+
/// This is used for version enforcement.
90+
final String? currentAppVersion;
91+
92+
/// The latest required app version from the remote configuration.
93+
/// Returns `null` if remote config is not available.
94+
String? get latestAppVersion => remoteConfig?.appStatus.latestAppVersion;
95+
8796
/// The current theme mode (light, dark, or system), derived from [settings].
8897
/// Defaults to [ThemeMode.system] if [settings] are not yet loaded.
8998
ThemeMode get themeMode {
@@ -152,6 +161,7 @@ class AppState extends Equatable {
152161
userContentPreferences,
153162
selectedBottomNavigationIndex,
154163
environment,
164+
currentAppVersion,
155165
];
156166

157167
/// Creates a copy of this [AppState] with the given fields replaced with
@@ -167,6 +177,7 @@ class AppState extends Equatable {
167177
UserContentPreferences? userContentPreferences,
168178
int? selectedBottomNavigationIndex,
169179
local_config.AppEnvironment? environment,
180+
String? currentAppVersion,
170181
}) {
171182
return AppState(
172183
status: status ?? this.status,
@@ -182,6 +193,7 @@ class AppState extends Equatable {
182193
selectedBottomNavigationIndex:
183194
selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex,
184195
environment: environment ?? this.environment,
196+
currentAppVersion: currentAppVersion ?? this.currentAppVersion,
185197
);
186198
}
187199
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import 'package:logging/logging.dart';
2+
import 'package:package_info_plus/package_info_plus.dart';
3+
4+
/// {@template package_info_service}
5+
/// An abstract service for retrieving application package information.
6+
///
7+
/// This interface allows for mocking and provides a clean way to access
8+
/// platform-specific app details like version, build number, etc.
9+
/// {@endtemplate}
10+
abstract class PackageInfoService {
11+
/// {@macro package_info_service}
12+
const PackageInfoService();
13+
14+
/// Retrieves the application's version string (e.g., "1.0.0").
15+
///
16+
/// Returns `null` if the version cannot be determined (e.g., on unsupported
17+
/// platforms or during an error).
18+
Future<String?> getAppVersion();
19+
}
20+
21+
/// {@template package_info_service_impl}
22+
/// A concrete implementation of [PackageInfoService] using `package_info_plus`.
23+
/// {@endtemplate}
24+
class PackageInfoServiceImpl implements PackageInfoService {
25+
/// {@macro package_info_service_impl}
26+
PackageInfoServiceImpl({Logger? logger})
27+
: _logger = logger ?? Logger('PackageInfoServiceImpl');
28+
29+
final Logger _logger;
30+
31+
@override
32+
Future<String?> getAppVersion() async {
33+
try {
34+
final packageInfo = await PackageInfo.fromPlatform();
35+
_logger.info(
36+
'Successfully fetched package info. Version: ${packageInfo.version}',
37+
);
38+
return packageInfo.version;
39+
} catch (e, s) {
40+
_logger.warning(
41+
'Failed to get app version from platform. '
42+
'This might be expected on some platforms (e.g., web in certain contexts).',
43+
e,
44+
s,
45+
);
46+
return null;
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)