updates
This commit is contained in:
parent
d6144c4483
commit
4493bcea5e
47 changed files with 371 additions and 8 deletions
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
223
lib/widgets/splash_screen.dart
Normal file
223
lib/widgets/splash_screen.dart
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue