mobile-music/lib/widgets/splash_screen.dart
2026-08-04 20:19:10 -04:00

223 lines
6.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SplashGate> createState() => _SplashGateState();
}
class _SplashGateState extends State<SplashGate> {
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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
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<double> 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;
}