diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index be89dd1..b68f2bf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -28,5 +28,14 @@ "type": "int" } } + }, + "seasonLabel": "SEASON", + "seasonStages": "{count} stages", + "@seasonStages": { + "placeholders": { + "count": { + "type": "int" + } + } } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4e563ff..6753c8a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -14,5 +14,7 @@ "playAgain": "다시 하기", "nextStage": "다음 스테이지", "streakMilestone": "{days}일 연속 플레이! 대단해요!", - "almostThere": "{percent}% 달성!" + "almostThere": "{percent}% 달성!", + "seasonLabel": "SEASON", + "seasonStages": "{count}개 스테이지" } diff --git a/lib/ui/screens/season_title_screen.dart b/lib/ui/screens/season_title_screen.dart new file mode 100644 index 0000000..30a0c82 --- /dev/null +++ b/lib/ui/screens/season_title_screen.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/gen/app_localizations.dart'; +import '../../state/providers.dart'; +import '../widgets/season_background.dart'; +import 'home_screen.dart'; + +/// Cold-start interstitial: "SEASON 1 · First Bloom". Tap anywhere or wait +/// ~1.6s. If content somehow fails to load we bail straight to home. +class SeasonTitleScreen extends ConsumerStatefulWidget { + const SeasonTitleScreen({super.key}); + + @override + ConsumerState createState() => + _SeasonTitleScreenState(); +} + +class _SeasonTitleScreenState extends ConsumerState { + Timer? _auto; + bool _navigated = false; + bool _dataTimerArmed = false; + + void _go() { + if (_navigated || !mounted) return; + _navigated = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeScreen()), + ); + } + + @override + void dispose() { + _auto?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final seasons = ref.watch(seasonsProvider); + final l10n = AppLocalizations.of(context)!; + return seasons.when( + loading: () { + _auto ??= Timer(const Duration(milliseconds: 2500), _go); + return const Scaffold( + backgroundColor: Color(0xFF0E1430), body: SizedBox()); + }, + error: (e, st) { + WidgetsBinding.instance.addPostFrameCallback((_) => _go()); + return const Scaffold( + backgroundColor: Color(0xFF0E1430), body: SizedBox()); + }, + data: (list) { + if (!_dataTimerArmed) { + _dataTimerArmed = true; + _auto?.cancel(); + _auto = Timer(const Duration(milliseconds: 1600), _go); + } + final pack = list.first; + final locale = Localizations.localeOf(context).languageCode; + final number = int.tryParse(pack.seasonId.split('_').last) ?? 1; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _go, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + SeasonBackground(theme: pack.theme), + SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${l10n.seasonLabel} $number', + style: TextStyle( + letterSpacing: 6, + fontSize: 14, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 10), + Text( + pack.titleFor(locale), + style: const TextStyle( + fontSize: 38, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + Text( + l10n.seasonStages(pack.stages.length), + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/screens/splash_screen.dart b/lib/ui/screens/splash_screen.dart index e75d57c..195830c 100644 --- a/lib/ui/screens/splash_screen.dart +++ b/lib/ui/screens/splash_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'home_screen.dart'; +import 'season_title_screen.dart'; -Widget _defaultNextScreen() => const HomeScreen(); +Widget _defaultNextScreen() => const SeasonTitleScreen(); /// Logo-assembly splash: four glossy blocks fly in to form a 2x2 mark, the /// wordmark fades in, then we hand off. SaveRepository is already opened in diff --git a/test/ui/season_title_screen_test.dart b/test/ui/season_title_screen_test.dart new file mode 100644 index 0000000..5c403b2 --- /dev/null +++ b/test/ui/season_title_screen_test.dart @@ -0,0 +1,68 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/models/season.dart'; +import 'package:block_seasons/game/models/stage.dart'; +import 'package:block_seasons/l10n/gen/app_localizations.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:block_seasons/ui/screens/home_screen.dart'; +import 'package:block_seasons/ui/screens/season_title_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +SeasonPack _pack() => SeasonPack( + schemaVersion: 1, + seasonId: 'season_001', + version: 1, + title: const {'en': 'First Bloom', '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', + ), + ], + ); + +Widget _app(SaveRepository repo) => ProviderScope( + overrides: [ + saveRepositoryProvider.overrideWithValue(repo), + seasonsProvider.overrideWith((ref) async => [_pack()]), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SeasonTitleScreen(), + ), + ); + +void main() { + testWidgets('shows season title then auto-advances to home', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + await tester.pumpWidget(_app(repo)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('First Bloom'), findsOneWidget); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.byType(HomeScreen), findsOneWidget); + }); + + testWidgets('tap skips immediately', (tester) async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + await tester.pumpWidget(_app(repo)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('First Bloom')); + await tester.pumpAndSettle(); + expect(find.byType(HomeScreen), findsOneWidget); + }); +}