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 0000000..ff04b9b Binary files /dev/null and b/docs/screenshots/sim_home_ko.png differ 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);