Loading...
Loading...
Enforces opt-in, rewarded-first monetization over the service seam — a rewarded view grants exactly one benefit and only on a genuine reward outcome (a sealed Rewarded/Dismissed/NoFill), earn loops capped per day against an injected Clock, the earn control preloaded and hidden unless an ad is loaded so a fresh account never dead-ends on "no ads available" (a Guideline 2.1 risk), interstitials only at a natural break under a count-and-elapsed cap owned by the service so callers stay dumb, no banner on the primary work surface, one non-consumable unlock plus Restore re-checked each launch and committed through the single write path before the UI reacts, one derived isEntitled provider as the only gate, and the entitlement restored BEFORE the ad SDK initializes so a paying user never starts it, resolves consent, or sees a tracking prompt. Use when adding ads, wiring a rewarded earn loop, placing an interstitial, building a paywall, gating an entitlement, or setting up ad units.
npx skill4agent add zakariaf/flutter-skills ads-and-iap-monetizationservice-boundary-and-nativeAdsServiceBillingServiceservice-boundary-and-nativeRewardedDismissedNoFillDismissedNoFillboolClockreferences/ad-unit-setup.mdclock.now()DateTime.now()maybeShowInterstitial()release-and-store-shippingisEntitledAdsService.init()references/entitlement-and-restore.mdisEntitledpersistence-drift--dart-definescripts/check-release-ad-ids.sh// Policy layer: depends on the interfaces and the entitlement gate — never on an SDK.
sealed class RewardOutcome { const RewardOutcome(); }
final class Rewarded extends RewardOutcome { const Rewarded(); }
final class Dismissed extends RewardOutcome { const Dismissed(); } // closed early
final class NoFill extends RewardOutcome { const NoFill(); } // no ad existed
class CreditEconomy extends Notifier<CreditState> {
static const int freeCreditsPerDay = 1; // the allowance the design assumes
static const int maxEarnedPerDay = 3; // the integrity ceiling (rule 4)
CreditState build() => const CreditState();
/// Opt-in earn path. Grants ONLY on `Rewarded`, and only under the daily cap.
Future<Result<Credit, MonetizationFailure>> watchToEarn() async {
if (ref.read(isEntitledProvider).valueOrNull ?? false) return _grant(); // no ad at all
if (state.earnedToday(ref.read(clockProvider)) >= maxEarnedPerDay) {
return const Err(EarnCapReached());
}
return switch (await ref.read(adsServiceProvider).showRewarded()) {
Rewarded() => _grant(),
Dismissed() => const Err(RewardNotEarned()), // distinct: the user closed it
NoFill() => const Err(NoAdAvailable()), // distinct: nothing to show
};
}
}// Shown only while an ad is genuinely loaded; the free path stays reachable regardless.
final rewardedReadyProvider =
StreamProvider<bool>((ref) => ref.watch(adsServiceProvider).rewardedAvailability);
if (ref.watch(rewardedReadyProvider).valueOrNull ?? false)
const WatchToEarnButton(),Future<void> onTaskCompleted(WidgetRef ref) async {
if (ref.read(isEntitledProvider).valueOrNull ?? false) return; // rule 9
await ref.read(adsServiceProvider).maybeShowInterstitial(); // owns both cap halves
}// Entitlement first, ads only if the user is not entitled (rule 10).
// Startup-sequence mechanics are owned by `app-startup-and-bootstrap`.
final entitlement = await ref.read(billingServiceProvider).restore(); // failure ⇒ not entitled
if (!entitlement.isEntitled) {
await ref.read(adsServiceProvider).init(); // consent / tracking prompt happens in here
await ref.read(adsServiceProvider).preloadRewarded();
}initCount == 0DismissedNoFillboolinit()RewardedClockmaybeShowInterstitial()isEntitledAdsService.init()initCount == 0scripts/check-release-ad-ids.shservice-boundary-and-nativeAdsServiceBillingServiceapp-startup-and-bootstrapmain()state-management-riverpodpersistence-drifterror-handling-typed-resultsResultFailurerelease-and-store-shippingin_app_purchasegoogle_mobile_ads