From 607278928ba27953950a92ee9bc96ce53fad24d8 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 17:25:39 +0900 Subject: [PATCH] Add daily streak system and normalize bundle id to com.airkjw.blockseasons Pure advanceStreak (1-day grace none, milestone flags at 3/7/14/30), persisted in the save blob; StreakNotifier advances on every finished attempt; home screen flame chip and milestone snackbar. Fix flutter create's camelCased iOS bundle id and underscored Android application id to the agreed lowercase form before any store registration. Co-Authored-By: Claude Fable 5 --- android/app/build.gradle.kts | 5 +- docs/screenshots/sim_home_ko.png | Bin 0 -> 88582 bytes ios/Runner.xcodeproj/project.pbxproj | 12 ++--- lib/data/save_repository.dart | 23 +++++++++ lib/data/streak.dart | 49 ++++++++++++++++++ lib/l10n/app_en.arb | 10 +++- lib/l10n/app_ko.arb | 3 +- lib/state/providers.dart | 6 +++ lib/state/streak_notifier.dart | 16 ++++++ lib/ui/screens/game_screen.dart | 12 +++++ lib/ui/screens/home_screen.dart | 21 +++++++- test/data/streak_test.dart | 74 +++++++++++++++++++++++++++ test/state/streak_notifier_test.dart | 38 ++++++++++++++ test/widget_test.dart | 17 ++++-- 14 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 docs/screenshots/sim_home_ko.png create mode 100644 lib/data/streak.dart create mode 100644 lib/state/streak_notifier.dart create mode 100644 test/data/streak_test.dart create mode 100644 test/state/streak_notifier_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6cf713b..258055c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,9 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.airkjw.block_seasons" + // Store-facing application id; the internal namespace keeps the + // generated package name. + applicationId = "com.airkjw.blockseasons" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/docs/screenshots/sim_home_ko.png b/docs/screenshots/sim_home_ko.png new file mode 100644 index 0000000000000000000000000000000000000000..ff04b9b66aa34bbfbcbaa56d1d5bae2f66a5b8d6 GIT binary patch literal 88582 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 literal 0 HcmV?d00001 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d7b1962..f16ab31 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -478,7 +478,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -495,7 +495,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -513,7 +513,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -529,7 +529,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -661,7 +661,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -684,7 +684,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons; + PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart index 227961a..0543a00 100644 --- a/lib/data/save_repository.dart +++ b/lib/data/save_repository.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import 'streak.dart'; + class StageProgress { const StageProgress({required this.stars, required this.bestScore}); @@ -24,6 +26,14 @@ class SaveRepository { bestScore: value['bestScore'] as int, ); } + final streak = json['streak'] as Map?; + if (streak != null) { + _streak = StreakState( + current: streak['current'] as int, + best: streak['best'] as int, + lastYmd: streak['lastYmd'] as String?, + ); + } } } @@ -34,6 +44,14 @@ class SaveRepository { final SharedPreferences _prefs; final Map _progress = {}; + StreakState _streak = StreakState.initial; + + StreakState get streak => _streak; + + Future saveStreak(StreakState streak) { + _streak = streak; + return _flush(); + } static String _id(String seasonId, String stageId) => '$seasonId/$stageId'; @@ -88,6 +106,11 @@ class SaveRepository { 'bestScore': entry.value.bestScore, }, }, + 'streak': { + 'current': _streak.current, + 'best': _streak.best, + 'lastYmd': _streak.lastYmd, + }, }), ); } diff --git a/lib/data/streak.dart b/lib/data/streak.dart new file mode 100644 index 0000000..2ae24b3 --- /dev/null +++ b/lib/data/streak.dart @@ -0,0 +1,49 @@ +/// Daily streak: at least one stage attempt (win or lose) per local +/// calendar day keeps it alive. No clock-cheat defense — single-player, +/// low stakes. +class StreakState { + const StreakState({ + required this.current, + required this.best, + required this.lastYmd, + this.hitMilestone, + }); + + static const initial = StreakState(current: 0, best: 0, lastYmd: null); + + static const milestones = [3, 7, 14, 30]; + + final int current; + final int best; + + /// Local date of the last counted play, as `yyyy-MM-dd`. + final String? lastYmd; + + /// Set when this advance just reached a milestone (celebrate once). + final int? hitMilestone; +} + +String _ymd(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + +StreakState advanceStreak(StreakState state, DateTime now) { + final today = _ymd(now); + if (state.lastYmd == today) { + return StreakState( + current: state.current, + best: state.best, + lastYmd: today, + ); + } + // Normalized constructor handles month/year boundaries (and DST). + final yesterday = _ymd(DateTime(now.year, now.month, now.day - 1)); + final current = state.lastYmd == yesterday ? state.current + 1 : 1; + return StreakState( + current: current, + best: current > state.best ? current : state.best, + lastYmd: today, + hitMilestone: StreakState.milestones.contains(current) ? current : null, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eaf884f..fc72489 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12,5 +12,13 @@ "plusFiveMoves": "+5 moves (ad)", "giveUp": "Give up", "playAgain": "Play again", - "nextStage": "Next stage" + "nextStage": "Next stage", + "streakMilestone": "{days}-day streak! Keep it up!", + "@streakMilestone": { + "placeholders": { + "days": { + "type": "int" + } + } + } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 844900c..5dd475f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -12,5 +12,6 @@ "plusFiveMoves": "+5 이동 (광고)", "giveUp": "포기하기", "playAgain": "다시 하기", - "nextStage": "다음 스테이지" + "nextStage": "다음 스테이지", + "streakMilestone": "{days}일 연속 플레이! 대단해요!" } diff --git a/lib/state/providers.dart b/lib/state/providers.dart index acdf6ef..707e35e 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -2,11 +2,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/content_repository.dart'; import '../data/save_repository.dart'; +import '../data/streak.dart'; import '../game/models/season.dart'; import '../services/audio_service.dart'; import 'game_session_notifier.dart'; import 'progress_notifier.dart'; import 'season_flow_notifier.dart'; +import 'streak_notifier.dart'; final gameSessionProvider = NotifierProvider( @@ -39,3 +41,7 @@ final contentRepositoryProvider = final seasonsProvider = FutureProvider>( (ref) => ref.read(contentRepositoryProvider).availableSeasons(), ); + +final streakProvider = NotifierProvider( + StreakNotifier.new, +); diff --git a/lib/state/streak_notifier.dart b/lib/state/streak_notifier.dart new file mode 100644 index 0000000..db47805 --- /dev/null +++ b/lib/state/streak_notifier.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/streak.dart'; +import 'providers.dart'; + +/// Daily streak state; advanced once per stage attempt (win or lose). +class StreakNotifier extends Notifier { + @override + StreakState build() => ref.read(saveRepositoryProvider).streak; + + Future onStagePlayed(DateTime now) async { + final next = advanceStreak(state, now); + await ref.read(saveRepositoryProvider).saveStreak(next); + state = next; + } +} diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index d30ae88..7e189ff 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -113,12 +113,24 @@ class _GameScreenState extends ConsumerState { .recordWin(stars: next.starsEarned, score: next.score); } if (next.phase == GamePhase.lost) audio.play(Sfx.lose); + if (next.phase == GamePhase.won || next.phase == GamePhase.lost) { + ref.read(streakProvider.notifier).onStagePlayed(DateTime.now()); + } } } @override Widget build(BuildContext context) { ref.listen(gameSessionProvider, _onSessionChange); + ref.listen(streakProvider, (prev, next) { + final milestone = next.hitMilestone; + if (milestone != null && prev?.hitMilestone != milestone) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.streakMilestone(milestone))), + ); + } + }); final view = ref.watch(gameSessionProvider); if (view == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index dc6389b..d91d944 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../l10n/gen/app_localizations.dart'; +import '../../state/providers.dart'; import 'season_map_screen.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final streak = ref.watch(streakProvider); return Scaffold( body: SafeArea( child: Center( @@ -21,6 +24,20 @@ class HomeScreen extends StatelessWidget { fontWeight: FontWeight.bold, ), ), + if (streak.current > 0) ...[ + const SizedBox(height: 12), + Chip( + avatar: const Icon( + Icons.local_fire_department, + color: Colors.deepOrange, + size: 20, + ), + label: Text( + '${streak.current}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], const SizedBox(height: 48), FilledButton( style: FilledButton.styleFrom( diff --git a/test/data/streak_test.dart b/test/data/streak_test.dart new file mode 100644 index 0000000..65c62bf --- /dev/null +++ b/test/data/streak_test.dart @@ -0,0 +1,74 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/data/streak.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('advanceStreak', () { + test('first ever play starts the streak at 1', () { + final next = advanceStreak(StreakState.initial, DateTime(2026, 6, 11)); + expect(next.current, 1); + expect(next.best, 1); + expect(next.lastYmd, '2026-06-11'); + }); + + test('same-day repeat play changes nothing', () { + final day1 = advanceStreak(StreakState.initial, DateTime(2026, 6, 11)); + final again = advanceStreak(day1, DateTime(2026, 6, 11, 23, 59)); + expect(again.current, 1); + expect(again.best, 1); + }); + + test('consecutive days grow the streak and best follows', () { + var s = advanceStreak(StreakState.initial, DateTime(2026, 6, 11)); + s = advanceStreak(s, DateTime(2026, 6, 12)); + s = advanceStreak(s, DateTime(2026, 6, 13)); + expect(s.current, 3); + expect(s.best, 3); + }); + + test('missing a day resets current but keeps best', () { + var s = advanceStreak(StreakState.initial, DateTime(2026, 6, 11)); + s = advanceStreak(s, DateTime(2026, 6, 12)); + s = advanceStreak(s, DateTime(2026, 6, 15)); + expect(s.current, 1); + expect(s.best, 2); + }); + + test('month boundaries count as consecutive days', () { + var s = advanceStreak(StreakState.initial, DateTime(2026, 6, 30)); + s = advanceStreak(s, DateTime(2026, 7, 1)); + expect(s.current, 2); + }); + + test('milestone crossings are flagged once', () { + var s = StreakState(current: 2, best: 6, lastYmd: '2026-06-10'); + final next = advanceStreak(s, DateTime(2026, 6, 11)); + expect(next.current, 3); + expect(next.hitMilestone, 3); + + s = StreakState(current: 3, best: 6, lastYmd: '2026-06-11'); + final after = advanceStreak(s, DateTime(2026, 6, 12)); + expect(after.hitMilestone, isNull, reason: '4 is not a milestone'); + }); + }); + + group('SaveRepository streak persistence', () { + test('round-trips streak state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final repo = SaveRepository(prefs); + expect(repo.streak.current, 0); + + await repo.saveStreak( + const StreakState(current: 5, best: 9, lastYmd: '2026-06-11'), + ); + final reloaded = SaveRepository(prefs); + expect(reloaded.streak.current, 5); + expect(reloaded.streak.best, 9); + expect(reloaded.streak.lastYmd, '2026-06-11'); + }); + }); +} diff --git a/test/state/streak_notifier_test.dart b/test/state/streak_notifier_test.dart new file mode 100644 index 0000000..af4e6c6 --- /dev/null +++ b/test/state/streak_notifier_test.dart @@ -0,0 +1,38 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('onStagePlayed advances, persists, and surfaces milestones', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final repo = SaveRepository(prefs); + final container = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + final notifier = container.read(streakProvider.notifier); + expect(container.read(streakProvider).current, 0); + + await notifier.onStagePlayed(DateTime(2026, 6, 11)); + await notifier.onStagePlayed(DateTime(2026, 6, 12)); + await notifier.onStagePlayed(DateTime(2026, 6, 13)); + + final state = container.read(streakProvider); + expect(state.current, 3); + expect(state.hitMilestone, 3); + + // Persisted: a fresh repository over the same prefs sees the streak. + expect(SaveRepository(prefs).streak.current, 3); + + // Same-day replays neither grow nor re-flag. + await notifier.onStagePlayed(DateTime(2026, 6, 13, 22)); + expect(container.read(streakProvider).current, 3); + expect(container.read(streakProvider).hitMilestone, isNull); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index dbdb802..05be38c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,10 +1,21 @@ -import 'package:flutter_test/flutter_test.dart'; - import 'package:block_seasons/app.dart'; +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { testWidgets('home screen shows title and play button', (tester) async { - await tester.pumpWidget(const BlockSeasonsApp()); + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + + await tester.pumpWidget( + ProviderScope( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + child: const BlockSeasonsApp(), + ), + ); await tester.pumpAndSettle(); expect(find.text('Block Seasons'), findsOneWidget);