-
Notifications
You must be signed in to change notification settings - Fork 4
feat: implement app upgrade screen from the entry point of the app #586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
e22cbfe
feeb4f0
687b60e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import 'package:universal_platform/universal_platform.dart'; | ||
|
|
||
| enum UpdateType { | ||
| force, | ||
| flexible, | ||
| none, | ||
| } | ||
|
|
||
| class AppVersionCheckModel { | ||
| final String version; | ||
| final String? releaseNotes; | ||
| final bool showLaterButton; | ||
| final bool showIgnoreButton; | ||
| final Map<String, String> updateUrls; | ||
| final List<String> platforms; | ||
|
|
||
| static const List<String> _defaultPlatforms = [ | ||
| 'android', | ||
| 'ios', | ||
| ]; | ||
|
|
||
| static const Map<String, String> _defaultUpdateUrls = { | ||
| 'ios': 'https://apps.apple.com/app/qubic-wallet/id6502265811', | ||
| 'android': 'https://play.google.com/store/apps/details?id=org.qubic.wallet', | ||
| }; | ||
|
|
||
| AppVersionCheckModel({ | ||
| required this.version, | ||
| this.releaseNotes, | ||
| this.showLaterButton = false, | ||
| this.showIgnoreButton = false, | ||
| this.updateUrls = _defaultUpdateUrls, | ||
| this.platforms = _defaultPlatforms, | ||
| }); | ||
|
|
||
| /// Derive update type from button visibility: | ||
| /// - If both buttons are hidden -> force update | ||
| /// - If any button is visible -> flexible update | ||
| UpdateType get updateType { | ||
| if (!showLaterButton && !showIgnoreButton) { | ||
| return UpdateType.force; | ||
| } | ||
| return UpdateType.flexible; | ||
| } | ||
|
|
||
| static AppVersionCheckModel? fromJson(Map<String, dynamic>? json) { | ||
| if (json == null || json.isEmpty) { | ||
| return null; | ||
| } | ||
|
|
||
| final updateUrls = json['update_urls'] != null | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ahmed-tarek-salem The App Store and Play Store URLs are constant — they're fully determined by our fixed bundle ID / package name (org.qubic.wallet), so we can build them in the app instead of reading them from the remote JSON. Letting the remote payload supply the URL means anyone who can write to (or MITM) static.qubic.org can serve a forced update (show_later_button: false + show_ignore_button: false) that routes every user — on a screen they can't dismiss — to an attacker-controlled APK or seed-entry phishing page. |
||
| ? Map<String, String>.from(json['update_urls']) | ||
| : _defaultUpdateUrls; | ||
|
|
||
| final platforms = json['platforms'] != null | ||
| ? List<String>.from(json['platforms']) | ||
| : _defaultPlatforms; | ||
|
|
||
| return AppVersionCheckModel( | ||
| version: json['version'], | ||
| releaseNotes: json['release_notes'], | ||
| showLaterButton: json['show_later_button'] ?? false, | ||
| showIgnoreButton: json['show_ignore_button'] ?? false, | ||
| updateUrls: updateUrls, | ||
| platforms: platforms, | ||
| ); | ||
| } | ||
|
|
||
| bool isApplicableForCurrentPlatform() { | ||
| if (UniversalPlatform.isIOS) return platforms.contains('ios'); | ||
| if (UniversalPlatform.isAndroid) return platforms.contains('android'); | ||
| return false; | ||
| } | ||
|
|
||
| String? getUpdateUrlForPlatform() { | ||
| if (UniversalPlatform.isIOS) return updateUrls['ios']; | ||
| if (UniversalPlatform.isAndroid) return updateUrls['android']; | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_mobx/flutter_mobx.dart'; | ||
| import 'package:go_router/go_router.dart'; | ||
| import 'package:qubic_wallet/di.dart'; | ||
| import 'package:qubic_wallet/flutter_flow/theme_paddings.dart'; | ||
| import 'package:qubic_wallet/helpers/app_logger.dart'; | ||
| import 'package:qubic_wallet/l10n/l10n.dart'; | ||
| import 'package:qubic_wallet/models/app_version_check_model.dart'; | ||
| import 'package:qubic_wallet/stores/app_update_store.dart'; | ||
| import 'package:qubic_wallet/stores/settings_store.dart'; | ||
| import 'package:qubic_wallet/styles/text_styles.dart'; | ||
| import 'package:qubic_wallet/styles/themed_controls.dart'; | ||
| import 'package:url_launcher/url_launcher_string.dart'; | ||
|
|
||
| part 'components/app_update_logo.dart'; | ||
| part 'components/app_update_header.dart'; | ||
| part 'components/app_update_info_card.dart'; | ||
| part 'components/app_update_buttons.dart'; | ||
|
|
||
| class AppUpdateScreen extends StatelessWidget { | ||
| const AppUpdateScreen({super.key}); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final appUpdateStore = getIt<AppUpdateStore>(); | ||
| final settingsStore = getIt<SettingsStore>(); | ||
|
|
||
| return Observer( | ||
| builder: (context) { | ||
| final versionInfo = appUpdateStore.currentVersionInfo; | ||
|
|
||
| if (versionInfo == null) { | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (context.mounted) { | ||
| context.go('/'); | ||
| } | ||
| }); | ||
| return const Scaffold( | ||
| body: Center(child: CircularProgressIndicator()), | ||
| ); | ||
| } | ||
|
|
||
| final isForceUpdate = versionInfo.updateType == UpdateType.force; | ||
|
|
||
| return Scaffold( | ||
| backgroundColor: LightThemeColors.background, | ||
| body: SafeArea( | ||
| child: Padding( | ||
| padding: const EdgeInsets.all(ThemePaddings.hugePadding), | ||
| child: Column( | ||
| mainAxisAlignment: MainAxisAlignment.center, | ||
| crossAxisAlignment: CrossAxisAlignment.stretch, | ||
| children: [ | ||
| const Spacer(), | ||
| const _AppUpdateLogo(), | ||
| _AppUpdateHeader(isForceUpdate: isForceUpdate), | ||
| ThemedControls.spacerVerticalBig(), | ||
| _AppUpdateInfoCard( | ||
| versionInfo: versionInfo, | ||
| currentVersion: settingsStore.versionInfo, | ||
| onUpdatePressed: () => _launchUpdateUrl(versionInfo), | ||
| ), | ||
| const Spacer(), | ||
| _AppUpdateButtons( | ||
| versionInfo: versionInfo, | ||
| onUpdatePressed: () => _launchUpdateUrl(versionInfo), | ||
| onLaterPressed: () { | ||
| appUpdateStore.handleLaterAction(); | ||
| context.go('/'); | ||
| }, | ||
| onIgnorePressed: () { | ||
| appUpdateStore.handleIgnoreAction(versionInfo.version); | ||
| context.go('/'); | ||
| }, | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| Future<void> _launchUpdateUrl(AppVersionCheckModel versionInfo) async { | ||
| final url = versionInfo.getUpdateUrlForPlatform(); | ||
| if (url == null) { | ||
| appLogger.e('[AppUpdateScreen] No update URL available for platform'); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await launchUrlString(url, mode: LaunchMode.externalApplication); | ||
| } catch (e) { | ||
| appLogger.e('[AppUpdateScreen] Failed to launch update URL: $e'); | ||
| return; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| part of '../app_update_screen.dart'; | ||
|
|
||
| class _AppUpdateButtons extends StatelessWidget { | ||
| final AppVersionCheckModel versionInfo; | ||
| final VoidCallback onUpdatePressed; | ||
| final VoidCallback onLaterPressed; | ||
| final VoidCallback onIgnorePressed; | ||
|
|
||
| const _AppUpdateButtons({ | ||
| required this.versionInfo, | ||
| required this.onUpdatePressed, | ||
| required this.onLaterPressed, | ||
| required this.onIgnorePressed, | ||
| }); | ||
|
|
||
| bool get _hasAllOptions => | ||
| versionInfo.showLaterButton && versionInfo.showIgnoreButton; | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final l10n = l10nOf(context); | ||
|
|
||
| // Scenario 1: Force update - only Update button at bottom | ||
| if (versionInfo.updateType == UpdateType.force) { | ||
| return ThemedControls.primaryButtonBig( | ||
| onPressed: onUpdatePressed, | ||
| text: l10n.updateButton, | ||
| ); | ||
| } | ||
|
|
||
| final showLater = versionInfo.showLaterButton; | ||
| final showIgnore = versionInfo.showIgnoreButton; | ||
|
|
||
| // Scenario 3: All three options - Update in card, Later & Skip in row | ||
| if (_hasAllOptions) { | ||
| return Row( | ||
| children: [ | ||
| Expanded( | ||
| child: ThemedControls.transparentButtonNormal( | ||
| onPressed: onLaterPressed, | ||
| text: l10n.laterButton, | ||
| ), | ||
| ), | ||
| ThemedControls.spacerHorizontalNormal(), | ||
| Expanded( | ||
| child: ThemedControls.dangerButtonBigWithClild( | ||
| onPressed: onIgnorePressed, | ||
| child: Text( | ||
| l10n.ignoreVersionButton, | ||
| style: TextStyles.destructiveButtonText, | ||
| ), | ||
| ), | ||
| ), | ||
| ], | ||
| ); | ||
| } | ||
|
|
||
| // Scenario 2: Two options - Update first, then the other option | ||
| return Column( | ||
| crossAxisAlignment: CrossAxisAlignment.stretch, | ||
| children: [ | ||
| ThemedControls.primaryButtonBig( | ||
| onPressed: onUpdatePressed, | ||
| text: l10n.updateButton, | ||
| ), | ||
| if (showLater) | ||
| ThemedControls.transparentButtonNormal( | ||
| onPressed: onLaterPressed, | ||
| text: l10n.laterButton, | ||
| ), | ||
| if (showIgnore) | ||
| ThemedControls.dangerButtonBigWithClild( | ||
| onPressed: onIgnorePressed, | ||
| child: Text( | ||
| l10n.ignoreVersionButton, | ||
| style: TextStyles.destructiveButtonText, | ||
| ), | ||
| ), | ||
| ], | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| part of '../app_update_screen.dart'; | ||
|
|
||
| class _AppUpdateHeader extends StatelessWidget { | ||
| final bool isForceUpdate; | ||
|
|
||
| const _AppUpdateHeader({required this.isForceUpdate}); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final l10n = l10nOf(context); | ||
|
|
||
| return Column( | ||
| children: [ | ||
| Text( | ||
| isForceUpdate ? l10n.updateRequiredTitle : l10n.updateAvailableTitle, | ||
| style: TextStyles.textEnormous.copyWith( | ||
| fontWeight: FontWeight.bold, | ||
| color: LightThemeColors.primary, | ||
| ), | ||
| textAlign: TextAlign.center, | ||
| ), | ||
| ThemedControls.spacerVerticalBig(), | ||
| Text( | ||
| isForceUpdate | ||
| ? l10n.updateRequiredMessage | ||
| : l10n.updateAvailableMessage, | ||
| style: TextStyles.textNormal.copyWith( | ||
| color: LightThemeColors.textColorSecondary, | ||
| ), | ||
| textAlign: TextAlign.center, | ||
| ), | ||
| ], | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ahmed-tarek-salem 10 new keys added only to app_en.arb. Missing from app_de/es/fr/ru/tr/vi/zh.arb. Non-English users get fallback English on a forced upgrade gate.