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
|
|
@ -48,12 +48,9 @@
|
||||||
<data android:scheme="mofueltaxback-onedrive" />
|
<data android:scheme="mofueltaxback-onedrive" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Real AdMob App ID (from your own AdMob console). The ad unit
|
<!-- Real AdMob App ID (from your own AdMob console). The interstitial
|
||||||
served under it is still Google's official *test* interstitial
|
unit served under it (lib/services/ad_service.dart) is also a
|
||||||
ID for now (lib/services/ad_service.dart) — that keeps showing
|
real one from the same account, not Google's test ID. -->
|
||||||
real, clearly-labeled test creatives (no revenue) until a real
|
|
||||||
interstitial ad unit ID created under this app is swapped in
|
|
||||||
there too. -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
android:value="ca-app-pub-9212406812117696~6413414381"/>
|
android:value="ca-app-pub-9212406812117696~6413414381"/>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
_totalController = TextEditingController(
|
_totalController = TextEditingController(
|
||||||
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
|
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
|
||||||
);
|
);
|
||||||
|
// So an ad is ready by the time _save() finishes without delaying it.
|
||||||
|
context.read<AppState>().preloadFuelSaveAd();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
/// Shared warning-dialog shell for both purge actions — [message] is the
|
||||||
/// caller's job to make specific and unambiguous, since this is the only
|
/// caller's job to make specific and unambiguous, since this is the only
|
||||||
/// thing standing between the user and an unrecoverable delete.
|
/// thing standing between the user and an unrecoverable delete.
|
||||||
|
|
@ -283,7 +302,7 @@ class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
||||||
height: 16,
|
height: 16,
|
||||||
width: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Icon(Icons.sync),
|
: _syncNowIcon(context, appState),
|
||||||
label: const Text('Sync Now'),
|
label: const Text('Sync Now'),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/onboarding_keys.dart';
|
import '../services/onboarding_keys.dart';
|
||||||
|
import '../widgets/cloud_backup_action_button.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
import 'faq_screen.dart';
|
import 'faq_screen.dart';
|
||||||
import 'vehicle_detail_screen.dart';
|
import 'vehicle_detail_screen.dart';
|
||||||
|
|
@ -31,6 +32,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const CloudBackupActionButton(),
|
||||||
const FaqActionButton(),
|
const FaqActionButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../services/app_state.dart';
|
||||||
import '../services/estimated_refund.dart';
|
import '../services/estimated_refund.dart';
|
||||||
import '../services/onboarding_keys.dart';
|
import '../services/onboarding_keys.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/cloud_backup_action_button.dart';
|
||||||
import '../widgets/hero_banner.dart';
|
import '../widgets/hero_banner.dart';
|
||||||
import '../widgets/receipt_capture.dart';
|
import '../widgets/receipt_capture.dart';
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
|
|
@ -144,6 +145,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
||||||
tooltip: 'Log Fuel Receipt',
|
tooltip: 'Log Fuel Receipt',
|
||||||
onPressed: () => _addReceipt(context),
|
onPressed: () => _addReceipt(context),
|
||||||
),
|
),
|
||||||
|
const CloudBackupActionButton(),
|
||||||
const FaqActionButton(),
|
const FaqActionButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -12,6 +13,7 @@ import '../services/fuel_report.dart';
|
||||||
import '../services/fuel_report_images.dart';
|
import '../services/fuel_report_images.dart';
|
||||||
import '../services/fuel_report_pdf.dart';
|
import '../services/fuel_report_pdf.dart';
|
||||||
import '../services/onboarding_keys.dart';
|
import '../services/onboarding_keys.dart';
|
||||||
|
import '../widgets/cloud_backup_action_button.dart';
|
||||||
import 'faq_screen.dart';
|
import 'faq_screen.dart';
|
||||||
|
|
||||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
/// 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());
|
final (start, end) = defaultReportDateRange(DateTime.now());
|
||||||
_startDate = start;
|
_startDate = start;
|
||||||
_endDate = end;
|
_endDate = end;
|
||||||
|
context.read<AppState>().preloadReportAd();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate({required bool isStart}) async {
|
Future<void> _pickDate({required bool isStart}) async {
|
||||||
|
|
@ -118,6 +121,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
||||||
try {
|
try {
|
||||||
final bytes = await _buildPdfBytes(report);
|
final bytes = await _buildPdfBytes(report);
|
||||||
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
||||||
|
if (mounted) unawaited(context.read<AppState>().maybeShowReportAd());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +132,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
||||||
try {
|
try {
|
||||||
final bytes = await _buildPdfBytes(report);
|
final bytes = await _buildPdfBytes(report);
|
||||||
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
||||||
|
if (mounted) unawaited(context.read<AppState>().maybeShowReportAd());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _busy = false);
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +199,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Fuel Report'),
|
title: const Text('Fuel Report'),
|
||||||
actions: const [FaqActionButton()],
|
actions: const [CloudBackupActionButton(), FaqActionButton()],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/onboarding_keys.dart';
|
import '../services/onboarding_keys.dart';
|
||||||
|
import '../widgets/cloud_backup_action_button.dart';
|
||||||
import 'data_settings_screen.dart';
|
import 'data_settings_screen.dart';
|
||||||
import 'faq_screen.dart';
|
import 'faq_screen.dart';
|
||||||
import 'ui_settings_screen.dart';
|
import 'ui_settings_screen.dart';
|
||||||
|
|
@ -22,7 +23,7 @@ class SettingsScreen extends StatelessWidget {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Settings'),
|
title: const Text('Settings'),
|
||||||
actions: const [FaqActionButton()],
|
actions: const [CloudBackupActionButton(), FaqActionButton()],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,12 @@ class UserAgreementScreen extends StatelessWidget {
|
||||||
'this device. If you connect a cloud storage account (Google Drive, '
|
'this device. If you connect a cloud storage account (Google Drive, '
|
||||||
'Dropbox, OneDrive, or your own WebDAV server) in Settings, a copy is '
|
'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. '
|
'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(
|
_Section(
|
||||||
title: 'Privacy',
|
title: 'Privacy',
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,67 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
/// The single ad placement this app has: one interstitial, gating the
|
/// This app has three ad placements:
|
||||||
/// *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.
|
|
||||||
///
|
///
|
||||||
/// Showing an ad opens a gate that stays open for [gateValidity]; a sync
|
/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see
|
||||||
/// attempted after that window closes has to show (and have the user sit
|
/// [CloudSyncService.syncNow]'s `beforeUpload` hook and
|
||||||
/// through the start of) another one before it's allowed to push data to
|
/// [AppState.syncNow]) — deliberately not pulling/merging remote
|
||||||
/// the cloud. Selecting/connecting a cloud provider is never gated — only
|
/// changes down. Rewarded, not a plain interstitial, because AdMob's
|
||||||
/// the upload side of a sync is, via [showGateAd].
|
/// 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
|
/// Placements 2 and 3 are plain, not rewarded, because nothing is being
|
||||||
/// [_interstitialAdUnitId] below are both the real ones from your AdMob
|
/// unlocked — the save/export already happened by the time either fires,
|
||||||
/// console. ios/Runner/Info.plist's `GADApplicationIdentifier` is still
|
/// so there's no benefit to condition on watching it; they're just natural
|
||||||
/// Google's test iOS App ID, though — an AdMob App ID is registered
|
/// post-task transitions, which is exactly what plain interstitials are
|
||||||
/// per-platform, so it needs its own real iOS App ID (and a real iOS
|
/// for. Neither ever blocks or gates anything, unlike placement 1.
|
||||||
/// interstitial ad unit ID here) from the AdMob console if this app ever
|
///
|
||||||
/// ships on iOS.
|
/// 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
|
/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the
|
||||||
/// caller may proceed" — they're kept distinct only so [AppState] can tell
|
/// 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
|
/// 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 }
|
enum AdGateResult { alreadyOpen, justShown, blocked }
|
||||||
|
|
||||||
class AdService {
|
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
|
/// How long a successfully-shown ad keeps the upload gate open before the
|
||||||
/// next sync attempt has to show another one.
|
/// next sync attempt has to show another one.
|
||||||
static const gateValidity = Duration(minutes: 5);
|
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;
|
bool _sdkInitialized = false;
|
||||||
DateTime? _lastShownAt;
|
DateTime? _lastShownAt;
|
||||||
InterstitialAd? _preloadedAd;
|
RewardedAd? _preloadedAd;
|
||||||
Completer<void>? _loadCompleter;
|
Completer<void>? _loadCompleter;
|
||||||
|
|
||||||
|
InterstitialAd? _preloadedReportAd;
|
||||||
|
InterstitialAd? _preloadedFuelSaveAd;
|
||||||
|
|
||||||
bool get _gateOpen {
|
bool get _gateOpen {
|
||||||
final lastShownAt = _lastShownAt;
|
final lastShownAt = _lastShownAt;
|
||||||
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
|
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
|
/// Idempotent (a no-op after the first call) and safe to call
|
||||||
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
|
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
|
||||||
/// than unconditionally at app startup, so a user who never connects
|
/// than unconditionally at app startup, so a user who never connects
|
||||||
/// cloud sync never triggers any ad-related network activity at all.
|
/// cloud sync never triggers any ad-related network activity at all.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_sdkInitialized) return;
|
final alreadyInitialized = _sdkInitialized;
|
||||||
_sdkInitialized = true;
|
await _ensureSdkInitialized();
|
||||||
await MobileAds.instance.initialize();
|
if (!alreadyInitialized) unawaited(_preload());
|
||||||
unawaited(_preload());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _preload() {
|
Future<void> _preload() {
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
_loadCompleter = completer;
|
_loadCompleter = completer;
|
||||||
InterstitialAd.load(
|
RewardedAd.load(
|
||||||
adUnitId: _interstitialAdUnitId,
|
adUnitId: _rewardedAdUnitId,
|
||||||
request: const AdRequest(),
|
request: const AdRequest(),
|
||||||
adLoadCallback: InterstitialAdLoadCallback(
|
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||||
onAdLoaded: (ad) {
|
onAdLoaded: (ad) {
|
||||||
_preloadedAd = ad;
|
_preloadedAd = ad;
|
||||||
if (!completer.isCompleted) completer.complete();
|
if (!completer.isCompleted) completer.complete();
|
||||||
},
|
},
|
||||||
onAdFailedToLoad: (error) {
|
onAdFailedToLoad: (error) {
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Rewarded ad failed to load: ${error.code} ${error.domain} ${error.message}');
|
||||||
if (!completer.isCompleted) completer.complete();
|
if (!completer.isCompleted) completer.complete();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -77,15 +143,18 @@ class AdService {
|
||||||
|
|
||||||
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
|
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
|
||||||
/// ad was already shown within [gateValidity]; otherwise shows the
|
/// ad was already shown within [gateValidity]; otherwise shows the
|
||||||
/// preloaded interstitial and returns [AdGateResult.justShown] once it
|
/// preloaded rewarded ad and returns [AdGateResult.justShown]
|
||||||
/// actually starts displaying (the only "watched it" signal a plain
|
/// once the user actually earns the reward (i.e. watches it through, not
|
||||||
/// interstitial — as opposed to a rewarded ad — can give us), or
|
/// just that it opened), or [AdGateResult.blocked] if none was available
|
||||||
/// [AdGateResult.blocked] if none was available in time or it failed to
|
/// in time, it failed to show, or the user dismissed it before earning
|
||||||
/// show. A [AdGateResult.blocked] result means the caller must not
|
/// the reward. A [AdGateResult.blocked] result means the caller must not
|
||||||
/// proceed with uploading.
|
/// proceed with uploading.
|
||||||
///
|
///
|
||||||
/// Bounded throughout so a slow ad load or a stuck ad SDK callback can
|
/// The load step is bounded to a few seconds so a slow ad load can never
|
||||||
/// never hang a sync indefinitely. Always lines up the next interstitial
|
/// 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 —
|
/// afterward, whether this attempt succeeded or not, so the next call —
|
||||||
/// whether that's because this one failed or because [gateValidity]
|
/// whether that's because this one failed or because [gateValidity]
|
||||||
/// elapsed — has the best chance of a preloaded ad ready to go.
|
/// elapsed — has the best chance of a preloaded ad ready to go.
|
||||||
|
|
@ -103,22 +172,150 @@ class AdService {
|
||||||
return AdGateResult.blocked;
|
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>();
|
final showed = Completer<bool>();
|
||||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
onAdShowedFullScreenContent: (ad) {
|
onAdShowedFullScreenContent: (ad) {
|
||||||
_lastShownAt = DateTime.now();
|
|
||||||
if (!showed.isCompleted) showed.complete(true);
|
if (!showed.isCompleted) showed.complete(true);
|
||||||
},
|
},
|
||||||
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
||||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] $logLabel failed to show: ${error.code} ${error.domain} ${error.message}');
|
||||||
ad.dispose();
|
ad.dispose();
|
||||||
if (!showed.isCompleted) showed.complete(false);
|
if (!showed.isCompleted) showed.complete(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await ad.show();
|
await ad.show();
|
||||||
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
return showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||||
unawaited(_preload());
|
|
||||||
return opened ? AdGateResult.justShown : AdGateResult.blocked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,22 @@ class AppState extends ChangeNotifier {
|
||||||
/// for sync failures.
|
/// for sync failures.
|
||||||
String? purchaseError;
|
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;
|
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
||||||
|
|
||||||
|
|
@ -171,6 +187,96 @@ class AppState extends ChangeNotifier {
|
||||||
return until != null && DateTime.now().toUtc().isBefore(until);
|
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
|
/// The store's own formatted, localized price for the ad-free-year
|
||||||
/// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize]
|
/// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize]
|
||||||
/// has loaded it, or if the product isn't configured in the store yet.
|
/// has loaded it, or if the product isn't configured in the store yet.
|
||||||
|
|
@ -186,6 +292,7 @@ class AppState extends ChangeNotifier {
|
||||||
try {
|
try {
|
||||||
await database.init();
|
await database.init();
|
||||||
await _refreshFromDatabase();
|
await _refreshFromDatabase();
|
||||||
|
firstUsedAt = await database.getOrCreateFirstUsedAt();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
||||||
|
|
@ -236,6 +343,7 @@ class AppState extends ChangeNotifier {
|
||||||
vehicles = await database.getVehicles();
|
vehicles = await database.getVehicles();
|
||||||
fuelEntries = await database.getFuelEntries();
|
fuelEntries = await database.getFuelEntries();
|
||||||
adFreeUntil = await database.getAdFreeUntil();
|
adFreeUntil = await database.getAdFreeUntil();
|
||||||
|
hasUnsyncedChanges = await database.hasDirtyRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||||
|
|
@ -384,13 +492,17 @@ class AppState extends ChangeNotifier {
|
||||||
// skips the gate entirely — beforeUpload stays null (CloudSyncService
|
// skips the gate entirely — beforeUpload stays null (CloudSyncService
|
||||||
// treats that as "nothing to check", same as any other call site that
|
// 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.
|
// 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) {
|
if (!adsCurrentlyDisabled) {
|
||||||
// Idempotent, and only ever reached once sync is actually configured
|
// Idempotent, and only ever reached once sync is actually configured
|
||||||
// — a user who never connects cloud storage never triggers any
|
// — a user who never connects cloud storage never triggers any
|
||||||
// ad-related activity at all.
|
// ad-related activity at all.
|
||||||
unawaited(adService.initialize());
|
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();
|
final result = await adService.showGateAd();
|
||||||
// Fired, not awaited: the upsell dialog is purely informational —
|
// Fired, not awaited: the upsell dialog is purely informational —
|
||||||
// this sync (specifically the upload [beforeUpload] is about to
|
// this sync (specifically the upload [beforeUpload] is about to
|
||||||
|
|
@ -591,6 +703,7 @@ class AppState extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
await database.saveFuelEntry(entry);
|
await database.saveFuelEntry(entry);
|
||||||
await _persist();
|
await _persist();
|
||||||
|
unawaited(_maybeShowFuelSaveAd());
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,7 +816,17 @@ class AppState extends ChangeNotifier {
|
||||||
Future<void> _persist() async {
|
Future<void> _persist() async {
|
||||||
await _refreshFromDatabase();
|
await _refreshFromDatabase();
|
||||||
notifyListeners();
|
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])
|
/// `false` return means the gate is closed (see [AdService.showGateAd])
|
||||||
/// and this call returns [SyncResult.adGateBlocked] without uploading
|
/// and this call returns [SyncResult.adGateBlocked] without uploading
|
||||||
/// anything, having still pulled/merged whatever the remote side had.
|
/// 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({
|
Future<SyncResult> syncNow({
|
||||||
bool keepLocalReceiptCopies = false,
|
bool keepLocalReceiptCopies = false,
|
||||||
Duration staleLockAge = const Duration(minutes: 10),
|
Duration staleLockAge = const Duration(minutes: 10),
|
||||||
Future<bool> Function()? beforeUpload,
|
Future<bool> Function({required bool everSyncedBefore})? beforeUpload,
|
||||||
}) async {
|
}) async {
|
||||||
final appFolderId = _appFolderId;
|
final appFolderId = _appFolderId;
|
||||||
if (!provider.isSignedIn || appFolderId == null) {
|
if (!provider.isSignedIn || appFolderId == null) {
|
||||||
|
|
@ -197,7 +201,8 @@ class CloudSyncService {
|
||||||
await _pullAndMerge(session, remoteInfo.id);
|
await _pullAndMerge(session, remoteInfo.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (beforeUpload != null && !await beforeUpload()) {
|
if (beforeUpload != null &&
|
||||||
|
!await beforeUpload(everSyncedBefore: remoteInfo != null)) {
|
||||||
return SyncResult.adGateBlocked();
|
return SyncResult.adGateBlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +262,16 @@ class CloudSyncService {
|
||||||
if (remoteHasEntitlementTable) {
|
if (remoteHasEntitlementTable) {
|
||||||
await db.execute(mergeAdFreeEntitlementSql);
|
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 {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await db.execute('DETACH DATABASE remote_db');
|
await db.execute('DETACH DATABASE remote_db');
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class DatabaseService {
|
||||||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 5,
|
version: 6,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
|
|
@ -76,6 +76,7 @@ class DatabaseService {
|
||||||
await db.execute(createFuelEntriesTableSql);
|
await db.execute(createFuelEntriesTableSql);
|
||||||
await db.execute(createFuelEntriesIndexSql);
|
await db.execute(createFuelEntriesIndexSql);
|
||||||
await db.execute(createAdFreeEntitlementTableSql);
|
await db.execute(createAdFreeEntitlementTableSql);
|
||||||
|
await db.execute(createAppUsageTableSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
||||||
|
|
@ -94,6 +95,9 @@ class DatabaseService {
|
||||||
if (oldVersion < 5) {
|
if (oldVersion < 5) {
|
||||||
await db.execute(createAdFreeEntitlementTableSql);
|
await db.execute(createAdFreeEntitlementTableSql);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
await db.execute(createAppUsageTableSql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
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
|
/// True if an active (non-deleted) vehicle other than [excludeId] already
|
||||||
/// has this VIN. VIN must stay unique even though it's editable, so
|
/// has this VIN. VIN must stay unique even though it's editable, so
|
||||||
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
/// 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
|
/// Selects fuel entries whose receipt photo still needs uploading to
|
||||||
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
/// 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
|
/// "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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+2
|
version: 1.0.0+4
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
@ -93,8 +93,8 @@ dependencies:
|
||||||
printing: ^5.14.3
|
printing: ^5.14.3
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
# AdMob interstitial shown once per app session, right before the first
|
# AdMob rewarded interstitial shown once per gate window, right before the
|
||||||
# sync's upload-to-cloud phase — see lib/services/ad_service.dart.
|
# first sync's upload-to-cloud phase — see lib/services/ad_service.dart.
|
||||||
google_mobile_ads: ^9.1.0
|
google_mobile_ads: ^9.1.0
|
||||||
|
|
||||||
# One-time "remove ads for a year" purchase — see
|
# One-time "remove ads for a year" purchase — see
|
||||||
|
|
|
||||||
|
|
@ -179,9 +179,11 @@ void main() {
|
||||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||||
syncService.configure('app-folder-id');
|
syncService.configure('app-folder-id');
|
||||||
|
|
||||||
|
var everSyncedBeforeSeen = false;
|
||||||
final result = await syncService.syncNow(
|
final result = await syncService.syncNow(
|
||||||
beforeUpload: () async {
|
beforeUpload: ({required everSyncedBefore}) async {
|
||||||
calls.add('beforeUpload');
|
calls.add('beforeUpload');
|
||||||
|
everSyncedBeforeSeen = everSyncedBefore;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -190,6 +192,8 @@ void main() {
|
||||||
expect(result.adGateBlocked, isFalse);
|
expect(result.adGateBlocked, isFalse);
|
||||||
expect(calls.first, 'download', reason: 'pull/merge happens first');
|
expect(calls.first, 'download', reason: 'pull/merge happens first');
|
||||||
expect(calls[1], 'beforeUpload');
|
expect(calls[1], 'beforeUpload');
|
||||||
|
expect(everSyncedBeforeSeen, isTrue,
|
||||||
|
reason: 'a remote data file already existed for this sync');
|
||||||
// Everything after beforeUpload is an upload (the data-file push,
|
// Everything after beforeUpload is an upload (the data-file push,
|
||||||
// possibly preceded by pending receipt uploads — none pending here).
|
// possibly preceded by pending receipt uploads — none pending here).
|
||||||
expect(calls.skip(2), everyElement(startsWith('upload:')));
|
expect(calls.skip(2), everyElement(startsWith('upload:')));
|
||||||
|
|
@ -204,15 +208,19 @@ void main() {
|
||||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||||
syncService.configure('app-folder-id');
|
syncService.configure('app-folder-id');
|
||||||
|
|
||||||
|
var everSyncedBeforeSeen = true;
|
||||||
await syncService.syncNow(
|
await syncService.syncNow(
|
||||||
beforeUpload: () async {
|
beforeUpload: ({required everSyncedBefore}) async {
|
||||||
calls.add('beforeUpload');
|
calls.add('beforeUpload');
|
||||||
|
everSyncedBeforeSeen = everSyncedBefore;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(calls.first, 'beforeUpload');
|
expect(calls.first, 'beforeUpload');
|
||||||
expect(calls, contains('upload:$dataFileName'));
|
expect(calls, contains('upload:$dataFileName'));
|
||||||
|
expect(everSyncedBeforeSeen, isFalse,
|
||||||
|
reason: 'no remote data file existed — this is the first sync ever');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a false return blocks the upload but keeps whatever was already pulled/merged',
|
test('a false return blocks the upload but keeps whatever was already pulled/merged',
|
||||||
|
|
@ -228,7 +236,8 @@ void main() {
|
||||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||||
syncService.configure('app-folder-id');
|
syncService.configure('app-folder-id');
|
||||||
|
|
||||||
final result = await syncService.syncNow(beforeUpload: () async => false);
|
final result =
|
||||||
|
await syncService.syncNow(beforeUpload: ({required everSyncedBefore}) async => false);
|
||||||
|
|
||||||
expect(result.ranSync, isFalse);
|
expect(result.ranSync, isFalse);
|
||||||
expect(result.adGateBlocked, isTrue);
|
expect(result.adGateBlocked, isTrue);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ void main() {
|
||||||
|
|
||||||
await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200,
|
await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200,
|
||||||
scrollable: find.byType(Scrollable));
|
scrollable: find.byType(Scrollable));
|
||||||
|
await tester.ensureVisible(find.text('See the FAQ for more'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('See the FAQ for more'));
|
await tester.tap(find.text('See the FAQ for more'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue