Ad and purchases enabled (still need actual testing)

This commit is contained in:
Courtney Arnold 2026-08-16 18:27:56 -05:00
parent 2fcc6dcab7
commit faf319322a
35 changed files with 1676 additions and 34 deletions

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/main_shell.dart';
import 'screens/user_agreement_screen.dart';
import 'services/app_state.dart';
import 'theme/app_theme.dart';
@ -67,6 +68,9 @@ class AppRoot extends StatelessWidget {
),
);
}
if (!appState.hasAcceptedUserAgreement) {
return const UserAgreementScreen();
}
return const MainShell();
},
);

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -6,6 +7,7 @@ import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/receipt_parser.dart';
import '../widgets/backup_reminder.dart';
import 'receipt_image_screen.dart';
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
@ -112,7 +114,8 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
setState(() => _saving = true);
try {
await context.read<AppState>().addFuelEntry(
final appState = context.read<AppState>();
await appState.addFuelEntry(
vehicleId: widget.vehicleId,
date: _date,
gallons: double.parse(_gallonsController.text),
@ -120,7 +123,21 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
totalCost: double.parse(_totalController.text),
receiptImage: widget.imageFile,
);
if (mounted) Navigator.of(context).pop();
if (!mounted) return;
// Ask while this screen (and its context) is still fully alive,
// before popping — simpler than trying to show a dialog against a
// context whose widget is mid-removal.
var wantsBackupSetup = false;
if (!appState.hasCloudBackupConfigured) {
wantsBackupSetup = await showBackupReminderDialog(context);
if (!mounted) return;
}
Navigator.of(context).pop();
if (wantsBackupSetup) {
unawaited(openCloudBackupSetup(context));
}
} finally {
if (mounted) setState(() => _saving = false);
}

View file

@ -269,6 +269,11 @@ class _MainShellState extends State<MainShell> {
if (navigator.canPop()) navigator.pop();
_tourPushedDataScreen = false;
}
// Whether the tour finished naturally (on the Settings tab, since the
// last step lives there) or was skipped mid-way through (on whichever
// tab that step happened to be on), always land back on Receipts —
// that's the tab a first-time user should actually start using.
_jumpToTab(0);
unawaited(context.read<AppState>().markOnboardingTourSeen());
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
@ -46,6 +47,49 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ads', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (appState.adsCurrentlyDisabled) ...[
Text(
'Ads are disabled until '
'${DateFormat.yMMMd().format(appState.adFreeUntil!.toLocal())}.',
style: Theme.of(context).textTheme.bodyMedium,
),
] else ...[
Text(
'This app shows a single ad, at most once per app session, right '
'before syncing your data to the cloud.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
if (appState.purchaseError != null) ...[
Text(
appState.purchaseError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 8),
],
FilledButton.icon(
onPressed: () => context.read<AppState>().buyAdFreeYear(),
icon: const Icon(Icons.block),
label: Text(
appState.adFreeYearPriceLabel == null
? 'Remove Ads for a Year'
: 'Remove Ads for a Year — ${appState.adFreeYearPriceLabel}',
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
/// The very first thing shown on a fresh install — before the onboarding
/// tour, before anything else — gating [MainShell] entirely until the user
/// taps "I Agree" (see [AppRoot] in main.dart and
/// [AppState.hasAcceptedUserAgreement]/[AppState.acceptUserAgreement]).
/// Its whole purpose is the liability section below: this app stores data
/// on the user's own device (and, optionally, their own cloud storage
/// account) with no server or backend of ours involved, so we have no
/// ability to recover anything for them if it's lost.
class UserAgreementScreen extends StatelessWidget {
const UserAgreementScreen({super.key});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('User Agreement'), automaticallyImplyLeading: false),
body: SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
'Please read and accept the following before using Show Me The Fuel '
'Refund.',
style: textTheme.bodyLarge,
),
const SizedBox(height: 20),
_Section(
title: 'Your Data Lives on Your Device',
body:
'Every vehicle, fuel receipt, and photo you log is stored locally on '
'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.",
),
_Section(
title: 'We Are Not Responsible for Your Data or Any Data Loss',
body:
'The developer is not responsible for your data, or for any loss, '
'corruption, or unavailability of it, for any reason — including but '
'not limited to device loss, theft, or damage; an uninstall or app/OS '
'update; a bug in this app; or an outage or account issue with a cloud '
'storage provider you connect. Backing up your data (by connecting a '
'cloud storage account, and by keeping it connected and in good '
'standing) is entirely your responsibility. If you never connect a '
'backup, the only copy of your data is the one on this device.',
),
_Section(
title: 'Provided As-Is',
body:
'This app is provided "as is," without warranty of any kind, express or '
'implied. Fuel and gallon values are read automatically from your '
'receipt photos and may be misread — always review them before relying '
'on them, including for a refund claim.',
),
_Section(
title: 'Not Tax or Legal Advice',
body:
"This app helps you organize receipts for Missouri's Motor Fuel Tax "
'Refund program; it does not provide tax or legal advice, and does not '
'guarantee your eligibility for any refund. The program itself is set '
'by Missouri law, which can change at any time — always confirm current '
'eligibility and rates with the Missouri Department of Revenue.',
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => SystemNavigator.pop(),
child: const Text('Decline'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () => context.read<AppState>().acceptUserAgreement(),
child: const Text('I Agree'),
),
),
],
),
),
],
),
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final String body;
const _Section({required this.title, required this.body});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Text(body, style: textTheme.bodyMedium),
],
),
);
}
}

