Files
BlockSeasons/lib/ui/screens/season_map_screen.dart
T
airkjw 6d2d97bfcc fix: guard journey map against an empty season list
Prevents StateError when data builder is called with empty list
by displaying loading indicator instead of passing empty list to
activeSeason(), matching title screen behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:31:22 +09:00

304 lines
10 KiB
Dart

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) => list.isEmpty
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: _JourneyMap(pack: activeSeason(list)),
);
}
}
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;
}