diff --git a/.gitignore b/.gitignore index 3820a95..930bae3 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Release signing credentials — never commit. The keystore itself lives +# outside the repo entirely (~/.android/), but the passwords in here are +# sensitive regardless. +/android/key.properties +*.jks +*.keystore diff --git a/README.md b/README.md index fce924e..0c409cd 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ In [Google Cloud Console](https://console.cloud.google.com/): 3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen (shows as "restricted/sensitive" — expected, fine in Testing mode). 4. **Credentials → Create Credentials → OAuth client ID**, three times: - - **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the + - **Android**: package name `com.ohbrer.show_me_the_fuel_refund` + the SHA-1 of your debug keystore (`keytool -list -v -keystore ~/.android/debug.keystore`, password `android`), and later your release keystore's SHA-1 too. This client ID itself is never referenced in diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 267a318..373db13 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,24 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing credentials — see key.properties (gitignored; the +// keystore file itself lives outside the repo, at the path it points to). +// Missing entirely just means a debug-signed release build, same as +// before, so this doesn't break local dev if key.properties isn't set up. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { - namespace = "com.courtneyarnold.fuel_tax_tracker" + namespace = "com.ohbrer.show_me_the_fuel_refund" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,8 +28,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.courtneyarnold.fuel_tax_tracker" + applicationId = "com.ohbrer.show_me_the_fuel_refund" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -25,11 +37,31 @@ android { versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Real release signing once key.properties exists (see above); + // falls back to the debug key otherwise, so `flutter run + // --release` still works for local testing without it. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..d7cc735 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# google_mlkit_text_recognition's plugin code references all of ML Kit's +# regional script recognizers (Chinese/Devanagari/Japanese/Korean) +# generically, even though this app only depends on (and only ever uses) +# the default Latin recognizer. R8 can't resolve the others since their +# artifacts genuinely aren't on the classpath — safe to silence, they're +# never called at runtime here. +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c63541b..41c4c13 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,15 @@ + + UIStatusBarHidden + + GADApplicationIdentifier + ca-app-pub-3940256099942544~1458002511 diff --git a/lib/main.dart b/lib/main.dart index f1afe70..384e789 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); }, ); diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart index a1290bf..d421438 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -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 { setState(() => _saving = true); try { - await context.read().addFuelEntry( + final appState = context.read(); + await appState.addFuelEntry( vehicleId: widget.vehicleId, date: _date, gallons: double.parse(_gallonsController.text), @@ -120,7 +123,21 @@ class _ConfirmFuelEntryScreenState extends State { 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); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index be381c7..18dc9e3 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -269,6 +269,11 @@ class _MainShellState extends State { 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().markOnboardingTourSeen()); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6b86714..0229d81 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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().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), diff --git a/lib/screens/user_agreement_screen.dart b/lib/screens/user_agreement_screen.dart new file mode 100644 index 0000000..0266499 --- /dev/null +++ b/lib/screens/user_agreement_screen.dart @@ -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().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), + ], + ), + ); + } +} diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart new file mode 100644 index 0000000..f2e7150 --- /dev/null +++ b/lib/services/ad_service.dart @@ -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? _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 initialize() async { + if (_sdkInitialized) return; + _sdkInitialized = true; + await MobileAds.instance.initialize(); + unawaited(_preload()); + } + + Future _preload() { + final completer = Completer(); + _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 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(); + 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: () {}); + } +} diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index bd6ff94..de36d2a 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -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 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>? _connectivitySubscription; Future 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 _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 _assumeRootCloudFolder() => + _selectCloudFolder(parentId: 'root', breadcrumbPath: 'My Files'); + Future 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 _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 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 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 _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 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 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. diff --git a/lib/services/cloud/cloud_storage_provider.dart b/lib/services/cloud/cloud_storage_provider.dart index 358412b..4b17d13 100644 --- a/lib/services/cloud/cloud_storage_provider.dart +++ b/lib/services/cloud/cloud_storage_provider.dart @@ -95,6 +95,16 @@ abstract class CloudStorageSession { /// if none exists. If duplicates exist, the earliest-created one wins. Future 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 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 findFile({required String folderId, required String name}); diff --git a/lib/services/cloud/dropbox_provider.dart b/lib/services/cloud/dropbox_provider.dart index 48fbfb5..9282862 100644 --- a/lib/services/cloud/dropbox_provider.dart +++ b/lib/services/cloud/dropbox_provider.dart @@ -244,6 +244,17 @@ class DropboxSession implements CloudStorageSession { return childPath; } + @override + Future 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?; + return metadata?['path_display'] as String? ?? toPath; + } + @override Future findFile({required String folderId, required String name}) async { final filePath = _childPath(folderId, name); diff --git a/lib/services/cloud/google_drive_provider.dart b/lib/services/cloud/google_drive_provider.dart index 98825bc..81f3634 100644 --- a/lib/services/cloud/google_drive_provider.dart +++ b/lib/services/cloud/google_drive_provider.dart @@ -157,6 +157,20 @@ class GoogleDriveSession implements CloudStorageSession { return created.id!; } + @override + Future 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 findFile({required String folderId, required String name}) async { final result = await _api.files.list( diff --git a/lib/services/cloud/onedrive_provider.dart b/lib/services/cloud/onedrive_provider.dart index 18c8259..583d401 100644 --- a/lib/services/cloud/onedrive_provider.dart +++ b/lib/services/cloud/onedrive_provider.dart @@ -245,6 +245,22 @@ class OneDriveSession implements CloudStorageSession { return created['id'] as String; } + @override + Future 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; + return json['id'] as String? ?? folderId; + } + @override Future findFile({required String folderId, required String name}) async { final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name'); diff --git a/lib/services/cloud/webdav_provider.dart b/lib/services/cloud/webdav_provider.dart index 1fd91f8..4df9e71 100644 --- a/lib/services/cloud/webdav_provider.dart +++ b/lib/services/cloud/webdav_provider.dart @@ -282,6 +282,22 @@ class WebDavSession implements CloudStorageSession { return childUri.path; } + @override + Future 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 findFile({required String folderId, required String name}) async { final fileUri = _childUri(_uriFor(folderId), name); diff --git a/lib/services/cloud_oauth_config.dart b/lib/services/cloud_oauth_config.dart index f85e0a9..2f426f8 100644 --- a/lib/services/cloud_oauth_config.dart +++ b/lib/services/cloud_oauth_config.dart @@ -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 / diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart index 535af37..c0d00a3 100644 --- a/lib/services/cloud_sync_service.dart +++ b/lib/services/cloud_sync_service.dart @@ -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 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 syncNow({ bool keepLocalReceiptCopies = false, Duration staleLockAge = const Duration(minutes: 10), + Future 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'); diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index dfcac2c..67755be 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -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 _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 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 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 diff --git a/lib/services/db_schema.dart b/lib/services/db_schema.dart index 1082cf3..05d7da3 100644 --- a/lib/services/db_schema.dart +++ b/lib/services/db_schema.dart @@ -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 +'''; diff --git a/lib/services/purchase_service.dart b/lib/services/purchase_service.dart new file mode 100644 index 0000000..f7dae5f --- /dev/null +++ b/lib/services/purchase_service.dart @@ -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>? _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 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 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 _handlePurchaseUpdates(List 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(); + 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(); + } +} diff --git a/lib/widgets/backup_reminder.dart b/lib/widgets/backup_reminder.dart new file mode 100644 index 0000000..6a5d235 --- /dev/null +++ b/lib/widgets/backup_reminder.dart @@ -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 showBackupReminderDialog(BuildContext context) async { + final wantsSetup = await showDialog( + 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 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 _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(); +} diff --git a/lib/widgets/onboarding_tour_overlay.dart b/lib/widgets/onboarding_tour_overlay.dart index 2af2bd7..067824f 100644 --- a/lib/widgets/onboarding_tour_overlay.dart +++ b/lib/widgets/onboarding_tour_overlay.dart @@ -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 _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')), ], ), diff --git a/pubspec.lock b/pubspec.lock index ff92767..4dce795 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -392,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.1" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: "8094e1ace8b0da33fe79027ca6959763c96a28855025cb7c8ec60838e1d56ed8" + url: "https://pub.dev" + source: hosted + version: "9.1.0" google_sign_in: dependency: "direct main" description: @@ -544,6 +552,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: "direct main" + description: + name: in_app_purchase_android + sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "9602e249a0e30351f047d5715957f27709ed7b42f631fba8941dcad51489932a" + url: "https://pub.dev" + source: hosted + version: "0.4.11+1" intl: dependency: "direct main" description: @@ -1133,6 +1173,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d + url: "https://pub.dev" + source: hosted + version: "4.14.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8941af5..952b866 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,6 +93,15 @@ dependencies: printing: ^5.14.3 url_launcher: ^6.3.2 + # AdMob interstitial shown once per app session, right before the first + # sync's upload-to-cloud phase — see lib/services/ad_service.dart. + google_mobile_ads: ^9.1.0 + + # One-time "remove ads for a year" purchase — see + # lib/services/purchase_service.dart. + in_app_purchase: ^3.3.0 + in_app_purchase_android: ^0.5.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/ad_free_entitlement_test.dart b/test/ad_free_entitlement_test.dart new file mode 100644 index 0000000..0ce40e4 --- /dev/null +++ b/test/ad_free_entitlement_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Covers the "pay to remove ads for a year" entitlement outside of the +/// merge SQL itself (see db_merge_test.dart for that): [DatabaseService]'s +/// storage round-trip, and [AppState.adsCurrentlyDisabled]'s pure +/// expiry-comparison logic. +void main() { + group('DatabaseService ad-free entitlement storage', () { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('ad_free_entitlement_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('getAdFreeUntil is null before any purchase is recorded', () async { + expect(await database.getAdFreeUntil(), isNull); + }); + + test('setAdFreeUntil then getAdFreeUntil round-trips the value', () async { + final until = DateTime.utc(2027, 8, 13); + final now = DateTime.utc(2026, 8, 13); + + await database.setAdFreeUntil(until, now); + + expect(await database.getAdFreeUntil(), until); + }); + + test('setAdFreeUntil overwrites a previous value rather than adding a row', () async { + await database.setAdFreeUntil(DateTime.utc(2027, 1, 1), DateTime.utc(2026, 1, 1)); + await database.setAdFreeUntil(DateTime.utc(2028, 1, 1), DateTime.utc(2027, 1, 1)); + + final rows = await database.rawDb.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(await database.getAdFreeUntil(), DateTime.utc(2028, 1, 1)); + }); + }); + + group('AppState.adsCurrentlyDisabled', () { + test('false when no entitlement has ever been recorded', () { + final appState = AppState(); + expect(appState.adsCurrentlyDisabled, isFalse); + }); + + test('true when the entitlement date is in the future', () { + final appState = AppState(); + appState.adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 30)); + expect(appState.adsCurrentlyDisabled, isTrue); + }); + + test('false once the entitlement date is in the past (the year lapsed)', () { + final appState = AppState(); + appState.adFreeUntil = DateTime.now().toUtc().subtract(const Duration(days: 1)); + expect(appState.adsCurrentlyDisabled, isFalse); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/backup_reminder_test.dart b/test/backup_reminder_test.dart new file mode 100644 index 0000000..420dd0c --- /dev/null +++ b/test/backup_reminder_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; +import 'package:fuel_tax_tracker/services/cloud_sync_service.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; +import 'package:fuel_tax_tracker/widgets/backup_reminder.dart'; + +/// Covers the "your data isn't backed up" nudge shown after saving a fuel +/// entry with no cloud backup configured — see +/// `ConfirmFuelEntryScreen._save`. The dialog and the spotlight it can lead +/// to (`showBackupReminderDialog`/`openCloudBackupSetup`) are tested here +/// as standalone functions rather than through a full save, matching +/// settings_purge_test.dart's convention of not exercising actions that +/// need an initialized database. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('showBackupReminderDialog', () { + Future openDialog(WidgetTester tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return result; + } + + testWidgets('shows the warning with both actions', (tester) async { + await openDialog(tester); + + expect(find.text("Your Data Isn't Backed Up"), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + expect(find.text('Set Up Backup'), findsOneWidget); + }); + + testWidgets('Not Now resolves false and closes the dialog', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Not Now')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(find.text("Your Data Isn't Backed Up"), findsNothing); + }); + + testWidgets('Set Up Backup resolves true and closes the dialog', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Set Up Backup')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(find.text("Your Data Isn't Backed Up"), findsNothing); + }); + }); + + group('openCloudBackupSetup', () { + testWidgets('pushes Settings > Data with the cloud storage card spotlighted', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => openCloudBackupSetup(context), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsOneWidget); + expect(find.text('Back Up to the Cloud'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget); + }); + + testWidgets('dismissing the spotlight leaves the Data screen up', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => openCloudBackupSetup(context), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Got It')); + await tester.pumpAndSettle(); + + expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'spotlight dismissed'); + expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'still on the Data screen'); + }); + }); + + group('AppState.hasCloudBackupConfigured', () { + test('false with no cloud provider connected at all', () { + final appState = AppState(); + expect(appState.hasCloudBackupConfigured, isFalse); + }); + + test('false when a provider is connected but no folder has been chosen', () { + final appState = AppState() + ..cloudSync = CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService()); + + expect(appState.hasCloudBackupConfigured, isFalse); + }); + + test('true once a backup folder has been configured', () { + final appState = AppState() + ..cloudSync = + (CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService()) + ..configure('folder-id')); + + expect(appState.hasCloudBackupConfigured, isTrue); + }); + }); +} + +class _FakeProvider implements CloudStorageProvider { + @override + CloudProviderId get id => CloudProviderId.googleDrive; + + @override + String get displayName => 'Fake Provider'; + + @override + bool get isSignedIn => true; + + @override + String? get accountLabel => 'tester@example.com'; + + @override + Future attemptSilentSignIn() async => true; + + @override + Future signIn() async => accountLabel!; + + @override + Future signOut() async {} + + @override + CloudStorageSession beginSession() => throw UnimplementedError(); +} diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart index 928e393..6359c8a 100644 --- a/test/cloud_sync_service_test.dart +++ b/test/cloud_sync_service_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; import 'package:fuel_tax_tracker/services/cloud_sync_service.dart'; import 'package:fuel_tax_tracker/services/database_service.dart'; +import 'package:fuel_tax_tracker/services/db_schema.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -114,6 +115,41 @@ void main() { reason: 'should not nest another app folder inside a folder already named that'); }); + test( + 'selectAppFolder moves an already-configured app folder to a new parent instead of ' + 'creating a fresh one there', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + // Establishes an app folder under the first parent — e.g. the root + // default a fresh connection assumes (see AppState.connectProvider) — + // before the user picks somewhere else. + await syncService.selectAppFolder('root'); + expect(syncService.isConfigured, isTrue); + + final newFolderId = await syncService.selectAppFolder('new-parent-id'); + + expect(session.moveFolderCalls, ['root/$appFolderName->new-parent-id']); + expect(newFolderId, 'new-parent-id/moved'); + expect(session.findOrCreateFolderCalls, ['root/$appFolderName'], + reason: 'only the very first (root) selection created a folder; the second moved it'); + }); + + test( + 'selectAppFolder adopts a same-named folder already at the destination instead of ' + 'moving onto/alongside it', () async { + final session = _FakeSession() + ..foldersByParent['new-parent-id'] = [CloudFolder(id: 'shared-folder-id', name: appFolderName)]; + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + await syncService.selectAppFolder('root'); + + final folderId = await syncService.selectAppFolder('new-parent-id'); + + expect(folderId, 'shared-folder-id'); + expect(session.moveFolderCalls, isEmpty, reason: 'adopted the existing one, did not move onto it'); + }); + test('syncNow is a no-op when not configured with a folder yet', () async { final session = _FakeSession(); final provider = _FakeProvider(session); @@ -124,6 +160,89 @@ void main() { expect(result.ranSync, isFalse); expect(session.createdLockNames, isEmpty); }); + + group('beforeUpload hook', () { + test('is awaited after pulling/merging remote changes but before anything is uploaded', + () async { + final calls = []; + // A real copy of the (correctly schema'd, currently empty) local + // database — CloudSyncService ATTACHes and merges against whatever + // downloadFileBytes returns, so it has to be an actual valid SQLite + // database, not arbitrary bytes. + final remoteDbBytes = await File(databaseService.databasePath).readAsBytes(); + final session = _FakeSession(); + session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1'); + session.remoteDbBytes = remoteDbBytes; + session.onDownload = () => calls.add('download'); + session.onUpload = (name) => calls.add('upload:$name'); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final result = await syncService.syncNow( + beforeUpload: () async => calls.add('beforeUpload'), + ); + + expect(result.ranSync, isTrue); + expect(calls.first, 'download', reason: 'pull/merge happens first'); + expect(calls[1], 'beforeUpload'); + // Everything after beforeUpload is an upload (the data-file push, + // possibly preceded by pending receipt uploads — none pending here). + expect(calls.skip(2), everyElement(startsWith('upload:'))); + expect(calls, contains('upload:$dataFileName')); + }); + + test('is awaited even when there is nothing to pull (no existing remote data file yet)', + () async { + final calls = []; + final session = _FakeSession()..onUpload = (name) => calls.add('upload:$name'); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + await syncService.syncNow(beforeUpload: () async => calls.add('beforeUpload')); + + expect(calls.first, 'beforeUpload'); + expect(calls, contains('upload:$dataFileName')); + }); + + test('omitting it changes nothing — every other syncNow call site keeps working', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final result = await syncService.syncNow(); + + expect(result.ranSync, isTrue); + }); + }); + + test( + 'pull/merge does not throw against a remote snapshot from before the ' + 'ad_free_entitlement table existed', () async { + // Simulates a real user's pre-existing cloud folder: a DB file uploaded + // by an older app version, with only the schema that existed then (no + // ad_free_entitlement table) — CloudSyncService must skip merging that + // table rather than throw "no such table: remote_db.ad_free_entitlement". + final oldSchemaDbPath = p.join(tempDir.path, 'old_schema_remote.db'); + final oldSchemaDb = await databaseFactory.openDatabase(oldSchemaDbPath); + await oldSchemaDb.execute(createVehiclesTableSql); + await oldSchemaDb.execute(createFuelEntriesTableSql); + await oldSchemaDb.execute(createFuelEntriesIndexSql); + await oldSchemaDb.close(); + + final session = _FakeSession(); + session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1'); + session.remoteDbBytes = await File(oldSchemaDbPath).readAsBytes(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final result = await syncService.syncNow(); + + expect(result.ranSync, isTrue); + }); } class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { @@ -144,12 +263,29 @@ class _FakeSession implements CloudStorageSession { final Map uploadedFileFolders = {}; final List findOrCreateFolderCalls = []; CloudFileInfo? existingDataFile; + void Function()? onDownload; + void Function(String name)? onUpload; + + /// Bytes [downloadFileBytes] returns — needs to be a real, attachable + /// SQLite database (with the vehicles/fuel_entries schema) for + /// [CloudSyncService]'s ATTACH-based merge to succeed against it, not + /// just arbitrary bytes. Tests that exercise the pull/merge path set this + /// to a copy of their own [DatabaseService]'s (already correctly + /// schema'd) database file. + List remoteDbBytes = const []; @override bool get supportsSharedWithMe => false; + /// Folders [listFolders] returns, keyed by the `parentId` they'd be + /// listed under — empty (nothing at any destination) unless a test sets + /// this up, e.g. to simulate a same-named app folder someone else + /// already created at the destination. + final Map> foldersByParent = {}; + @override - Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; + Future> listFolders({String? parentId, bool sharedWithMe = false}) async => + foldersByParent[parentId] ?? []; @override Future findOrCreateFolder({required String parentId, required String name}) async { @@ -157,13 +293,24 @@ class _FakeSession implements CloudStorageSession { return '$parentId/$name'; } + final List moveFolderCalls = []; + + @override + Future moveFolder({required String folderId, required String newParentId}) async { + moveFolderCalls.add('$folderId->$newParentId'); + return '$newParentId/moved'; + } + @override Future findFile({required String folderId, required String name}) async { return name == dataFileName ? existingDataFile : null; } @override - Future> downloadFileBytes(String fileId) async => []; + Future> downloadFileBytes(String fileId) async { + onDownload?.call(); + return remoteDbBytes; + } @override Future uploadFile({ @@ -175,6 +322,7 @@ class _FakeSession implements CloudStorageSession { }) async { uploadedFileNames.add(name); uploadedFileFolders[name] = folderId; + onUpload?.call(name); return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1'); } diff --git a/test/db_merge_test.dart b/test/db_merge_test.dart index 17bddda..79b0e74 100644 --- a/test/db_merge_test.dart +++ b/test/db_merge_test.dart @@ -36,6 +36,7 @@ void main() { try { await local.execute(mergeVehiclesSql); await local.execute(mergeFuelEntriesSql); + await local.execute(mergeAdFreeEntitlementSql); } finally { await local.execute('DETACH DATABASE remote_db'); } @@ -153,6 +154,59 @@ void main() { expect(rows.first['receipt_image_path'], isNull); }); }); + + group('ad_free_entitlement merge', () { + test('pulls in the entitlement when only remote has one', () async { + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 5000, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['id'], 1); + expect(rows.first['ad_free_until'], 5000); + }); + + test('remote wins when strictly newer than local', () async { + await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000)); + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], 9000); + }); + + test('local wins on a tie or when strictly newer', () async { + await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000)); + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], 9000); + }); + + test('a null ad_free_until merges in fine (e.g. purchase consumed/lapsed remotely)', () async { + await remote.insert( + 'ad_free_entitlement', _entitlementRow(adFreeUntil: null, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], isNull); + }); + + test('no-op when neither side has a row yet', () async { + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, isEmpty); + }); + }); } Future _openFreshDb(String path) async { @@ -160,6 +214,7 @@ Future _openFreshDb(String path) async { await db.execute(createVehiclesTableSql); await db.execute(createFuelEntriesTableSql); await db.execute(createFuelEntriesIndexSql); + await db.execute(createAdFreeEntitlementTableSql); return db; } @@ -180,6 +235,12 @@ Map _vehicleRow({ 'dirty': dirty, }; +Map _entitlementRow({int? adFreeUntil, int updatedAt = 0}) => { + 'id': 1, + 'ad_free_until': adFreeUntil, + 'updated_at': updatedAt, + }; + Map _fuelEntryRow({ required String id, required String vehicleId, diff --git a/test/fuel_report_images_test.dart b/test/fuel_report_images_test.dart index 3328a4c..f6b3459 100644 --- a/test/fuel_report_images_test.dart +++ b/test/fuel_report_images_test.dart @@ -158,6 +158,9 @@ class _FakeSession implements CloudStorageSession { @override Future findOrCreateFolder({required String parentId, required String name}) async => ''; @override + Future moveFolder({required String folderId, required String newParentId}) async => + folderId; + @override Future findFile({required String folderId, required String name}) async => null; @override Future uploadFile({ diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart index 6ef3b15..96299e8 100644 --- a/test/main_shell_test.dart +++ b/test/main_shell_test.dart @@ -241,6 +241,8 @@ void main() { expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped'); expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone'); expect(appState.hasSeenOnboardingTour, isTrue); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget, + reason: 'returned to Receipts, not left on the Settings tab the tour ended on'); final prefs = await SharedPreferences.getInstance(); expect(prefs.getBool('has_seen_onboarding_tour'), isTrue, @@ -256,14 +258,30 @@ void main() { expect(find.text('What This App Does'), findsNothing); expect(appState.hasSeenOnboardingTour, isTrue); - expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget, - reason: 'stayed on the tab it started on'); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); }); - testWidgets('Skip Tour from the last step still pops the tour-pushed Data screen', + testWidgets('Skip Tour from a later step still returns to Receipts, not the tab it was on', (tester) async { await pumpShellWithFreshTour(tester); + // Two steps in, the tour has already jumped to the Vehicles tab. + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('Skip Tour from the last step pops the tour-pushed Data screen and returns to ' + 'Receipts', (tester) async { + await pumpShellWithFreshTour(tester); + for (var i = 0; i < 5; i++) { await tester.tap(find.widgetWithText(FilledButton, 'Next')); await tester.pumpAndSettle(); @@ -274,6 +292,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(DataSettingsScreen), findsNothing); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); }); }); } diff --git a/test/user_agreement_screen_test.dart b/test/user_agreement_screen_test.dart new file mode 100644 index 0000000..e828635 --- /dev/null +++ b/test/user_agreement_screen_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/main.dart'; +import 'package:fuel_tax_tracker/screens/main_shell.dart'; +import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [UserAgreementScreen] — the liability gate `main.dart`'s +/// `AppRoot` shows before [MainShell] until [AppState.hasAcceptedUserAgreement] +/// is true, so a fresh install sees it before anything else, including the +/// onboarding tour. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpScreen(WidgetTester tester) async { + // AppState.hasAcceptedUserAgreement defaults to true precisely so that + // ad-hoc AppState() construction in every other screen's tests doesn't + // trigger this gate — these tests explicitly flip it to false to + // reproduce a real fresh install's state. + final appState = AppState() + ..isLoading = false + ..hasAcceptedUserAgreement = false; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UserAgreementScreen()), + ), + ); + return appState; + } + + testWidgets('shows the data-loss liability section and both actions', (tester) async { + await pumpScreen(tester); + + expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget); + expect(find.textContaining('developer is not responsible for your data'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'I Agree'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Decline'), findsOneWidget); + }); + + testWidgets('I Agree marks the agreement accepted and persists it', (tester) async { + final appState = await pumpScreen(tester); + + await tester.tap(find.widgetWithText(FilledButton, 'I Agree')); + await tester.pumpAndSettle(); + + expect(appState.hasAcceptedUserAgreement, isTrue); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('has_accepted_user_agreement'), isTrue, + reason: 'persisted so the agreement never shows again on this device'); + }); + + testWidgets('Decline asks the platform to close the app, without accepting', (tester) async { + final appState = await pumpScreen(tester); + + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Decline')); + await tester.pumpAndSettle(); + + expect(calls.map((c) => c.method), contains('SystemNavigator.pop')); + expect(appState.hasAcceptedUserAgreement, isFalse); + }); + + group('AppRoot gating', () { + Future pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async { + final appState = AppState() + ..isLoading = false + ..hasAcceptedUserAgreement = hasAcceptedUserAgreement; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: AppRoot()), + ), + ); + return appState; + } + + testWidgets('shows UserAgreementScreen instead of MainShell before it is accepted', + (tester) async { + await pumpRoot(tester, hasAcceptedUserAgreement: false); + + expect(find.byType(UserAgreementScreen), findsOneWidget); + expect(find.byType(MainShell), findsNothing); + }); + + testWidgets('shows MainShell once the agreement is accepted', (tester) async { + final appState = await pumpRoot(tester, hasAcceptedUserAgreement: false); + + await tester.tap(find.widgetWithText(FilledButton, 'I Agree')); + await tester.pumpAndSettle(); + + expect(appState.hasAcceptedUserAgreement, isTrue); + expect(find.byType(UserAgreementScreen), findsNothing); + expect(find.byType(MainShell), findsOneWidget); + }); + + testWidgets('shows MainShell directly when already accepted (returning user)', (tester) async { + await pumpRoot(tester, hasAcceptedUserAgreement: true); + + expect(find.byType(UserAgreementScreen), findsNothing); + expect(find.byType(MainShell), findsOneWidget); + }); + }); +}