View file

@ -0,0 +1,96 @@
import 'dart:async';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// The single ad placement this app has: one interstitial, shown at most
/// once per app session, right before the *upload-to-cloud* phase of the
/// first sync that reaches it (see [CloudSyncService.syncNow]'s
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not before
/// pulling/merging remote changes down, and never anywhere else in the app.
///
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) is
/// the real one from your AdMob console. [_interstitialAdUnitId] below is
/// TEMPORARILY back on Google's official test interstitial ID — your real
/// one (`ca-app-pub-9212406812117696/9482586380`) was returning no-fill
/// (error code 3), most likely just because it's brand new; this swap is
/// only to confirm the trigger/preload/display mechanism itself works
/// while that warms up. Swap the real one back in once it's serving.
/// ios/Runner/Info.plist's `GADApplicationIdentifier` is still Google's
/// test iOS App ID, though — an AdMob App ID is registered per-platform, so
/// it needs its own real iOS App ID (and a real iOS interstitial ad unit
/// ID here) from the AdMob console if this app ever ships on iOS.
class AdService {
static const _interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
bool _sdkInitialized = false;
bool _shownThisSession = false;
InterstitialAd? _preloadedAd;
Completer<void>? _loadCompleter;
/// Starts the Mobile Ads SDK and begins preloading this session's one
/// interstitial. Idempotent (a no-op after the first call) and safe to
/// call speculatively — [AppState] calls this from [AppState.syncNow]
/// rather than unconditionally at app startup, so a user who never
/// connects cloud sync never triggers any ad-related network activity at
/// all.
Future<void> initialize() async {
if (_sdkInitialized) return;
_sdkInitialized = true;
await MobileAds.instance.initialize();
unawaited(_preload());
}
Future<void> _preload() {
final completer = Completer<void>();
_loadCompleter = completer;
InterstitialAd.load(
adUnitId: _interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
_preloadedAd = ad;
if (!completer.isCompleted) completer.complete();
},
onAdFailedToLoad: (error) {
if (!completer.isCompleted) completer.complete();
},
),
);
return completer.future;
}
/// Shows the preloaded interstitial if this is the first call this app
/// session; every call after that (or if no ad ever became available) is
/// a no-op. Waits for the ad to actually be dismissed before returning,
/// so the caller — the sync engine, right before it starts uploading —
/// genuinely happens *after* the ad, not just alongside it; bounded so a
/// slow ad load or a stuck ad SDK callback can never block sync
/// indefinitely.
Future<void> maybeShowBeforeSync() async {
if (_shownThisSession) return;
_shownThisSession = true;
if (_preloadedAd == null) {
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
}
final ad = _preloadedAd;
_preloadedAd = null;
if (ad == null) return;
final dismissed = Completer<void>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
if (!dismissed.isCompleted) dismissed.complete();
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
if (!dismissed.isCompleted) dismissed.complete();
},
);
await ad.show();
await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {});
}
}

View file

