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

@ -28,6 +28,7 @@ const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _prefsKeyThemeMode = 'theme_mode';
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement';
const _prefsKeyShowEstimatedFuelRefund = 'show_estimated_fuel_refund';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@ -91,6 +92,12 @@ class AppState extends ChangeNotifier {
DateTime? lastSyncedAt;
Object? lastSyncError;
/// Set by the widget tree at startup (see `main.dart`) to show the
/// "remove ads for a year" upsell — [syncNow] calls this the moment an ad
/// was actually just shown to gate an upload, never on a sync that finds
/// the gate already open from a recent prior view.
VoidCallback? onAdWatched;
bool keepReceiptPhotosLocally = false;
int staleLockMinutes = defaultStaleLockMinutes;
@ -104,6 +111,14 @@ class AppState extends ChangeNotifier {
ThemeMode themeMode = ThemeMode.system;
/// Whether to show an estimated Missouri Highway Fuel Tax Refund amount
/// alongside gallons/cost figures throughout the app (Receipts hero
/// banner, receipt line items, report totals — see
/// lib/services/estimated_refund.dart). Off by default: it's a rough,
/// rate-contingent estimate, not something every user necessarily wants
/// cluttering their totals.
bool showEstimatedFuelRefund = false;
/// Whether the first-launch guided tour ([OnboardingTour]) has already
/// been shown. Defaults to `true` here (not `false`) specifically so
/// that widget tests constructing `AppState()` directly and skipping
@ -183,6 +198,7 @@ class AppState extends ChangeNotifier {
);
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false;
showEstimatedFuelRefund = prefs.getBool(_prefsKeyShowEstimatedFuelRefund) ?? false;
} catch (e) {
initError = e;
isLoading = false;
@ -365,19 +381,24 @@ class AppState extends ChangeNotifier {
if (isSyncing || sync == null || !sync.isConfigured) return;
// A user with a currently-active "remove ads for a year" purchase
// skips the ad entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to do here", same as any other call site
// that never passed one) and the ad SDK isn't even touched this sync.
Future<void> Function()? beforeUpload;
// skips the gate entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to check", same as any other call site that
// never passed one) and the ad SDK isn't even touched this sync.
Future<bool> Function()? beforeUpload;
if (!adsCurrentlyDisabled) {
// Idempotent, and only ever reached once sync is actually configured
// — a user who never connects cloud storage never triggers any
// ad-related activity at all. Not awaited: it's fine if this
// session's one ad is still loading by the time beforeUpload below
// is reached — maybeShowBeforeSync degrades gracefully (just skips
// showing it) if it isn't ready in time.
// ad-related activity at all.
unawaited(adService.initialize());
beforeUpload = adService.maybeShowBeforeSync;
beforeUpload = () async {
final result = await adService.showGateAd();
// Fired, not awaited: the upsell dialog is purely informational —
// this sync (specifically the upload [beforeUpload] is about to
// unblock) shouldn't wait on however long it takes the user to
// dismiss it.
if (result == AdGateResult.justShown) onAdWatched?.call();
return result != AdGateResult.blocked;
};
}
isSyncing = true;
@ -393,6 +414,12 @@ class AppState extends ChangeNotifier {
await _refreshFromDatabase();
lastSyncedAt = DateTime.now();
lastSyncError = null;
} else if (result.adGateBlocked) {
// Whatever the remote side had was still pulled/merged in — only the
// upload was withheld — so the on-screen data should reflect that
// even though this doesn't count as a completed sync.
await _refreshFromDatabase();
lastSyncError = 'Watch a short ad to finish syncing your data to the cloud.';
} else if (result.error != null) {
lastSyncError = result.error;
}
@ -422,19 +449,39 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> setShowEstimatedFuelRefund(bool value) async {
showEstimatedFuelRefund = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyShowEstimatedFuelRefund, value);
notifyListeners();
}
/// Kicks off the platform purchase UI for a year of no ads — see
/// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted
/// asynchronously, once the store confirms the purchase (see
/// [_grantAdFreeYear]), not immediately when this returns.
Future<void> buyAdFreeYear() => purchaseService.buyAdFreeYear();
/// [PurchaseService]'s `onPurchaseGranted` callback: records a fresh
/// year of ad-free time starting now, regardless of any time already
/// remaining on a previous purchase — buying again before the current
/// year lapses simply resets the clock rather than stacking.
Future<void> _grantAdFreeYear() async {
final now = DateTime.now().toUtc();
await database.setAdFreeUntil(now.add(const Duration(days: 365)), now);
/// Re-checks Play Billing for an active purchase and, if one is found,
/// re-grants the entitlement locally — see [PurchaseService.restorePurchase].
/// For a user who reinstalled or switched devices without cloud backup
/// connected, so there was nothing local to sync the entitlement back
/// down from. Safe to call any time; does nothing if there's no active
/// purchase to find.
Future<void> restoreAdFreeYear() => purchaseService.restorePurchase();
/// [PurchaseService]'s `onPurchaseGranted` callback: records a year of
/// ad-free time anchored to [purchaseTime] — Play Billing's own record of
/// when the purchase actually happened, not "now" — so restoring a
/// purchase made months ago correctly reflects however much of that year
/// is already gone, rather than handing out a fresh extra year. Buying
/// again before the current year lapses simply resets the clock to a
/// fresh year from that new purchase, rather than stacking.
Future<void> _grantAdFreeYear(DateTime purchaseTime) async {
await database.setAdFreeUntil(
purchaseTime.add(const Duration(days: 365)),
DateTime.now().toUtc(),
);
purchaseError = null;
await _persist();
}
@ -547,13 +594,19 @@ class AppState extends ChangeNotifier {
return entry;
}
/// Corrects the logged values (date, gallons, price/gal, total cost) for
/// an existing fuel entry — the receipt photo itself isn't editable here,
/// only the data recorded about it (e.g. fixing a misread OCR value).
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
/// carries over untouched.
/// Corrects the logged values (date, gallons, price/gal, total cost, and
/// which vehicle it's attached to) for an existing fuel entry — the
/// receipt photo itself isn't editable here, only the data recorded
/// about it (e.g. fixing a misread OCR value, or a receipt that got
/// logged under the wrong vehicle). [entryId]'s receipt photo reference
/// (local path and/or cloud file id) carries over untouched even when
/// [vehicleId] changes — the underlying photo file stays exactly where
/// it already is (including whichever vehicle's folder it was filed
/// under locally/in the cloud); only the database's own record of which
/// vehicle owns this entry moves.
Future<void> updateFuelEntry({
required String entryId,
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
@ -562,7 +615,7 @@ class AppState extends ChangeNotifier {
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
final updated = FuelEntry(
id: existing.id,
vehicleId: existing.vehicleId,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,