223 lines
6.7 KiB
Dart
223 lines
6.7 KiB
Dart
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;
|
||
}
|