@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'ad_service.dart';
import 'cloud/cloud_storage_provider.dart';
import 'cloud/dropbox_provider.dart';
import 'cloud/google_drive_provider.dart';
@ -16,6 +17,7 @@ import 'cloud/onedrive_provider.dart';
import 'cloud/webdav_provider.dart';
import 'cloud_sync_service.dart';
import 'database_service.dart';
import 'purchase_service.dart';
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
const _prefsKeyCloudFolderId = 'cloud_folder_id';
@ -25,6 +27,7 @@ const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _prefsKeyThemeMode = 'theme_mode';
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@ -54,6 +57,17 @@ class DuplicateVinException implements Exception {
/// handing back updated Dart objects).
class AppState extends ChangeNotifier {
final DatabaseService database = DatabaseService();
final AdService adService = AdService();
/// `late final ... =` (lazy) rather than eagerly constructed like
/// [adService] above, specifically so the field initializer can
/// reference [_grantAdFreeYear]/[_setPurchaseError] as callbacks — by
/// the time anything actually touches this field (including
/// [dispose]), `this` is fully constructed either way.
late final PurchaseService purchaseService = PurchaseService(
onPurchaseGranted: _grantAdFreeYear,
onPurchaseError: _setPurchaseError,
);
/// Every storage backend the user can choose from in Settings.
final List<CloudStorageProvider> availableProviders = [
@ -101,14 +115,52 @@ class AppState extends ChangeNotifier {
/// "never shown before."
bool hasSeenOnboardingTour = true;
/// Whether the user has accepted the user agreement (see
/// lib/screens/user_agreement_screen.dart) — gates every screen in the
/// app, including the onboarding tour, until accepted. Defaults to
/// `true` here for the same widget-test-bypass reason as
/// [hasSeenOnboardingTour] above; [init] overwrites it from the
/// persisted value (defaulting to `false` there) for real app startups.
bool hasAcceptedUserAgreement = true;
/// Set if [init] fails. The UI shows this (with a retry option) instead
/// of spinning forever — an unhandled exception here previously left
/// `isLoading` stuck at true with no feedback at all.
Object? initError;
/// The furthest-known "ads disabled until" date from the "remove ads for
/// a year" purchase — null if never purchased (or the last purchase's
/// year has fully lapsed and nothing newer has been merged in). Synced
/// through the cloud like vehicles/fuel entries — see
/// [DatabaseService.getAdFreeUntil].
DateTime? adFreeUntil;
/// A user-facing message from the most recent failed purchase attempt,
/// for the Settings screen to display — mirrors [lastSyncError]'s role
/// for sync failures.
String? purchaseError;
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
String? get cloudAccountLabel => activeProvider?.accountLabel;
/// True once a provider is connected *and* a backup folder has actually
/// been picked — [isCloudConnected] alone isn't enough, since a signed-in
/// provider with no folder chosen yet still won't back anything up (see
/// [CloudSyncService.isConfigured]). Drives the "your data isn't backed
/// up" reminder shown after saving a fuel entry — see
/// lib/widgets/backup_reminder.dart.
bool get hasCloudBackupConfigured => cloudSync?.isConfigured ?? false;
bool get adsCurrentlyDisabled {
final until = adFreeUntil;
return until != null && DateTime.now().toUtc().isBefore(until);
}
/// 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.
String? get adFreeYearPriceLabel => purchaseService.priceLabel;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> init() async {
@ -130,6 +182,7 @@ class AppState extends ChangeNotifier {
orElse: () => ThemeMode.system,
);
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false;
} catch (e) {
initError = e;
isLoading = false;
@ -147,17 +200,26 @@ class AppState extends ChangeNotifier {
});
unawaited(_restoreCloudConnection());
// Independent of cloud sync (unlike adService, which only ever starts
// once a sync is actually attempted) — a user should be able to buy
// ad-free time whether or not they've ever connected cloud storage, so
// this loads eagerly at startup. Non-blocking: a slow/unavailable
// store connection should never delay the rest of app startup.
unawaited(purchaseService.initialize());
}
@override
void dispose() {
_connectivitySubscription?.cancel();
purchaseService.dispose();
super.dispose();
}
Future<void> _refreshFromDatabase() async {
vehicles = await database.getVehicles();
fuelEntries = await database.getFuelEntries();
adFreeUntil = await database.getAdFreeUntil();
}
CloudStorageProvider? _providerById(CloudProviderId id) {
@ -207,6 +269,8 @@ class AppState extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyActiveProviderId, id.name);
notifyListeners();
await _assumeRootCloudFolder();
}
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
@ -233,8 +297,21 @@ class AppState extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyActiveProviderId, id.name);
notifyListeners();
await _assumeRootCloudFolder();
}
/// Called right after a fresh connect: rather than making the user
/// immediately go pick a folder before anything can sync, assume the
/// root of the provider ("My Files") — matching
/// [CloudFolderBrowserScreen]'s own root label — is where the app's
/// folder belongs, the same way [chooseCloudFolder] would if the user
/// had picked it themselves. If they later pick somewhere else,
/// [CloudSyncService.selectAppFolder] moves this same folder (and
/// everything already synced into it) there instead of abandoning it.
Future<void> _assumeRootCloudFolder() =>
_selectCloudFolder(parentId: 'root', breadcrumbPath: 'My Files');
Future<void> disconnectCloud() async {
final provider = activeProvider;
if (provider == null) return;
@ -256,6 +333,17 @@ class AppState extends ChangeNotifier {
required String parentId,
required String breadcrumbPath,
String? currentFolderName,
}) =>
_selectCloudFolder(
parentId: parentId,
breadcrumbPath: breadcrumbPath,
currentFolderName: currentFolderName,
);
Future<void> _selectCloudFolder({
required String parentId,
required String breadcrumbPath,
String? currentFolderName,
}) async {
final sync = cloudSync;
if (sync == null) return;
@ -276,12 +364,29 @@ class AppState extends ChangeNotifier {
final sync = cloudSync;
if (isSyncing || sync == null || !sync.isConfigured) return;
// A user with a currently-active "remove ads for a year" purchase
// skips the ad entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to do here", same as any other call site
// that never passed one) and the ad SDK isn't even touched this sync.
Future<void> Function()? beforeUpload;
if (!adsCurrentlyDisabled) {
// Idempotent, and only ever reached once sync is actually configured
// — a user who never connects cloud storage never triggers any
// ad-related activity at all. Not awaited: it's fine if this
// session's one ad is still loading by the time beforeUpload below
// is reached — maybeShowBeforeSync degrades gracefully (just skips
// showing it) if it isn't ready in time.
unawaited(adService.initialize());
beforeUpload = adService.maybeShowBeforeSync;
}
isSyncing = true;
notifyListeners();
final result = await sync.syncNow(
keepLocalReceiptCopies: keepReceiptPhotosLocally,
staleLockAge: Duration(minutes: staleLockMinutes),
beforeUpload: beforeUpload,
);
if (result.ranSync) {
@ -317,6 +422,29 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
/// Kicks off the platform purchase UI for a year of no ads — see
/// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted
/// asynchronously, once the store confirms the purchase (see
/// [_grantAdFreeYear]), not immediately when this returns.
Future<void> buyAdFreeYear() => purchaseService.buyAdFreeYear();
/// [PurchaseService]'s `onPurchaseGranted` callback: records a fresh
/// year of ad-free time starting now, regardless of any time already
/// remaining on a previous purchase — buying again before the current
/// year lapses simply resets the clock rather than stacking.
Future<void> _grantAdFreeYear() async {
final now = DateTime.now().toUtc();
await database.setAdFreeUntil(now.add(const Duration(days: 365)), now);
purchaseError = null;
await _persist();
}
/// [PurchaseService]'s `onPurchaseError` callback.
void _setPurchaseError(String message) {
purchaseError = message;
notifyListeners();
}
/// Called once the first-launch guided tour finishes or is skipped, so
/// it never shows again on this device.
Future<void> markOnboardingTourSeen() async {
@ -326,6 +454,15 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
/// Called once the user taps "I Agree" on the user agreement screen, so
/// it never shows again on this device.
Future<void> acceptUserAgreement() async {
hasAcceptedUserAgreement = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyHasAcceptedUserAgreement, true);
notifyListeners();
}
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
/// Settings "Advanced" section, which restricts the picker to that range
/// anyway; this is a defensive backstop for any other caller.

View file

@ -95,6 +95,16 @@ abstract class CloudStorageSession {
/// if none exists. If duplicates exist, the earliest-created one wins.
Future<String> findOrCreateFolder({required String parentId, required String name});
/// Relocates the existing folder [folderId] to be a child of
/// [newParentId] instead of wherever it currently lives, preserving its
/// contents and (name/subtree) intact. Used when the user picks a
/// different location for the app's already-existing folder (see
/// [CloudSyncService.selectAppFolder]), so its synced data and receipts
/// move with it instead of being abandoned in place while a fresh, empty
/// folder is created at the new location. Returns the folder's
/// (possibly new, for a path-addressed provider) id.
Future<String> moveFolder({required String folderId, required String newParentId});
/// Looks up a file's ID + versionTag by name within [folderId] without
/// downloading its content, or null if no such file exists yet.
Future<CloudFileInfo?> findFile({required String folderId, required String name});

View file

@ -244,6 +244,17 @@ class DropboxSession implements CloudStorageSession {
return childPath;
}
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async {
final name = folderId.substring(folderId.lastIndexOf('/') + 1);
final toPath = _childPath(newParentId, name);
if (toPath == folderId) return folderId;
final json = await _post('/2/files/move_v2', {'from_path': folderId, 'to_path': toPath});
final metadata = json['metadata'] as Map<String, dynamic>?;
return metadata?['path_display'] as String? ?? toPath;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final filePath = _childPath(folderId, name);

View file

@ -157,6 +157,20 @@ class GoogleDriveSession implements CloudStorageSession {
return created.id!;
}
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async {
final current = await _api.files.get(folderId, $fields: 'parents') as drive.File;
final oldParents = (current.parents ?? []).join(',');
final updated = await _api.files.update(
drive.File(),
folderId,
addParents: newParentId,
removeParents: oldParents.isEmpty ? null : oldParents,
$fields: 'id',
);
return updated.id ?? folderId;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final result = await _api.files.list(

View file

@ -245,6 +245,22 @@ class OneDriveSession implements CloudStorageSession {
return created['id'] as String;
}
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async {
final response = await http.patch(
_graph('/me/drive/${_itemSegment(folderId)}'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({
'parentReference': {'id': newParentId},
}),
);
if (response.statusCode != 200) {
throw StateError('OneDrive API error (moveFolder): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['id'] as String? ?? folderId;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');

View file

@ -282,6 +282,22 @@ class WebDavSession implements CloudStorageSession {
return childUri.path;
}
@override
Future<String> moveFolder({required String folderId, required String newParentId}) async {
final name = _nameFromPath(folderId);
final destination = _childUri(_uriFor(newParentId), name);
final response = await _send('MOVE', _uriFor(folderId), headers: {
'Authorization': _provider._authHeader,
'Destination': destination.toString(),
'Overwrite': 'F',
});
if (response.statusCode != 201 && response.statusCode != 204) {
throw StateError('WebDAV MOVE failed: ${response.statusCode} ${response.body}');
}
return destination.path;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final fileUri = _childUri(_uriFor(folderId), name);

View file

@ -14,7 +14,7 @@ class CloudOAuthConfig {
/// The Web application OAuth client ID. Required for sign-in to work on
/// Android.
static const String? googleAndroidServerClientId =
'871282023812-v230gah5qimen6kaa2mh1usl8l5otnio.apps.googleusercontent.com';
'482287068996-rea2e3fcbqeb0dq3ogh40ro8e1fm2m5j.apps.googleusercontent.com';
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
/// directly in ios/Runner/Info.plist.
@ -25,7 +25,7 @@ class CloudOAuthConfig {
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
static const String? dropboxAppKey = 'x79aoq4zhhmu0a0';
static const String? dropboxAppKey = 'tmrok3snmkqpu1m';
/// Custom URL scheme redirect registered in the Dropbox app console.
/// The scheme "mofueltaxback-dropbox" is already wired up in
@ -42,7 +42,7 @@ class CloudOAuthConfig {
// application" platform.
/// The Application (client) ID from the Azure app registration.
static const String? oneDriveClientId = '0574b661-1b12-44d8-86f0-ee59361fad1c';
static const String? oneDriveClientId = '87ef0bae-89ff-4359-94df-64de333cfc08';
/// Custom URL scheme redirect registered in Azure. The scheme
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /

View file

@ -70,9 +70,11 @@ class CloudSyncService {
_lastKnownRemoteVersionTag = null;
}
/// Finds-or-creates the `Show Me The Fuel Refund` folder under [parentId]
/// (a folder the user picked in the folder browser) and configures this
/// service to use it. Returns the resulting folder ID.
/// Points this service at the `Show Me The Fuel Refund` folder under
/// [parentId] (a folder the user picked in the folder browser, or —
/// see [AppState.connectProvider] — the assumed root location a fresh
/// connection defaults to) and configures this service to use it.
/// Returns the resulting folder ID.
///
/// If the user picked a folder that's *already* named
/// `Show Me The Fuel Refund` — [currentFolderName] carries that name up
@ -82,12 +84,45 @@ class CloudSyncService {
/// setup, but re-picking that same folder later (e.g. after reinstalling)
/// would double it up as `/Show Me The Fuel Refund/Show Me The Fuel
/// Refund`.
///
/// Otherwise, three cases:
/// - A same-named folder already exists directly under [parentId] (e.g.
/// one someone else already set up there to share) — adopt it as-is,
/// the same convergence behavior as before, so two people pointed at
/// the same shared parent end up sharing one app folder either way.
/// - This service already has an app folder configured somewhere else
/// (from a previous [selectAppFolder] call, or the root default a
/// fresh connection starts with) and [parentId] has no folder of its
/// own yet — *move* the existing one there via
/// [CloudStorageSession.moveFolder], preserving its contents, rather
/// than creating a fresh empty folder and abandoning the old one with
/// all its synced data still in it.
/// - Neither of the above (nothing configured yet, nothing at the
/// destination) — create a fresh one, same as always.
Future<String> selectAppFolder(String parentId, {String? currentFolderName}) async {
final session = provider.beginSession();
try {
final folderId = currentFolderName == appFolderName
? parentId
: await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
if (currentFolderName == appFolderName) {
configure(parentId);
return parentId;
}
CloudFolder? existingAtDestination;
for (final folder in await session.listFolders(parentId: parentId)) {
if (folder.name == appFolderName) {
existingAtDestination = folder;
break;
}
}
final String folderId;
if (existingAtDestination != null) {
folderId = existingAtDestination.id;
} else if (_appFolderId != null) {
folderId = await session.moveFolder(folderId: _appFolderId!, newParentId: parentId);
} else {
folderId = await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
}
configure(folderId);
return folderId;
} finally {
@ -106,9 +141,17 @@ class CloudSyncService {
/// treats it as abandoned (e.g. that device crashed or went offline
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
///
/// [beforeUpload], if given, is awaited once — after any pull/merge of
/// remote changes has finished, but before anything local gets uploaded
/// (pending receipt photos or the database snapshot itself). This is the
/// one hook [AppState] uses to show the app's single ad placement, since
/// it's meant to run before *pushing* local data up, not before *pulling*
/// remote data down.
Future<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
Future<void> Function()? beforeUpload,
}) async {
final appFolderId = _appFolderId;
if (!provider.isSignedIn || appFolderId == null) {
@ -137,6 +180,10 @@ class CloudSyncService {
await _pullAndMerge(session, remoteInfo.id);
}
if (beforeUpload != null) {
await beforeUpload();
}
final allReceiptsUploaded =
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
@ -178,6 +225,21 @@ class CloudSyncService {
try {
await db.execute(mergeVehiclesSql);
await db.execute(mergeFuelEntriesSql);
// ad_free_entitlement is a newer table than vehicles/fuel_entries —
// unlike those two (present since the very first schema version, so
// any remote snapshot ever pushed already has them), a remote
// snapshot pushed before this table existed genuinely won't have
// it. Merging against it unconditionally would throw ("no such
// table") on exactly that snapshot; skipping when absent just means
// "that older snapshot has no entitlement info to contribute",
// which is correct — local's own value (if any) is left as-is.
final remoteHasEntitlementTable = (await db.rawQuery(
"SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'ad_free_entitlement'",
)).isNotEmpty;
if (remoteHasEntitlementTable) {
await db.execute(mergeAdFreeEntitlementSql);
}
} finally {
try {
await db.execute('DETACH DATABASE remote_db');

View file

@ -65,7 +65,7 @@ class DatabaseService {
final dbPath = p.join(_rootDirectory.path, dbFileName);
_db = await openDatabase(
dbPath,
version: 4,
version: 5,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@ -75,6 +75,7 @@ class DatabaseService {
await db.execute(createVehiclesTableSql);
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
await db.execute(createAdFreeEntitlementTableSql);
}
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
@ -90,6 +91,9 @@ class DatabaseService {
if (oldVersion < 4) {
await _migrateToVehicleIdSchema(db);
}
if (oldVersion < 5) {
await db.execute(createAdFreeEntitlementTableSql);
}
}
Future<void> _migrateToVehicleIdSchema(Database db) async {
@ -157,6 +161,33 @@ class DatabaseService {
return rows.map(FuelEntry.fromMap).toList();
}
/// The furthest-known "ads disabled until" date — null if no purchase
/// has ever been recorded (locally or merged in from another synced
/// device). See [createAdFreeEntitlementTableSql] for why this is its
/// own small synced table rather than a local-only preference.
Future<DateTime?> getAdFreeUntil() async {
final rows = await _db.query('ad_free_entitlement', where: 'id = 1', limit: 1);
if (rows.isEmpty) return null;
final millis = rows.single['ad_free_until'] as int?;
return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
}
/// Records a purchase's grant (or extension) of ad-free time. [until] is
/// intentionally not compared against any existing value here — the
/// caller (a fresh purchase) always means "now plus a year", which is
/// always further out than whatever was there before.
Future<void> setAdFreeUntil(DateTime until, DateTime updatedAt) async {
await _db.insert(
'ad_free_entitlement',
{
'id': 1,
'ad_free_until': until.toUtc().millisecondsSinceEpoch,
'updated_at': updatedAt.toUtc().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// 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

View file

@ -28,6 +28,26 @@ const createFuelEntriesTableSql = '''
const createFuelEntriesIndexSql =
'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)';
/// A single-row table (`id` is always `1`) holding the furthest-known
/// "ads disabled until" date from the one-time "remove ads for a year"
/// purchase — see `PurchaseService`/`AppState.adsCurrentlyDisabled`.
///
/// This exists as its own synced table (rather than, say, a
/// SharedPreferences value) specifically so it survives an app
/// reinstall: the purchase itself is a *consumable* Play Store product
/// (needed so it can be bought again once the year lapses), and Play
/// Store has no memory of a consumed purchase to "restore" — this table,
/// merged through the same cloud sync as vehicles/fuel_entries, is what
/// lets a user with cloud sync configured get their remaining ad-free
/// time back after reinstalling, instead of losing it.
const createAdFreeEntitlementTableSql = '''
CREATE TABLE ad_free_entitlement (
id INTEGER PRIMARY KEY CHECK (id = 1),
ad_free_until INTEGER,
updated_at INTEGER NOT NULL
)
''';
/// 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
@ -75,3 +95,18 @@ const mergeFuelEntriesSql = '''
LEFT JOIN main.fuel_entries l ON l.id = r.id
WHERE l.id IS NULL OR r.updated_at > l.updated_at
''';
/// Same "newest `updated_at` wins" rule as [mergeVehiclesSql], applied to
/// the single `id = 1` entitlement row instead of many rows — whichever
/// device most recently purchased (or already had) a further-out
/// `ad_free_until` date wins, so this can never make an entitlement
/// *shorter* by merging in a stale copy from a device that hasn't synced
/// in a while.
const mergeAdFreeEntitlementSql = '''
INSERT OR REPLACE INTO main.ad_free_entitlement
(id, ad_free_until, updated_at)
SELECT r.id, r.ad_free_until, r.updated_at
FROM remote_db.ad_free_entitlement r
LEFT JOIN main.ad_free_entitlement l ON l.id = r.id
WHERE l.id IS NULL OR r.updated_at > l.updated_at
''';

View file

@ -0,0 +1,112 @@
import 'dart:async';
import 'dart:io';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
/// Wraps the app's one purchasable product: a *consumable* one-time
/// purchase that grants a year of no ads (see
/// [AppState.adsCurrentlyDisabled]) — consumable specifically so it can be
/// bought again once that year lapses, unlike a plain non-consumable
/// (which Play Store would only ever let you own once, permanently) or an
/// auto-renewing subscription (which would charge the user again every
/// year without them actively choosing to).
///
/// [adFreeYearProductId] is a placeholder — nothing will actually load or
/// be purchasable until a real in-app product with this same ID exists in
/// your Google Play Console (Monetize > Products > In-app products),
/// configured as a *managed product*, with whatever price you choose
/// there (Play Billing doesn't take a price from the app itself). Rename
/// the constant below to match whatever product ID you actually create,
/// if you'd rather not use this one.
class PurchaseService {
static const adFreeYearProductId = 'ad_free_year';
/// Called once for every successful (or restored) purchase of
/// [adFreeYearProductId] — [AppState] is what actually records the
/// resulting entitlement; this service only reports that a purchase
/// happened.
final void Function() onPurchaseGranted;
/// Called with a user-facing message when a purchase attempt fails —
/// [AppState] surfaces this via [AppState.purchaseError] for the
/// Settings screen to display.
final void Function(String message)? onPurchaseError;
PurchaseService({required this.onPurchaseGranted, this.onPurchaseError});
StreamSubscription<List<PurchaseDetails>>? _subscription;
ProductDetails? _product;
/// The store's own formatted, localized price string (e.g. `"$4.99"`)
/// once [initialize] has loaded the product — null before that, or if
/// the product ID above doesn't match anything configured in the store
/// yet.
String? get priceLabel => _product?.price;
Future<void> initialize() async {
final available = await InAppPurchase.instance.isAvailable();
if (!available) return;
_subscription = InAppPurchase.instance.purchaseStream.listen(
_handlePurchaseUpdates,
onDone: () => _subscription?.cancel(),
onError: (_) {},
);
final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId});
if (response.productDetails.isNotEmpty) {
_product = response.productDetails.first;
}
}
/// Kicks off the platform purchase UI. Does nothing (and reports an
/// error) if the product hasn't loaded — either the store isn't
/// available, or [adFreeYearProductId] doesn't match a real product yet.
Future<void> buyAdFreeYear() async {
final product = _product;
if (product == null) {
onPurchaseError?.call("This purchase isn't available right now.");
return;
}
await InAppPurchase.instance.buyConsumable(
purchaseParam: PurchaseParam(productDetails: product),
);
}
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
for (final purchase in purchases) {
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
if (purchase.productID == adFreeYearProductId) {
onPurchaseGranted();
}
// Android specifically: a *consumable* purchase has to be
// explicitly "consumed" or Play Billing considers it still owned
// and refuses to sell it again next year. iOS has no equivalent
// step — StoreKit consumables are inherently one-shot already.
if (Platform.isAndroid) {
final androidAddition =
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
await androidAddition.consumePurchase(purchase);
}
if (purchase.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchase);
}
case PurchaseStatus.error:
onPurchaseError?.call(purchase.error?.message ?? 'The purchase failed.');
if (purchase.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchase);
}
case PurchaseStatus.pending:
case PurchaseStatus.canceled:
break;
}
}
}
void dispose() {
_subscription?.cancel();
}
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../screens/data_settings_screen.dart';
import '../services/onboarding_keys.dart';
import 'onboarding_tour_overlay.dart';
/// Shown right after a fuel entry is saved (see
/// `ConfirmFuelEntryScreen._save`) when no cloud backup is configured yet —
/// nudges the user toward Settings > Data before they forget and eventually
/// lose everything to a lost, damaged, or replaced device. Returns true if
/// they chose to set one up now.
Future<bool> showBackupReminderDialog(BuildContext context) async {
final wantsSetup = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Your Data Isn't Backed Up"),
content: const Text(
"Until you enable a backup method, everything you log — including this "
"receipt — only lives on this device. If it's lost, damaged, or "
"replaced, that data could be lost with it.\n\n"
'Would you like to set up a backup method now?',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Not Now')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Set Up Backup')),
],
),
);
return wantsSetup ?? false;
}
/// Pushes Settings > Data with its cloud storage card spotlighted — the
/// same visual treatment the first-launch tour's "Back Up to the Cloud"
/// step uses (see [OnboardingTourOverlay] / `MainShell`), but as a one-off
/// standalone overlay rather than part of a multi-step tour.
Future<void> openCloudBackupSetup(BuildContext context) {
return Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const _SpotlightedDataSettingsScreen()),
);
}
class _SpotlightedDataSettingsScreen extends StatefulWidget {
const _SpotlightedDataSettingsScreen();
@override
State<_SpotlightedDataSettingsScreen> createState() => _SpotlightedDataSettingsScreenState();
}
class _SpotlightedDataSettingsScreenState extends State<_SpotlightedDataSettingsScreen> {
OverlayEntry? _entry;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _showSpotlight());
}
@override
void dispose() {
_entry?.remove();
super.dispose();
}
/// Mirrors `MainShell._waitForTargets`/`_showTourStep` for this one
/// target — duplicated rather than shared because that logic is
/// entangled with the tour's multi-step sequencing, which doesn't apply
/// to this standalone spotlight.
Future<void> _showSpotlight() async {
final key = OnboardingKeys.cloudStorageCard;
for (var attempt = 0; attempt < 30; attempt++) {
if (!mounted) return;
final renderObject = key.currentContext?.findRenderObject();
if (renderObject is RenderBox && renderObject.hasSize) break;
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
key.currentContext?.findRenderObject()?.showOnScreen(duration: Duration.zero);
for (var i = 0; i < 5; i++) {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
_entry = OverlayEntry(
builder: (_) => OnboardingTourOverlay(
targetKeys: [key],
title: 'Back Up to the Cloud',
description: 'Connect a cloud storage account here to back up every vehicle, receipt, '
"and photo you log — so nothing is lost if this device is lost, damaged, or "
'replaced.',
stepNumber: 1,
totalSteps: 1,
skipLabel: 'Close',
onNext: _dismiss,
onSkip: _dismiss,
),
);
Overlay.of(context, rootOverlay: true).insert(_entry!);
}
void _dismiss() {
_entry?.remove();
_entry = null;
}
@override
Widget build(BuildContext context) => const DataSettingsScreen();
}

View file

@ -24,6 +24,12 @@ class OnboardingTourOverlay extends StatelessWidget {
final VoidCallback onNext;
final VoidCallback onSkip;
/// Label for the [onSkip] button — defaults to the multi-step tour's own
/// wording. A standalone, single-step spotlight (e.g. the "back up your
/// data" reminder in lib/widgets/backup_reminder.dart) isn't a "tour", so
/// it passes something more fitting like 'Close' instead.
final String skipLabel;
const OnboardingTourOverlay({
super.key,
required this.targetKeys,
@ -33,6 +39,7 @@ class OnboardingTourOverlay extends StatelessWidget {
required this.totalSteps,
required this.onNext,
required this.onSkip,
this.skipLabel = 'Skip Tour',
});
List<Rect> _targetRects() {
@ -126,14 +133,16 @@ class OnboardingTourOverlay extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Step $stepNumber of $totalSteps',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
if (totalSteps > 1) ...[
Text(
'Step $stepNumber of $totalSteps',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
],
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(description, style: Theme.of(context).textTheme.bodyMedium),
@ -141,7 +150,7 @@ class OnboardingTourOverlay extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(onPressed: onSkip, child: const Text('Skip Tour')),
TextButton(onPressed: onSkip, child: Text(skipLabel)),
FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')),
],
),