feat: session content sync trigger and newest-season selection

Add seasonRefreshProvider (once-per-session FutureProvider) and activeSeason()
helper; HomeScreen listens and invalidates seasonsProvider when new packs arrive;
season_map_screen and season_title_screen switch from list.first to activeSeason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:28:39 +09:00
parent 73a56aeeb1
commit 4fa5564975
5 changed files with 76 additions and 2 deletions
+10
View File
@@ -44,6 +44,16 @@ final seasonsProvider = FutureProvider<List<SeasonPack>>(
(ref) => ref.read(contentRepositoryProvider).availableSeasons(),
);
/// One background content sync per app session. Home listens and refreshes
/// the season list when new packs arrived.
final seasonRefreshProvider = FutureProvider<bool>(
(ref) => ref.read(contentRepositoryProvider).refresh(),
);
/// The season players land in by default: the newest available.
/// (availableSeasons is sorted by seasonId ascending.)
SeasonPack activeSeason(List<SeasonPack> seasons) => seasons.last;
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
StreakNotifier.new,
);
+5
View File
@@ -21,6 +21,11 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(seasonRefreshProvider, (_, next) {
if (next is AsyncData<bool> && next.value == true) {
ref.invalidate(seasonsProvider);
}
});
final l10n = AppLocalizations.of(context)!;
final streak = ref.watch(streakProvider);
final best = ref.watch(endlessBestProvider);
+1 -1
View File
@@ -21,7 +21,7 @@ class SeasonMapScreen extends ConsumerWidget {
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
data: (list) => _JourneyMap(pack: list.first),
data: (list) => _JourneyMap(pack: activeSeason(list)),
);
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ class _SeasonTitleScreenState extends ConsumerState<SeasonTitleScreen> {
_auto?.cancel();
_auto = Timer(const Duration(milliseconds: 1600), _go);
}
final pack = list.first;
final pack = activeSeason(list);
final locale = Localizations.localeOf(context).languageCode;
final number = int.tryParse(pack.seasonId.split('_').last) ?? 1;
return GestureDetector(
+59
View File
@@ -0,0 +1,59 @@
import 'package:block_seasons/data/content_repository.dart';
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/game/models/stage.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeRepo extends ContentRepository {
_FakeRepo(this.result);
final bool result;
int calls = 0;
@override
Future<bool> refresh() async {
calls++;
return result;
}
}
SeasonPack _pack(String id) => SeasonPack(
schemaVersion: 1,
seasonId: id,
version: 1,
title: const {'en': 'Test Season', 'ko': '테스트 시즌'},
theme: SeasonTheme.fallback,
stages: [
StageConfig(
id: 's1',
seed: 1,
moveLimit: 10,
preset: const [],
objectives: const [],
stars: const StarThresholds(twoMovesLeft: 2, threeMovesLeft: 4),
generatorProfile: 'mid',
),
],
);
void main() {
test('seasonRefreshProvider runs refresh once and exposes the result',
() async {
final repo = _FakeRepo(true);
final container = ProviderContainer(
overrides: [contentRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
expect(await container.read(seasonRefreshProvider.future), isTrue);
// Re-reading does not re-run (FutureProvider caches).
expect(await container.read(seasonRefreshProvider.future), isTrue);
expect(repo.calls, 1);
});
test('activeSeason picks the newest by id', () {
final p1 = _pack('season_001');
final p2 = _pack('season_002');
expect(activeSeason([p1, p2]).seasonId, 'season_002');
});
}