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:
@@ -20,8 +20,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// Store-facing application id; the internal namespace keeps the
|
||||||
applicationId = "com.airkjw.block_seasons"
|
// generated package name.
|
||||||
|
applicationId = "com.airkjw.blockseasons"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -478,7 +478,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -529,7 +529,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -661,7 +661,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -684,7 +684,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockSeasons;
|
PRODUCT_BUNDLE_IDENTIFIER = com.airkjw.blockseasons;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'streak.dart';
|
||||||
|
|
||||||
class StageProgress {
|
class StageProgress {
|
||||||
const StageProgress({required this.stars, required this.bestScore});
|
const StageProgress({required this.stars, required this.bestScore});
|
||||||
|
|
||||||
@@ -24,6 +26,14 @@ class SaveRepository {
|
|||||||
bestScore: value['bestScore'] as int,
|
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 SharedPreferences _prefs;
|
||||||
final Map<String, StageProgress> _progress = {};
|
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';
|
static String _id(String seasonId, String stageId) => '$seasonId/$stageId';
|
||||||
|
|
||||||
@@ -88,6 +106,11 @@ class SaveRepository {
|
|||||||
'bestScore': entry.value.bestScore,
|
'bestScore': entry.value.bestScore,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'streak': {
|
||||||
|
'current': _streak.current,
|
||||||
|
'best': _streak.best,
|
||||||
|
'lastYmd': _streak.lastYmd,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -12,5 +12,13 @@
|
|||||||
"plusFiveMoves": "+5 moves (ad)",
|
"plusFiveMoves": "+5 moves (ad)",
|
||||||
"giveUp": "Give up",
|
"giveUp": "Give up",
|
||||||
"playAgain": "Play again",
|
"playAgain": "Play again",
|
||||||
"nextStage": "Next stage"
|
"nextStage": "Next stage",
|
||||||
|
"streakMilestone": "{days}-day streak! Keep it up!",
|
||||||
|
"@streakMilestone": {
|
||||||
|
"placeholders": {
|
||||||
|
"days": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -12,5 +12,6 @@
|
|||||||
"plusFiveMoves": "+5 이동 (광고)",
|
"plusFiveMoves": "+5 이동 (광고)",
|
||||||
"giveUp": "포기하기",
|
"giveUp": "포기하기",
|
||||||
"playAgain": "다시 하기",
|
"playAgain": "다시 하기",
|
||||||
"nextStage": "다음 스테이지"
|
"nextStage": "다음 스테이지",
|
||||||
|
"streakMilestone": "{days}일 연속 플레이! 대단해요!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../data/content_repository.dart';
|
import '../data/content_repository.dart';
|
||||||
import '../data/save_repository.dart';
|
import '../data/save_repository.dart';
|
||||||
|
import '../data/streak.dart';
|
||||||
import '../game/models/season.dart';
|
import '../game/models/season.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
import 'progress_notifier.dart';
|
import 'progress_notifier.dart';
|
||||||
import 'season_flow_notifier.dart';
|
import 'season_flow_notifier.dart';
|
||||||
|
import 'streak_notifier.dart';
|
||||||
|
|
||||||
final gameSessionProvider =
|
final gameSessionProvider =
|
||||||
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||||
@@ -39,3 +41,7 @@ final contentRepositoryProvider =
|
|||||||
final seasonsProvider = FutureProvider<List<SeasonPack>>(
|
final seasonsProvider = FutureProvider<List<SeasonPack>>(
|
||||||
(ref) => ref.read(contentRepositoryProvider).availableSeasons(),
|
(ref) => ref.read(contentRepositoryProvider).availableSeasons(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
|
||||||
|
StreakNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,12 +113,24 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
.recordWin(stars: next.starsEarned, score: next.score);
|
.recordWin(stars: next.starsEarned, score: next.score);
|
||||||
}
|
}
|
||||||
if (next.phase == GamePhase.lost) audio.play(Sfx.lose);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
ref.listen<GameViewState?>(gameSessionProvider, _onSessionChange);
|
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);
|
final view = ref.watch(gameSessionProvider);
|
||||||
if (view == null) {
|
if (view == null) {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
|
import '../../state/providers.dart';
|
||||||
import 'season_map_screen.dart';
|
import 'season_map_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final streak = ref.watch(streakProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -21,6 +24,20 @@ class HomeScreen extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
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),
|
const SizedBox(height: 48),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+14
-3
@@ -1,10 +1,21 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:block_seasons/app.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() {
|
void main() {
|
||||||
testWidgets('home screen shows title and play button', (tester) async {
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Block Seasons'), findsOneWidget);
|
expect(find.text('Block Seasons'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user