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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:25:39 +09:00
parent 7bc26447f7
commit 607278928b
14 changed files with 271 additions and 15 deletions
+23
View File
@@ -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<String, dynamic>?;
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<String, StageProgress> _progress = {};
StreakState _streak = StreakState.initial;
StreakState get streak => _streak;
Future<void> 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,
},
}),
);
}
+49
View File
@@ -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,
);
}
+9 -1
View File
@@ -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"
}
}
}
}
+2 -1
View File
@@ -12,5 +12,6 @@
"plusFiveMoves": "+5 이동 (광고)",
"giveUp": "포기하기",
"playAgain": "다시 하기",
"nextStage": "다음 스테이지"
"nextStage": "다음 스테이지",
"streakMilestone": "{days}일 연속 플레이! 대단해요!"
}
+6
View File
@@ -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<GameSessionNotifier, GameViewState?>(
@@ -39,3 +41,7 @@ final contentRepositoryProvider =
final seasonsProvider = FutureProvider<List<SeasonPack>>(
(ref) => ref.read(contentRepositoryProvider).availableSeasons(),
);
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
StreakNotifier.new,
);
+16
View File
@@ -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<StreakState> {
@override
StreakState build() => ref.read(saveRepositoryProvider).streak;
Future<void> onStagePlayed(DateTime now) async {
final next = advanceStreak(state, now);
await ref.read(saveRepositoryProvider).saveStreak(next);
state = next;
}
}
+12
View File
@@ -113,12 +113,24 @@ class _GameScreenState extends ConsumerState<GameScreen> {
.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<GameViewState?>(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()));
+19 -2
View File
@@ -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(