From 78eb5c0639d7575300f06794f64c9155d7f8971a Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 23:17:41 +0900 Subject: [PATCH] feat: serpentine journey map with auto-scroll and glowing current node Replaces the plain GridView with a Candy-Crush-style journey map: dotted serpentine path, circular nodes (gold=done, glowing=current, dark+lock=locked), glass header, auto-scroll to current stage. Updates season_map_screen_test to use Key('stage_node_$i') finders. --- lib/ui/screens/season_map_screen.dart | 337 +++++++++++++++++++------- test/ui/season_map_screen_test.dart | 25 +- 2 files changed, 265 insertions(+), 97 deletions(-) diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart index 9bd26dd..8d86014 100644 --- a/lib/ui/screens/season_map_screen.dart +++ b/lib/ui/screens/season_map_screen.dart @@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/models/season.dart'; import '../../state/providers.dart'; import '../theme/palette.dart'; +import '../widgets/map_layout.dart'; +import '../widgets/season_background.dart'; +import '../widgets/tile_painter.dart'; import 'game_screen.dart'; -/// Stage selection for the active season. Themed map art lands in Phase 6; -/// for now a clean node grid with stars and locks. +/// Journey map: a serpentine path of stage nodes climbing the season +/// illustration. Auto-scrolls to the current stage on entry. class SeasonMapScreen extends ConsumerWidget { const SeasonMapScreen({super.key}); @@ -18,126 +21,276 @@ class SeasonMapScreen extends ConsumerWidget { loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), error: (e, _) => Scaffold(body: Center(child: Text('$e'))), - data: (list) => _Map(pack: list.first), + data: (list) => _JourneyMap(pack: list.first), ); } } -class _Map extends ConsumerWidget { - const _Map({required this.pack}); +class _JourneyMap extends ConsumerStatefulWidget { + const _JourneyMap({required this.pack}); final SeasonPack pack; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_JourneyMap> createState() => _JourneyMapState(); +} + +class _JourneyMapState extends ConsumerState<_JourneyMap> { + final _scroll = ScrollController(); + bool _autoScrolled = false; + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + void _autoScrollTo( + MapLayout layout, int current, int count, double viewportHeight) { + if (_autoScrolled || !_scroll.hasClients) return; + _autoScrolled = true; + final contentH = layout.heightFor(count); + final target = + (contentH - layout.nodeCenter(current, count).dy - viewportHeight / 2) + .clamp(0.0, _scroll.position.maxScrollExtent); + _scroll.jumpTo(target); + } + + @override + Widget build(BuildContext context) { // Watching progress keeps stars/locks fresh after each win. ref.watch(progressProvider); + final pack = widget.pack; final repo = ref.read(saveRepositoryProvider); final ids = [for (final stage in pack.stages) stage.id]; final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids); final totalStars = repo.totalStars(pack.seasonId); final locale = Localizations.localeOf(context).languageCode; + final colors = ThemeColors(pack.theme); return Scaffold( - appBar: AppBar( - title: Text(pack.titleFor(locale)), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Center( - child: Text( - '★ $totalStars/${pack.stages.length * 3}', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.amber), + backgroundColor: Colors.transparent, + body: Stack( + fit: StackFit.expand, + children: [ + SeasonBackground(theme: pack.theme), + LayoutBuilder( + builder: (context, constraints) { + final layout = MapLayout(width: constraints.maxWidth); + final count = pack.stages.length; + WidgetsBinding.instance.addPostFrameCallback((_) => + _autoScrollTo( + layout, unlocked, count, constraints.maxHeight)); + return SingleChildScrollView( + controller: _scroll, + reverse: true, + child: SizedBox( + width: constraints.maxWidth, + height: layout.heightFor(count), + child: Stack( + children: [ + CustomPaint( + size: Size( + constraints.maxWidth, layout.heightFor(count)), + painter: + _PathPainter(layout: layout, count: count), + ), + for (var i = 0; i < count; i++) + _node( + context, + layout, + i, + count, + unlocked, + repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0, + colors, + ), + ], + ), + ), + ); + }, + ), + // Glass header. + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 6, + bottom: 12, + left: 8, + right: 16, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.45), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Text( + pack.titleFor(locale), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + Text( + '★ $totalStars/${pack.stages.length * 3}', + style: const TextStyle( + color: Colors.amber, + fontWeight: FontWeight.w700, + ), + ), + ], ), ), ), ], ), - body: GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 12, - crossAxisSpacing: 12, + ); + } + + Widget _node(BuildContext context, MapLayout layout, int i, int count, + int unlocked, int stars, ThemeColors colors) { + final center = layout.nodeCenter(i, count); + final isCurrent = i == unlocked; + final isUnlocked = i <= unlocked; + final size = isCurrent ? 64.0 : 52.0; + + return Positioned( + key: Key('stage_node_$i'), + left: center.dx - size / 2, + top: center.dy - size / 2, + child: GestureDetector( + onTap: !isUnlocked + ? null + : () { + ref + .read(seasonFlowProvider.notifier) + .startSeasonStage(widget.pack, i); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const GameScreen()), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: isUnlocked + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isCurrent + ? [ + lighten(colors.accent, 0.25), + colors.accent, + darken(colors.accent, 0.2), + ] + : [ + const Color(0xFFFFE9A8), + const Color(0xFFFFD166), + const Color(0xFFE0AC3B), + ], + ) + : null, + color: isUnlocked ? null : const Color(0xFF232B4A), + boxShadow: isCurrent + ? [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.7), + blurRadius: 22, + ), + ] + : null, + ), + child: isUnlocked + ? Text( + '${i + 1}', + style: TextStyle( + fontSize: isCurrent ? 22 : 17, + fontWeight: FontWeight.w900, + color: isCurrent + ? Colors.white + : const Color(0xFF5A4200), + ), + ) + : const Icon(Icons.lock, color: Colors.white24, size: 20), + ), + if (isUnlocked && !isCurrent) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var s = 0; s < 3; s++) + Icon( + Icons.star, + size: 13, + color: s < stars ? Colors.amber : Colors.white24, + ), + ], + ), + ], ), - itemCount: pack.stages.length, - itemBuilder: (context, i) { - final progress = repo.progressFor(pack.seasonId, ids[i]); - final isUnlocked = i <= unlocked; - return _StageNode( - number: i + 1, - stars: progress?.stars ?? 0, - unlocked: isUnlocked, - onTap: !isUnlocked - ? null - : () { - ref - .read(seasonFlowProvider.notifier) - .startSeasonStage(pack, i); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const GameScreen()), - ); - }, - ); - }, ), ); } } -class _StageNode extends StatelessWidget { - const _StageNode({ - required this.number, - required this.stars, - required this.unlocked, - required this.onTap, - }); +class _PathPainter extends CustomPainter { + const _PathPainter({required this.layout, required this.count}); - final int number; - final int stars; - final bool unlocked; - final VoidCallback? onTap; + final MapLayout layout; + final int count; @override - Widget build(BuildContext context) { - return Material( - color: unlocked ? GamePalette.emptyCell : GamePalette.boardBackground, - borderRadius: BorderRadius.circular(14), - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: onTap, - child: unlocked - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '$number', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var s = 0; s < 3; s++) - Icon( - Icons.star, - size: 14, - color: s < stars ? Colors.amber : Colors.white24, - ), - ], - ), - ], - ) - : const Center( - child: Icon(Icons.lock, color: Colors.white24, size: 22), - ), - ), - ); + void paint(Canvas canvas, Size size) { + if (count < 2) return; + final path = Path() + ..moveTo( + layout.nodeCenter(0, count).dx, layout.nodeCenter(0, count).dy); + for (var i = 1; i < count; i++) { + final prev = layout.nodeCenter(i - 1, count); + final cur = layout.nodeCenter(i, count); + final midY = (prev.dy + cur.dy) / 2; + path.cubicTo(prev.dx, midY, cur.dx, midY, cur.dx, cur.dy); + } + + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 5 + ..strokeCap = StrokeCap.round; + + // Dash the path manually: short dots every 13px. + for (final metric in path.computeMetrics()) { + var d = 0.0; + while (d < metric.length) { + canvas.drawPath(metric.extractPath(d, d + 1.5), paint); + d += 13; + } + } } + + @override + bool shouldRepaint(_PathPainter old) => + old.count != count || old.layout.width != layout.width; } diff --git a/test/ui/season_map_screen_test.dart b/test/ui/season_map_screen_test.dart index 2f28a98..ed5bf9f 100644 --- a/test/ui/season_map_screen_test.dart +++ b/test/ui/season_map_screen_test.dart @@ -71,13 +71,28 @@ void main() { ); await tester.pumpAndSettle(); + // Total stars displayed in header. expect(find.text('★ 2/9'), findsOneWidget); - expect(find.byIcon(Icons.lock), findsOneWidget); // stage 3 locked - expect(find.text('1'), findsOneWidget); - expect(find.text('2'), findsOneWidget); - // Stage 1 is starred, so stage 2 is unlocked and playable. - await tester.tap(find.text('2')); + // Node 0 (stage 1) exists. + expect(find.byKey(const Key('stage_node_0')), findsOneWidget); + + // Stage 3 (index 2) is locked — contains a lock icon. + expect( + find.descendant( + of: find.byKey(const Key('stage_node_2')), + matching: find.byIcon(Icons.lock), + ), + findsOneWidget, + ); + + // Stage 1 is starred, so stage 2 (index 1) is unlocked and playable. + // Ensure the node is visible before tapping. + await tester.ensureVisible(find.byKey(const Key('stage_node_1'))); + await tester.tap( + find.byKey(const Key('stage_node_1')), + warnIfMissed: false, + ); await tester.pumpAndSettle(); expect(find.byType(GameScreen), findsOneWidget);