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