diff --git a/README.md b/README.md index 6d025032..9830906d 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,24 @@ You can add accounts to a wallet by: ### 4. Vault Import & Export - Vault files contain **encrypted wallets and keys** for backup/migration - Encrypted with **AES-256** using a password provided by the user -- **Minimum password length:** 8 characters (no recovery possible if forgotten) +- **Minimum password length:** 12 characters (no recovery possible if forgotten) - When exporting, the vault password can be the same as or different from the wallet password - Vaults can also be imported into the **[Qubic Web Wallet](https://qubic.wallet.org)** --- +### 5. App Message +- Displays static messages fetched from the backend to inform users about maintenance periods, updates, or other announcements. +- The message can be of two types: + - Blocking: Shows a maintenance screen that prevents app use until the period ends. + - Non-blocking: Shows a one-time informational dialog that can be dismissed. +- Each message includes metadata such as: + - Target platform (ios, android, macos, or all) + - Validity period (startDate, endDate) + - Blocking flag (true or false) + +--- + ## RPC Communication Qubic Wallet interacts with the network using the following RPC endpoints: diff --git a/assets/icons/maintenance.svg b/assets/icons/maintenance.svg new file mode 100644 index 00000000..bbff9a08 --- /dev/null +++ b/assets/icons/maintenance.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/config.dart b/lib/config.dart index 94b41980..fd75ea05 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -35,9 +35,12 @@ abstract class Config { static const useNativeSnackbar = false; static const URL_WebExplorer = "https://explorer.qubic.org"; - static const networkQubicMainnet = "Qubic Mainnet"; + static const qubicStaticApiUrl = + "https://raw.githubusercontent.com/ahmed-tarek-salem/qubic-appcast-test/refs/heads/main"; + static const qubicStaticMessages = "/message.json"; + //Qubic Helper Utilities static final qubicHelper = QubicHelperConfig( win64: QubicHelperConfigEntry( diff --git a/lib/di.dart b/lib/di.dart index 1b978909..26f9250c 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -1,24 +1,26 @@ import 'package:app_links/app_links.dart'; import 'package:get_it/get_it.dart'; -import 'package:qubic_wallet/services/screenshot_service.dart'; -import 'package:qubic_wallet/services/qr_scanner_service.dart'; import 'package:qubic_wallet/helpers/global_snack_bar.dart'; import 'package:qubic_wallet/models/wallet_connect/wallet_connect_modals_controller.dart'; import 'package:qubic_wallet/resources/apis/archive/qubic_archive_api.dart'; import 'package:qubic_wallet/resources/apis/live/qubic_live_api.dart'; +import 'package:qubic_wallet/resources/apis/qubic_helpers_api.dart'; +import 'package:qubic_wallet/resources/apis/static/qubic_static_api.dart'; import 'package:qubic_wallet/resources/apis/stats/qubic_stats_api.dart'; import 'package:qubic_wallet/resources/hive_storage.dart'; import 'package:qubic_wallet/resources/qubic_cmd.dart'; import 'package:qubic_wallet/resources/secure_storage.dart'; import 'package:qubic_wallet/services/biometric_service.dart'; +import 'package:qubic_wallet/services/qr_scanner_service.dart'; +import 'package:qubic_wallet/services/screenshot_service.dart'; import 'package:qubic_wallet/services/wallet_connect_service.dart'; +import 'package:qubic_wallet/stores/app_message_store.dart'; import 'package:qubic_wallet/stores/application_store.dart'; +import 'package:qubic_wallet/stores/dapp_store.dart'; import 'package:qubic_wallet/stores/network_store.dart'; import 'package:qubic_wallet/stores/root_jailbreak_flag_store.dart'; import 'package:qubic_wallet/stores/settings_store.dart'; -import 'package:qubic_wallet/stores/dapp_store.dart'; import 'package:qubic_wallet/timed_controller.dart'; -import 'package:qubic_wallet/resources/apis/qubic_helpers_api.dart'; final GetIt getIt = GetIt.instance; @@ -31,11 +33,14 @@ Future setupDI() async { QubicArchiveApi(getIt())); getIt.registerSingleton(QubicStatsApi(getIt())); getIt.registerSingleton(QubicHelpersApi()); + getIt.registerSingleton(QubicStaticApi()); //Stores getIt.registerSingleton(ApplicationStore()); getIt.registerSingleton(SettingsStore()); getIt.registerSingleton(DappStore()); + getIt.registerSingleton(AppMessageStore()); + getIt.registerSingleton(SecureStorage()); await getIt().initialize(); getIt.registerSingleton(HiveStorage()); diff --git a/lib/dtos/app_message_dto.dart b/lib/dtos/app_message_dto.dart new file mode 100644 index 00000000..16c20893 --- /dev/null +++ b/lib/dtos/app_message_dto.dart @@ -0,0 +1,63 @@ +class AppMessageDto { + final String? id; + final String title; + final String message; + final bool blocking; + final String platform; // e.g. "all", "ios", "android" + final DateTime? startDate; + final DateTime? endDate; + final bool disabled; + + AppMessageDto({ + required this.id, + required this.title, + required this.message, + required this.blocking, + required this.platform, + this.startDate, + this.endDate, + this.disabled = false, + }); + + factory AppMessageDto.fromJson(Map json) { + return AppMessageDto( + id: json['id'], + title: json['title'] ?? '', + message: json['message'] ?? '', + blocking: json['blocking'] ?? false, + platform: json['platform'] ?? 'all', + startDate: + json['startDate'] == null ? null : _parseDate(json['startDate']), + endDate: json['endDate'] == null ? null : _parseDate(json['endDate']), + disabled: json['disabled'] ?? false, + ); + } + + /// Check if the message is currently active + bool get isActive { + final now = DateTime.now().toUtc(); + if (startDate != null && now.isBefore(startDate!)) return false; + if (endDate != null && now.isAfter(endDate!)) return false; + return true; + } + + /// Check if message applies to current platform + bool appliesToPlatform(String currentPlatform) { + final lowerPlatform = platform.toLowerCase(); + return lowerPlatform == 'all' || + lowerPlatform == currentPlatform.toLowerCase(); + } + + bool isValid(String currentPlatform) { + return isActive && appliesToPlatform(currentPlatform) && !disabled; + } + + static DateTime? _parseDate(dynamic value) { + if (value == null) return null; + try { + return DateTime.parse(value).toUtc(); + } catch (_) { + return null; + } + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8a745be9..a51196f2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -838,5 +838,6 @@ "revealSeedCopiedToClipboardMessage": "Privater Seed wurde vorübergehend in die Zwischenablage kopiert (für 1 Minute gespeichert)", "revealSeedWarningTitle": "Teile deinen privaten Seed nicht!", "revealSeedWarningDescription": "Wenn jemand deinen privaten Seed hat, hat er vollständige Kontrolle über dein Konto", - "blockedScreenshotWarning": "Screenshot geschützt — dieser Bildschirm bleibt privat." + "blockedScreenshotWarning": "Screenshot geschützt — dieser Bildschirm bleibt privat.", + "maintenanceRefreshButton": "Aktualisieren" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 69ad16db..6925168f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -901,5 +901,6 @@ "revealSeedCopiedToClipboardMessage": "Private seed temporarily copied to clipboard (stored for 1 minute)", "revealSeedWarningTitle": "Do not share your Private Seed!", "revealSeedWarningDescription": "If someone has your private seed, they will have full control of your account", - "blockedScreenshotWarning": "Screenshot protected — this screen is private." + "blockedScreenshotWarning": "Screenshot protected — this screen is private.", + "maintenanceRefreshButton": "Refresh" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0af02916..86d7c9db 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -837,5 +837,6 @@ "revealSeedCopiedToClipboardMessage": "La semilla privada se copió temporalmente (por 1 minuto) al portapapeles", "revealSeedWarningTitle": "¡No compartas tu semilla privada!", "revealSeedWarningDescription": "Si alguien tiene tu semilla privada, tendrá control total de tu cuenta", - "blockedScreenshotWarning": "Captura protegida: esta pantalla es privada." + "blockedScreenshotWarning": "Captura protegida: esta pantalla es privada.", + "maintenanceRefreshButton": "Actualizar" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d8302427..b6c041c0 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -843,5 +843,6 @@ "revealSeedCopiedToClipboardMessage": "La phrase secrète a été copiée temporairement (pendant 1 minute) dans le presse-papiers ", "revealSeedWarningTitle": "Ne partagez pas votre phrase secrète!", "revealSeedWarningDescription": "Si quelqu’un possède votre phrase secrète, il aura un contrôle total sur votre compte", - "blockedScreenshotWarning": "Capture protégée — cet écran reste privé." + "blockedScreenshotWarning": "Capture protégée — cet écran reste privé.", + "maintenanceRefreshButton": "Rafraîchir" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 792e0269..0421b338 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -838,5 +838,6 @@ "revealSeedCopiedToClipboardMessage": "Приватная сид-фраза временно скопирована в буфер обмена (хранится 1 минуту)", "revealSeedWarningTitle": "Никогда не делитесь своей приватной сид-фразой!", "revealSeedWarningDescription": "Если кто-то получит вашу приватную сид-фразу, он получит полный доступ к вашему аккаунту", - "blockedScreenshotWarning": "Скриншот защищён — экран остаётся приватным." + "blockedScreenshotWarning": "Скриншот защищён — экран остаётся приватным.", + "maintenanceRefreshButton": "Обновить" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index d7b41baf..e8a57603 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -838,5 +838,6 @@ "revealSeedCopiedToClipboardMessage": "Özel anahtar geçici olarak panoya kopyalandı (1 dakika saklanır)", "revealSeedWarningTitle": "Özel Anahtarınızı Paylaşmayın!", "revealSeedWarningDescription": "Birisi özel anahtarınıza sahipse, hesabınız üzerinde tam kontrol sahibi olur", - "blockedScreenshotWarning": "Ekran görüntüsü korundu — bu ekran gizli kalır." + "blockedScreenshotWarning": "Ekran görüntüsü korundu — bu ekran gizli kalır.", + "maintenanceRefreshButton": "Yenile" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 19a81756..70b13fdc 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -901,5 +901,6 @@ "revealSeedCopiedToClipboardMessage": "Seed riêng tư đã được sao chép tạm thời vào clipboard (được lưu trong 1 phút)", "revealSeedWarningTitle": "Đừng chia sẻ seed riêng tư của bạn!", "revealSeedWarningDescription": "Nếu ai đó có seed riêng tư của bạn, họ sẽ có toàn quyền kiểm soát tài khoản của bạn", - "blockedScreenshotWarning": "Chụp màn hình được bảo vệ — màn hình này là riêng tư." -} + "blockedScreenshotWarning": "Chụp màn hình được bảo vệ — màn hình này là riêng tư.", + "maintenanceRefreshButton": "Làm mới" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8ca8cff8..75f1aafd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -838,5 +838,6 @@ "revealSeedCopiedToClipboardMessage": "私密助记词已临时复制到剪贴板(保存 1 分钟)", "revealSeedWarningTitle": "请勿泄露您的私密助记词!", "revealSeedWarningDescription": "如果他人获取了您的私密助记词,他们将完全控制您的账户", - "blockedScreenshotWarning": "螢幕截圖已保護,此畫面保持隱私。" + "blockedScreenshotWarning": "螢幕截圖已保護,此畫面保持隱私。", + "maintenanceRefreshButton": "重新整理" } \ No newline at end of file diff --git a/lib/pages/main/maintenance_screen.dart b/lib/pages/main/maintenance_screen.dart new file mode 100644 index 00000000..639bacd9 --- /dev/null +++ b/lib/pages/main/maintenance_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:qubic_wallet/di.dart'; +import 'package:qubic_wallet/dtos/app_message_dto.dart'; +import 'package:qubic_wallet/flutter_flow/theme_paddings.dart'; +import 'package:qubic_wallet/l10n/l10n.dart'; +import 'package:qubic_wallet/stores/app_message_store.dart'; +import 'package:qubic_wallet/styles/app_icons.dart'; +import 'package:qubic_wallet/styles/text_styles.dart'; +import 'package:qubic_wallet/styles/themed_controls.dart'; + +class MaintenanceScreen extends StatelessWidget { + final AppMessageDto? appMessage; + const MaintenanceScreen({super.key, this.appMessage}); + + @override + Widget build(BuildContext context) { + final l10n = l10nOf(context); + return PopScope( + canPop: false, + child: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: ThemePaddings.normalPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + AppIcons.maintenance, + height: 100, + colorFilter: const ColorFilter.mode( + LightThemeColors.primary40, BlendMode.srcIn), + ), + ThemedControls.spacerVerticalNormal(), + Text(appMessage?.title ?? "", + style: TextStyles.alertHeader, textAlign: TextAlign.center), + ThemedControls.spacerVerticalSmall(), + Text(appMessage?.message ?? "", + style: TextStyles.alertText, textAlign: TextAlign.center), + ThemedControls.spacerVerticalNormal(), + Observer(builder: (context) { + bool isLoading = getIt().isLoading; + return ThemedControls.primaryButtonSmall( + onPressed: () async { + final navigator = Navigator.of(context); + final message = + await getIt().getAppMessage(); + if (message == null) { + navigator.pop(); + } + }, + text: isLoading ? "" : l10n.maintenanceRefreshButton, + icon: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: LightThemeColors.background), + ) + : null); + }), + ], + ), + )), + ), + ); + } +} diff --git a/lib/pages/main/tab_wallet_contents.dart b/lib/pages/main/tab_wallet_contents.dart index ff9d001a..4f6626f8 100644 --- a/lib/pages/main/tab_wallet_contents.dart +++ b/lib/pages/main/tab_wallet_contents.dart @@ -18,8 +18,10 @@ import 'package:qubic_wallet/flutter_flow/theme_paddings.dart'; import 'package:qubic_wallet/helpers/app_logger.dart'; import 'package:qubic_wallet/helpers/show_alert_dialog.dart'; import 'package:qubic_wallet/l10n/l10n.dart'; +import 'package:qubic_wallet/pages/main/maintenance_screen.dart'; import 'package:qubic_wallet/pages/main/wallet_contents/add_account_modal_bottom_sheet.dart'; import 'package:qubic_wallet/pages/main/wallet_contents/add_wallet_connect/add_wallet_connect.dart'; +import 'package:qubic_wallet/stores/app_message_store.dart'; import 'package:qubic_wallet/stores/application_store.dart'; import 'package:qubic_wallet/stores/network_store.dart'; import 'package:qubic_wallet/stores/root_jailbreak_flag_store.dart'; @@ -67,6 +69,7 @@ class _TabWalletContentsState extends State { } } }); + checkAppMessage(); _scrollController.addListener(() { if (_scrollController.offset > sliverExpanded) { @@ -150,6 +153,30 @@ class _TabWalletContentsState extends State { appStore.triggerAddAccountModal(); } + void checkAppMessage() async { + final message = await getIt().getAppMessage(); + if (message == null || !mounted) return; + if (message.blocking) { + Future.delayed(const Duration(seconds: 1), () { + if (mounted) { + pushScreenWithoutNavBar( + context, MaintenanceScreen(appMessage: message)); + } + }); + } else { + showDialog( + context: context, + builder: (context) { + return getAlertDialog( + message.title, + message.message, + primaryButtonLabel: l10nWrapper.l10n!.generalButtonOK, + primaryButtonFunction: () => Navigator.of(context).pop(), + ); + }); + } + } + @override Widget build(BuildContext context) { final l10n = l10nOf(context); @@ -159,6 +186,7 @@ class _TabWalletContentsState extends State { edgeOffset: kToolbarHeight, onRefresh: () async { await _timedController.interruptFetchTimer(); + checkAppMessage(); }, backgroundColor: LightThemeColors.refreshIndicatorBackground, child: Container( diff --git a/lib/resources/apis/static/qubic_static_api.dart b/lib/resources/apis/static/qubic_static_api.dart new file mode 100644 index 00000000..5dc9e5c3 --- /dev/null +++ b/lib/resources/apis/static/qubic_static_api.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:qubic_wallet/config.dart'; +import 'package:qubic_wallet/dtos/app_message_dto.dart'; +import 'package:qubic_wallet/models/app_error.dart'; +import 'package:qubic_wallet/services/dio_client.dart'; + +class QubicStaticApi { + late Dio _dio; + + QubicStaticApi() { + _dio = DioClient.getDio(baseUrl: Config.qubicStaticApiUrl); + } + + Future getAppMessage() async { + try { + final response = await _dio.get(Config.qubicStaticMessages); + final decodedResponse = json.decode(response.data); + return decodedResponse["id"] == null + ? null + : AppMessageDto.fromJson(decodedResponse); + } catch (error) { + throw ErrorHandler.handleError(error); + } + } +} diff --git a/lib/stores/app_message_store.dart b/lib/stores/app_message_store.dart new file mode 100644 index 00000000..3b422100 --- /dev/null +++ b/lib/stores/app_message_store.dart @@ -0,0 +1,34 @@ +import 'package:mobx/mobx.dart'; +import 'package:qubic_wallet/di.dart'; +import 'package:qubic_wallet/dtos/app_message_dto.dart'; +import 'package:qubic_wallet/helpers/app_logger.dart'; +import 'package:qubic_wallet/resources/apis/static/qubic_static_api.dart'; +import 'package:universal_platform/universal_platform.dart'; + +part 'app_message_store.g.dart'; + +// ignore: library_private_types_in_public_api +class AppMessageStore = _AppMessageStore with _$AppMessageStore; + +abstract class _AppMessageStore with Store { + @observable + bool isLoading = false; + + @action + Future getAppMessage() async { + isLoading = true; + try { + final message = await getIt().getAppMessage(); + if (message != null && + message.isValid(UniversalPlatform.operatingSystem)) { + return message; + } + return null; + } catch (e) { + appLogger.e(e); + return null; + } finally { + isLoading = false; + } + } +} diff --git a/lib/stores/app_message_store.g.dart b/lib/stores/app_message_store.g.dart new file mode 100644 index 00000000..58d31f5a --- /dev/null +++ b/lib/stores/app_message_store.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_message_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$AppMessageStore on _AppMessageStore, Store { + late final _$isLoadingAtom = + Atom(name: '_AppMessageStore.isLoading', context: context); + + @override + bool get isLoading { + _$isLoadingAtom.reportRead(); + return super.isLoading; + } + + @override + set isLoading(bool value) { + _$isLoadingAtom.reportWrite(value, super.isLoading, () { + super.isLoading = value; + }); + } + + late final _$getAppMessageAsyncAction = + AsyncAction('_AppMessageStore.getAppMessage', context: context); + + @override + Future getAppMessage() { + return _$getAppMessageAsyncAction.run(() => super.getAppMessage()); + } + + @override + String toString() { + return ''' +isLoading: ${isLoading} + '''; + } +} diff --git a/lib/stores/settings_store.dart b/lib/stores/settings_store.dart index bbb7c2f8..738ef953 100644 --- a/lib/stores/settings_store.dart +++ b/lib/stores/settings_store.dart @@ -64,20 +64,6 @@ abstract class _SettingsStore with Store { await secureStorage.setWalletSettings(settings); } - @action - Future setTOTPKey(String key) async { - settings.TOTPKey = key; - settings = Settings.clone(settings); - await secureStorage.setWalletSettings(settings); - } - - @action - Future clearTOTPKey() async { - settings.TOTPKey = null; - settings = Settings.clone(settings); - await secureStorage.setWalletSettings(settings); - } - @action Future setAutoLockTimeout(int value) async { settings.autoLockTimeout = value; diff --git a/lib/stores/settings_store.g.dart b/lib/stores/settings_store.g.dart index bc4c7426..b553117f 100644 --- a/lib/stores/settings_store.g.dart +++ b/lib/stores/settings_store.g.dart @@ -108,22 +108,6 @@ mixin _$SettingsStore on _SettingsStore, Store { return _$setBiometricsAsyncAction.run(() => super.setBiometrics(value)); } - late final _$setTOTPKeyAsyncAction = - AsyncAction('_SettingsStore.setTOTPKey', context: context); - - @override - Future setTOTPKey(String key) { - return _$setTOTPKeyAsyncAction.run(() => super.setTOTPKey(key)); - } - - late final _$clearTOTPKeyAsyncAction = - AsyncAction('_SettingsStore.clearTOTPKey', context: context); - - @override - Future clearTOTPKey() { - return _$clearTOTPKeyAsyncAction.run(() => super.clearTOTPKey()); - } - late final _$setAutoLockTimeoutAsyncAction = AsyncAction('_SettingsStore.setAutoLockTimeout', context: context); diff --git a/lib/styles/app_icons.dart b/lib/styles/app_icons.dart index b437162f..436e6000 100644 --- a/lib/styles/app_icons.dart +++ b/lib/styles/app_icons.dart @@ -35,4 +35,5 @@ abstract class AppIcons { static const String support = '${_path}support.svg'; static const String google = '${_path}google.svg'; static const String pendingAndFailedTrx = '${_path}clock-alert-outline.svg'; + static const String maintenance = '${_path}maintenance.svg'; }