feat: ship ad-supported only — gate Remove Ads IAP behind kIapEnabled=false

The developer is an individual without a Korean business registration, so
the App Store / Play paid-apps (merchant) agreements can't be completed.
Hide the Remove Ads + Restore tiles and skip IAP init; ads always show.
AdMob revenue is independent of those agreements. Reversible: flip
kIapEnabled to re-enable once a merchant agreement exists. Bump to build 2;
drop the now-unused IAP review-screenshot generator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 11:55:48 +09:00
parent e9d7f7cef6
commit 8b5bbd9531
12 changed files with 365 additions and 248 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/feature_flags.dart';
import 'l10n/gen/app_localizations.dart';
import 'state/providers.dart';
import 'ui/screens/splash_screen.dart';
@@ -24,7 +25,7 @@ class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp>
// Eagerly start the IAP service so its purchase stream is live for the
// whole session — restores and interrupted/deferred transactions are
// delivered (and completed) even if the player never opens Settings.
ref.read(iapServiceProvider);
if (kIapEnabled) ref.read(iapServiceProvider);
// Start background music for the current context (menu by default).
ref.read(musicServiceProvider).playKey(ref.read(activeThemeProvider).bgm);
});
+13
View File
@@ -0,0 +1,13 @@
/// Compile-time feature toggles.
library;
/// Whether the in-app purchase ("Remove Ads") is offered.
///
/// Disabled for launch: the developer is an individual without a Korean
/// business registration, so the App Store / Play paid-apps (merchant)
/// agreements can't be completed. The app ships ad-supported only. AdMob
/// revenue is independent of those agreements, so ads still pay out.
///
/// To re-enable later (once a merchant agreement exists): flip this to `true`,
/// re-activate the `remove_ads` product in both stores, and ship a new build.
const bool kIapEnabled = false;
+46 -35
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/feature_flags.dart';
import '../../game/models/season.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
@@ -13,18 +14,8 @@ class SettingsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final adsRemoved = ref.watch(adsRemovedProvider);
final soundOn = ref.watch(soundEnabledProvider);
final musicOn = ref.watch(musicEnabledProvider);
final iap = ref.read(iapServiceProvider);
ref.listen<bool>(adsRemovedProvider, (prev, next) {
if (next && !(prev ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.adsRemovedThanks)),
);
}
});
return Stack(
fit: StackFit.expand,
@@ -51,31 +42,9 @@ class SettingsScreen extends ConsumerWidget {
onChanged: (v) =>
ref.read(musicEnabledProvider.notifier).set(v),
),
const Divider(),
ListTile(
title: Text(l10n.removeAds),
subtitle: Text(l10n.removeAdsDescription),
trailing: adsRemoved
? const Icon(Icons.check_circle, color: Colors.green)
: Text(iap.product?.price ?? ''),
onTap: adsRemoved
? null
: () async {
if (!iap.available || iap.product == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.purchaseUnavailable)),
);
return;
}
await iap.buyRemoveAds();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.restore),
title: Text(l10n.restorePurchases),
onTap: () => iap.restorePurchases(),
),
// The "Remove Ads" purchase is gated off at launch (no merchant
// agreement); the app ships ad-supported only. See kIapEnabled.
if (kIapEnabled) ..._iapTiles(context, ref, l10n),
const SizedBox(height: 24),
Center(
child: Text(
@@ -92,4 +61,46 @@ class SettingsScreen extends ConsumerWidget {
],
);
}
List<Widget> _iapTiles(
BuildContext context, WidgetRef ref, AppLocalizations l10n) {
final adsRemoved = ref.watch(adsRemovedProvider);
final iap = ref.read(iapServiceProvider);
ref.listen<bool>(adsRemovedProvider, (prev, next) {
if (next && !(prev ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.adsRemovedThanks)),
);
}
});
return [
const Divider(),
ListTile(
title: Text(l10n.removeAds),
subtitle: Text(l10n.removeAdsDescription),
trailing: adsRemoved
? const Icon(Icons.check_circle, color: Colors.green)
: Text(iap.product?.price ?? ''),
onTap: adsRemoved
? null
: () async {
if (!iap.available || iap.product == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.purchaseUnavailable)),
);
return;
}
await iap.buyRemoveAds();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.restore),
title: Text(l10n.restorePurchases),
onTap: () => iap.restorePurchases(),
),
];
}
}