feat(#1129): sponsor window on the first launch after an install or upgrade
SponsorPromptGate wraps the first screen and, once it has drawn, shows a Support Offband dialog: Sponsor (opens offband.org/donate), Not now, and Don't show again. It shows once per installed version and build, saved before the dialog opens so a crash can't loop it. Hidden on iOS, as the About screen's Donate link is (#1051). A link that fails to open shows the About screen's persistent "Could not open the link". Both keys are app-wide and classified in scoping_inventory.dart. English strings only; untranslated.json regenerated. Tests: 10 widget tests plus the wiring through the gate. Each of five guards (iOS, Don't show again, same version, the gate's call, the failure message) was removed in turn and a test failed. Full suite 2074 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix/1128-channel-key-history
parent
96ed46afd1
commit
65b91cffdf
@ -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<void> maybeShow(
|
||||
BuildContext context, {
|
||||
String? version,
|
||||
bool? isIOS,
|
||||
SharedPreferences? prefs,
|
||||
Future<bool> 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<bool> _open(Future<bool> 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<bool> _openExternally(Uri uri) =>
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
|
||||
static Future<String?> _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<SponsorPromptGate> createState() => _SponsorPromptGateState();
|
||||
}
|
||||
|
||||
class _SponsorPromptGateState extends State<SponsorPromptGate> {
|
||||
@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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<SharedPreferences> _prefs([Map<String, Object> 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<void> _launch(
|
||||
WidgetTester tester,
|
||||
SharedPreferences prefs, {
|
||||
String version = '1.8.0+69',
|
||||
bool isIOS = false,
|
||||
Future<bool> 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 = <Uri>[];
|
||||
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');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue