Ad and purchases enabled (still need actual testing)
This commit is contained in:
parent
2fcc6dcab7
commit
faf319322a
35 changed files with 1676 additions and 34 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -43,3 +43,10 @@ app.*.map.json
|
||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/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
|
||||||
|
|
|
||||||
|
|
@ -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
|
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
|
||||||
(shows as "restricted/sensitive" — expected, fine in Testing mode).
|
(shows as "restricted/sensitive" — expected, fine in Testing mode).
|
||||||
4. **Credentials → Create Credentials → OAuth client ID**, three times:
|
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
|
SHA-1 of your debug keystore (`keytool -list -v -keystore
|
||||||
~/.android/debug.keystore`, password `android`), and later your release
|
~/.android/debug.keystore`, password `android`), and later your release
|
||||||
keystore's SHA-1 too. This client ID itself is never referenced in
|
keystore's SHA-1 too. This client ID itself is never referenced in
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "com.courtneyarnold.fuel_tax_tracker"
|
namespace = "com.ohbrer.show_me_the_fuel_refund"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
|
@ -15,8 +28,7 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "com.ohbrer.show_me_the_fuel_refund"
|
||||||
applicationId = "com.courtneyarnold.fuel_tax_tracker"
|
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|
@ -25,11 +37,31 @@ android {
|
||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Real release signing once key.properties exists (see above);
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// falls back to the debug key otherwise, so `flutter run
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// --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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
android/app/proguard-rules.pro
vendored
Normal file
14
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -48,6 +48,15 @@
|
||||||
<data android:scheme="mofueltaxback-onedrive" />
|
<data android:scheme="mofueltaxback-onedrive" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- Real AdMob App ID (from your own AdMob console). The ad unit
|
||||||
|
served under it is still Google's official *test* interstitial
|
||||||
|
ID for now (lib/services/ad_service.dart) — that keeps showing
|
||||||
|
real, clearly-labeled test creatives (no revenue) until a real
|
||||||
|
interstitial ad unit ID created under this app is swapped in
|
||||||
|
there too. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="ca-app-pub-9212406812117696~6413414381"/>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.courtneyarnold.fuel_tax_tracker
|
package com.ohbrer.show_me_the_fuel_refund
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
|
@ -108,5 +108,11 @@
|
||||||
</array>
|
</array>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<!-- Google's official *test* AdMob App ID (safe to publish/commit —
|
||||||
|
see https://developers.google.com/admob/flutter/test-ads). Swap
|
||||||
|
for your own real AdMob App ID before shipping — see
|
||||||
|
lib/services/ad_service.dart for the matching ad unit ID. -->
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>ca-app-pub-3940256099942544~1458002511</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/main_shell.dart';
|
import 'screens/main_shell.dart';
|
||||||
|
import 'screens/user_agreement_screen.dart';
|
||||||
import 'services/app_state.dart';
|
import 'services/app_state.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
|
|
@ -67,6 +68,9 @@ class AppRoot extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!appState.hasAcceptedUserAgreement) {
|
||||||
|
return const UserAgreementScreen();
|
||||||
|
}
|
||||||
return const MainShell();
|
return const MainShell();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -6,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/receipt_parser.dart';
|
import '../services/receipt_parser.dart';
|
||||||
|
import '../widgets/backup_reminder.dart';
|
||||||
import 'receipt_image_screen.dart';
|
import 'receipt_image_screen.dart';
|
||||||
|
|
||||||
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
|
/// 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);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await context.read<AppState>().addFuelEntry(
|
final appState = context.read<AppState>();
|
||||||
|
await appState.addFuelEntry(
|
||||||
vehicleId: widget.vehicleId,
|
vehicleId: widget.vehicleId,
|
||||||
date: _date,
|
date: _date,
|
||||||
gallons: double.parse(_gallonsController.text),
|
gallons: double.parse(_gallonsController.text),
|
||||||
|
|
@ -120,7 +123,21 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
totalCost: double.parse(_totalController.text),
|
totalCost: double.parse(_totalController.text),
|
||||||
receiptImage: widget.imageFile,
|
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 {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,11 @@ class _MainShellState extends State<MainShell> {
|
||||||
if (navigator.canPop()) navigator.pop();
|
if (navigator.canPop()) navigator.pop();
|
||||||
_tourPushedDataScreen = false;
|
_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());
|
unawaited(context.read<AppState>().markOnboardingTourSeen());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
|
|
@ -46,6 +47,49 @@ class SettingsScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
|
||||||
128
lib/screens/user_agreement_screen.dart
Normal file
128
lib/screens/user_agreement_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/services/ad_service.dart
Normal file
96
lib/services/ad_service.dart
Normal 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: () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/fuel_entry.dart';
|
import '../models/fuel_entry.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
|
import 'ad_service.dart';
|
||||||
import 'cloud/cloud_storage_provider.dart';
|
import 'cloud/cloud_storage_provider.dart';
|
||||||
import 'cloud/dropbox_provider.dart';
|
import 'cloud/dropbox_provider.dart';
|
||||||
import 'cloud/google_drive_provider.dart';
|
import 'cloud/google_drive_provider.dart';
|
||||||
|
|
@ -16,6 +17,7 @@ import 'cloud/onedrive_provider.dart';
|
||||||
import 'cloud/webdav_provider.dart';
|
import 'cloud/webdav_provider.dart';
|
||||||
import 'cloud_sync_service.dart';
|
import 'cloud_sync_service.dart';
|
||||||
import 'database_service.dart';
|
import 'database_service.dart';
|
||||||
|
import 'purchase_service.dart';
|
||||||
|
|
||||||
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
||||||
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
||||||
|
|
@ -25,6 +27,7 @@ const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||||
const _prefsKeyThemeMode = 'theme_mode';
|
const _prefsKeyThemeMode = 'theme_mode';
|
||||||
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
|
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
|
||||||
|
const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement';
|
||||||
|
|
||||||
const defaultStaleLockMinutes = 10;
|
const defaultStaleLockMinutes = 10;
|
||||||
const minStaleLockMinutes = 1;
|
const minStaleLockMinutes = 1;
|
||||||
|
|
@ -54,6 +57,17 @@ class DuplicateVinException implements Exception {
|
||||||
/// handing back updated Dart objects).
|
/// handing back updated Dart objects).
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
final DatabaseService database = DatabaseService();
|
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.
|
/// Every storage backend the user can choose from in Settings.
|
||||||
final List<CloudStorageProvider> availableProviders = [
|
final List<CloudStorageProvider> availableProviders = [
|
||||||
|
|
@ -101,14 +115,52 @@ class AppState extends ChangeNotifier {
|
||||||
/// "never shown before."
|
/// "never shown before."
|
||||||
bool hasSeenOnboardingTour = true;
|
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
|
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||||
/// of spinning forever — an unhandled exception here previously left
|
/// of spinning forever — an unhandled exception here previously left
|
||||||
/// `isLoading` stuck at true with no feedback at all.
|
/// `isLoading` stuck at true with no feedback at all.
|
||||||
Object? initError;
|
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;
|
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
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;
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
|
@ -130,6 +182,7 @@ class AppState extends ChangeNotifier {
|
||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
|
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
|
||||||
|
hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
initError = e;
|
initError = e;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -147,17 +200,26 @@ class AppState extends ChangeNotifier {
|
||||||
});
|
});
|
||||||
|
|
||||||
unawaited(_restoreCloudConnection());
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_connectivitySubscription?.cancel();
|
_connectivitySubscription?.cancel();
|
||||||
|
purchaseService.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshFromDatabase() async {
|
Future<void> _refreshFromDatabase() async {
|
||||||
vehicles = await database.getVehicles();
|
vehicles = await database.getVehicles();
|
||||||
fuelEntries = await database.getFuelEntries();
|
fuelEntries = await database.getFuelEntries();
|
||||||
|
adFreeUntil = await database.getAdFreeUntil();
|
||||||
}
|
}
|
||||||
|
|
||||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||||
|
|
@ -207,6 +269,8 @@ class AppState extends ChangeNotifier {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
await _assumeRootCloudFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
||||||
|
|
@ -233,8 +297,21 @@ class AppState extends ChangeNotifier {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||||
notifyListeners();
|
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 {
|
Future<void> disconnectCloud() async {
|
||||||
final provider = activeProvider;
|
final provider = activeProvider;
|
||||||
if (provider == null) return;
|
if (provider == null) return;
|
||||||
|
|
@ -256,6 +333,17 @@ class AppState extends ChangeNotifier {
|
||||||
required String parentId,
|
required String parentId,
|
||||||
required String breadcrumbPath,
|
required String breadcrumbPath,
|
||||||
String? currentFolderName,
|
String? currentFolderName,
|
||||||
|
}) =>
|
||||||
|
_selectCloudFolder(
|
||||||
|
parentId: parentId,
|
||||||
|
breadcrumbPath: breadcrumbPath,
|
||||||
|
currentFolderName: currentFolderName,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _selectCloudFolder({
|
||||||
|
required String parentId,
|
||||||
|
required String breadcrumbPath,
|
||||||
|
String? currentFolderName,
|
||||||
}) async {
|
}) async {
|
||||||
final sync = cloudSync;
|
final sync = cloudSync;
|
||||||
if (sync == null) return;
|
if (sync == null) return;
|
||||||
|
|
@ -276,12 +364,29 @@ class AppState extends ChangeNotifier {
|
||||||
final sync = cloudSync;
|
final sync = cloudSync;
|
||||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
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;
|
isSyncing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final result = await sync.syncNow(
|
final result = await sync.syncNow(
|
||||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
||||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
staleLockAge: Duration(minutes: staleLockMinutes),
|
||||||
|
beforeUpload: beforeUpload,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.ranSync) {
|
if (result.ranSync) {
|
||||||
|
|
@ -317,6 +422,29 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
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
|
/// Called once the first-launch guided tour finishes or is skipped, so
|
||||||
/// it never shows again on this device.
|
/// it never shows again on this device.
|
||||||
Future<void> markOnboardingTourSeen() async {
|
Future<void> markOnboardingTourSeen() async {
|
||||||
|
|
@ -326,6 +454,15 @@ class AppState extends ChangeNotifier {
|
||||||
notifyListeners();
|
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
|
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||||
/// Settings "Advanced" section, which restricts the picker to that range
|
/// Settings "Advanced" section, which restricts the picker to that range
|
||||||
/// anyway; this is a defensive backstop for any other caller.
|
/// anyway; this is a defensive backstop for any other caller.
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,16 @@ abstract class CloudStorageSession {
|
||||||
/// if none exists. If duplicates exist, the earliest-created one wins.
|
/// if none exists. If duplicates exist, the earliest-created one wins.
|
||||||
Future<String> findOrCreateFolder({required String parentId, required String name});
|
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
|
/// Looks up a file's ID + versionTag by name within [folderId] without
|
||||||
/// downloading its content, or null if no such file exists yet.
|
/// downloading its content, or null if no such file exists yet.
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name});
|
Future<CloudFileInfo?> findFile({required String folderId, required String name});
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,17 @@ class DropboxSession implements CloudStorageSession {
|
||||||
return childPath;
|
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
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||||
final filePath = _childPath(folderId, name);
|
final filePath = _childPath(folderId, name);
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,20 @@ class GoogleDriveSession implements CloudStorageSession {
|
||||||
return created.id!;
|
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
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||||
final result = await _api.files.list(
|
final result = await _api.files.list(
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,22 @@ class OneDriveSession implements CloudStorageSession {
|
||||||
return created['id'] as String;
|
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
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
|
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,22 @@ class WebDavSession implements CloudStorageSession {
|
||||||
return childUri.path;
|
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
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||||
final fileUri = _childUri(_uriFor(folderId), name);
|
final fileUri = _childUri(_uriFor(folderId), name);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class CloudOAuthConfig {
|
||||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||||
/// Android.
|
/// Android.
|
||||||
static const String? googleAndroidServerClientId =
|
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
|
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||||
/// directly in ios/Runner/Info.plist.
|
/// directly in ios/Runner/Info.plist.
|
||||||
|
|
@ -25,7 +25,7 @@ class CloudOAuthConfig {
|
||||||
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
||||||
|
|
||||||
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
|
/// 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.
|
/// Custom URL scheme redirect registered in the Dropbox app console.
|
||||||
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
||||||
|
|
@ -42,7 +42,7 @@ class CloudOAuthConfig {
|
||||||
// application" platform.
|
// application" platform.
|
||||||
|
|
||||||
/// The Application (client) ID from the Azure app registration.
|
/// 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
|
/// Custom URL scheme redirect registered in Azure. The scheme
|
||||||
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
|
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,11 @@ class CloudSyncService {
|
||||||
_lastKnownRemoteVersionTag = null;
|
_lastKnownRemoteVersionTag = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds-or-creates the `Show Me The Fuel Refund` folder under [parentId]
|
/// Points this service at the `Show Me The Fuel Refund` folder under
|
||||||
/// (a folder the user picked in the folder browser) and configures this
|
/// [parentId] (a folder the user picked in the folder browser, or —
|
||||||
/// service to use it. Returns the resulting folder ID.
|
/// 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
|
/// If the user picked a folder that's *already* named
|
||||||
/// `Show Me The Fuel Refund` — [currentFolderName] carries that name up
|
/// `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)
|
/// 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
|
/// would double it up as `/Show Me The Fuel Refund/Show Me The Fuel
|
||||||
/// Refund`.
|
/// 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 {
|
Future<String> selectAppFolder(String parentId, {String? currentFolderName}) async {
|
||||||
final session = provider.beginSession();
|
final session = provider.beginSession();
|
||||||
try {
|
try {
|
||||||
final folderId = currentFolderName == appFolderName
|
if (currentFolderName == appFolderName) {
|
||||||
? parentId
|
configure(parentId);
|
||||||
: await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
|
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);
|
configure(folderId);
|
||||||
return folderId;
|
return folderId;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -106,9 +141,17 @@ class CloudSyncService {
|
||||||
/// treats it as abandoned (e.g. that device crashed or went offline
|
/// treats it as abandoned (e.g. that device crashed or went offline
|
||||||
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
|
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
|
||||||
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
|
/// 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({
|
Future<SyncResult> syncNow({
|
||||||
bool keepLocalReceiptCopies = false,
|
bool keepLocalReceiptCopies = false,
|
||||||
Duration staleLockAge = const Duration(minutes: 10),
|
Duration staleLockAge = const Duration(minutes: 10),
|
||||||
|
Future<void> Function()? beforeUpload,
|
||||||
}) async {
|
}) async {
|
||||||
final appFolderId = _appFolderId;
|
final appFolderId = _appFolderId;
|
||||||
if (!provider.isSignedIn || appFolderId == null) {
|
if (!provider.isSignedIn || appFolderId == null) {
|
||||||
|
|
@ -137,6 +180,10 @@ class CloudSyncService {
|
||||||
await _pullAndMerge(session, remoteInfo.id);
|
await _pullAndMerge(session, remoteInfo.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (beforeUpload != null) {
|
||||||
|
await beforeUpload();
|
||||||
|
}
|
||||||
|
|
||||||
final allReceiptsUploaded =
|
final allReceiptsUploaded =
|
||||||
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
|
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
|
||||||
|
|
||||||
|
|
@ -178,6 +225,21 @@ class CloudSyncService {
|
||||||
try {
|
try {
|
||||||
await db.execute(mergeVehiclesSql);
|
await db.execute(mergeVehiclesSql);
|
||||||
await db.execute(mergeFuelEntriesSql);
|
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 {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await db.execute('DETACH DATABASE remote_db');
|
await db.execute('DETACH DATABASE remote_db');
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class DatabaseService {
|
||||||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 4,
|
version: 5,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
|
|
@ -75,6 +75,7 @@ class DatabaseService {
|
||||||
await db.execute(createVehiclesTableSql);
|
await db.execute(createVehiclesTableSql);
|
||||||
await db.execute(createFuelEntriesTableSql);
|
await db.execute(createFuelEntriesTableSql);
|
||||||
await db.execute(createFuelEntriesIndexSql);
|
await db.execute(createFuelEntriesIndexSql);
|
||||||
|
await db.execute(createAdFreeEntitlementTableSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
||||||
|
|
@ -90,6 +91,9 @@ class DatabaseService {
|
||||||
if (oldVersion < 4) {
|
if (oldVersion < 4) {
|
||||||
await _migrateToVehicleIdSchema(db);
|
await _migrateToVehicleIdSchema(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 5) {
|
||||||
|
await db.execute(createAdFreeEntitlementTableSql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
||||||
|
|
@ -157,6 +161,33 @@ class DatabaseService {
|
||||||
return rows.map(FuelEntry.fromMap).toList();
|
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
|
/// True if an active (non-deleted) vehicle other than [excludeId] already
|
||||||
/// has this VIN. VIN must stay unique even though it's editable, so
|
/// has this VIN. VIN must stay unique even though it's editable, so
|
||||||
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,26 @@ const createFuelEntriesTableSql = '''
|
||||||
const createFuelEntriesIndexSql =
|
const createFuelEntriesIndexSql =
|
||||||
'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)';
|
'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
|
/// Selects fuel entries whose receipt photo still needs uploading to
|
||||||
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
||||||
/// "has a local path": once a row is uploaded, its local copy may still be
|
/// "has a local path": once a row is uploaded, its local copy may still be
|
||||||
|
|
@ -75,3 +95,18 @@ const mergeFuelEntriesSql = '''
|
||||||
LEFT JOIN main.fuel_entries l ON l.id = r.id
|
LEFT JOIN main.fuel_entries l ON l.id = r.id
|
||||||
WHERE l.id IS NULL OR r.updated_at > l.updated_at
|
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
|
||||||
|
''';
|
||||||
|
|
|
||||||
112
lib/services/purchase_service.dart
Normal file
112
lib/services/purchase_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
lib/widgets/backup_reminder.dart
Normal file
108
lib/widgets/backup_reminder.dart
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,12 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
final VoidCallback onNext;
|
final VoidCallback onNext;
|
||||||
final VoidCallback onSkip;
|
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({
|
const OnboardingTourOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.targetKeys,
|
required this.targetKeys,
|
||||||
|
|
@ -33,6 +39,7 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
required this.totalSteps,
|
required this.totalSteps,
|
||||||
required this.onNext,
|
required this.onNext,
|
||||||
required this.onSkip,
|
required this.onSkip,
|
||||||
|
this.skipLabel = 'Skip Tour',
|
||||||
});
|
});
|
||||||
|
|
||||||
List<Rect> _targetRects() {
|
List<Rect> _targetRects() {
|
||||||
|
|
@ -126,14 +133,16 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
if (totalSteps > 1) ...[
|
||||||
'Step $stepNumber of $totalSteps',
|
Text(
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
'Step $stepNumber of $totalSteps',
|
||||||
color: colorScheme.primary,
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
color: colorScheme.primary,
|
||||||
),
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(description, style: Theme.of(context).textTheme.bodyMedium),
|
Text(description, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
|
@ -141,7 +150,7 @@ class OnboardingTourOverlay extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton(onPressed: onSkip, child: const Text('Skip Tour')),
|
TextButton(onPressed: onSkip, child: Text(skipLabel)),
|
||||||
FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')),
|
FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
72
pubspec.lock
72
pubspec.lock
|
|
@ -392,6 +392,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.1"
|
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:
|
google_sign_in:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -544,6 +552,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
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:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1133,6 +1173,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,15 @@ dependencies:
|
||||||
printing: ^5.14.3
|
printing: ^5.14.3
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
|
# AdMob interstitial shown once per app session, right before the first
|
||||||
|
# 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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
|
||||||
88
test/ad_free_entitlement_test.dart
Normal file
88
test/ad_free_entitlement_test.dart
Normal file
|
|
@ -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<String?> getApplicationDocumentsPath() async => tempPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getTemporaryPath() async => tempPath;
|
||||||
|
}
|
||||||
198
test/backup_reminder_test.dart
Normal file
198
test/backup_reminder_test.dart
Normal file
|
|
@ -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<bool?> 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<bool> attemptSilentSignIn() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> signIn() async => accountLabel!;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CloudStorageSession beginSession() => throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
@ -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/cloud_storage_provider.dart';
|
||||||
import 'package:fuel_tax_tracker/services/cloud_sync_service.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/database_service.dart';
|
||||||
|
import 'package:fuel_tax_tracker/services/db_schema.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
import 'package:plugin_platform_interface/plugin_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');
|
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 {
|
test('syncNow is a no-op when not configured with a folder yet', () async {
|
||||||
final session = _FakeSession();
|
final session = _FakeSession();
|
||||||
final provider = _FakeProvider(session);
|
final provider = _FakeProvider(session);
|
||||||
|
|
@ -124,6 +160,89 @@ void main() {
|
||||||
expect(result.ranSync, isFalse);
|
expect(result.ranSync, isFalse);
|
||||||
expect(session.createdLockNames, isEmpty);
|
expect(session.createdLockNames, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('beforeUpload hook', () {
|
||||||
|
test('is awaited after pulling/merging remote changes but before anything is uploaded',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
// 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 = <String>[];
|
||||||
|
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 {
|
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||||
|
|
@ -144,12 +263,29 @@ class _FakeSession implements CloudStorageSession {
|
||||||
final Map<String, String> uploadedFileFolders = {};
|
final Map<String, String> uploadedFileFolders = {};
|
||||||
final List<String> findOrCreateFolderCalls = [];
|
final List<String> findOrCreateFolderCalls = [];
|
||||||
CloudFileInfo? existingDataFile;
|
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<int> remoteDbBytes = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get supportsSharedWithMe => false;
|
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<String, List<CloudFolder>> foldersByParent = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
|
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async =>
|
||||||
|
foldersByParent[parentId] ?? [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||||
|
|
@ -157,13 +293,24 @@ class _FakeSession implements CloudStorageSession {
|
||||||
return '$parentId/$name';
|
return '$parentId/$name';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<String> moveFolderCalls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> moveFolder({required String folderId, required String newParentId}) async {
|
||||||
|
moveFolderCalls.add('$folderId->$newParentId');
|
||||||
|
return '$newParentId/moved';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||||
return name == dataFileName ? existingDataFile : null;
|
return name == dataFileName ? existingDataFile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<int>> downloadFileBytes(String fileId) async => <int>[];
|
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||||
|
onDownload?.call();
|
||||||
|
return remoteDbBytes;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<CloudFileInfo> uploadFile({
|
Future<CloudFileInfo> uploadFile({
|
||||||
|
|
@ -175,6 +322,7 @@ class _FakeSession implements CloudStorageSession {
|
||||||
}) async {
|
}) async {
|
||||||
uploadedFileNames.add(name);
|
uploadedFileNames.add(name);
|
||||||
uploadedFileFolders[name] = folderId;
|
uploadedFileFolders[name] = folderId;
|
||||||
|
onUpload?.call(name);
|
||||||
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
|
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ void main() {
|
||||||
try {
|
try {
|
||||||
await local.execute(mergeVehiclesSql);
|
await local.execute(mergeVehiclesSql);
|
||||||
await local.execute(mergeFuelEntriesSql);
|
await local.execute(mergeFuelEntriesSql);
|
||||||
|
await local.execute(mergeAdFreeEntitlementSql);
|
||||||
} finally {
|
} finally {
|
||||||
await local.execute('DETACH DATABASE remote_db');
|
await local.execute('DETACH DATABASE remote_db');
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +154,59 @@ void main() {
|
||||||
expect(rows.first['receipt_image_path'], isNull);
|
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<Database> _openFreshDb(String path) async {
|
Future<Database> _openFreshDb(String path) async {
|
||||||
|
|
@ -160,6 +214,7 @@ Future<Database> _openFreshDb(String path) async {
|
||||||
await db.execute(createVehiclesTableSql);
|
await db.execute(createVehiclesTableSql);
|
||||||
await db.execute(createFuelEntriesTableSql);
|
await db.execute(createFuelEntriesTableSql);
|
||||||
await db.execute(createFuelEntriesIndexSql);
|
await db.execute(createFuelEntriesIndexSql);
|
||||||
|
await db.execute(createAdFreeEntitlementTableSql);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,6 +235,12 @@ Map<String, Object?> _vehicleRow({
|
||||||
'dirty': dirty,
|
'dirty': dirty,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Map<String, Object?> _entitlementRow({int? adFreeUntil, int updatedAt = 0}) => {
|
||||||
|
'id': 1,
|
||||||
|
'ad_free_until': adFreeUntil,
|
||||||
|
'updated_at': updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, Object?> _fuelEntryRow({
|
Map<String, Object?> _fuelEntryRow({
|
||||||
required String id,
|
required String id,
|
||||||
required String vehicleId,
|
required String vehicleId,
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ class _FakeSession implements CloudStorageSession {
|
||||||
@override
|
@override
|
||||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async => '';
|
Future<String> findOrCreateFolder({required String parentId, required String name}) async => '';
|
||||||
@override
|
@override
|
||||||
|
Future<String> moveFolder({required String folderId, required String newParentId}) async =>
|
||||||
|
folderId;
|
||||||
|
@override
|
||||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async => null;
|
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async => null;
|
||||||
@override
|
@override
|
||||||
Future<CloudFileInfo> uploadFile({
|
Future<CloudFileInfo> uploadFile({
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,8 @@ void main() {
|
||||||
expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped');
|
expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped');
|
||||||
expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone');
|
expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone');
|
||||||
expect(appState.hasSeenOnboardingTour, isTrue);
|
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();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
expect(prefs.getBool('has_seen_onboarding_tour'), isTrue,
|
expect(prefs.getBool('has_seen_onboarding_tour'), isTrue,
|
||||||
|
|
@ -256,14 +258,30 @@ void main() {
|
||||||
|
|
||||||
expect(find.text('What This App Does'), findsNothing);
|
expect(find.text('What This App Does'), findsNothing);
|
||||||
expect(appState.hasSeenOnboardingTour, isTrue);
|
expect(appState.hasSeenOnboardingTour, isTrue);
|
||||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget,
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
reason: 'stayed on the tab it started on');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
(tester) async {
|
||||||
await pumpShellWithFreshTour(tester);
|
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++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Next'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
@ -274,6 +292,7 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(DataSettingsScreen), findsNothing);
|
expect(find.byType(DataSettingsScreen), findsNothing);
|
||||||
|
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
test/user_agreement_screen_test.dart
Normal file
121
test/user_agreement_screen_test.dart
Normal file
|
|
@ -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<AppState> 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 = <MethodCall>[];
|
||||||
|
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<AppState> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue