diff --git a/deploy/app-ads.txt b/deploy/app-ads.txt
new file mode 100644
index 0000000..5343af7
--- /dev/null
+++ b/deploy/app-ads.txt
@@ -0,0 +1 @@
+google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0
diff --git a/deploy/index.html b/deploy/index.html
index 95a70d8..333c2dd 100644
--- a/deploy/index.html
+++ b/deploy/index.html
@@ -1,89 +1,69 @@
-
-
-
-
- Welcome to Firebase Hosting
+
+
+
+
+Block Seasons — 시즌마다 새로워지는 블록 퍼즐
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
Welcome
-
Firebase Hosting Setup Complete
-
You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!
-
Open Hosting Documentation
-
-
Firebase SDK Loading…
+
Block Seasons
+
시즌마다 새로워지는 블록 퍼즐 · A seasonal block puzzle
-
-
+
+ 시즌제 — 몇 주마다 새 테마와 스테이지
+ 일러스트 여정 맵 + 엔드리스 모드
+ 광고 강요 없는 공정한 설계, 일회성 ‘광고 제거’ 지원
+ 오프라인 플레이 (시즌 1 내장)
+
+
+
A cozy 8×8 block puzzle. Drop three pieces, clear lines, and enjoy a fresh themed
+ season every few weeks — no app update needed. Season 1 plays fully offline.
+
+
+
+
지원 / Support
+
문의 사항은 airkjw@gmail.com 으로 보내주세요.
+ 보통 2~3일 내에 답변드립니다. · For support, email
+ airkjw@gmail.com .
+
+
+
© 2026 Joungwook Kwon · Block Seasons
+
+
+
diff --git a/deploy/privacy-policy.html b/deploy/privacy-policy.html
new file mode 100644
index 0000000..f64333a
--- /dev/null
+++ b/deploy/privacy-policy.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+Block Seasons — Privacy Policy / 개인정보처리방침
+
+
+
+
+
+Block Seasons 개인정보처리방침
+최종 업데이트: 2026년 6월 14일 · 문의: airkjw@gmail.com
+
+본 방침은 모바일 게임 Block Seasons (이하 "본 앱")의 개인정보 처리에 관한 내용을 설명합니다. 본 앱은 계정 가입이 필요 없으며, 이름·이메일 등 직접적인 개인 식별 정보를 수집하지 않습니다.
+
+1. 수집하는 정보
+
+ 광고 식별자 (Android 광고 ID / Apple IDFA): 광고 게재 및 측정을 위해 광고 파트너(Google AdMob)가 사용합니다.
+ 사용 데이터 (앱 이용 통계, 화면·이벤트 상호작용): 앱 품질 개선과 분석을 위해 Firebase Analytics가 수집합니다.
+ 기기 정보 (기기 모델, 운영체제 버전, 대략적 지역 등): 광고·분석의 기본 진단 정보로 사용됩니다.
+
+본 앱 개발자는 위 정보를 통해 개인을 식별하지 않으며, 별도의 서버에 개인정보를 저장하지 않습니다. 게임 진행·설정은 기기 내부(로컬)에만 저장됩니다.
+
+2. 정보 이용 목적
+
+ 광고 게재 및 수익 창출 (무료 제공을 위한 광고 기반 모델)
+ 앱 사용성 분석 및 기능·난이도 개선
+ 오류 진단 및 안정성 향상
+
+
+3. 제3자 제공 및 처리
+본 앱은 다음 제3자 서비스를 사용하며, 해당 서비스의 정책에 따라 정보가 처리됩니다.
+
+
+4. 추적 및 맞춤 광고 (iOS)
+iOS에서는 앱 실행 시 추적 허용(App Tracking Transparency) 동의를 요청합니다. 동의하면 맞춤형 광고가 제공될 수 있고, 거부해도 본 앱의 모든 기능을 정상적으로 이용할 수 있으며 비맞춤형 광고가 표시됩니다.
+
+5. 인앱 구매
+일회성 "광고 제거(Remove Ads)" 인앱 구매를 제공합니다. 결제는 Apple App Store 또는 Google Play를 통해 처리되며, 개발자는 결제 카드 등 결제 수단 정보를 수집하거나 보관하지 않습니다.
+
+6. 아동의 개인정보
+본 앱은 만 13세 미만 아동을 주 대상으로 하지 않으며, 아동의 개인정보를 고의로 수집하지 않습니다.
+
+7. 데이터 보관 및 삭제
+로컬 저장 데이터는 앱 삭제 시 함께 제거됩니다. 광고/분석 데이터의 처리·삭제는 위 제3자 정책을 따릅니다. 관련 문의는 아래 이메일로 연락 주십시오.
+
+8. 문의
+개인정보 관련 문의: airkjw@gmail.com
+
+
+
+
+Block Seasons Privacy Policy
+Last updated: June 14, 2026 · Contact: airkjw@gmail.com
+
+This policy describes how the mobile game Block Seasons ("the App") handles information. The App requires no account sign-up and does not collect directly identifying personal information such as your name or email.
+
+1. Information We Collect
+
+ Advertising identifier (Android Advertising ID / Apple IDFA): used by our advertising partner (Google AdMob) to serve and measure ads.
+ Usage data (app interaction, screen and event analytics): collected by Firebase Analytics to improve app quality.
+ Device information (device model, OS version, coarse region): used for advertising and analytics diagnostics.
+
+The developer does not use this information to identify you personally and stores no personal data on its own servers. Game progress and settings are stored only locally on your device.
+
+2. How We Use Information
+
+ To serve ads and generate revenue (an ad-supported free model)
+ To analyze usage and improve features and difficulty
+ To diagnose errors and improve stability
+
+
+3. Third Parties
+
+
+4. Tracking & Personalized Ads (iOS)
+On iOS the App requests App Tracking Transparency permission. If you allow it, personalized ads may be shown. If you decline, the App works fully and shows non-personalized ads.
+
+5. In-App Purchases
+A one-time "Remove Ads" purchase is offered. Payment is handled by the Apple App Store or Google Play; the developer does not collect or store your payment details.
+
+6. Children's Privacy
+The App is not primarily directed at children under 13 and does not knowingly collect personal information from children.
+
+7. Data Retention & Deletion
+Locally stored data is removed when the App is uninstalled. Advertising and analytics data follow the third-party policies above. For requests, contact us below.
+
+8. Contact
+Privacy inquiries: airkjw@gmail.com
+
+
+
diff --git a/docs/blockseasons-site/app-ads.txt b/docs/blockseasons-site/app-ads.txt
new file mode 100644
index 0000000..5343af7
--- /dev/null
+++ b/docs/blockseasons-site/app-ads.txt
@@ -0,0 +1 @@
+google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0
diff --git a/docs/blockseasons-site/privacy-policy.html b/docs/blockseasons-site/privacy-policy.html
new file mode 100644
index 0000000..f64333a
--- /dev/null
+++ b/docs/blockseasons-site/privacy-policy.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+Block Seasons — Privacy Policy / 개인정보처리방침
+
+
+
+
+
+Block Seasons 개인정보처리방침
+최종 업데이트: 2026년 6월 14일 · 문의: airkjw@gmail.com
+
+본 방침은 모바일 게임 Block Seasons (이하 "본 앱")의 개인정보 처리에 관한 내용을 설명합니다. 본 앱은 계정 가입이 필요 없으며, 이름·이메일 등 직접적인 개인 식별 정보를 수집하지 않습니다.
+
+1. 수집하는 정보
+
+ 광고 식별자 (Android 광고 ID / Apple IDFA): 광고 게재 및 측정을 위해 광고 파트너(Google AdMob)가 사용합니다.
+ 사용 데이터 (앱 이용 통계, 화면·이벤트 상호작용): 앱 품질 개선과 분석을 위해 Firebase Analytics가 수집합니다.
+ 기기 정보 (기기 모델, 운영체제 버전, 대략적 지역 등): 광고·분석의 기본 진단 정보로 사용됩니다.
+
+본 앱 개발자는 위 정보를 통해 개인을 식별하지 않으며, 별도의 서버에 개인정보를 저장하지 않습니다. 게임 진행·설정은 기기 내부(로컬)에만 저장됩니다.
+
+2. 정보 이용 목적
+
+ 광고 게재 및 수익 창출 (무료 제공을 위한 광고 기반 모델)
+ 앱 사용성 분석 및 기능·난이도 개선
+ 오류 진단 및 안정성 향상
+
+
+3. 제3자 제공 및 처리
+본 앱은 다음 제3자 서비스를 사용하며, 해당 서비스의 정책에 따라 정보가 처리됩니다.
+
+
+4. 추적 및 맞춤 광고 (iOS)
+iOS에서는 앱 실행 시 추적 허용(App Tracking Transparency) 동의를 요청합니다. 동의하면 맞춤형 광고가 제공될 수 있고, 거부해도 본 앱의 모든 기능을 정상적으로 이용할 수 있으며 비맞춤형 광고가 표시됩니다.
+
+5. 인앱 구매
+일회성 "광고 제거(Remove Ads)" 인앱 구매를 제공합니다. 결제는 Apple App Store 또는 Google Play를 통해 처리되며, 개발자는 결제 카드 등 결제 수단 정보를 수집하거나 보관하지 않습니다.
+
+6. 아동의 개인정보
+본 앱은 만 13세 미만 아동을 주 대상으로 하지 않으며, 아동의 개인정보를 고의로 수집하지 않습니다.
+
+7. 데이터 보관 및 삭제
+로컬 저장 데이터는 앱 삭제 시 함께 제거됩니다. 광고/분석 데이터의 처리·삭제는 위 제3자 정책을 따릅니다. 관련 문의는 아래 이메일로 연락 주십시오.
+
+8. 문의
+개인정보 관련 문의: airkjw@gmail.com
+
+
+
+
+Block Seasons Privacy Policy
+Last updated: June 14, 2026 · Contact: airkjw@gmail.com
+
+This policy describes how the mobile game Block Seasons ("the App") handles information. The App requires no account sign-up and does not collect directly identifying personal information such as your name or email.
+
+1. Information We Collect
+
+ Advertising identifier (Android Advertising ID / Apple IDFA): used by our advertising partner (Google AdMob) to serve and measure ads.
+ Usage data (app interaction, screen and event analytics): collected by Firebase Analytics to improve app quality.
+ Device information (device model, OS version, coarse region): used for advertising and analytics diagnostics.
+
+The developer does not use this information to identify you personally and stores no personal data on its own servers. Game progress and settings are stored only locally on your device.
+
+2. How We Use Information
+
+ To serve ads and generate revenue (an ad-supported free model)
+ To analyze usage and improve features and difficulty
+ To diagnose errors and improve stability
+
+
+3. Third Parties
+
+
+4. Tracking & Personalized Ads (iOS)
+On iOS the App requests App Tracking Transparency permission. If you allow it, personalized ads may be shown. If you decline, the App works fully and shows non-personalized ads.
+
+5. In-App Purchases
+A one-time "Remove Ads" purchase is offered. Payment is handled by the Apple App Store or Google Play; the developer does not collect or store your payment details.
+
+6. Children's Privacy
+The App is not primarily directed at children under 13 and does not knowingly collect personal information from children.
+
+7. Data Retention & Deletion
+Locally stored data is removed when the App is uninstalled. Advertising and analytics data follow the third-party policies above. For requests, contact us below.
+
+8. Contact
+Privacy inquiries: airkjw@gmail.com
+
+
+
diff --git a/docs/store/iap_review_remove_ads.png b/docs/store/iap_review_remove_ads.png
deleted file mode 100644
index 8c80118..0000000
Binary files a/docs/store/iap_review_remove_ads.png and /dev/null differ
diff --git a/docs/zardu_closed_testers_list_20251225.csv b/docs/zardu_closed_testers_list_20251225.csv
new file mode 100644
index 0000000..428e5fe
--- /dev/null
+++ b/docs/zardu_closed_testers_list_20251225.csv
@@ -0,0 +1,28 @@
+zardu.plum@gmail.com
+zardu.sqa.101@gmail.com
+zardu.sqa.102@gmail.com
+zardu.sqa.103@gmail.com
+zardu.sqa.104@gmail.com
+zardu.sqa.105@gmail.com
+zardu.sqa.106@gmail.com
+zardu.sqa.107@gmail.com
+zardu.sqa.108@gmail.com
+zardu.sqa.109@gmail.com
+zardu.sqa.110@gmail.com
+zardu.sqa.111@gmail.com
+zardu.sqa.112@gmail.com
+zardu.sqa.113@gmail.com
+zardu.sqa.114@gmail.com
+zardu.sqa.115@gmail.com
+zardu.sqa.116@gmail.com
+zardu.sqa.117@gmail.com
+zardu.sqa.118@gmail.com
+zardu.sqa.119@gmail.com
+zardu.sqa.120@gmail.com
+zardu.sqa.121@gmail.com
+axiom.kor@gmail.com
+boson.seoul@gmail.com
+cepheid.space@gmail.com
+diffraction.rayman@gmail.com
+duality.frame@gmail.com
+quantum.tteokshop@gmail.com
diff --git a/lib/app.dart b/lib/app.dart
index 9bcb083..47ef2df 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -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
// 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);
});
diff --git a/lib/core/feature_flags.dart b/lib/core/feature_flags.dart
new file mode 100644
index 0000000..99131da
--- /dev/null
+++ b/lib/core/feature_flags.dart
@@ -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;
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index d8ae741..f8ce2a4 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -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(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 _iapTiles(
+ BuildContext context, WidgetRef ref, AppLocalizations l10n) {
+ final adsRemoved = ref.watch(adsRemovedProvider);
+ final iap = ref.read(iapServiceProvider);
+
+ ref.listen(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(),
+ ),
+ ];
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index b152dfb..b409fe3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.0+1
+version: 1.0.0+2
environment:
sdk: ^3.9.2
diff --git a/test/tool/generate_iap_review_screenshot_test.dart b/test/tool/generate_iap_review_screenshot_test.dart
deleted file mode 100644
index 3217e0c..0000000
--- a/test/tool/generate_iap_review_screenshot_test.dart
+++ /dev/null
@@ -1,128 +0,0 @@
-// Headless generator for the App Store IAP review screenshot: renders the
-// Settings screen (where "광고 제거 / Remove Ads" is purchased) to a PNG.
-//
-// flutter test test/tool/generate_iap_review_screenshot_test.dart
-//
-// Output: docs/store/iap_review_remove_ads.png
-import 'dart:io';
-import 'dart:ui' as ui;
-
-import 'package:block_seasons/data/save_repository.dart';
-import 'package:block_seasons/l10n/gen/app_localizations.dart';
-import 'package:block_seasons/services/iap_service.dart';
-import 'package:block_seasons/state/providers.dart';
-import 'package:block_seasons/ui/screens/settings_screen.dart';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/rendering.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:in_app_purchase/in_app_purchase.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-// A stand-in IAP service that reports the product as available with a price,
-// so the Settings row renders exactly as a shopper sees it. initialize() is
-// never called, so no real store query happens.
-class _FakeIap extends IapService {
- _FakeIap() : super(onEntitlementGranted: _noop);
- static Future _noop() async {}
-
- @override
- bool get available => true;
-
- @override
- ProductDetails get product => ProductDetails(
- id: 'remove_ads',
- title: 'Remove Ads',
- description: 'Remove banner and interstitial ads',
- price: r'$1.99',
- rawPrice: 1.99,
- currencyCode: 'USD',
- );
-}
-
-Future _loadFont() async {
- final bytes =
- File('/System/Library/Fonts/Supplemental/Arial.ttf').readAsBytesSync();
- final loader = FontLoader('Arial')
- ..addFont(Future.value(ByteData.view(bytes.buffer)));
- await loader.load();
-}
-
-Widget _wrap(ProviderContainer c, Widget screen, GlobalKey key) =>
- UncontrolledProviderScope(
- container: c,
- child: RepaintBoundary(
- key: key,
- child: MaterialApp(
- debugShowCheckedModeBanner: false,
- locale: const Locale('en'),
- localizationsDelegates: AppLocalizations.localizationsDelegates,
- supportedLocales: AppLocalizations.supportedLocales,
- theme: ThemeData(
- fontFamily: 'Arial',
- colorScheme: ColorScheme.fromSeed(
- seedColor: const Color(0xFF5B7FFF),
- brightness: Brightness.dark,
- ),
- useMaterial3: true,
- ),
- home: screen,
- ),
- ),
- );
-
-void main() {
- testWidgets('generate IAP review screenshot', (tester) async {
- // in_app_purchase only registers a native platform for android/iOS/macOS;
- // on a desktop target it registers none, so constructing the real
- // IapService never makes a store channel call (which would throw async in
- // the headless binding). The fake overrides available/product anyway.
- debugDefaultTargetPlatformOverride = TargetPlatform.linux;
-
- await _loadFont();
-
- const dpr = 3.0;
- tester.view.devicePixelRatio = dpr;
- tester.view.physicalSize = const Size(1242, 2688); // 6.5" — accepted size
- addTearDown(tester.view.resetPhysicalSize);
- addTearDown(tester.view.resetDevicePixelRatio);
-
- SharedPreferences.setMockInitialValues({});
- final repo = SaveRepository(await SharedPreferences.getInstance());
-
- final key = GlobalKey();
- final c = ProviderContainer(overrides: [
- saveRepositoryProvider.overrideWithValue(repo),
- iapServiceProvider.overrideWithValue(_FakeIap()),
- ]);
- await tester.pumpWidget(_wrap(c, const SettingsScreen(), key));
- await tester.pump();
- await tester.pump(const Duration(milliseconds: 450));
-
- var ro = key.currentContext!.findRenderObject()!;
- while (!ro.isRepaintBoundary) {
- ro = ro.parent!;
- }
- final layer = ro.debugLayer! as OffsetLayer;
- final bounds = ro.paintBounds;
- final bytes = await tester.runAsync(() async {
- final image = await layer.toImage(bounds, pixelRatio: dpr);
- final data = await image.toByteData(format: ui.ImageByteFormat.png);
- image.dispose();
- return data;
- });
- File('docs/store/iap_review_remove_ads.png')
- ..parent.createSync(recursive: true)
- ..writeAsBytesSync(bytes!.buffer.asUint8List());
- c.dispose();
-
- // Reset before the body ends so flutter_test's foundation-var invariant
- // check (which runs before addTearDown) passes.
- debugDefaultTargetPlatformOverride = null;
-
- expect(
- File('docs/store/iap_review_remove_ads.png').existsSync(), isTrue);
- });
-}