import 'package:flutter/material.dart'; 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'; /// 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}); @override Widget build(BuildContext context, WidgetRef ref) { final seasons = ref.watch(seasonsProvider); return seasons.when( loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), error: (e, _) => Scaffold(body: Center(child: Text('$e'))), data: (list) => _JourneyMap(pack: list.first), ); } } class _JourneyMap extends ConsumerStatefulWidget { const _JourneyMap({required this.pack}); final SeasonPack pack; @override 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 seasonComplete = totalStars == pack.stages.length * 3 && pack.stages.isNotEmpty; final locale = Localizations.localeOf(context).languageCode; final colors = ThemeColors(pack.theme); return Scaffold( 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; if (!_autoScrolled) { 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, seasonComplete, ), ], ), ), ); }, ), // 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, ), ), ], ), ), ), ], ), ); } Widget _node(BuildContext context, MapLayout layout, int i, int count, int unlocked, int stars, ThemeColors colors, bool seasonComplete) { final center = layout.nodeCenter(i, count); final isCurrent = i == unlocked && !seasonComplete; 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 : GamePalette.lockedNode, 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, ), ], ), ], ), ), ); } } class _PathPainter extends CustomPainter { const _PathPainter({required this.layout, required this.count}); final MapLayout layout; final int count; @override 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; }