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

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
@ -51,7 +52,13 @@ class _OnboardingStep {
final String title;
final String description;
final List<GlobalKey> targetKeys;
final void Function() activate;
/// Gets the app into the right state to show this step (switch tabs,
/// push a route, ...). Returns the route it just pushed, if any — so
/// [_MainShellState._waitForTargets] can wait for that specific route's
/// transition to settle before spotlighting something on it — or null
/// for a step (like every tab switch) that didn't push one.
final ModalRoute<dynamic>? Function() activate;
const _OnboardingStep({
required this.title,
@ -80,8 +87,8 @@ class _MainShellState extends State<MainShell> {
_OnboardingStep(
title: 'What This App Does',
description: 'This app helps you collect and organize your Missouri fuel purchase '
"receipts so you can claim Missouri's Motor Fuel Tax Refund — currently 12.5¢ back "
'per gallon.\n\n'
"receipts so you can claim Missouri's Motor Fuel Tax Refund — up to 12.5¢/gal "
'for highway use, up to 29.5¢/gal non-highway.\n\n'
'Disclaimer: this refund is available for as long as Missouri lawmakers continue to '
'offer it, and state law could change or end the program at any time. Always confirm '
'current eligibility and rates with the Missouri Department of Revenue before filing.',
@ -90,7 +97,7 @@ class _MainShellState extends State<MainShell> {
// be when the tour starts (normally the Receipts tab, since that's
// MainShell's default).
targetKeys: const [],
activate: () {},
activate: () => null,
),
_OnboardingStep(
title: 'Add Receipts Here',
@ -98,7 +105,10 @@ class _MainShellState extends State<MainShell> {
'Snap a photo of it and the app reads the date, gallons, and price for you '
'automatically.',
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
activate: () => _jumpToTab(0),
activate: () {
_jumpToTab(0);
return null;
},
),
_OnboardingStep(
title: 'Add Vehicles Here',
@ -106,7 +116,10 @@ class _MainShellState extends State<MainShell> {
'receipt gets linked to one of your vehicles, so add one here first before logging '
'a receipt for it.',
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
activate: () => _jumpToTab(1),
activate: () {
_jumpToTab(1);
return null;
},
),
_OnboardingStep(
title: 'Choose a Report Date Range',
@ -126,14 +139,20 @@ class _MainShellState extends State<MainShell> {
OnboardingKeys.reportShareAndPrintRow,
OnboardingKeys.refundFormLink,
],
activate: () => _jumpToTab(2),
activate: () {
_jumpToTab(2);
return null;
},
),
_OnboardingStep(
title: 'Find Data Settings',
description: 'This is the Settings tab — tap Data here to manage where your vehicles, '
'receipts, and photos get backed up.',
targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry],
activate: () => _jumpToTab(3),
activate: () {
_jumpToTab(3);
return null;
},
),
_OnboardingStep(
title: 'Back Up to the Cloud',
@ -143,13 +162,14 @@ class _MainShellState extends State<MainShell> {
targetKeys: [OnboardingKeys.cloudStorageCard],
activate: () {
_tourPushedDataScreen = true;
final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen());
// Not awaited: Navigator.push's returned future only completes on
// pop, not on the pushed route finishing its build — the polling
// wait in _showTourStep (for the target key's RenderObject to
// exist) is what actually waits for this screen to be ready.
unawaited(Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
));
// exist, then for `route` itself — returned below — to finish its
// transition) is what actually waits for this screen to be ready.
unawaited(Navigator.of(context).push(route));
return route;
},
),
];
@ -207,7 +227,7 @@ class _MainShellState extends State<MainShell> {
/// page (like the Reports tab's refund-form link) could still be
/// off-screen despite having a real, laid-out RenderBox, and would get
/// "highlighted" somewhere the user can't actually see.
Future<void> _waitForTargets(List<GlobalKey> keys) async {
Future<void> _waitForTargets(List<GlobalKey> keys, ModalRoute<dynamic>? pushedRoute) async {
for (var attempt = 0; attempt < 30; attempt++) {
if (!mounted) return;
final allResolved = keys.every((key) {
@ -229,12 +249,40 @@ class _MainShellState extends State<MainShell> {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
// The "Back Up to the Cloud" step's activate() pushes a route and
// returns it as [pushedRoute] — wait for that push transition to fully
// settle before capturing its target's position, or the spotlight ends
// up shifted by however far the slide-in hadn't yet finished. A no-op
// for every other step, which only switches tabs and so has no route
// to pass here.
await waitForRouteTransition(pushedRoute?.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;
}
}
Future<void> _showTourStep() async {
final step = _tourSteps[_tourStepIndex];
step.activate();
await _waitForTargets(step.targetKeys);
final pushedRoute = step.activate();
await _waitForTargets(step.targetKeys, pushedRoute);
if (!mounted) return;
_tourEntry?.remove();