Updates to add more ads and fix ad types.
This commit is contained in:
parent
ad850d2be6
commit
ad2d932842
17 changed files with 552 additions and 59 deletions
|
|
@ -54,6 +54,8 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
_totalController = TextEditingController(
|
||||
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
|
||||
);
|
||||
// So an ad is ready by the time _save() finishes without delaying it.
|
||||
context.read<AppState>().preloadFuelSaveAd();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -92,6 +92,25 @@ class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The resting (non-syncing) Sync Now icon — badged with the same
|
||||
/// play-triangle [CloudBackupActionButton] uses, for anyone who isn't on
|
||||
/// an active ad-free purchase, since tapping this can trigger the
|
||||
/// rewarded ad gate (see [AppState.syncNow]/`AdService.showGateAd`). Not
|
||||
/// a live prediction of whether *this* tap specifically will show one —
|
||||
/// same general "this leads to an ad-supported feature" disclosure the
|
||||
/// icon-badge uses elsewhere, not an attempt to account for the open
|
||||
/// gate window or the free-first-sync case.
|
||||
Widget _syncNowIcon(BuildContext context, AppState appState) {
|
||||
const icon = Icon(Icons.sync);
|
||||
if (appState.adsCurrentlyDisabled) return icon;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Badge(
|
||||
backgroundColor: colors.tertiary,
|
||||
label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary),
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared warning-dialog shell for both purge actions — [message] is the
|
||||
/// caller's job to make specific and unambiguous, since this is the only
|
||||
/// thing standing between the user and an unrecoverable delete.
|
||||
|
|
@ -283,7 +302,7 @@ class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
|||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.sync),
|
||||
: _syncNowIcon(context, appState),
|
||||
label: const Text('Sync Now'),
|
||||
),
|
||||
TextButton.icon(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../widgets/cloud_backup_action_button.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
|
@ -31,6 +32,7 @@ class HomeScreen extends StatelessWidget {
|
|||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
),
|
||||
),
|
||||
const CloudBackupActionButton(),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../services/app_state.dart';
|
|||
import '../services/estimated_refund.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/cloud_backup_action_button.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
|
|
@ -144,6 +145,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
tooltip: 'Log Fuel Receipt',
|
||||
onPressed: () => _addReceipt(context),
|
||||
),
|
||||
const CloudBackupActionButton(),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -12,6 +13,7 @@ import '../services/fuel_report.dart';
|
|||
import '../services/fuel_report_images.dart';
|
||||
import '../services/fuel_report_pdf.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../widgets/cloud_backup_action_button.dart';
|
||||
import 'faq_screen.dart';
|
||||
|
||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
||||
|
|
@ -59,6 +61,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
final (start, end) = defaultReportDateRange(DateTime.now());
|
||||
_startDate = start;
|
||||
_endDate = end;
|
||||
context.read<AppState>().preloadReportAd();
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
|
|
@ -118,6 +121,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
try {
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
||||
if (mounted) unawaited(context.read<AppState>().maybeShowReportAd());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
|
@ -128,6 +132,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
try {
|
||||
final bytes = await _buildPdfBytes(report);
|
||||
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
||||
if (mounted) unawaited(context.read<AppState>().maybeShowReportAd());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
|
@ -194,7 +199,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Fuel Report'),
|
||||
actions: const [FaqActionButton()],
|
||||
actions: const [CloudBackupActionButton(), FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../widgets/cloud_backup_action_button.dart';
|
||||
import 'data_settings_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'ui_settings_screen.dart';
|
||||
|
|
@ -22,7 +23,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: const [FaqActionButton()],
|
||||
actions: const [CloudBackupActionButton(), FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
|
|||
|
|
@ -61,7 +61,12 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
'this device. If you connect a cloud storage account (Google Drive, '
|
||||
'Dropbox, OneDrive, or your own WebDAV server) in Settings, a copy is '
|
||||
'also kept there — in storage you control, not on any server we run. '
|
||||
"We don't operate a backend, and we don't have a copy of your data.",
|
||||
"We don't operate a backend, and we don't have a copy of your data.\n\n"
|
||||
"This is also how sharing works: if the folder you connect to is one "
|
||||
"you share with someone else, anyone else who connects the app to "
|
||||
"that same folder sees and can edit the same vehicles, fuel entries, "
|
||||
"and receipt photos you do. Data in a shared folder isn't private to "
|
||||
"just you.",
|
||||
),
|
||||
_Section(
|
||||
title: 'Privacy',
|
||||
|
|
|
|||
|
|
@ -1,25 +1,67 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
/// 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.
|
||||
/// This app has three ad placements:
|
||||
///
|
||||
/// 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].
|
||||
/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see
|
||||
/// [CloudSyncService.syncNow]'s `beforeUpload` hook and
|
||||
/// [AppState.syncNow]) — deliberately not pulling/merging remote
|
||||
/// changes down. Rewarded, not a plain interstitial, because AdMob's
|
||||
/// interstitial policy requires those to sit at natural transition
|
||||
/// points and never gate access to app functionality — conditioning an
|
||||
/// in-app benefit on watching an ad through to completion is what the
|
||||
/// reward formats exist for. See
|
||||
/// https://support.google.com/admob/answer/6201362 and
|
||||
/// https://support.google.com/admob/answer/6128543. Plain Rewarded,
|
||||
/// not Rewarded Interstitial: the app already has its own explicit
|
||||
/// opt-in trigger for this (the Sync Now button / the "not synced"
|
||||
/// icon, both direct user taps that call [AppState.syncNow]), so
|
||||
/// Rewarded Interstitial's main advantage — being safe to show without
|
||||
/// one — doesn't buy anything extra here, and plain Rewarded gives
|
||||
/// full control over the pre-ad copy instead of Google's generic
|
||||
/// built-in opt-in screen. 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) 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 very first sync a cloud folder ever sees skips
|
||||
/// this entirely — see [CloudSyncService.syncNow]'s `everSyncedBefore`
|
||||
/// parameter.
|
||||
/// 2. A plain interstitial shown after a report export/share/print
|
||||
/// completes (see [AppState.maybeShowReportAd], called from
|
||||
/// `ReportScreen._share`/`_print`).
|
||||
/// 3. A plain interstitial shown after a fuel entry is saved (see
|
||||
/// [AppState.addFuelEntry]'s call to its own fuel-save-ad decision
|
||||
/// logic).
|
||||
///
|
||||
/// 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.
|
||||
/// Placements 2 and 3 are plain, not rewarded, because nothing is being
|
||||
/// unlocked — the save/export already happened by the time either fires,
|
||||
/// so there's no benefit to condition on watching it; they're just natural
|
||||
/// post-task transitions, which is exactly what plain interstitials are
|
||||
/// for. Neither ever blocks or gates anything, unlike placement 1.
|
||||
///
|
||||
/// This class only owns ad-format *mechanics* — load, preload, show — for
|
||||
/// all three. Deciding *when* each is actually due (grace periods for a
|
||||
/// new user, earned watch-credits, the ad-free purchase) is [AppState]'s
|
||||
/// job, not this class's: those decisions need domain state (how long
|
||||
/// they've used the app, how many fuel entries they've saved) this class
|
||||
/// has no business knowing about. So every `maybeShowX` method here is
|
||||
/// unconditional other than "is something preloaded" — callers are
|
||||
/// expected to have already decided the ad should show before calling.
|
||||
///
|
||||
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml),
|
||||
/// [_rewardedAdUnitId], [_reportInterstitialAdUnitId], and
|
||||
/// [_fuelSaveInterstitialAdUnitId] below are all real ones from your AdMob
|
||||
/// console — ad unit IDs are tied to the format they were created as, so
|
||||
/// each placement needs its own unit created under the matching format
|
||||
/// (Rewarded / Interstitial / Interstitial) in the console, they can't
|
||||
/// share one. 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 real
|
||||
/// iOS ad unit IDs 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
|
||||
|
|
@ -28,46 +70,70 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
|
|||
enum AdGateResult { alreadyOpen, justShown, blocked }
|
||||
|
||||
class AdService {
|
||||
static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
|
||||
/// The real *Rewarded* ad unit for the sync gate — distinct from
|
||||
/// `9482586380` below, which is a plain Interstitial unit and wrong
|
||||
/// format for [RewardedAd.load].
|
||||
static const _rewardedAdUnitId = 'ca-app-pub-9212406812117696/8520582833';
|
||||
|
||||
/// The one real Interstitial ad unit created under this app so far —
|
||||
/// shared by both plain-interstitial placements below. Perfectly valid
|
||||
/// for one ad unit to back multiple `InterstitialAd.load` call sites in
|
||||
/// the same app; the only downside is AdMob's dashboard won't be able to
|
||||
/// break impressions/revenue out by placement. Create a second
|
||||
/// Interstitial unit and give [_fuelSaveInterstitialAdUnitId] its own ID
|
||||
/// later if that per-placement visibility ends up mattering.
|
||||
static const _reportInterstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
|
||||
|
||||
static const _fuelSaveInterstitialAdUnitId = _reportInterstitialAdUnitId;
|
||||
|
||||
/// 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);
|
||||
|
||||
/// Safety net against a stuck SDK callback after [_preloadedAd] is shown
|
||||
/// — not sized to normal watch time, which this deliberately doesn't
|
||||
/// bound. Generous rather than tight: firing early would wrongly block a
|
||||
/// user who's still legitimately watching.
|
||||
static const _earnedRewardSafetyTimeout = Duration(minutes: 5);
|
||||
|
||||
bool _sdkInitialized = false;
|
||||
DateTime? _lastShownAt;
|
||||
InterstitialAd? _preloadedAd;
|
||||
RewardedAd? _preloadedAd;
|
||||
Completer<void>? _loadCompleter;
|
||||
|
||||
InterstitialAd? _preloadedReportAd;
|
||||
InterstitialAd? _preloadedFuelSaveAd;
|
||||
|
||||
bool get _gateOpen {
|
||||
final lastShownAt = _lastShownAt;
|
||||
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
|
||||
}
|
||||
|
||||
/// Starts the Mobile Ads SDK and begins preloading an interstitial.
|
||||
/// Starts the Mobile Ads SDK and begins preloading a rewarded ad.
|
||||
/// 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;
|
||||
await MobileAds.instance.initialize();
|
||||
unawaited(_preload());
|
||||
final alreadyInitialized = _sdkInitialized;
|
||||
await _ensureSdkInitialized();
|
||||
if (!alreadyInitialized) unawaited(_preload());
|
||||
}
|
||||
|
||||
Future<void> _preload() {
|
||||
final completer = Completer<void>();
|
||||
_loadCompleter = completer;
|
||||
InterstitialAd.load(
|
||||
adUnitId: _interstitialAdUnitId,
|
||||
RewardedAd.load(
|
||||
adUnitId: _rewardedAdUnitId,
|
||||
request: const AdRequest(),
|
||||
adLoadCallback: InterstitialAdLoadCallback(
|
||||
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||
onAdLoaded: (ad) {
|
||||
_preloadedAd = ad;
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
onAdFailedToLoad: (error) {
|
||||
debugPrint(
|
||||
'[AdService] Rewarded ad failed to load: ${error.code} ${error.domain} ${error.message}');
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
),
|
||||
|
|
@ -77,15 +143,18 @@ class AdService {
|
|||
|
||||
/// 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
|
||||
/// preloaded rewarded ad and returns [AdGateResult.justShown]
|
||||
/// once the user actually earns the reward (i.e. watches it through, not
|
||||
/// just that it opened), or [AdGateResult.blocked] if none was available
|
||||
/// in time, it failed to show, or the user dismissed it before earning
|
||||
/// the reward. 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
|
||||
/// The load step is bounded to a few seconds so a slow ad load can never
|
||||
/// hang a sync indefinitely; the watch step isn't, since the reward is
|
||||
/// legitimately expected to take as long as the user spends on the ad —
|
||||
/// [_earnedRewardSafetyTimeout] only guards against a genuinely stuck SDK
|
||||
/// callback, not normal watch time. Always lines up the next ad
|
||||
/// 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.
|
||||
|
|
@ -103,22 +172,150 @@ class AdService {
|
|||
return AdGateResult.blocked;
|
||||
}
|
||||
|
||||
final earnedReward = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
if (!earnedReward.isCompleted) earnedReward.complete(false);
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
debugPrint(
|
||||
'[AdService] Rewarded ad failed to show: ${error.code} ${error.domain} ${error.message}');
|
||||
ad.dispose();
|
||||
if (!earnedReward.isCompleted) earnedReward.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show(
|
||||
onUserEarnedReward: (ad, reward) {
|
||||
_lastShownAt = DateTime.now();
|
||||
if (!earnedReward.isCompleted) earnedReward.complete(true);
|
||||
},
|
||||
);
|
||||
final earned =
|
||||
await earnedReward.future.timeout(_earnedRewardSafetyTimeout, onTimeout: () => false);
|
||||
unawaited(_preload());
|
||||
return earned ? AdGateResult.justShown : AdGateResult.blocked;
|
||||
}
|
||||
|
||||
/// Starts the Mobile Ads SDK (if [initialize] hasn't already) and begins
|
||||
/// preloading the report-export interstitial. Idempotent and safe to call
|
||||
/// speculatively — [AppState] calls this when the Reports tab is first
|
||||
/// built, independently of whether cloud sync is ever configured, so the
|
||||
/// ad is ready by the time the user shares/prints without delaying the
|
||||
/// export itself.
|
||||
Future<void> preloadReportAd() async {
|
||||
await _ensureSdkInitialized();
|
||||
unawaited(_preloadPlainInterstitial(
|
||||
adUnitId: _reportInterstitialAdUnitId,
|
||||
logLabel: 'Report interstitial',
|
||||
onLoaded: (ad) => _preloadedReportAd = ad,
|
||||
));
|
||||
}
|
||||
|
||||
/// Best-effort interstitial shown after a report export/share/print
|
||||
/// completes. Unlike [showGateAd], this never blocks or gates anything —
|
||||
/// the export has already happened by the time this is called. Whether
|
||||
/// this is actually due (grace periods, earned credit) is entirely
|
||||
/// [AppState]'s call, made before this is ever invoked — this method
|
||||
/// itself is unconditional: shows whatever's preloaded, or does nothing
|
||||
/// if nothing was ready in time (this doesn't wait — showing it late,
|
||||
/// after the user's already moved on, would be worse than not showing it
|
||||
/// at all). Returns whether an ad actually showed, so the caller knows
|
||||
/// whether to treat its credit as earned.
|
||||
Future<bool> maybeShowReportAd() async {
|
||||
final ad = _preloadedReportAd;
|
||||
_preloadedReportAd = null;
|
||||
final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial');
|
||||
unawaited(_preloadPlainInterstitial(
|
||||
adUnitId: _reportInterstitialAdUnitId,
|
||||
logLabel: 'Report interstitial',
|
||||
onLoaded: (ad) => _preloadedReportAd = ad,
|
||||
));
|
||||
return shown;
|
||||
}
|
||||
|
||||
/// Same shape as [preloadReportAd], for the fuel-save interstitial —
|
||||
/// [AppState] calls this once the confirm-entry screen is first built.
|
||||
Future<void> preloadFuelSaveAd() async {
|
||||
await _ensureSdkInitialized();
|
||||
unawaited(_preloadPlainInterstitial(
|
||||
adUnitId: _fuelSaveInterstitialAdUnitId,
|
||||
logLabel: 'Fuel-save interstitial',
|
||||
onLoaded: (ad) => _preloadedFuelSaveAd = ad,
|
||||
));
|
||||
}
|
||||
|
||||
/// Same shape and caveats as [maybeShowReportAd], for the fuel-save
|
||||
/// interstitial.
|
||||
Future<bool> maybeShowFuelSaveAd() async {
|
||||
final ad = _preloadedFuelSaveAd;
|
||||
_preloadedFuelSaveAd = null;
|
||||
final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial');
|
||||
unawaited(_preloadPlainInterstitial(
|
||||
adUnitId: _fuelSaveInterstitialAdUnitId,
|
||||
logLabel: 'Fuel-save interstitial',
|
||||
onLoaded: (ad) => _preloadedFuelSaveAd = ad,
|
||||
));
|
||||
return shown;
|
||||
}
|
||||
|
||||
Future<void> _ensureSdkInitialized() async {
|
||||
if (_sdkInitialized) return;
|
||||
_sdkInitialized = true;
|
||||
await MobileAds.instance.initialize();
|
||||
}
|
||||
|
||||
Future<void> _preloadPlainInterstitial({
|
||||
required String adUnitId,
|
||||
required String logLabel,
|
||||
required void Function(InterstitialAd ad) onLoaded,
|
||||
}) {
|
||||
final completer = Completer<void>();
|
||||
InterstitialAd.load(
|
||||
adUnitId: adUnitId,
|
||||
request: const AdRequest(),
|
||||
adLoadCallback: InterstitialAdLoadCallback(
|
||||
onAdLoaded: (ad) {
|
||||
onLoaded(ad);
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
onAdFailedToLoad: (error) {
|
||||
debugPrint(
|
||||
'[AdService] $logLabel failed to load: ${error.code} ${error.domain} ${error.message}');
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Shows [ad] if non-null and reports back whether it actually displayed
|
||||
/// (as opposed to merely being asked to) — the same "watched it" signal
|
||||
/// [showGateAd] gets from a rewarded ad's earn callback, just sourced
|
||||
/// from [FullScreenContentCallback.onAdShowedFullScreenContent] since a
|
||||
/// plain interstitial has no reward callback to key off instead. Bounded
|
||||
/// the same way [showGateAd]'s load step is: a plain interstitial has no
|
||||
/// legitimate long "watch" phase the way a rewarded ad does, so 8 seconds
|
||||
/// is generous rather than a real constraint.
|
||||
Future<bool> _showPlainInterstitial(InterstitialAd? ad, {required String logLabel}) async {
|
||||
if (ad == null) return false;
|
||||
|
||||
final showed = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdShowedFullScreenContent: (ad) {
|
||||
_lastShownAt = DateTime.now();
|
||||
if (!showed.isCompleted) showed.complete(true);
|
||||
},
|
||||
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
debugPrint(
|
||||
'[AdService] $logLabel failed to show: ${error.code} ${error.domain} ${error.message}');
|
||||
ad.dispose();
|
||||
if (!showed.isCompleted) showed.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show();
|
||||
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||
unawaited(_preload());
|
||||
return opened ? AdGateResult.justShown : AdGateResult.blocked;
|
||||
return showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,22 @@ class AppState extends ChangeNotifier {
|
|||
/// for sync failures.
|
||||
String? purchaseError;
|
||||
|
||||
/// The earliest-known moment this app was ever used — null until [init]
|
||||
/// loads it. Drives the new-user ad grace period (see
|
||||
/// [_maybeShowFuelSaveAd]/[maybeShowReportAd]); see
|
||||
/// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced
|
||||
/// rather than a local-only preference.
|
||||
DateTime? firstUsedAt;
|
||||
|
||||
/// True if there's local data not yet pushed to the cloud — either
|
||||
/// nothing has synced yet, or something changed since the last
|
||||
/// successful push. Refreshed alongside [vehicles]/[fuelEntries] in
|
||||
/// [_refreshFromDatabase], so it's accurate after every local mutation
|
||||
/// and every sync attempt. Drives the "not backed up" icon (see
|
||||
/// `CloudBackupActionButton`) once backup *is* configured — before that,
|
||||
/// [hasCloudBackupConfigured] alone already covers it.
|
||||
bool hasUnsyncedChanges = false;
|
||||
|
||||
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
||||
|
||||
|
|
@ -171,6 +187,96 @@ class AppState extends ChangeNotifier {
|
|||
return until != null && DateTime.now().toUtc().isBefore(until);
|
||||
}
|
||||
|
||||
/// How long after first ever opening this app (see [firstUsedAt]) a new
|
||||
/// user sees no interstitials at all — shared by the report and
|
||||
/// fuel-save placements.
|
||||
static const _newUserAdGracePeriod = Duration(minutes: 5);
|
||||
|
||||
/// Below this many total fuel entries ever saved, the fuel-save
|
||||
/// interstitial doesn't show — on top of, not instead of,
|
||||
/// [_newUserAdGracePeriod].
|
||||
static const _fuelSaveAdGraceSaves = 4;
|
||||
|
||||
/// Watching the fuel-save interstitial buys a credit that lasts until
|
||||
/// *either* of these runs out, whichever comes first.
|
||||
static const _fuelSaveAdCreditDuration = Duration(minutes: 5);
|
||||
static const _fuelSaveAdCreditSaves = 2;
|
||||
|
||||
/// Watching the report interstitial buys this much flat credit.
|
||||
static const _reportAdCreditDuration = Duration(minutes: 10);
|
||||
|
||||
DateTime? _lastFuelSaveAdShownAt;
|
||||
int _fuelSavesSinceAd = 0;
|
||||
DateTime? _lastReportAdShownAt;
|
||||
|
||||
/// Preloads the report-export interstitial (see [AdService.preloadReportAd])
|
||||
/// — call once when the Reports tab is first built, so an ad is ready by
|
||||
/// the time [maybeShowReportAd] is called. A no-op for a user with an
|
||||
/// active ad-free purchase.
|
||||
void preloadReportAd() {
|
||||
if (!adsCurrentlyDisabled) unawaited(adService.preloadReportAd());
|
||||
}
|
||||
|
||||
/// Best-effort ad shown after a report export/share/print completes —
|
||||
/// see [AdService.maybeShowReportAd]. A no-op for a user with an active
|
||||
/// ad-free purchase, during the new-user grace period (see
|
||||
/// [firstUsedAt]), or while a previously-watched report ad's credit is
|
||||
/// still active.
|
||||
Future<void> maybeShowReportAd() async {
|
||||
if (adsCurrentlyDisabled || _inNewUserGracePeriod) return;
|
||||
|
||||
final lastShown = _lastReportAdShownAt;
|
||||
if (lastShown != null && DateTime.now().difference(lastShown) < _reportAdCreditDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adService.maybeShowReportAd()) {
|
||||
_lastReportAdShownAt = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Preloads the fuel-save interstitial (see [AdService.preloadFuelSaveAd])
|
||||
/// — call once when the confirm-entry screen is first built. A no-op for
|
||||
/// a user with an active ad-free purchase.
|
||||
void preloadFuelSaveAd() {
|
||||
if (!adsCurrentlyDisabled) unawaited(adService.preloadFuelSaveAd());
|
||||
}
|
||||
|
||||
bool get _inNewUserGracePeriod {
|
||||
final firstUsed = firstUsedAt;
|
||||
return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod;
|
||||
}
|
||||
|
||||
/// Best-effort ad shown after a fuel entry is saved — see
|
||||
/// [AdService.maybeShowFuelSaveAd]. A no-op for a user with an active ad
|
||||
/// -free purchase, during the new-user grace period or the first
|
||||
/// [_fuelSaveAdGraceSaves] saves, while a previously-watched fuel-save
|
||||
/// ad's credit is still active, or — deliberately — whenever cloud
|
||||
/// backup isn't configured: [showBackupReminderDialog] already claims
|
||||
/// that same save's attention in that case (see its call site in
|
||||
/// `ConfirmFuelEntryScreen._save`), and letting both compete for the
|
||||
/// same moment is exactly the stacked-full-screen-surfaces problem this
|
||||
/// scoping avoids. Skipping this way costs nothing — the credit/grace
|
||||
/// state simply isn't touched, so the check is just as "due" next save.
|
||||
Future<void> _maybeShowFuelSaveAd() async {
|
||||
if (adsCurrentlyDisabled || !hasCloudBackupConfigured) return;
|
||||
if (_inNewUserGracePeriod) return;
|
||||
if (fuelEntries.length <= _fuelSaveAdGraceSaves) return;
|
||||
|
||||
final lastShown = _lastFuelSaveAdShownAt;
|
||||
if (lastShown != null &&
|
||||
DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
|
||||
_fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
|
||||
_fuelSavesSinceAd++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adService.maybeShowFuelSaveAd()) {
|
||||
_lastFuelSaveAdShownAt = DateTime.now();
|
||||
_fuelSavesSinceAd = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The store's own formatted, localized price for the ad-free-year
|
||||
/// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize]
|
||||
/// has loaded it, or if the product isn't configured in the store yet.
|
||||
|
|
@ -186,6 +292,7 @@ class AppState extends ChangeNotifier {
|
|||
try {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
firstUsedAt = await database.getOrCreateFirstUsedAt();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
||||
|
|
@ -236,6 +343,7 @@ class AppState extends ChangeNotifier {
|
|||
vehicles = await database.getVehicles();
|
||||
fuelEntries = await database.getFuelEntries();
|
||||
adFreeUntil = await database.getAdFreeUntil();
|
||||
hasUnsyncedChanges = await database.hasDirtyRows();
|
||||
}
|
||||
|
||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||
|
|
@ -384,13 +492,17 @@ class AppState extends ChangeNotifier {
|
|||
// 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;
|
||||
Future<bool> Function({required bool everSyncedBefore})? 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.
|
||||
unawaited(adService.initialize());
|
||||
beforeUpload = () async {
|
||||
beforeUpload = ({required everSyncedBefore}) async {
|
||||
// The very first sync a cloud folder ever sees goes through for
|
||||
// free — see [CloudSyncService.syncNow]'s `everSyncedBefore` doc.
|
||||
if (!everSyncedBefore) return true;
|
||||
|
||||
final result = await adService.showGateAd();
|
||||
// Fired, not awaited: the upsell dialog is purely informational —
|
||||
// this sync (specifically the upload [beforeUpload] is about to
|
||||
|
|
@ -591,6 +703,7 @@ class AppState extends ChangeNotifier {
|
|||
);
|
||||
await database.saveFuelEntry(entry);
|
||||
await _persist();
|
||||
unawaited(_maybeShowFuelSaveAd());
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +816,17 @@ class AppState extends ChangeNotifier {
|
|||
Future<void> _persist() async {
|
||||
await _refreshFromDatabase();
|
||||
notifyListeners();
|
||||
unawaited(syncNow());
|
||||
// Only a paid ("remove ads for a year") user gets synced automatically
|
||||
// on every change — for anyone else this would mean an ad-gated
|
||||
// syncNow() firing silently in the background, disconnected from
|
||||
// anything the user just did, which is exactly the surprise-ad problem
|
||||
// this app's ad placements are designed to avoid everywhere else.
|
||||
// Sync still always happens on demand: `Data > Sync Now`, tapping
|
||||
// `CloudBackupActionButton` when it shows "not synced", and connecting
|
||||
// a provider/folder for the first time (see [selectAppFolder]) all
|
||||
// call [syncNow] directly, and the very first sync a folder ever sees
|
||||
// is free regardless (see [syncNow]'s `everSyncedBefore` handling).
|
||||
if (adsCurrentlyDisabled) unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,10 +165,14 @@ class CloudSyncService {
|
|||
/// `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.
|
||||
/// `everSyncedBefore` tells the caller whether a remote data file already
|
||||
/// existed — `false` means this is the very first sync this shared cloud
|
||||
/// folder has ever seen, which [AppState] uses to let that one upload
|
||||
/// through for free, with no ad required.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
Future<bool> Function()? beforeUpload,
|
||||
Future<bool> Function({required bool everSyncedBefore})? beforeUpload,
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!provider.isSignedIn || appFolderId == null) {
|
||||
|
|
@ -197,7 +201,8 @@ class CloudSyncService {
|
|||
await _pullAndMerge(session, remoteInfo.id);
|
||||
}
|
||||
|
||||
if (beforeUpload != null && !await beforeUpload()) {
|
||||
if (beforeUpload != null &&
|
||||
!await beforeUpload(everSyncedBefore: remoteInfo != null)) {
|
||||
return SyncResult.adGateBlocked();
|
||||
}
|
||||
|
||||
|
|
@ -257,6 +262,16 @@ class CloudSyncService {
|
|||
if (remoteHasEntitlementTable) {
|
||||
await db.execute(mergeAdFreeEntitlementSql);
|
||||
}
|
||||
|
||||
// Same reasoning as ad_free_entitlement above — app_usage is newer
|
||||
// than vehicles/fuel_entries, so an older remote snapshot genuinely
|
||||
// won't have it.
|
||||
final remoteHasAppUsageTable = (await db.rawQuery(
|
||||
"SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'app_usage'",
|
||||
)).isNotEmpty;
|
||||
if (remoteHasAppUsageTable) {
|
||||
await db.execute(mergeAppUsageSql);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await db.execute('DETACH DATABASE remote_db');
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class DatabaseService {
|
|||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 5,
|
||||
version: 6,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
|
|
@ -76,6 +76,7 @@ class DatabaseService {
|
|||
await db.execute(createFuelEntriesTableSql);
|
||||
await db.execute(createFuelEntriesIndexSql);
|
||||
await db.execute(createAdFreeEntitlementTableSql);
|
||||
await db.execute(createAppUsageTableSql);
|
||||
}
|
||||
|
||||
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
||||
|
|
@ -94,6 +95,9 @@ class DatabaseService {
|
|||
if (oldVersion < 5) {
|
||||
await db.execute(createAdFreeEntitlementTableSql);
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
await db.execute(createAppUsageTableSql);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
||||
|
|
@ -188,6 +192,35 @@ class DatabaseService {
|
|||
);
|
||||
}
|
||||
|
||||
/// The earliest-known moment this app was ever used, across every device
|
||||
/// this account has synced from — see [createAppUsageTableSql]. Recorded
|
||||
/// once, the first time this is ever called with no existing row (a
|
||||
/// fresh install with nothing to merge down yet); reinstalling *after*
|
||||
/// cloud sync was connected instead pulls the original value back down
|
||||
/// via [mergeAppUsageSql] before this is next called, so the new-user ad
|
||||
/// grace period can't be replayed by reinstalling.
|
||||
Future<DateTime> getOrCreateFirstUsedAt() async {
|
||||
final rows = await _db.query('app_usage', where: 'id = 1', limit: 1);
|
||||
if (rows.isNotEmpty) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(rows.single['first_used_at'] as int, isUtc: true);
|
||||
}
|
||||
final now = DateTime.now().toUtc();
|
||||
await _db.insert('app_usage', {'id': 1, 'first_used_at': now.millisecondsSinceEpoch});
|
||||
return now;
|
||||
}
|
||||
|
||||
/// True if any vehicle or fuel entry row has local changes not yet
|
||||
/// pushed to the cloud (see the `dirty` column note on this class).
|
||||
/// Exposed only as this one yes/no signal, never the raw flag — it
|
||||
/// drives [AppState.hasUnsyncedChanges], which the "not backed up" icon
|
||||
/// reads.
|
||||
Future<bool> hasDirtyRows() async {
|
||||
final vehicleRows = await _db.query('vehicles', where: 'dirty = 1', limit: 1);
|
||||
if (vehicleRows.isNotEmpty) return true;
|
||||
final entryRows = await _db.query('fuel_entries', where: 'dirty = 1', limit: 1);
|
||||
return entryRows.isNotEmpty;
|
||||
}
|
||||
|
||||
/// True if an active (non-deleted) vehicle other than [excludeId] already
|
||||
/// has this VIN. VIN must stay unique even though it's editable, so
|
||||
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
||||
|
|
|
|||
|
|
@ -48,6 +48,29 @@ const createAdFreeEntitlementTableSql = '''
|
|||
)
|
||||
''';
|
||||
|
||||
/// A single-row table (`id` is always `1`) holding the earliest-known
|
||||
/// moment this app was ever used — see `AppState.firstUsedAt` and
|
||||
/// `DatabaseService.getOrCreateFirstUsedAt`. Synced like
|
||||
/// `ad_free_entitlement`, for the same reason: without it, reinstalling
|
||||
/// would reset the new-user ad grace period every time.
|
||||
const createAppUsageTableSql = '''
|
||||
CREATE TABLE app_usage (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
first_used_at INTEGER NOT NULL
|
||||
)
|
||||
''';
|
||||
|
||||
/// Unlike every other merge statement here, this keeps whichever row has
|
||||
/// the *smaller* `first_used_at` — merging in a device that's had the app
|
||||
/// longer can only push "first use" earlier, never later.
|
||||
const mergeAppUsageSql = '''
|
||||
INSERT OR REPLACE INTO main.app_usage (id, first_used_at)
|
||||
SELECT r.id, r.first_used_at
|
||||
FROM remote_db.app_usage r
|
||||
LEFT JOIN main.app_usage l ON l.id = r.id
|
||||
WHERE l.id IS NULL OR r.first_used_at < l.first_used_at
|
||||
''';
|
||||
|
||||
/// Selects fuel entries whose receipt photo still needs uploading to
|
||||
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
||||
/// "has a local path": once a row is uploaded, its local copy may still be
|
||||
|
|
|
|||
58
lib/widgets/cloud_backup_action_button.dart
Normal file
58
lib/widgets/cloud_backup_action_button.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import 'backup_reminder.dart';
|
||||
|
||||
/// AppBar action flagging that this device's data isn't fully backed up —
|
||||
/// the same "shared widget dropped into every tab's AppBar" pattern
|
||||
/// [FaqActionButton] uses. Renders nothing once backup is configured *and*
|
||||
/// there's nothing unsynced (see [AppState.hasCloudBackupConfigured] /
|
||||
/// [AppState.hasUnsyncedChanges]); there's nothing left to flag at that
|
||||
/// point. Covers two different states behind one icon:
|
||||
/// - Backup was never set up — tapping opens the setup flow.
|
||||
/// - Backup *is* set up but something local hasn't been pushed up yet
|
||||
/// (most users, most of the time, since only a paid user auto-syncs on
|
||||
/// every change — see [AppState.hasUnsyncedChanges]) — tapping tries a
|
||||
/// sync right now, going through the normal ad gate.
|
||||
///
|
||||
/// The small play-triangle badge marks this as potentially leading to an
|
||||
/// ad: syncing needs a rewarded ad to push local changes up on every sync
|
||||
/// but the first (see `AdService.showGateAd`) — unless the user has an
|
||||
/// active ad-free purchase, in which case syncing is silent and free. The
|
||||
/// badge only shows for someone who'd actually see an ad, so a paid user
|
||||
/// gets a plain icon instead.
|
||||
class CloudBackupActionButton extends StatelessWidget {
|
||||
const CloudBackupActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final configured = appState.hasCloudBackupConfigured;
|
||||
if (configured && !appState.hasUnsyncedChanges) return const SizedBox.shrink();
|
||||
|
||||
final showAdBadge = !appState.adsCurrentlyDisabled;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
const icon = Icon(Icons.cloud_off_outlined);
|
||||
|
||||
return IconButton(
|
||||
tooltip: configured ? 'Not synced yet — tap to sync now' : 'Not backed up — set up cloud backup',
|
||||
onPressed: () {
|
||||
if (configured) {
|
||||
unawaited(appState.syncNow());
|
||||
} else {
|
||||
openCloudBackupSetup(context);
|
||||
}
|
||||
},
|
||||
icon: showAdBadge
|
||||
? Badge(
|
||||
backgroundColor: colors.tertiary,
|
||||
label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary),
|
||||
child: icon,
|
||||
)
|
||||
: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue