A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
|
|
@ -2,37 +2,53 @@ import 'dart:async';
|
|||
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
/// The single ad placement this app has: one interstitial, shown at most
|
||||
/// once per app session, right before the *upload-to-cloud* phase of the
|
||||
/// first sync that reaches it (see [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not before
|
||||
/// pulling/merging remote changes down, and never anywhere else in the app.
|
||||
/// The single ad placement this app has: one interstitial, gating the
|
||||
/// *upload* phase of a cloud sync (see [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not pulling/
|
||||
/// merging remote changes down, and never anywhere else in the app.
|
||||
///
|
||||
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) is
|
||||
/// the real one from your AdMob console. [_interstitialAdUnitId] below is
|
||||
/// TEMPORARILY back on Google's official test interstitial ID — your real
|
||||
/// one (`ca-app-pub-9212406812117696/9482586380`) was returning no-fill
|
||||
/// (error code 3), most likely just because it's brand new; this swap is
|
||||
/// only to confirm the trigger/preload/display mechanism itself works
|
||||
/// while that warms up. Swap the real one back in once it's serving.
|
||||
/// ios/Runner/Info.plist's `GADApplicationIdentifier` is still Google's
|
||||
/// test iOS App ID, though — an AdMob App ID is registered per-platform, so
|
||||
/// it needs its own real iOS App ID (and a real iOS interstitial ad unit
|
||||
/// ID here) from the AdMob console if this app ever ships on iOS.
|
||||
/// Showing an ad opens a gate that stays open for [gateValidity]; a sync
|
||||
/// attempted after that window closes has to show (and have the user sit
|
||||
/// through the start of) another one before it's allowed to push data to
|
||||
/// the cloud. Selecting/connecting a cloud provider is never gated — only
|
||||
/// the upload side of a sync is, via [showGateAd].
|
||||
///
|
||||
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) and
|
||||
/// [_interstitialAdUnitId] below are both the real ones from your AdMob
|
||||
/// console. ios/Runner/Info.plist's `GADApplicationIdentifier` is still
|
||||
/// Google's test iOS App ID, though — an AdMob App ID is registered
|
||||
/// per-platform, so it needs its own real iOS App ID (and a real iOS
|
||||
/// interstitial ad unit ID here) from the AdMob console if this app ever
|
||||
/// ships on iOS.
|
||||
/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the
|
||||
/// caller may proceed" — they're kept distinct only so [AppState] can tell
|
||||
/// whether *this* call is the one that actually put a user in front of an
|
||||
/// ad, which is the trigger for the "remove ads for a year" upsell dialog.
|
||||
/// [AdGateResult.blocked] means the caller must not proceed.
|
||||
enum AdGateResult { alreadyOpen, justShown, blocked }
|
||||
|
||||
class AdService {
|
||||
static const _interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
|
||||
static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
|
||||
|
||||
/// How long a successfully-shown ad keeps the upload gate open before the
|
||||
/// next sync attempt has to show another one.
|
||||
static const gateValidity = Duration(minutes: 5);
|
||||
|
||||
bool _sdkInitialized = false;
|
||||
bool _shownThisSession = false;
|
||||
DateTime? _lastShownAt;
|
||||
InterstitialAd? _preloadedAd;
|
||||
Completer<void>? _loadCompleter;
|
||||
|
||||
/// Starts the Mobile Ads SDK and begins preloading this session's one
|
||||
/// interstitial. Idempotent (a no-op after the first call) and safe to
|
||||
/// call speculatively — [AppState] calls this from [AppState.syncNow]
|
||||
/// rather than unconditionally at app startup, so a user who never
|
||||
/// connects cloud sync never triggers any ad-related network activity at
|
||||
/// all.
|
||||
bool get _gateOpen {
|
||||
final lastShownAt = _lastShownAt;
|
||||
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
|
||||
}
|
||||
|
||||
/// Starts the Mobile Ads SDK and begins preloading an interstitial.
|
||||
/// Idempotent (a no-op after the first call) and safe to call
|
||||
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
|
||||
/// than unconditionally at app startup, so a user who never connects
|
||||
/// cloud sync never triggers any ad-related network activity at all.
|
||||
Future<void> initialize() async {
|
||||
if (_sdkInitialized) return;
|
||||
_sdkInitialized = true;
|
||||
|
|
@ -59,16 +75,22 @@ class AdService {
|
|||
return completer.future;
|
||||
}
|
||||
|
||||
/// Shows the preloaded interstitial if this is the first call this app
|
||||
/// session; every call after that (or if no ad ever became available) is
|
||||
/// a no-op. Waits for the ad to actually be dismissed before returning,
|
||||
/// so the caller — the sync engine, right before it starts uploading —
|
||||
/// genuinely happens *after* the ad, not just alongside it; bounded so a
|
||||
/// slow ad load or a stuck ad SDK callback can never block sync
|
||||
/// indefinitely.
|
||||
Future<void> maybeShowBeforeSync() async {
|
||||
if (_shownThisSession) return;
|
||||
_shownThisSession = true;
|
||||
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
|
||||
/// ad was already shown within [gateValidity]; otherwise shows the
|
||||
/// preloaded interstitial and returns [AdGateResult.justShown] once it
|
||||
/// actually starts displaying (the only "watched it" signal a plain
|
||||
/// interstitial — as opposed to a rewarded ad — can give us), or
|
||||
/// [AdGateResult.blocked] if none was available in time or it failed to
|
||||
/// show. A [AdGateResult.blocked] result means the caller must not
|
||||
/// proceed with uploading.
|
||||
///
|
||||
/// Bounded throughout so a slow ad load or a stuck ad SDK callback can
|
||||
/// never hang a sync indefinitely. Always lines up the next interstitial
|
||||
/// afterward, whether this attempt succeeded or not, so the next call —
|
||||
/// whether that's because this one failed or because [gateValidity]
|
||||
/// elapsed — has the best chance of a preloaded ad ready to go.
|
||||
Future<AdGateResult> showGateAd() async {
|
||||
if (_gateOpen) return AdGateResult.alreadyOpen;
|
||||
|
||||
if (_preloadedAd == null) {
|
||||
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
|
||||
|
|
@ -76,21 +98,27 @@ class AdService {
|
|||
|
||||
final ad = _preloadedAd;
|
||||
_preloadedAd = null;
|
||||
if (ad == null) return;
|
||||
if (ad == null) {
|
||||
unawaited(_preload());
|
||||
return AdGateResult.blocked;
|
||||
}
|
||||
|
||||
final dismissed = Completer<void>();
|
||||
final showed = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
onAdShowedFullScreenContent: (ad) {
|
||||
_lastShownAt = DateTime.now();
|
||||
if (!showed.isCompleted) showed.complete(true);
|
||||
},
|
||||
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
if (!showed.isCompleted) showed.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show();
|
||||
await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {});
|
||||
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||
unawaited(_preload());
|
||||
return opened ? AdGateResult.justShown : AdGateResult.blocked;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -12,15 +12,30 @@ class SyncResult {
|
|||
final bool ranSync;
|
||||
final Object? error;
|
||||
|
||||
/// True when pull/merge completed but [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` gate (the app's ad-watch requirement — see
|
||||
/// [AdService.showGateAd]) came back closed, so nothing local was
|
||||
/// uploaded. Remote changes, if any, were still merged in.
|
||||
final bool adGateBlocked;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
error = null;
|
||||
error = null,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
error = null,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
SyncResult.failure(this.error)
|
||||
: ranSync = false,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.adGateBlocked()
|
||||
: ranSync = false,
|
||||
error = null,
|
||||
adGateBlocked = true;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of sync against the shared cloud folder — same
|
||||
|
|
@ -144,14 +159,16 @@ class CloudSyncService {
|
|||
///
|
||||
/// [beforeUpload], if given, is awaited once — after any pull/merge of
|
||||
/// remote changes has finished, but before anything local gets uploaded
|
||||
/// (pending receipt photos or the database snapshot itself). This is the
|
||||
/// one hook [AppState] uses to show the app's single ad placement, since
|
||||
/// it's meant to run before *pushing* local data up, not before *pulling*
|
||||
/// remote data down.
|
||||
/// (pending receipt photos or the database snapshot itself) — and its
|
||||
/// result decides whether the upload happens at all. This is the hook
|
||||
/// [AppState] uses to gate uploads behind the app's ad placement: a
|
||||
/// `false` return means the gate is closed (see [AdService.showGateAd])
|
||||
/// and this call returns [SyncResult.adGateBlocked] without uploading
|
||||
/// anything, having still pulled/merged whatever the remote side had.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
Future<void> Function()? beforeUpload,
|
||||
Future<bool> Function()? beforeUpload,
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!provider.isSignedIn || appFolderId == null) {
|
||||
|
|
@ -180,8 +197,8 @@ class CloudSyncService {
|
|||
await _pullAndMerge(session, remoteInfo.id);
|
||||
}
|
||||
|
||||
if (beforeUpload != null) {
|
||||
await beforeUpload();
|
||||
if (beforeUpload != null && !await beforeUpload()) {
|
||||
return SyncResult.adGateBlocked();
|
||||
}
|
||||
|
||||
final allReceiptsUploaded =
|
||||
|
|
|
|||
26
lib/services/estimated_refund.dart
Normal file
26
lib/services/estimated_refund.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/// The Missouri Highway Fuel Tax Refund rate per gallon — the basis for
|
||||
/// [estimatedFuelRefund]. Set by Missouri law and subject to change at any
|
||||
/// time; the UI Settings toggle that turns these estimates on says as much,
|
||||
/// and this is deliberately the highway rate only (not the separate,
|
||||
/// higher non-highway-use refund) since that's the one that applies to
|
||||
/// ordinary vehicle fill-ups this app is built around.
|
||||
const moHighwayFuelTaxRefundRatePerGallon = 0.125;
|
||||
|
||||
/// A rough estimate of the Missouri Highway Fuel Tax Refund for [gallons]
|
||||
/// of fuel, at the current rate. Purely informational — not tax advice,
|
||||
/// and not a substitute for confirming the actual claimable amount with
|
||||
/// the Missouri Department of Revenue.
|
||||
///
|
||||
/// [gallons] is rounded to the nearest whole gallon *before* multiplying —
|
||||
/// matching how the actual refund calculation works, rather than
|
||||
/// multiplying the precise (fractional) logged amount. The result is then
|
||||
/// rounded down to the nearest cent, never up, so this never overstates
|
||||
/// what's actually claimable.
|
||||
double estimatedFuelRefund(double gallons) {
|
||||
final roundedGallons = gallons.roundToDouble();
|
||||
final rawRefund = roundedGallons * moHighwayFuelTaxRefundRatePerGallon;
|
||||
// The tiny epsilon guards against binary floating-point representation
|
||||
// error nudging an exact cent value (e.g. 2.50) just under its true
|
||||
// value and floor()ing it down a cent it doesn't actually owe.
|
||||
return (rawRefund * 100 + 1e-9).floorToDouble() / 100;
|
||||
}
|
||||
|
|
@ -106,6 +106,18 @@ class FuelReport {
|
|||
}
|
||||
}
|
||||
|
||||
/// The date range the Reports tab defaults to on open: a year-long window
|
||||
/// from July 1 through the following June 30, matching how Missouri's
|
||||
/// fuel tax refund periods run. Which specific year that window falls in
|
||||
/// rolls over on August 1: from August of year Y through July of year
|
||||
/// Y+1, this stays July Y–June (Y+1) throughout — the most recently
|
||||
/// completed (or currently running) period — rather than jumping to a
|
||||
/// brand new, still-empty one the moment August 1 hits.
|
||||
(DateTime start, DateTime end) defaultReportDateRange(DateTime now) {
|
||||
final fiscalStartYear = now.month >= 8 ? now.year : now.year - 1;
|
||||
return (DateTime(fiscalStartYear, 7, 1), DateTime(fiscalStartYear + 1, 6, 30));
|
||||
}
|
||||
|
||||
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
|
||||
/// judged by each entry's purchase date, in local time), restricted to
|
||||
/// entries that actually have a receipt attached — a report meant to
|
||||
|
|
|
|||
|
|
@ -1,35 +1,52 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
/// Wraps the app's one purchasable product: a *consumable* one-time
|
||||
/// purchase that grants a year of no ads (see
|
||||
/// [AppState.adsCurrentlyDisabled]) — consumable specifically so it can be
|
||||
/// bought again once that year lapses, unlike a plain non-consumable
|
||||
/// (which Play Store would only ever let you own once, permanently) or an
|
||||
/// auto-renewing subscription (which would charge the user again every
|
||||
/// year without them actively choosing to).
|
||||
/// Wraps the app's one purchasable product: a one-year, non-auto-renewing
|
||||
/// *prepaid subscription* that grants a year of no ads (see
|
||||
/// [AppState.adsCurrentlyDisabled]).
|
||||
///
|
||||
/// Prepaid subscription, specifically — not a consumable, a plain
|
||||
/// non-consumable, or an auto-renewing subscription:
|
||||
/// - A *consumable* has to be acknowledged (= consumed, for this product
|
||||
/// type) within 3 days of purchase or Play Billing auto-refunds it —
|
||||
/// there's no way to leave it unconsumed so it stays restorable for the
|
||||
/// whole year. And once consumed, Play Billing forgets it existed, so a
|
||||
/// user who loses local data (no cloud backup connected) has nothing
|
||||
/// left to restore from. This is what this product used to be, before
|
||||
/// that gap was found.
|
||||
/// - A plain *non-consumable* would only ever let a Google account buy it
|
||||
/// once, permanently — doesn't fit "buy another year once this one
|
||||
/// lapses".
|
||||
/// - An *auto-renewing subscription* would charge the user again every
|
||||
/// year without them actively choosing to.
|
||||
/// - A *prepaid* subscription base plan fits all three constraints: it's
|
||||
/// acknowledged immediately (same 3-day rule, satisfied same as any
|
||||
/// purchase), stays active — and restorable via [restorePurchase] — for
|
||||
/// its whole prepaid year without needing to be consumed, then simply
|
||||
/// expires with no charge and can be bought again.
|
||||
///
|
||||
/// [adFreeYearProductId] is a placeholder — nothing will actually load or
|
||||
/// be purchasable until a real in-app product with this same ID exists in
|
||||
/// your Google Play Console (Monetize > Products > In-app products),
|
||||
/// configured as a *managed product*, with whatever price you choose
|
||||
/// there (Play Billing doesn't take a price from the app itself). Rename
|
||||
/// the constant below to match whatever product ID you actually create,
|
||||
/// if you'd rather not use this one.
|
||||
/// be purchasable until a real subscription with this same ID exists in
|
||||
/// your Google Play Console (Monetize > Products > Subscriptions), with a
|
||||
/// *prepaid* base plan named [_basePlanId] and whatever price/duration you
|
||||
/// choose there (Play Billing doesn't take a price from the app itself).
|
||||
/// Rename either constant to match whatever you actually create, if you'd
|
||||
/// rather not use these.
|
||||
class PurchaseService {
|
||||
static const adFreeYearProductId = 'ad_free_year';
|
||||
static const _basePlanId = 'ad-free-year-prepaid';
|
||||
|
||||
/// Called once for every successful (or restored) purchase of
|
||||
/// [adFreeYearProductId] — [AppState] is what actually records the
|
||||
/// resulting entitlement; this service only reports that a purchase
|
||||
/// happened.
|
||||
final void Function() onPurchaseGranted;
|
||||
/// [adFreeYearProductId], with the time Play Billing recorded the
|
||||
/// purchase actually happening — [AppState] anchors the year of ad-free
|
||||
/// time to that, not to "now", so restoring an existing purchase doesn't
|
||||
/// hand out a free extra year on top of time already elapsed.
|
||||
final void Function(DateTime purchaseTime) onPurchaseGranted;
|
||||
|
||||
/// Called with a user-facing message when a purchase attempt fails —
|
||||
/// [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Called with a user-facing message when a purchase or restore attempt
|
||||
/// fails — [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Settings screen to display.
|
||||
final void Function(String message)? onPurchaseError;
|
||||
|
||||
|
|
@ -40,8 +57,8 @@ class PurchaseService {
|
|||
|
||||
/// The store's own formatted, localized price string (e.g. `"$4.99"`)
|
||||
/// once [initialize] has loaded the product — null before that, or if
|
||||
/// the product ID above doesn't match anything configured in the store
|
||||
/// yet.
|
||||
/// neither the product ID nor base plan ID above matches anything
|
||||
/// configured in the store yet.
|
||||
String? get priceLabel => _product?.price;
|
||||
|
||||
Future<void> initialize() async {
|
||||
|
|
@ -55,23 +72,55 @@ class PurchaseService {
|
|||
);
|
||||
|
||||
final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId});
|
||||
if (response.productDetails.isNotEmpty) {
|
||||
_product = response.productDetails.first;
|
||||
_product = _selectOffer(response.productDetails);
|
||||
}
|
||||
|
||||
/// For a subscription product, [InAppPurchase.queryProductDetails]
|
||||
/// returns one [ProductDetails] per offer/base-plan combination it has,
|
||||
/// not one per product ID — picks the prepaid base plan set up for this
|
||||
/// product specifically, falling back to whichever offer loaded first so
|
||||
/// a Play Console setup with only the one base plan (the expected,
|
||||
/// common case here) still works without its name needing to match
|
||||
/// exactly.
|
||||
ProductDetails? _selectOffer(List<ProductDetails> offers) {
|
||||
for (final offer in offers) {
|
||||
if (offer is! GooglePlayProductDetails) continue;
|
||||
final index = offer.subscriptionIndex;
|
||||
final basePlanId =
|
||||
index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId;
|
||||
if (basePlanId == _basePlanId) return offer;
|
||||
}
|
||||
return offers.isEmpty ? null : offers.first;
|
||||
}
|
||||
|
||||
/// Kicks off the platform purchase UI. Does nothing (and reports an
|
||||
/// error) if the product hasn't loaded — either the store isn't
|
||||
/// available, or [adFreeYearProductId] doesn't match a real product yet.
|
||||
/// available, or neither [adFreeYearProductId] nor [_basePlanId] matches
|
||||
/// a real product yet.
|
||||
Future<void> buyAdFreeYear() async {
|
||||
final product = _product;
|
||||
if (product == null) {
|
||||
onPurchaseError?.call("This purchase isn't available right now.");
|
||||
return;
|
||||
}
|
||||
await InAppPurchase.instance.buyConsumable(
|
||||
purchaseParam: PurchaseParam(productDetails: product),
|
||||
);
|
||||
final purchaseParam = product is GooglePlayProductDetails
|
||||
? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken)
|
||||
: PurchaseParam(productDetails: product);
|
||||
await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
}
|
||||
|
||||
/// Re-derives the local entitlement from Play Billing's own record of an
|
||||
/// active (not yet expired) prepaid purchase — for a user who reinstalled
|
||||
/// or switched devices without cloud backup connected, where nothing
|
||||
/// local survived to sync the entitlement back down. A silent no-op if
|
||||
/// there's nothing currently active to find, which is the normal outcome
|
||||
/// for anyone who's never purchased, so that's not treated as an error.
|
||||
Future<void> restorePurchase() async {
|
||||
try {
|
||||
await InAppPurchase.instance.restorePurchases();
|
||||
} on InAppPurchaseException catch (e) {
|
||||
onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
|
||||
|
|
@ -80,16 +129,7 @@ class PurchaseService {
|
|||
case PurchaseStatus.purchased:
|
||||
case PurchaseStatus.restored:
|
||||
if (purchase.productID == adFreeYearProductId) {
|
||||
onPurchaseGranted();
|
||||
}
|
||||
// Android specifically: a *consumable* purchase has to be
|
||||
// explicitly "consumed" or Play Billing considers it still owned
|
||||
// and refuses to sell it again next year. iOS has no equivalent
|
||||
// step — StoreKit consumables are inherently one-shot already.
|
||||
if (Platform.isAndroid) {
|
||||
final androidAddition =
|
||||
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
|
||||
await androidAddition.consumePurchase(purchase);
|
||||
onPurchaseGranted(_purchaseTime(purchase));
|
||||
}
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
await InAppPurchase.instance.completePurchase(purchase);
|
||||
|
|
@ -106,6 +146,18 @@ class PurchaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Play reports this as epoch milliseconds in a string (see
|
||||
/// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the
|
||||
/// current time in the shouldn't-happen case that it's missing or
|
||||
/// malformed, rather than leaving the entitlement ungranted over a
|
||||
/// parsing hiccup.
|
||||
DateTime _purchaseTime(PurchaseDetails purchase) {
|
||||
final millis = int.tryParse(purchase.transactionDate ?? '');
|
||||
return millis == null
|
||||
? DateTime.now().toUtc()
|
||||
: DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue