import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../theme/tokens.dart'; /// How long the splash stays up before it fades away. The app mounts *behind* /// the splash (see [SplashGate]), so this window also masks real cold-start /// work (settings load, discovery, reconnect). const _splashHold = Duration(seconds: 2); const _splashFade = Duration(milliseconds: 350); /// Holds the [child] app under a cassette splash overlay, then fades the /// overlay out once [_splashHold] elapses. The child is live the whole time, /// so it finishes booting behind the splash rather than after it. class SplashGate extends StatefulWidget { const SplashGate({super.key, required this.child}); final Widget child; @override State createState() => _SplashGateState(); } class _SplashGateState extends State { Timer? _timer; bool _fading = false; // began fading out bool _gone = false; // fully removed from the tree @override void initState() { super.initState(); _timer = Timer(_splashHold, () { if (mounted) setState(() => _fading = true); }); } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( children: [ widget.child, if (!_gone) // Absorb taps while opaque; let them through once we start fading. IgnorePointer( ignoring: _fading, child: AnimatedOpacity( opacity: _fading ? 0 : 1, duration: _splashFade, curve: Curves.easeOut, onEnd: () { if (mounted && _fading) setState(() => _gone = true); }, child: const SplashScreen(), ), ), ], ); } } /// The full-screen cassette splash: a spinning cassette over the wordmark, on /// Timbre's near-black canvas. Rebuilds nothing per frame beyond the two /// spindle transforms. class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override State createState() => _SplashScreenState(); } class _SplashScreenState extends State with SingleTickerProviderStateMixin { // One full turn every 3s; both reels spin clockwise like a real cassette. late final AnimationController _spin = AnimationController( vsync: this, duration: const Duration(seconds: 3), )..repeat(); @override void dispose() { _spin.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ColoredBox( color: TimbreColors.background, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 240, child: _SplashCassette(spin: _spin), ), const SizedBox(height: TimbreSpacing.xl), Text( 'TIMBRE', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: TimbreColors.foreground, fontWeight: FontWeight.w600, letterSpacing: 8, ), ), const SizedBox(height: TimbreSpacing.md), // Accent hairline — the one lively color, kept small. Container( width: 32, height: 2, color: TimbreColors.accentDefault, ), const SizedBox(height: TimbreSpacing.md), Text( 'loading…', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: TimbreColors.dimmed, letterSpacing: 2, ), ), ], ), ), ); } } /// The cassette itself: static reels + shell (from the shared assets) with two /// spinning spindles. Mirrors [CassetteView]'s layering and geometry but drops /// the playback model — the reels sit at a fixed half-wound radius. class _SplashCassette extends StatelessWidget { const _SplashCassette({required this.spin}); final Animation spin; // Geometry measured off casette_shell.svg (viewBox 469×298), matching // CassetteView's _CassetteConfig. static const _viewBox = Size(469, 298); static const _leftReel = Offset(135, 135); static const _rightReel = Offset(334, 135); static const _spindleSize = 36.0; @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: _viewBox.width / _viewBox.height, child: FittedBox( fit: BoxFit.contain, child: SizedBox( width: _viewBox.width, height: _viewBox.height, child: Stack( children: [ // 1. Static tape discs behind the reel holes. Positioned.fill( child: CustomPaint(painter: _ReelPainter()), ), // 2. Spinning cogs. _spindle(_leftReel), _spindle(_rightReel), // 3. Static shell on top, framing the reels through its holes. Positioned.fill( child: SvgPicture.asset( 'assets/casette/casette_shell.svg', fit: BoxFit.fill, ), ), ], ), ), ), ); } Widget _spindle(Offset center) { final cog = SvgPicture.asset( 'assets/casette/spindle.svg', width: _spindleSize, height: _spindleSize, ); return Positioned( left: center.dx - _spindleSize / 2, top: center.dy - _spindleSize / 2, width: _spindleSize, height: _spindleSize, child: RotationTransition(turns: spin, child: cog), ); } } /// Paints the two wound-tape discs at a fixed half-wound radius, clipped to the /// window band so they never touch the label. The shell (on top) masks them to /// the reel holes + centre window. class _ReelPainter extends CustomPainter { static const _windowRect = Rect.fromLTRB(62.5, 98.5, 405.5, 172); static const _leftReel = Offset(135, 135); static const _rightReel = Offset(334, 135); static const _radius = 62.0; // fixed ~half-wound (see CassetteView physics) static const _tapeColor = Color(0xFF121212); static const _padColor = Color(0xFF1A1A1A); @override void paint(Canvas canvas, Size size) { canvas.save(); canvas.clipRect(_windowRect); canvas.drawRect(_windowRect, Paint()..color = _padColor); final tape = Paint()..color = _tapeColor; canvas.drawCircle(_leftReel, _radius, tape); canvas.drawCircle(_rightReel, _radius, tape); canvas.restore(); } @override bool shouldRepaint(_ReelPainter oldDelegate) => false; }