Temporary setup of alternative to advertisements
This commit is contained in:
parent
ad2d932842
commit
57a9793a40
14 changed files with 653 additions and 56 deletions
|
|
@ -7,7 +7,7 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<application
|
<application
|
||||||
android:label="fuel_tax_tracker"
|
android:label="Receipt Tracker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Fuel Tax Tracker</string>
|
<string>Receipt Tracker</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|
|
||||||
14
lib/app_navigator.dart
Normal file
14
lib/app_navigator.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A stable handle on the app's navigator, for the few places that need to
|
||||||
|
/// put something on screen from outside a widget's own build method:
|
||||||
|
///
|
||||||
|
/// - [AppState.onAdWatched]'s "remove ads for a year" upsell, which fires
|
||||||
|
/// from deep inside a background sync rather than from a widget.
|
||||||
|
/// - `showAdPlaceholder`, called by [AdService], which owns no
|
||||||
|
/// [BuildContext] of its own (real ads are native overlays that never
|
||||||
|
/// needed one).
|
||||||
|
///
|
||||||
|
/// Lives here rather than in `main.dart` so services and widgets can reach
|
||||||
|
/// it without importing the app's entry point.
|
||||||
|
final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'app_navigator.dart';
|
||||||
import 'screens/main_shell.dart';
|
import 'screens/main_shell.dart';
|
||||||
import 'screens/user_agreement_screen.dart';
|
import 'screens/user_agreement_screen.dart';
|
||||||
import 'services/app_state.dart';
|
import 'services/app_state.dart';
|
||||||
|
|
@ -11,12 +12,6 @@ void main() {
|
||||||
runApp(const FuelTaxTrackerApp());
|
runApp(const FuelTaxTrackerApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stable handle on the navigator so [AppState.onAdWatched] can show the
|
|
||||||
/// "remove ads" dialog from wherever the app happens to be — it fires from
|
|
||||||
/// deep inside a background sync, not from a widget's own build method, so
|
|
||||||
/// there's no local [BuildContext] to reach for at that point.
|
|
||||||
final navigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
|
|
||||||
class FuelTaxTrackerApp extends StatelessWidget {
|
class FuelTaxTrackerApp extends StatelessWidget {
|
||||||
const FuelTaxTrackerApp({super.key});
|
const FuelTaxTrackerApp({super.key});
|
||||||
|
|
||||||
|
|
@ -32,7 +27,7 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
||||||
};
|
};
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
title: 'Fuel Tax Tracker',
|
title: 'Receipt Tracker',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.light,
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: AppTheme.dark,
|
||||||
themeMode: appState.themeMode,
|
themeMode: appState.themeMode,
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,21 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// The entry is already committed by this point, so the ad isn't
|
||||||
|
// gating the save — it just fills the moment between saving and
|
||||||
|
// landing back on the list. Awaited rather than fired off, so the
|
||||||
|
// two don't race and the ad can't end up drawn over the Receipts
|
||||||
|
// tab after this screen has gone.
|
||||||
|
final adShown = await appState.maybeShowFuelSaveAd();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
// Ask while this screen (and its context) is still fully alive,
|
// Ask while this screen (and its context) is still fully alive,
|
||||||
// before popping — simpler than trying to show a dialog against a
|
// before popping — simpler than trying to show a dialog against a
|
||||||
// context whose widget is mid-removal.
|
// context whose widget is mid-removal. Held back when an ad just
|
||||||
|
// ran: one full-screen surface per save, and the reminder comes
|
||||||
|
// back around on the next save anyway.
|
||||||
var wantsBackupSetup = false;
|
var wantsBackupSetup = false;
|
||||||
if (!appState.hasCloudBackupConfigured) {
|
if (!adShown && !appState.hasCloudBackupConfigured) {
|
||||||
wantsBackupSetup = await showBackupReminderDialog(context);
|
wantsBackupSetup = await showBackupReminderDialog(context);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
lib/services/ad_config.dart
Normal file
17
lib/services/ad_config.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/// The single switch that turns real AdMob ads on.
|
||||||
|
///
|
||||||
|
/// While this is `false`, the app behaves in every observable way as though
|
||||||
|
/// ads were live — each placement is still *decided* on exactly the same
|
||||||
|
/// schedule (new-user grace periods, earned credits, the ad-free purchase,
|
||||||
|
/// the sync gate blocking uploads) — but where a real ad would appear, it
|
||||||
|
/// shows `AdPlaceholderScreen` instead, and the Google Mobile Ads SDK is
|
||||||
|
/// never initialized at all. No ad requests go out, so none of them can
|
||||||
|
/// come back "Account not approved yet" or no-fill.
|
||||||
|
///
|
||||||
|
/// That means the pacing and gating can be exercised and tuned for real
|
||||||
|
/// before AdMob approval lands.
|
||||||
|
///
|
||||||
|
/// Flipping this to `true` is the only change needed to go live: every
|
||||||
|
/// placement picks up its real ad unit from [AdService] and the placeholder
|
||||||
|
/// stops being reachable. Nothing else in the app branches on it.
|
||||||
|
const bool adsEnabled = false;
|
||||||
|
|
@ -3,6 +3,9 @@ import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
|
import '../widgets/ad_placeholder_screen.dart';
|
||||||
|
import 'ad_config.dart';
|
||||||
|
|
||||||
/// This app has three ad placements:
|
/// This app has three ad placements:
|
||||||
///
|
///
|
||||||
/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see
|
/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see
|
||||||
|
|
@ -42,6 +45,13 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
/// post-task transitions, which is exactly what plain interstitials are
|
/// post-task transitions, which is exactly what plain interstitials are
|
||||||
/// for. Neither ever blocks or gates anything, unlike placement 1.
|
/// for. Neither ever blocks or gates anything, unlike placement 1.
|
||||||
///
|
///
|
||||||
|
/// All three are gated behind [adsEnabled] (lib/services/ad_config.dart).
|
||||||
|
/// While that's false, every method here short-circuits to
|
||||||
|
/// [showAdPlaceholder] and the Mobile Ads SDK is never touched — no
|
||||||
|
/// requests, no failures — while the *decisions* about when each placement
|
||||||
|
/// fires stay exactly as they'd be with real ads, including the sync gate
|
||||||
|
/// still withholding uploads until the user acts.
|
||||||
|
///
|
||||||
/// This class only owns ad-format *mechanics* — load, preload, show — for
|
/// This class only owns ad-format *mechanics* — load, preload, show — for
|
||||||
/// all three. Deciding *when* each is actually due (grace periods for a
|
/// all three. Deciding *when* each is actually due (grace periods for a
|
||||||
/// new user, earned watch-credits, the ad-free purchase) is [AppState]'s
|
/// new user, earned watch-credits, the ad-free purchase) is [AppState]'s
|
||||||
|
|
@ -115,6 +125,7 @@ class AdService {
|
||||||
/// than unconditionally at app startup, so a user who never connects
|
/// than unconditionally at app startup, so a user who never connects
|
||||||
/// cloud sync never triggers any ad-related network activity at all.
|
/// cloud sync never triggers any ad-related network activity at all.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
if (!adsEnabled) return;
|
||||||
final alreadyInitialized = _sdkInitialized;
|
final alreadyInitialized = _sdkInitialized;
|
||||||
await _ensureSdkInitialized();
|
await _ensureSdkInitialized();
|
||||||
if (!alreadyInitialized) unawaited(_preload());
|
if (!alreadyInitialized) unawaited(_preload());
|
||||||
|
|
@ -161,6 +172,16 @@ class AdService {
|
||||||
Future<AdGateResult> showGateAd() async {
|
Future<AdGateResult> showGateAd() async {
|
||||||
if (_gateOpen) return AdGateResult.alreadyOpen;
|
if (_gateOpen) return AdGateResult.alreadyOpen;
|
||||||
|
|
||||||
|
// Stand-in for the rewarded ad: the upload still waits on the user
|
||||||
|
// actively dismissing something, and still opens the same
|
||||||
|
// [gateValidity] window afterward, so the gate's behavior is unchanged
|
||||||
|
// apart from what's on screen. See lib/services/ad_config.dart.
|
||||||
|
if (!adsEnabled) {
|
||||||
|
await showAdPlaceholder(mustConfirm: true);
|
||||||
|
_lastShownAt = DateTime.now();
|
||||||
|
return AdGateResult.justShown;
|
||||||
|
}
|
||||||
|
|
||||||
if (_preloadedAd == null) {
|
if (_preloadedAd == null) {
|
||||||
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
|
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +226,7 @@ class AdService {
|
||||||
/// ad is ready by the time the user shares/prints without delaying the
|
/// ad is ready by the time the user shares/prints without delaying the
|
||||||
/// export itself.
|
/// export itself.
|
||||||
Future<void> preloadReportAd() async {
|
Future<void> preloadReportAd() async {
|
||||||
|
if (!adsEnabled) return;
|
||||||
await _ensureSdkInitialized();
|
await _ensureSdkInitialized();
|
||||||
unawaited(_preloadPlainInterstitial(
|
unawaited(_preloadPlainInterstitial(
|
||||||
adUnitId: _reportInterstitialAdUnitId,
|
adUnitId: _reportInterstitialAdUnitId,
|
||||||
|
|
@ -224,6 +246,11 @@ class AdService {
|
||||||
/// at all). Returns whether an ad actually showed, so the caller knows
|
/// at all). Returns whether an ad actually showed, so the caller knows
|
||||||
/// whether to treat its credit as earned.
|
/// whether to treat its credit as earned.
|
||||||
Future<bool> maybeShowReportAd() async {
|
Future<bool> maybeShowReportAd() async {
|
||||||
|
if (!adsEnabled) {
|
||||||
|
await showAdPlaceholder(mustConfirm: false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
final ad = _preloadedReportAd;
|
final ad = _preloadedReportAd;
|
||||||
_preloadedReportAd = null;
|
_preloadedReportAd = null;
|
||||||
final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial');
|
final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial');
|
||||||
|
|
@ -238,6 +265,7 @@ class AdService {
|
||||||
/// Same shape as [preloadReportAd], for the fuel-save interstitial —
|
/// Same shape as [preloadReportAd], for the fuel-save interstitial —
|
||||||
/// [AppState] calls this once the confirm-entry screen is first built.
|
/// [AppState] calls this once the confirm-entry screen is first built.
|
||||||
Future<void> preloadFuelSaveAd() async {
|
Future<void> preloadFuelSaveAd() async {
|
||||||
|
if (!adsEnabled) return;
|
||||||
await _ensureSdkInitialized();
|
await _ensureSdkInitialized();
|
||||||
unawaited(_preloadPlainInterstitial(
|
unawaited(_preloadPlainInterstitial(
|
||||||
adUnitId: _fuelSaveInterstitialAdUnitId,
|
adUnitId: _fuelSaveInterstitialAdUnitId,
|
||||||
|
|
@ -249,6 +277,11 @@ class AdService {
|
||||||
/// Same shape and caveats as [maybeShowReportAd], for the fuel-save
|
/// Same shape and caveats as [maybeShowReportAd], for the fuel-save
|
||||||
/// interstitial.
|
/// interstitial.
|
||||||
Future<bool> maybeShowFuelSaveAd() async {
|
Future<bool> maybeShowFuelSaveAd() async {
|
||||||
|
if (!adsEnabled) {
|
||||||
|
await showAdPlaceholder(mustConfirm: false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
final ad = _preloadedFuelSaveAd;
|
final ad = _preloadedFuelSaveAd;
|
||||||
_preloadedFuelSaveAd = null;
|
_preloadedFuelSaveAd = null;
|
||||||
final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial');
|
final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial');
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ class AppState extends ChangeNotifier {
|
||||||
|
|
||||||
/// The earliest-known moment this app was ever used — null until [init]
|
/// The earliest-known moment this app was ever used — null until [init]
|
||||||
/// loads it. Drives the new-user ad grace period (see
|
/// loads it. Drives the new-user ad grace period (see
|
||||||
/// [_maybeShowFuelSaveAd]/[maybeShowReportAd]); see
|
/// [maybeShowFuelSaveAd]/[maybeShowReportAd]); see
|
||||||
/// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced
|
/// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced
|
||||||
/// rather than a local-only preference.
|
/// rather than a local-only preference.
|
||||||
DateTime? firstUsedAt;
|
DateTime? firstUsedAt;
|
||||||
|
|
@ -247,34 +247,47 @@ class AppState extends ChangeNotifier {
|
||||||
return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod;
|
return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort ad shown after a fuel entry is saved — see
|
/// Ad shown on saving a fuel entry — see [AdService.maybeShowFuelSaveAd].
|
||||||
/// [AdService.maybeShowFuelSaveAd]. A no-op for a user with an active ad
|
///
|
||||||
/// -free purchase, during the new-user grace period or the first
|
/// Called by `ConfirmFuelEntryScreen._save` *after* [addFuelEntry] has
|
||||||
/// [_fuelSaveAdGraceSaves] saves, while a previously-watched fuel-save
|
/// already committed the entry, and awaited before that screen pops. So
|
||||||
/// ad's credit is still active, or — deliberately — whenever cloud
|
/// it reads to the user as "tap Save, see the ad, land back on the
|
||||||
/// backup isn't configured: [showBackupReminderDialog] already claims
|
/// list", while the entry itself is never actually waiting on the ad —
|
||||||
/// that same save's attention in that case (see its call site in
|
/// which matters both for their data (nothing is lost if the app dies
|
||||||
/// `ConfirmFuelEntryScreen._save`), and letting both compete for the
|
/// mid-ad) and for AdMob's rule that a plain interstitial must never
|
||||||
/// same moment is exactly the stacked-full-screen-surfaces problem this
|
/// gate access to app functionality. That's the same rule that forced
|
||||||
/// scoping avoids. Skipping this way costs nothing — the credit/grace
|
/// the cloud-sync gate to a *rewarded* ad; the difference here is that
|
||||||
/// state simply isn't touched, so the check is just as "due" next save.
|
/// nothing is being withheld pending the ad.
|
||||||
Future<void> _maybeShowFuelSaveAd() async {
|
///
|
||||||
if (adsCurrentlyDisabled || !hasCloudBackupConfigured) return;
|
/// A no-op for a user with an active ad-free purchase, during the
|
||||||
if (_inNewUserGracePeriod) return;
|
/// new-user grace period or the first [_fuelSaveAdGraceSaves] saves, or
|
||||||
if (fuelEntries.length <= _fuelSaveAdGraceSaves) return;
|
/// while a previously-watched fuel-save ad's credit is still active.
|
||||||
|
///
|
||||||
|
/// Returns whether an ad actually showed. The caller uses that to hold
|
||||||
|
/// back [showBackupReminderDialog] for this one save: only ever one
|
||||||
|
/// full-screen surface per save (the "post-save arbitration" rule), but
|
||||||
|
/// the ad takes precedence rather than being suppressed outright — the
|
||||||
|
/// reminder recurs on the next save anyway, whereas suppressing meant a
|
||||||
|
/// user who never configured backup never saw this placement at all.
|
||||||
|
Future<bool> maybeShowFuelSaveAd() async {
|
||||||
|
if (adsCurrentlyDisabled) return false;
|
||||||
|
if (_inNewUserGracePeriod) return false;
|
||||||
|
if (fuelEntries.length <= _fuelSaveAdGraceSaves) return false;
|
||||||
|
|
||||||
final lastShown = _lastFuelSaveAdShownAt;
|
final lastShown = _lastFuelSaveAdShownAt;
|
||||||
if (lastShown != null &&
|
if (lastShown != null &&
|
||||||
DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
|
DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
|
||||||
_fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
|
_fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
|
||||||
_fuelSavesSinceAd++;
|
_fuelSavesSinceAd++;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await adService.maybeShowFuelSaveAd()) {
|
if (await adService.maybeShowFuelSaveAd()) {
|
||||||
_lastFuelSaveAdShownAt = DateTime.now();
|
_lastFuelSaveAdShownAt = DateTime.now();
|
||||||
_fuelSavesSinceAd = 0;
|
_fuelSavesSinceAd = 0;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store's own formatted, localized price for the ad-free-year
|
/// The store's own formatted, localized price for the ad-free-year
|
||||||
|
|
@ -703,7 +716,6 @@ class AppState extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
await database.saveFuelEntry(entry);
|
await database.saveFuelEntry(entry);
|
||||||
await _persist();
|
await _persist();
|
||||||
unawaited(_maybeShowFuelSaveAd());
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
156
lib/widgets/ad_placeholder_screen.dart
Normal file
156
lib/widgets/ad_placeholder_screen.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app_navigator.dart';
|
||||||
|
|
||||||
|
/// How long a non-gating placeholder stays up before closing itself —
|
||||||
|
/// short enough not to be a nuisance while testing, long enough to be
|
||||||
|
/// clearly noticed rather than flickering past.
|
||||||
|
const _autoDismissAfter = Duration(seconds: 4);
|
||||||
|
|
||||||
|
/// Stands in for a real ad while `adsEnabled` is false — see
|
||||||
|
/// lib/services/ad_config.dart for why that exists.
|
||||||
|
///
|
||||||
|
/// [mustConfirm] mirrors the difference between this app's two kinds of ad:
|
||||||
|
/// - `false` (the report-export and fuel-save interstitials): closes
|
||||||
|
/// itself after [_autoDismissAfter], the same "you didn't have to do
|
||||||
|
/// anything" feel as a plain interstitial.
|
||||||
|
/// - `true` (the cloud-sync gate): waits for the user to tap Continue,
|
||||||
|
/// standing in for a rewarded ad's watch-it-through requirement, since
|
||||||
|
/// the upload genuinely shouldn't proceed until they've acted.
|
||||||
|
///
|
||||||
|
/// Returns once the placeholder has been dismissed, so callers can treat it
|
||||||
|
/// exactly like awaiting a real ad's show-and-dismiss cycle. A no-op (and
|
||||||
|
/// an immediate return) if the navigator isn't mounted yet.
|
||||||
|
Future<void> showAdPlaceholder({required bool mustConfirm}) async {
|
||||||
|
final navigator = navigatorKey.currentState;
|
||||||
|
if (navigator == null) return;
|
||||||
|
|
||||||
|
await navigator.push(
|
||||||
|
PageRouteBuilder<void>(
|
||||||
|
opaque: true,
|
||||||
|
transitionDuration: const Duration(milliseconds: 180),
|
||||||
|
pageBuilder: (_, _, _) => AdPlaceholderScreen(mustConfirm: mustConfirm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdPlaceholderScreen extends StatefulWidget {
|
||||||
|
/// See [showAdPlaceholder] — true for the sync gate, false otherwise.
|
||||||
|
final bool mustConfirm;
|
||||||
|
|
||||||
|
const AdPlaceholderScreen({super.key, required this.mustConfirm});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdPlaceholderScreen> createState() => _AdPlaceholderScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdPlaceholderScreenState extends State<AdPlaceholderScreen> {
|
||||||
|
Timer? _ticker;
|
||||||
|
late int _secondsLeft = _autoDismissAfter.inSeconds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.mustConfirm) return;
|
||||||
|
_ticker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (!mounted) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _secondsLeft--);
|
||||||
|
if (_secondsLeft <= 0) _dismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dismiss() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
// A real ad can't be dismissed with the system back gesture either;
|
||||||
|
// letting it through here would let the sync gate be bypassed.
|
||||||
|
canPop: false,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Text(
|
||||||
|
'ADVERTISEMENT',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
letterSpacing: 1.6,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/icon/hero_icon.png',
|
||||||
|
height: 96,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Text(
|
||||||
|
'An advertisement will appear here',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Ads are not running yet. This placeholder stands in '
|
||||||
|
'for one so the rest of the app behaves exactly as it '
|
||||||
|
'will once they are.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.mustConfirm)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _dismiss,
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'Closing in $_secondsLeft…',
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: colors.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,34 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
||||||
|
|
||||||
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
|
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
|
|
||||||
|
// Raised *before* the camera/gallery opens rather than after it returns.
|
||||||
|
// [ImagePicker.pickImage] doesn't complete when the camera closes — it
|
||||||
|
// also downscales the captured photo (several MB straight off the
|
||||||
|
// sensor), which takes seconds on a busy device. With the progress
|
||||||
|
// shown only afterward, that whole stretch left the user staring at an
|
||||||
|
// unchanged Receipts list with no sign anything was happening, which
|
||||||
|
// reads as "the capture did nothing" — and tapping away during it
|
||||||
|
// genuinely does abandon the capture.
|
||||||
|
var progressShowing = false;
|
||||||
|
void showProgress() {
|
||||||
|
if (progressShowing || !context.mounted) return;
|
||||||
|
progressShowing = true;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const _ReceiptProgressDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void hideProgress() {
|
||||||
|
if (!progressShowing || !context.mounted) return;
|
||||||
|
progressShowing = false;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgress();
|
||||||
|
|
||||||
XFile? photo;
|
XFile? photo;
|
||||||
try {
|
try {
|
||||||
photo = await picker.pickImage(
|
photo = await picker.pickImage(
|
||||||
|
|
@ -37,6 +65,7 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
||||||
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
hideProgress();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -45,16 +74,16 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (photo == null) return;
|
// Cancelled (backed out of the camera, or didn't confirm the shot) —
|
||||||
|
// nothing to say about that, it was deliberate.
|
||||||
|
if (photo == null) {
|
||||||
|
hideProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final imageFile = File(photo.path);
|
final imageFile = File(photo.path);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
|
|
||||||
final ocrService = OcrService();
|
final ocrService = OcrService();
|
||||||
String recognizedText = '';
|
String recognizedText = '';
|
||||||
|
|
@ -69,7 +98,7 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
||||||
final parsed = ReceiptParser.parse(recognizedText);
|
final parsed = ReceiptParser.parse(recognizedText);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(context).pop(); // close the OCR loading spinner
|
hideProgress();
|
||||||
|
|
||||||
final state = parsed.state;
|
final state = parsed.state;
|
||||||
if (state != null && state != 'MO') {
|
if (state != null && state != 'MO') {
|
||||||
|
|
@ -90,6 +119,45 @@ Future<void> captureReceiptForVehicle(BuildContext context, String vehicleId) as
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown from the moment the camera/gallery is opened until the parsed
|
||||||
|
/// receipt is ready to review. Deliberately says what's happening rather
|
||||||
|
/// than showing a bare spinner: the wait here is mostly downscaling and
|
||||||
|
/// reading the photo, which is long enough on a slow device that an
|
||||||
|
/// unexplained spinner invites tapping away mid-capture.
|
||||||
|
class _ReceiptProgressDialog extends StatelessWidget {
|
||||||
|
const _ReceiptProgressDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
// Backing out here wouldn't cancel the underlying capture, it would
|
||||||
|
// just hide it and strand the user.
|
||||||
|
canPop: false,
|
||||||
|
child: Dialog(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text('Reading your receipt…', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'This can take a few seconds.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
|
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
|
||||||
/// warn if the receipt's address is somewhere else, and let the user
|
/// warn if the receipt's address is somewhere else, and let the user
|
||||||
/// decide whether to log it anyway (e.g. it might still be worth tracking
|
/// decide whether to log it anyway (e.g. it might still be worth tracking
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+4
|
version: 1.0.0+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
|
||||||
85
test/ad_placeholder_screen_test.dart
Normal file
85
test/ad_placeholder_screen_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fuel_tax_tracker/widgets/ad_placeholder_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
/// Pushes the placeholder onto a real navigator so its self-dismissal
|
||||||
|
/// (which pops its own route) has something to pop.
|
||||||
|
///
|
||||||
|
/// Deliberately avoids [WidgetTester.pumpAndSettle] while the
|
||||||
|
/// auto-dismiss ticker is alive — that timer reschedules a frame every
|
||||||
|
/// second, so settling would spin until it timed out rather than
|
||||||
|
/// returning.
|
||||||
|
Future<void> pushPlaceholder(WidgetTester tester, {required bool mustConfirm}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => AdPlaceholderScreen(mustConfirm: mustConfirm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
group('auto-dismissing placeholder (report + fuel-save interstitials)', () {
|
||||||
|
testWidgets('shows the logo and the future-ad message, with no Continue button',
|
||||||
|
(tester) async {
|
||||||
|
await pushPlaceholder(tester, mustConfirm: false);
|
||||||
|
|
||||||
|
expect(find.text('ADVERTISEMENT'), findsOneWidget);
|
||||||
|
expect(find.byType(Image), findsOneWidget, reason: 'the app logo');
|
||||||
|
expect(find.text('An advertisement will appear here'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Ads are not running yet'), findsOneWidget);
|
||||||
|
expect(find.text('Continue'), findsNothing,
|
||||||
|
reason: 'only the sync gate waits on the user');
|
||||||
|
|
||||||
|
// Let it close so the ticker is cancelled before the test ends.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('counts down and closes itself after four seconds', (tester) async {
|
||||||
|
await pushPlaceholder(tester, mustConfirm: false);
|
||||||
|
|
||||||
|
expect(find.textContaining('Closing in'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
expect(find.text('ADVERTISEMENT'), findsOneWidget,
|
||||||
|
reason: 'still up three seconds in');
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('ADVERTISEMENT'), findsNothing, reason: 'closed itself');
|
||||||
|
expect(find.text('open'), findsOneWidget, reason: 'back on the page underneath');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('confirming placeholder (the cloud-sync gate)', () {
|
||||||
|
testWidgets('waits for Continue instead of closing on its own', (tester) async {
|
||||||
|
await pushPlaceholder(tester, mustConfirm: true);
|
||||||
|
|
||||||
|
expect(find.text('Continue'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Closing in'), findsNothing,
|
||||||
|
reason: 'nothing counts down — the upload waits on the user');
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 10));
|
||||||
|
expect(find.text('ADVERTISEMENT'), findsOneWidget,
|
||||||
|
reason: 'still up well past the auto-dismiss window');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Continue'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('ADVERTISEMENT'), findsNothing);
|
||||||
|
expect(find.text('open'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
173
test/fuel_save_ad_gate_test.dart
Normal file
173
test/fuel_save_ad_gate_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fuel_tax_tracker/app_navigator.dart';
|
||||||
|
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||||
|
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||||
|
|
||||||
|
/// Covers [AppState.maybeShowFuelSaveAd]'s "when" decision — the grace
|
||||||
|
/// periods and watch-credits that sit in front of the fuel-save ad — by
|
||||||
|
/// calling it directly against a hand-built [AppState] rather than through
|
||||||
|
/// a real save, which would need an initialized database.
|
||||||
|
///
|
||||||
|
/// With `adsEnabled` false (lib/services/ad_config.dart), the underlying
|
||||||
|
/// [AdService.maybeShowFuelSaveAd] always resolves to showing the
|
||||||
|
/// placeholder, so what's under test here is purely the gating above it:
|
||||||
|
/// if a test sees no placeholder, a gate stopped it.
|
||||||
|
///
|
||||||
|
/// These push against the real [navigatorKey], since that's what
|
||||||
|
/// `showAdPlaceholder` pushes onto — an app that doesn't wire it up would
|
||||||
|
/// make the placeholder silently do nothing, which is itself one of the
|
||||||
|
/// failure modes worth pinning down.
|
||||||
|
void main() {
|
||||||
|
/// Mirrors a well-used install: past the new-user window and well past
|
||||||
|
/// the first few saves, with no ad-free purchase.
|
||||||
|
AppState buildEstablishedUser({int entries = 9}) {
|
||||||
|
return AppState()
|
||||||
|
..firstUsedAt = DateTime.now().subtract(const Duration(days: 3))
|
||||||
|
..adFreeUntil = null
|
||||||
|
..fuelEntries = List.generate(
|
||||||
|
entries,
|
||||||
|
(i) => FuelEntry(
|
||||||
|
id: 'e$i',
|
||||||
|
vehicleId: 'v1',
|
||||||
|
date: DateTime.now(),
|
||||||
|
gallons: 13.405,
|
||||||
|
pricePerGallon: 3.899,
|
||||||
|
totalCost: 52.27,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpHost(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
navigatorKey: navigatorKey,
|
||||||
|
home: const Scaffold(body: Text('receipts')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one save's worth of the gate to completion and reports what it
|
||||||
|
/// decided, running [whileShowing] at the point a placeholder would be
|
||||||
|
/// on screen.
|
||||||
|
///
|
||||||
|
/// The gate's future is deliberately *not* awaited up front: when a
|
||||||
|
/// placeholder does show, that future only completes once the
|
||||||
|
/// placeholder pops itself, and it's [WidgetTester.pump] that advances
|
||||||
|
/// the timer doing the popping — awaiting first deadlocks the two
|
||||||
|
/// against each other.
|
||||||
|
Future<bool> runGate(
|
||||||
|
WidgetTester tester,
|
||||||
|
AppState appState, {
|
||||||
|
void Function()? whileShowing,
|
||||||
|
}) async {
|
||||||
|
bool? result;
|
||||||
|
unawaited(appState.maybeShowFuelSaveAd().then((value) => result = value));
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
whileShowing?.call();
|
||||||
|
|
||||||
|
// Enough for the placeholder's four-second auto-dismiss, harmless
|
||||||
|
// when nothing came up.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(result, isNotNull, reason: 'the gate should have settled by now');
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('an established user gets the ad on save', (tester) async {
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser();
|
||||||
|
|
||||||
|
final shown = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown, isTrue, reason: 'the caller needs this to hold back the backup reminder');
|
||||||
|
expect(find.text('receipts'), findsOneWidget, reason: 'placeholder closed itself');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cloud backup being unconfigured does not suppress it', (tester) async {
|
||||||
|
// The regression this is really here for: the gate used to bail out
|
||||||
|
// whenever backup wasn't set up, which meant a user who never
|
||||||
|
// configured backup never saw this placement at all.
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser();
|
||||||
|
expect(appState.hasCloudBackupConfigured, isFalse, reason: 'no cloudSync wired up');
|
||||||
|
|
||||||
|
final shown = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no ad during the first five minutes of app usage', (tester) async {
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser()
|
||||||
|
..firstUsedAt = DateTime.now().subtract(const Duration(minutes: 2));
|
||||||
|
|
||||||
|
final shown = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no ad for the first four saves', (tester) async {
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser(entries: 4);
|
||||||
|
|
||||||
|
final shown = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an active ad-free purchase suppresses it', (tester) async {
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser()
|
||||||
|
..adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 300));
|
||||||
|
|
||||||
|
final shown = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shown, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the credit bought by one ad covers the next two saves', (tester) async {
|
||||||
|
await pumpHost(tester);
|
||||||
|
final appState = buildEstablishedUser();
|
||||||
|
|
||||||
|
expect(await runGate(tester, appState), isTrue, reason: 'due');
|
||||||
|
expect(await runGate(tester, appState), isFalse, reason: 'credited save 1');
|
||||||
|
expect(await runGate(tester, appState), isFalse, reason: 'credited save 2');
|
||||||
|
|
||||||
|
final fourth = await runGate(
|
||||||
|
tester,
|
||||||
|
appState,
|
||||||
|
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fourth, isTrue, reason: 'credit spent — due again');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||||
|
|
@ -215,37 +216,59 @@ void main() {
|
||||||
|
|
||||||
testWidgets('date range selector filters totals and the list, and recolors the active label',
|
testWidgets('date range selector filters totals and the list, and recolors the active label',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
// Aug 5 2026: within Current Month, Last 3 Months, and Year to Date.
|
// These dates are derived from "now" rather than written out, because
|
||||||
|
// the screen filters against DateTime.now() (see _DateRange.startDate).
|
||||||
|
// Fixed dates quietly change which bucket they land in as the calendar
|
||||||
|
// moves — an earlier version of this test hardcoded August dates and
|
||||||
|
// started failing the moment September arrived.
|
||||||
|
final now = DateTime.now();
|
||||||
|
final format = DateFormat.yMMMd();
|
||||||
|
|
||||||
|
// The 1st of this month: always inside all three ranges.
|
||||||
|
final recentDate = DateTime(now.year, now.month, 1);
|
||||||
|
// The 1st of last month: always inside Last 3 Months, never inside
|
||||||
|
// Current Month. Also inside Year to Date except in January, when
|
||||||
|
// "last month" lands in the previous year.
|
||||||
|
final midDate = DateTime(now.year, now.month - 1, 1);
|
||||||
|
final midInYearToDate = midDate.year == now.year;
|
||||||
|
// Jan 1 this year: always inside Year to Date. It only falls *outside*
|
||||||
|
// Last 3 Months from April onward — before that, Last 3 Months already
|
||||||
|
// reaches back to Jan 1 or earlier, so no date at all can be "Year to
|
||||||
|
// Date but not Last 3 Months" and that distinction isn't testable.
|
||||||
|
final oldDate = DateTime(now.year, 1, 1);
|
||||||
|
final oldOutsideLast3Months = oldDate.isBefore(DateTime(now.year, now.month - 2, 1));
|
||||||
|
// In January the 1st of this month *is* Jan 1, so these two would
|
||||||
|
// render the same date text and findsOneWidget would double-count.
|
||||||
|
final oldIsDistinctFromRecent = oldDate != recentDate;
|
||||||
|
|
||||||
final recentEntry = FuelEntry(
|
final recentEntry = FuelEntry(
|
||||||
id: 'recent',
|
id: 'recent',
|
||||||
vehicleId: 'v1',
|
vehicleId: 'v1',
|
||||||
date: DateTime(2026, 8, 5),
|
date: recentDate,
|
||||||
gallons: 10,
|
gallons: 10,
|
||||||
pricePerGallon: 3,
|
pricePerGallon: 3,
|
||||||
totalCost: 30,
|
totalCost: 30,
|
||||||
updatedAt: DateTime.utc(2026, 8, 5),
|
updatedAt: recentDate.toUtc(),
|
||||||
receiptDriveFileId: 'r1',
|
receiptDriveFileId: 'r1',
|
||||||
);
|
);
|
||||||
// Jul 1 2026: within Last 3 Months and Year to Date, not Current Month.
|
|
||||||
final midEntry = FuelEntry(
|
final midEntry = FuelEntry(
|
||||||
id: 'mid',
|
id: 'mid',
|
||||||
vehicleId: 'v1',
|
vehicleId: 'v1',
|
||||||
date: DateTime(2026, 7, 1),
|
date: midDate,
|
||||||
gallons: 20,
|
gallons: 20,
|
||||||
pricePerGallon: 3,
|
pricePerGallon: 3,
|
||||||
totalCost: 60,
|
totalCost: 60,
|
||||||
updatedAt: DateTime.utc(2026, 7, 1),
|
updatedAt: midDate.toUtc(),
|
||||||
receiptDriveFileId: 'r2',
|
receiptDriveFileId: 'r2',
|
||||||
);
|
);
|
||||||
// Mar 1 2026: within Year to Date only.
|
|
||||||
final oldEntry = FuelEntry(
|
final oldEntry = FuelEntry(
|
||||||
id: 'old',
|
id: 'old',
|
||||||
vehicleId: 'v1',
|
vehicleId: 'v1',
|
||||||
date: DateTime(2026, 3, 1),
|
date: oldDate,
|
||||||
gallons: 40,
|
gallons: 40,
|
||||||
pricePerGallon: 3,
|
pricePerGallon: 3,
|
||||||
totalCost: 120,
|
totalCost: 120,
|
||||||
updatedAt: DateTime.utc(2026, 3, 1),
|
updatedAt: oldDate.toUtc(),
|
||||||
receiptDriveFileId: 'r3',
|
receiptDriveFileId: 'r3',
|
||||||
);
|
);
|
||||||
final appState = AppState()
|
final appState = AppState()
|
||||||
|
|
@ -264,27 +287,38 @@ void main() {
|
||||||
.widgetList<Text>(find.text(label))
|
.widgetList<Text>(find.text(label))
|
||||||
.any((t) => t.style?.fontWeight == FontWeight.w800);
|
.any((t) => t.style?.fontWeight == FontWeight.w800);
|
||||||
|
|
||||||
// Defaults to Year to Date: all three visible.
|
// Defaults to Year to Date.
|
||||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||||
expect(find.text('Jul 1, 2026'), findsOneWidget);
|
if (midInYearToDate) {
|
||||||
expect(find.text('Mar 1, 2026'), findsOneWidget);
|
expect(find.text(format.format(midDate)), findsOneWidget);
|
||||||
|
}
|
||||||
|
if (oldIsDistinctFromRecent) {
|
||||||
|
expect(find.text(format.format(oldDate)), findsOneWidget);
|
||||||
|
}
|
||||||
expect(isBold('Year to Date'), isTrue, reason: 'selected by default');
|
expect(isBold('Year to Date'), isTrue, reason: 'selected by default');
|
||||||
|
|
||||||
await tester.tap(find.text('Last 3 Months'));
|
await tester.tap(find.text('Last 3 Months'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||||
expect(find.text('Jul 1, 2026'), findsOneWidget);
|
expect(find.text(format.format(midDate)), findsOneWidget,
|
||||||
expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 months back');
|
reason: 'last month is always within the last three');
|
||||||
|
if (oldOutsideLast3Months) {
|
||||||
|
expect(find.text(format.format(oldDate)), findsNothing,
|
||||||
|
reason: 'older than three months back');
|
||||||
|
}
|
||||||
expect(isBold('Last 3 Months'), isTrue);
|
expect(isBold('Last 3 Months'), isTrue);
|
||||||
expect(isBold('Year to Date'), isFalse, reason: 'no longer selected');
|
expect(isBold('Year to Date'), isFalse, reason: 'no longer selected');
|
||||||
|
|
||||||
await tester.tap(find.text('Current Month'));
|
await tester.tap(find.text('Current Month'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||||
expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month');
|
expect(find.text(format.format(midDate)), findsNothing,
|
||||||
expect(find.text('Mar 1, 2026'), findsNothing);
|
reason: 'not in the current month');
|
||||||
|
if (oldIsDistinctFromRecent) {
|
||||||
|
expect(find.text(format.format(oldDate)), findsNothing);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue