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