A whole lot of stuff

This commit is contained in:
Courtney Arnold 2026-08-18 11:04:58 -05:00
parent faf319322a
commit ad850d2be6
50 changed files with 3013 additions and 213 deletions

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
/// Shown right after a user watches the app's one ad placement (see
/// [AdService.showGateAd] / [AppState.syncNow]) — not on every sync, only
/// the moment an ad was actually just shown to them, since that's when the
/// "you could skip these" pitch is most relevant. Purely informational: it
/// doesn't block or delay the sync that triggered it, which is already
/// proceeding underneath it by the time this appears.
Future<void> showAdFreeUpsellDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tired of Ads?'),
content: Consumer<AppState>(
builder: (context, appState, _) => Text(
appState.adFreeYearPriceLabel == null
? "You can remove ads for a full year with a single one-time purchase."
: 'You can remove ads for a full year for '
'${appState.adFreeYearPriceLabel} — a single one-time purchase.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Not Now'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
context.read<AppState>().buyAdFreeYear();
},
child: const Text('Remove Ads'),
),
],
),
);
}

View file

@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../screens/data_settings_screen.dart';
import '../screens/faq_screen.dart';
import '../services/onboarding_keys.dart';
import 'onboarding_tour_overlay.dart';
const _backedUpFaqQuestion = 'Is my data backed up?';
/// Shown right after a fuel entry is saved (see
/// `ConfirmFuelEntryScreen._save`) when no cloud backup is configured yet —
/// nudges the user toward Settings > Data before they forget and eventually
@ -21,6 +25,19 @@ Future<bool> showBackupReminderDialog(BuildContext context) async {
'Would you like to set up a backup method now?',
),
actions: [
TextButton(
onPressed: () {
// Resolves as "not now" (they didn't choose to set backup up
// directly), but still takes them somewhere useful about it.
Navigator.of(context).pop(false);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const FaqScreen(initiallyExpandedQuestion: _backedUpFaqQuestion),
),
);
},
child: const Text('Learn More'),
),
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Not Now')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Set Up Backup')),
],
@ -80,6 +97,34 @@ class _SpotlightedDataSettingsScreenState extends State<_SpotlightedDataSettings
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
// This screen was reached via Navigator.push (see openCloudBackupSetup
// below) — wait for that push transition to fully settle before
// capturing the card's position, or the spotlight ends up shifted by
// however far the slide-in hadn't yet finished. ModalRoute.of(context)
// correctly resolves to that pushed route here, since this State's
// context lives inside it.
await waitForRouteTransition(ModalRoute.of(context)?.animation);
if (!mounted) return;
// The route's own AnimationController reports AnimationStatus.completed
// at this point, but empirically the render tree's transforms (from
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
// Android default as of Flutter 3.44) still reflect a mid-transition
// position for a few more frames after that — confirmed by walking the
// RenderObject ancestor chain and finding an active
// RenderFractionalTranslation still present, with a position matching
// the transition's *starting* offset rather than its resting
// Offset.zero. These extra frames give it time to actually settle
// before the spotlight measures anything. SchedulerBinding.endOfFrame
// (rather than a bare Future.delayed) so this genuinely waits on real
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
// while something has an actual frame scheduled, which a bare delay
// timer doesn't count as once the route's own transition has already
// finished.
for (var i = 0; i < 10; i++) {
if (!mounted) return;
await SchedulerBinding.instance.endOfFrame;
}
if (!mounted) return;
_entry = OverlayEntry(
builder: (_) => OnboardingTourOverlay(

View file

@ -68,12 +68,15 @@ class HeroStat extends StatelessWidget {
}
/// The icon + two-stat row shared by every hero banner: gallons on the
/// left, the hero icon in the middle, cost on the right.
/// left, the hero icon (or, if [estimatedRefundValue] is given, the
/// estimated fuel refund in its place — see the Receipts tab, the only
/// caller that ever passes it) in the middle, cost on the right.
class HeroStatsRow extends StatelessWidget {
final String gallonsValue;
final String costValue;
final String gallonsLabel;
final String costLabel;
final String? estimatedRefundValue;
const HeroStatsRow({
super.key,
@ -81,6 +84,7 @@ class HeroStatsRow extends StatelessWidget {
required this.costValue,
this.gallonsLabel = 'Total Gallons',
this.costLabel = 'Total Fuel Cost',
this.estimatedRefundValue,
});
@override
@ -91,18 +95,58 @@ class HeroStatsRow extends StatelessWidget {
Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
// Tall enough to extend past the bottom of the label+value text
// next to it, not just match the label's height — width scales
// automatically from the source's own aspect ratio since only
// height is given.
child: Image.asset(
'assets/icon/hero_icon.png',
height: 58,
fit: BoxFit.contain,
),
child: estimatedRefundValue == null
// Tall enough to extend past the bottom of the label+value
// text next to it, not just match the label's height — width
// scales automatically from the source's own aspect ratio
// since only height is given.
? Image.asset(
'assets/icon/hero_icon.png',
height: 58,
fit: BoxFit.contain,
)
: _EstimatedRefundStat(value: estimatedRefundValue!),
),
Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)),
],
);
}
}
/// The estimated-fuel-refund stat that takes the hero icon's place in the
/// middle of [HeroStatsRow] when that's turned on — smaller/more compact
/// than [HeroStat] since it sits in the narrower middle slot rather than
/// an [Expanded] one.
class _EstimatedRefundStat extends StatelessWidget {
final String value;
const _EstimatedRefundStat({required this.value});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Est. Refund',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.heroTextSecondary(context),
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
value,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppTheme.heroText,
fontSize: 30,
fontWeight: FontWeight.w800,
),
),
],
);
}
}

View file

@ -1,6 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Waits for a route's push/pop transition, if one is still in progress, to
/// finish. Needed before capturing a highlighted target's on-screen
/// position via `RenderBox.localToGlobal` (see `MainShell._waitForTargets`
/// / `_SpotlightedDataSettingsScreen`'s `_showSpotlight` in
/// backup_reminder.dart, both of which push a [MaterialPageRoute] and then
/// spotlight something on it): a [RenderBox] reports `hasSize` — and a
/// real, but not yet final, global position — as soon as its first layout
/// pass completes, which happens well before a [MaterialPageRoute]'s
/// ~300ms slide-in transition actually settles. Capturing the position too
/// early bakes in wherever the page was mid-slide, which is what produced
/// a spotlight rectangle visibly shifted from its target.
///
/// Takes the [Animation] directly — rather than a [BuildContext] to look
/// one up via `ModalRoute.of` — because the caller doesn't always have a
/// [BuildContext] that's actually inside the route in question: `MainShell`
/// pushes the route from its own (different, already-settled) route, so
/// `ModalRoute.of(mainShellContext)` would resolve to the wrong one.
/// Passing null (nothing to wait for) or an already-[AnimationStatus.completed]
/// animation both resolve immediately, so it's safe to call unconditionally.
Future<void> waitForRouteTransition(Animation<double>? animation) async {
if (animation == null || animation.status == AnimationStatus.completed) return;
final completer = Completer<void>();
void listener(AnimationStatus status) {
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
completer.complete();
}
}
animation.addStatusListener(listener);
await completer.future;
animation.removeStatusListener(listener);
}
/// The full-screen "spotlight" shown by one step of the first-launch
/// guided tour: a dimmed barrier with a cut-out hole around each of
/// [targetKeys]' current on-screen positions (or, for any key that isn't