diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 2891e60..6c87d53 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -6,6 +6,8 @@ import '../../game/models/stage.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../state/providers.dart'; import '../widgets/banner_ad_slot.dart'; +import '../widgets/fade_route.dart'; +import '../widgets/pressable_scale.dart'; import '../widgets/season_background.dart'; import 'game_screen.dart'; import 'season_map_screen.dart'; @@ -67,43 +69,45 @@ class HomeScreen extends ConsumerWidget { ), ], const SizedBox(height: 44), - FilledButton( - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 56, vertical: 18), - textStyle: Theme.of(context).textTheme.titleLarge, + PressableScale( + child: FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 56, vertical: 18), + textStyle: Theme.of(context).textTheme.titleLarge, + ), + onPressed: () { + if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; + Navigator.of(context).push( + fadeRoute(const SeasonMapScreen()), + ); + }, + child: Text(l10n.adventure), ), - onPressed: () { - if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SeasonMapScreen()), - ); - }, - child: Text(l10n.adventure), ), const SizedBox(height: 14), - OutlinedButton( - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 40, vertical: 14), - textStyle: Theme.of(context).textTheme.titleMedium, + PressableScale( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 40, vertical: 14), + textStyle: Theme.of(context).textTheme.titleMedium, + ), + onPressed: () { + if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; + ref.read(seasonFlowProvider.notifier).clear(); + ref.read(analyticsProvider).endlessStart(); + ref.read(gameSessionProvider.notifier).startStage( + StageConfig.endless( + seed: DateTime.now().millisecondsSinceEpoch, + ), + ); + Navigator.of(context).push( + fadeRoute(const GameScreen()), + ); + }, + child: Text(l10n.classic), ), - onPressed: () { - if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; - ref.read(seasonFlowProvider.notifier).clear(); - ref.read(analyticsProvider).endlessStart(); - ref.read(gameSessionProvider.notifier).startStage( - StageConfig.endless( - seed: DateTime.now().millisecondsSinceEpoch, - ), - ); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const GameScreen()), - ); - }, - child: Text(l10n.classic), ), if (best > 0) ...[ const SizedBox(height: 10), @@ -127,7 +131,7 @@ class HomeScreen extends ConsumerWidget { child: IconButton( icon: const Icon(Icons.settings, color: Colors.white70), onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), + fadeRoute(const SettingsScreen()), ), ), ), diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart index 8088cba..daed0cc 100644 --- a/lib/ui/screens/season_map_screen.dart +++ b/lib/ui/screens/season_map_screen.dart @@ -5,7 +5,9 @@ import '../../game/models/season.dart'; import '../../state/providers.dart'; import '../theme/palette.dart'; import '../widgets/banner_ad_slot.dart'; +import '../widgets/fade_route.dart'; import '../widgets/map_layout.dart'; +import '../widgets/pressable_scale.dart'; import '../widgets/season_background.dart'; import '../widgets/tile_painter.dart'; import 'game_screen.dart'; @@ -193,79 +195,81 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> { 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, + child: PressableScale( + child: GestureDetector( + onTap: !isUnlocked + ? null + : () { + ref + .read(seasonFlowProvider.notifier) + .startSeasonStage(widget.pack, i); + Navigator.of(context).push( + fadeRoute(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), ), - ] - : null, + ) + : const Icon(Icons.lock, color: Colors.white24, size: 20), ), - child: isUnlocked - ? Text( - '${i + 1}', - style: TextStyle( - fontSize: isCurrent ? 22 : 17, - fontWeight: FontWeight.w900, - color: isCurrent - ? Colors.white - : const Color(0xFF5A4200), + 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, ), - ) - : 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, - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); diff --git a/lib/ui/widgets/fade_route.dart b/lib/ui/widgets/fade_route.dart new file mode 100644 index 0000000..ba77b24 --- /dev/null +++ b/lib/ui/widgets/fade_route.dart @@ -0,0 +1,22 @@ +// lib/ui/widgets/fade_route.dart +import 'package:flutter/material.dart'; + +/// A gentle fade(+slight scale) page transition for in-app navigation. +Route fadeRoute(Widget page) { + return PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 320), + reverseTransitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, _, _) => page, + transitionsBuilder: (_, animation, _, child) { + final curved = + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.98, end: 1.0).animate(curved), + child: child, + ), + ); + }, + ); +} diff --git a/lib/ui/widgets/pressable_scale.dart b/lib/ui/widgets/pressable_scale.dart new file mode 100644 index 0000000..e7944cd --- /dev/null +++ b/lib/ui/widgets/pressable_scale.dart @@ -0,0 +1,37 @@ +// lib/ui/widgets/pressable_scale.dart +import 'package:flutter/material.dart'; + +/// Wraps a tappable child with a quick scale-down on press for tactile feel. +/// If [onTap] is provided it handles the tap; otherwise the child's own +/// gesture/button handles it and this only adds the visual squish. +class PressableScale extends StatefulWidget { + const PressableScale({super.key, required this.child, this.onTap}); + + final Widget child; + final VoidCallback? onTap; + + @override + State createState() => _PressableScaleState(); +} + +class _PressableScaleState extends State { + bool _down = false; + void _set(bool v) => setState(() => _down = v); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => _set(true), + onTapUp: (_) => _set(false), + onTapCancel: () => _set(false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _down ? 0.94 : 1.0, + duration: const Duration(milliseconds: 90), + curve: Curves.easeOut, + child: widget.child, + ), + ); + } +}