diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ee39732b..518d7627 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -503,6 +503,11 @@ "settings_aboutWebsite": "Offband website", "settings_aboutPlayStore": "Google Play", "settings_aboutDonate": "Donate or sponsor", + "sponsorPrompt_title": "Support Offband", + "sponsorPrompt_body": "Offband MeshCore is free, open source, and built by an independent developer. If it's useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.", + "sponsorPrompt_sponsor": "Sponsor", + "sponsorPrompt_notNow": "Not now", + "sponsorPrompt_dontShowAgain": "Don't show again", "settings_aboutLinkFailed": "Could not open the link", "settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)", "settings_aboutPresetsAttribution": "Regional radio presets: MeshCore's suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c3e399ee..d8608f70 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1738,6 +1738,36 @@ abstract class AppLocalizations { /// **'Donate or sponsor'** String get settings_aboutDonate; + /// No description provided for @sponsorPrompt_title. + /// + /// In en, this message translates to: + /// **'Support Offband'** + String get sponsorPrompt_title; + + /// No description provided for @sponsorPrompt_body. + /// + /// In en, this message translates to: + /// **'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'** + String get sponsorPrompt_body; + + /// No description provided for @sponsorPrompt_sponsor. + /// + /// In en, this message translates to: + /// **'Sponsor'** + String get sponsorPrompt_sponsor; + + /// No description provided for @sponsorPrompt_notNow. + /// + /// In en, this message translates to: + /// **'Not now'** + String get sponsorPrompt_notNow; + + /// No description provided for @sponsorPrompt_dontShowAgain. + /// + /// In en, this message translates to: + /// **'Don\'t show again'** + String get sponsorPrompt_dontShowAgain; + /// No description provided for @settings_aboutLinkFailed. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 42534f96..a9861832 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -965,6 +965,22 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a7445928..d9083f9b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -960,6 +960,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b15dc39c..173d2750 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -951,6 +951,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index b078fcbd..7ef73371 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -961,6 +961,22 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3fa3998d..843224e6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -965,6 +965,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index bf4f8823..6008a0da 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -963,6 +963,22 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 81a29318..a58a629f 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -963,6 +963,22 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 5dd63388..622de3a9 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -936,6 +936,22 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a3056a61..ea20999a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -936,6 +936,22 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 58ab517c..a67573e7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -957,6 +957,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 57ea8136..f65d6277 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -965,6 +965,22 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1622dd33..7db63029 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -963,6 +963,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 606cbf1f..798e1d5d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -962,6 +962,22 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 99ab7e6a..f98bd043 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -957,6 +957,22 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f399f48f..f4705559 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -954,6 +954,22 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index b67eace8..e5c93277 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -952,6 +952,22 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 4f0a1e50..0f20e062 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -960,6 +960,22 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5ff24cd3..87cb5037 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -928,6 +928,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_aboutDonate => 'Donate or sponsor'; + @override + String get sponsorPrompt_title => 'Support Offband'; + + @override + String get sponsorPrompt_body => + 'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'; + + @override + String get sponsorPrompt_sponsor => 'Sponsor'; + + @override + String get sponsorPrompt_notNow => 'Not now'; + + @override + String get sponsorPrompt_dontShowAgain => 'Don\'t show again'; + @override String get settings_aboutLinkFailed => 'Could not open the link'; diff --git a/lib/main.dart b/lib/main.dart index a1646dda..5a042e93 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,7 @@ import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; import 'widgets/keep_screen_awake.dart'; +import 'widgets/sponsor_prompt.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -393,7 +394,7 @@ class MeshCoreApp extends StatelessWidget { }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() - : const ScannerScreen(), + : const SponsorPromptGate(child: ScannerScreen()), ); }, ), diff --git a/lib/storage/scoping_inventory.dart b/lib/storage/scoping_inventory.dart index 8c7d2858..b5d4a368 100644 --- a/lib/storage/scoping_inventory.dart +++ b/lib/storage/scoping_inventory.dart @@ -275,6 +275,22 @@ const List storageSurfaces = [ 'previously audited: surfaced by this inventory', owner: 'lib/services/radio_preset_service.dart', ), + StorageSurface( + 'sponsor_prompt_shown_for', + verdict: ScopingVerdict.globalOnPurpose, + reason: + 'the app version the sponsor window last showed for (#1129). It ' + 'follows the installed app, not a radio', + owner: 'lib/widgets/sponsor_prompt.dart', + ), + StorageSurface( + 'sponsor_prompt_off', + verdict: ScopingVerdict.globalOnPurpose, + reason: + "the user's \"Don't show again\" for the sponsor window (#1129). A " + 'choice about the app, not about a radio', + owner: 'lib/widgets/sponsor_prompt.dart', + ), StorageSurface( 'anr_last_exit_ts_ms', verdict: ScopingVerdict.globalOnPurpose, diff --git a/lib/widgets/sponsor_prompt.dart b/lib/widgets/sponsor_prompt.dart new file mode 100644 index 00000000..8a6a5347 --- /dev/null +++ b/lib/widgets/sponsor_prompt.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../helpers/snack_bar_builder.dart'; +import '../l10n/l10n.dart'; +import '../storage/prefs_manager.dart'; +import '../utils/app_logger.dart'; +import '../utils/platform_info.dart'; + +enum _Choice { off, later, sponsor } + +/// The sponsor window shown once on the first launch after an install or an +/// upgrade (#1129). App-wide, never per radio. +class SponsorPrompt { + static const String shownForKey = 'sponsor_prompt_shown_for'; + static const String offKey = 'sponsor_prompt_off'; + static final Uri sponsorUri = Uri.parse('https://offband.org/donate'); + + /// Shows the window unless it has already shown for [version], the user + /// turned it off, or this is iOS (hidden there like About's Donate, #1051). + /// The version is saved before the window opens, so a crash can't loop it. + static Future maybeShow( + BuildContext context, { + String? version, + bool? isIOS, + SharedPreferences? prefs, + Future Function(Uri)? open, + }) async { + if (isIOS ?? PlatformInfo.isIOS) return; + final store = prefs ?? PrefsManager.instance; + if (store.getBool(offKey) ?? false) return; + final current = version ?? await _installedVersion(); + if (current == null || store.getString(shownForKey) == current) return; + await store.setString(shownForKey, current); + if (!context.mounted) return; + final choice = await showDialog<_Choice>( + context: context, + builder: (_) => const _SponsorDialog(), + ); + if (choice == _Choice.off) { + await store.setBool(offKey, true); + } else if (choice == _Choice.sponsor) { + if (await _open(open ?? _openExternally)) return; + if (context.mounted) { + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_aboutLinkFailed), + persist: true, + ); + } + } + } + + static Future _open(Future Function(Uri) open) async { + try { + return await open(sponsorUri); + } catch (e) { + appLogger.warn('Sponsor link did not open: $e', tag: 'Sponsor'); + return false; + } + } + + static Future _openExternally(Uri uri) => + launchUrl(uri, mode: LaunchMode.externalApplication); + + static Future _installedVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return '${info.version}+${info.buildNumber}'; + } catch (e) { + appLogger.error('Sponsor prompt: no app version: $e', tag: 'Sponsor'); + return null; + } + } +} + +/// Wraps the app's first screen and offers the sponsor window once it has +/// drawn. +class SponsorPromptGate extends StatefulWidget { + const SponsorPromptGate({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _SponsorPromptGateState(); +} + +class _SponsorPromptGateState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) SponsorPrompt.maybeShow(context); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class _SponsorDialog extends StatelessWidget { + const _SponsorDialog(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + void choose(_Choice choice) => Navigator.of(context).pop(choice); + return AlertDialog( + title: Text(l10n.sponsorPrompt_title), + content: Text(l10n.sponsorPrompt_body), + actions: [ + TextButton( + onPressed: () => choose(_Choice.off), + child: Text(l10n.sponsorPrompt_dontShowAgain), + ), + TextButton( + onPressed: () => choose(_Choice.later), + child: Text(l10n.sponsorPrompt_notNow), + ), + FilledButton( + onPressed: () => choose(_Choice.sponsor), + child: Text(l10n.sponsorPrompt_sponsor), + ), + ], + ); + } +} diff --git a/test/widgets/sponsor_prompt_test.dart b/test/widgets/sponsor_prompt_test.dart new file mode 100644 index 00000000..72a812fd --- /dev/null +++ b/test/widgets/sponsor_prompt_test.dart @@ -0,0 +1,185 @@ +// Sponsor prompt on the first launch after an install or upgrade (#1129). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:meshcore_open/widgets/sponsor_prompt.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future _prefs([Map values = const {}]) { + SharedPreferences.setMockInitialValues(values); + return SharedPreferences.getInstance(); +} + +/// Launches the app's first screen once: a button stands in for the scanner's +/// post-frame call. +Future _launch( + WidgetTester tester, + SharedPreferences prefs, { + String version = '1.8.0+69', + bool isIOS = false, + Future Function(Uri)? open, +}) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => SponsorPrompt.maybeShow( + context, + version: version, + isIOS: isIOS, + prefs: prefs, + open: open, + ), + child: const Text('launch'), + ), + ), + ), + ), + ); + await tester.tap(find.text('launch')); + await tester.pumpAndSettle(); +} + +final _title = find.text('Support Offband'); + +void main() { + testWidgets('a fresh install shows it, with all three choices', ( + tester, + ) async { + final prefs = await _prefs(); + await _launch(tester, prefs); + expect(_title, findsOneWidget); + expect(find.text('Sponsor'), findsOneWidget); + expect(find.text('Not now'), findsOneWidget); + expect(find.text("Don't show again"), findsOneWidget); + // Saved while the window is still open, so a crash can't loop it. + expect(prefs.getString(SponsorPrompt.shownForKey), '1.8.0+69'); + }); + + testWidgets('the same version does not show it again', (tester) async { + final prefs = await _prefs({SponsorPrompt.shownForKey: '1.8.0+69'}); + await _launch(tester, prefs); + expect(_title, findsNothing); + }); + + testWidgets('an upgrade shows it once more', (tester) async { + final prefs = await _prefs({SponsorPrompt.shownForKey: '1.7.0+68'}); + await _launch(tester, prefs); + expect(_title, findsOneWidget); + expect(prefs.getString(SponsorPrompt.shownForKey), '1.8.0+69'); + }); + + testWidgets('Not now closes it and the next upgrade asks again', ( + tester, + ) async { + final prefs = await _prefs(); + await _launch(tester, prefs); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(_title, findsNothing); + expect(prefs.getBool(SponsorPrompt.offKey), isNull); + + await _launch(tester, prefs, version: '1.8.1+70'); + expect(_title, findsOneWidget); + }); + + testWidgets("Don't show again sticks across upgrades", (tester) async { + final prefs = await _prefs(); + await _launch(tester, prefs); + await tester.tap(find.text("Don't show again")); + await tester.pumpAndSettle(); + expect(_title, findsNothing); + expect(prefs.getBool(SponsorPrompt.offKey), isTrue); + + await _launch(tester, prefs, version: '1.9.0+71'); + expect(_title, findsNothing); + }); + + testWidgets('iOS never shows it and records nothing', (tester) async { + final prefs = await _prefs(); + await _launch(tester, prefs, isIOS: true); + expect(_title, findsNothing); + expect(prefs.getString(SponsorPrompt.shownForKey), isNull); + }); + + testWidgets('the first screen offers it on its own after it draws', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + addTearDown(PrefsManager.reset); + PackageInfo.setMockInitialValues( + appName: 'Offband MeshCore', + packageName: 'app.offband.meshcore', + version: '1.8.0', + buildNumber: '69', + buildSignature: '', + ); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SponsorPromptGate(child: Scaffold(body: Text('scanner'))), + ), + ); + await tester.pumpAndSettle(); + expect(_title, findsOneWidget); + expect( + PrefsManager.instance.getString(SponsorPrompt.shownForKey), + '1.8.0+69', + ); + }); + + testWidgets('Sponsor opens the donate page and says nothing more', ( + tester, + ) async { + final prefs = await _prefs(); + final opened = []; + await _launch( + tester, + prefs, + open: (uri) async { + opened.add(uri); + return true; + }, + ); + await tester.tap(find.text('Sponsor')); + await tester.pumpAndSettle(); + expect(_title, findsNothing); + expect(opened, [SponsorPrompt.sponsorUri]); + expect(find.text('Could not open the link'), findsNothing); + expect(prefs.getBool(SponsorPrompt.offKey), isNull); + }); + + for (final failure in ['returns false', 'throws']) { + testWidgets('a Sponsor link that $failure says so and the message stays', ( + tester, + ) async { + final prefs = await _prefs(); + await _launch( + tester, + prefs, + open: (uri) async { + if (failure == 'throws') throw Exception('no browser'); + return false; + }, + ); + await tester.tap(find.text('Sponsor')); + await tester.pumpAndSettle(); + expect(find.text('Could not open the link'), findsOneWidget); + await tester.pump(const Duration(seconds: 10)); + expect(find.text('Could not open the link'), findsOneWidget); + }); + } + + test('Sponsor opens the donate page the About screen uses', () { + expect(SponsorPrompt.sponsorUri.toString(), 'https://offband.org/donate'); + }); +} diff --git a/untranslated.json b/untranslated.json index 11b9039f..4ad68511 100644 --- a/untranslated.json +++ b/untranslated.json @@ -112,6 +112,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -501,6 +506,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -890,6 +900,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -1279,6 +1294,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -1668,6 +1688,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2057,6 +2082,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2446,6 +2476,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -2835,6 +2870,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -3224,6 +3264,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -3613,6 +3658,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4002,6 +4052,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4391,6 +4446,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -4780,6 +4840,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -5169,6 +5234,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -5558,6 +5628,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -5947,6 +6022,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware", @@ -6336,6 +6416,11 @@ "settings_aboutWebsite", "settings_aboutPlayStore", "settings_aboutDonate", + "sponsorPrompt_title", + "sponsorPrompt_body", + "sponsorPrompt_sponsor", + "sponsorPrompt_notNow", + "sponsorPrompt_dontShowAgain", "settings_aboutLinkFailed", "settings_aboutPresetsAttribution", "settings_infoFirmware",