A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue