This commit is contained in:
Forrest 2026-08-04 20:19:10 -04:00
parent d6144c4483
commit 4493bcea5e
47 changed files with 371 additions and 8 deletions

View file

@ -10,6 +10,7 @@ import 'shell/app_shell.dart';
import 'state/providers.dart';
import 'theme/accent.dart';
import 'theme/app_theme.dart';
import 'widgets/splash_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -50,7 +51,9 @@ class TimbreApp extends ConsumerWidget {
title: 'Timbre',
debugShowCheckedModeBanner: false,
theme: buildTimbreTheme(accent),
home: const AppShell(),
// AppShell mounts under the splash and boots behind it; the splash fades
// out after a couple of seconds.
home: const SplashGate(child: AppShell()),
);
}
}

View file

@ -0,0 +1,223 @@
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;
}