From 8aa1ad7ac4733879cdfe53c6552b4fa06c6892b0 Mon Sep 17 00:00:00 2001 From: uday chauhan Date: Tue, 13 Oct 2026 11:19:24 +0530 Subject: [PATCH] feat: Developers Coffee cold-start motion sting Brass line-art cup + steam, wordmark fade, tap-to-skip, reduced-motion static frame. Tabs mount after it ends. Documented in DESIGN.md Motion. Co-Authored-By: Claude Sonnet 5.5 --- DESIGN.md | 2 + app/lib/app.dart | 24 +++++- app/lib/branding/dc_mark_painter.dart | 89 +++++++++++++++++++++ app/lib/branding/dc_motion.dart | 23 ++++++ app/lib/branding/dc_splash.dart | 108 ++++++++++++++++++++++++++ app/test/branding/dc_splash_test.dart | 47 +++++++++++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 app/lib/branding/dc_mark_painter.dart create mode 100644 app/lib/branding/dc_motion.dart create mode 100644 app/lib/branding/dc_splash.dart create mode 100644 app/test/branding/dc_splash_test.dart diff --git a/DESIGN.md b/DESIGN.md index e6360b3..d518efd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -143,6 +143,7 @@ Radius scale: `sm` 6px (small badges/tags), `md` 14px (buttons, cosmetic item ca - **Easing:** enter(ease-out) exit(ease-in) move(ease-in-out) - **Duration:** micro(50-100ms) short(150-250ms) medium(250-400ms) long(400-700ms) - **The one authored moment:** the first-launch reveal ceremony — the avatar assembles/materializes (fade + scale-in, medium duration, ease-out) before any UI chrome appears. This is the product's single biggest emotional beat and the only place a real choreography moment is earned; everywhere else, motion just confirms a state changed (equip, purchase, save). +- **Developers Coffee sting (brand chrome, not a product moment):** ~1.9s at cold start, before the app mounts. Brass line-art cup traces itself (0–500ms, ease-in-out), two steam curls rise and fade (400–1100ms, ease-out, staggered), the "Developers Coffee" wordmark (Instrument Serif) fades up (700–1000ms), then the whole sting fades out (1650–1900ms, ease-in). Tap skips. With reduced motion: static frame for 600ms. Flat brass strokes on `ground` only — no glow, no gradient. Timing lives in `app/lib/branding/dc_motion.dart`; the mark is `dc_mark_painter.dart`. The tabs (and native avatar overlay) mount only after it ends. ## Decisions Log | Date | Decision | Rationale | @@ -152,3 +153,4 @@ Radius scale: `sm` 6px (small badges/tags), `md` 14px (buttons, cosmetic item ca | 2026-09-23 | No gradients/glow anywhere, including for "premium" surfaces | Corrects a first-draft mistake (radial glow behind avatar) that matched a recognizable AI-generated look; real offset shadows only | | 2026-09-23 | Original brand-inspired cosmetic naming only, no real trademarks | Real brand names (Ray-Ban/Gucci/Prada) rejected for trademark/IP risk per docs/legal/asset-policy.md | | 2026-09-28 | External design critique's orange/`#111114`/generic-editorial-type direction rejected; execution-only polish accepted inside the existing system | Reverses no logged decision here — brass, warm charcoal and Instrument Serif stand. `AmiroCard`, `SectionLabel`, `EmptyState` and spacing/radius tokens (`AmiroSpacing`/`AmiroRadius`) extracted so screens stop re-deriving the same card decoration slightly differently each time. Disabled `FilledButton` now matches the already-written "40% opacity, no shadow" spec, which no screen had implemented. New product behaviour from the critique (outfit presets, background/lighting picker, before/after preview, unlock animations, live username check) deferred to separate follow-up work, not folded into this polish pass. | +| 2026-10-13 | Added Developers Coffee cold-start sting | Publisher brand mark; kept short, skippable, and brass-only so the first-launch avatar reveal stays the one authored product moment | diff --git a/app/lib/app.dart b/app/lib/app.dart index 29ad0f0..37bf097 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -8,6 +8,7 @@ import 'avatar/avatar_screen.dart'; import 'avatar/avatar_providers.dart'; import 'avatar/avatar_identity_sync.dart'; import 'avatar/persistent_thermion_overlay.dart'; +import 'branding/dc_splash.dart'; import 'discovery/passport_screen.dart'; import 'sharing/incoming_share_listener.dart'; import 'sharing/share_screen.dart'; @@ -22,11 +23,32 @@ class AmiroApp extends StatelessWidget { return MaterialApp( title: 'Amiro', theme: buildAmiroTheme(), - home: const IncomingShareListener(child: _RootTabs()), + home: const _SplashGate(), ); } } +/// Shows the Developers Coffee sting first; the tabs (and the native avatar +/// overlay) mount only after it ends so nothing native renders under it. +class _SplashGate extends StatefulWidget { + const _SplashGate(); + + @override + State<_SplashGate> createState() => _SplashGateState(); +} + +class _SplashGateState extends State<_SplashGate> { + var _splashDone = false; + + @override + Widget build(BuildContext context) { + if (_splashDone) { + return const IncomingShareListener(child: _RootTabs()); + } + return DcSplash(onDone: () => setState(() => _splashDone = true)); + } +} + class _RootTabs extends ConsumerStatefulWidget { const _RootTabs(); diff --git a/app/lib/branding/dc_mark_painter.dart b/app/lib/branding/dc_mark_painter.dart new file mode 100644 index 0000000..f8bae38 --- /dev/null +++ b/app/lib/branding/dc_mark_painter.dart @@ -0,0 +1,89 @@ + +import 'package:flutter/rendering.dart'; + +import '../theme/amiro_theme.dart'; + +/// Brass line-art cup with two steam curls, drawn in a 120x120 box. +/// Flat strokes only: no glow, no gradient (DESIGN.md). +class DcMarkPainter extends CustomPainter { + /// 0..1 progress of the cup outline trace. + final double cup; + + /// 0..1 progress of each steam curl (draws up, then fades). + final double steamA; + final double steamB; + + const DcMarkPainter({ + required this.cup, + required this.steamA, + required this.steamB, + }); + + static const _box = 120.0; + + static final Path _cupPath = Path() + ..moveTo(16, 50) + ..lineTo(92, 50) + ..lineTo(92, 78) + ..quadraticBezierTo(92, 98, 72, 98) + ..lineTo(36, 98) + ..quadraticBezierTo(16, 98, 16, 78) + ..close() + ..moveTo(92, 58) + ..cubicTo(112, 56, 112, 84, 90, 84) + ..moveTo(28, 108) + ..lineTo(80, 108); + + static final Path _steam1 = Path() + ..moveTo(40, 40) + ..cubicTo(32, 32, 48, 26, 40, 16) + ..cubicTo(36, 11, 40, 7, 42, 4); + + static final Path _steam2 = Path() + ..moveTo(62, 40) + ..cubicTo(54, 32, 70, 26, 62, 16) + ..cubicTo(58, 11, 62, 7, 64, 4); + + @override + void paint(Canvas canvas, Size size) { + final scale = size.shortestSide / _box; + canvas.save(); + canvas.scale(scale); + + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = AmiroColors.primary; + + _drawTrimmed(canvas, _cupPath, cup, stroke); + _drawSteam(canvas, _steam1, steamA, stroke); + _drawSteam(canvas, _steam2, steamB, stroke); + canvas.restore(); + } + + /// Steam draws up over the first 70% of its progress, then fades out. + void _drawSteam(Canvas canvas, Path path, double t, Paint stroke) { + if (t <= 0) return; + final draw = (t / 0.7).clamp(0.0, 1.0); + final fade = t <= 0.7 ? 1.0 : 1.0 - (t - 0.7) / 0.3; + final paint = Paint() + ..style = stroke.style + ..strokeWidth = stroke.strokeWidth + ..strokeCap = stroke.strokeCap + ..color = stroke.color.withValues(alpha: fade.clamp(0.0, 1.0)); + _drawTrimmed(canvas, path, draw, paint); + } + + void _drawTrimmed(Canvas canvas, Path path, double t, Paint paint) { + if (t <= 0) return; + for (final metric in path.computeMetrics()) { + canvas.drawPath(metric.extractPath(0, metric.length * t), paint); + } + } + + @override + bool shouldRepaint(DcMarkPainter old) => + old.cup != cup || old.steamA != steamA || old.steamB != steamB; +} diff --git a/app/lib/branding/dc_motion.dart b/app/lib/branding/dc_motion.dart new file mode 100644 index 0000000..8548895 --- /dev/null +++ b/app/lib/branding/dc_motion.dart @@ -0,0 +1,23 @@ +import 'package:flutter/animation.dart'; + +/// Developers Coffee sting timing (DESIGN.md "Motion"). One controller drives +/// every phase; each phase is an [Interval] of [total]. +abstract final class DcMotion { + static const total = Duration(milliseconds: 1900); + + /// Reduced-motion: a static frame, then straight to the app. + static const reducedHold = Duration(milliseconds: 600); + + /// Cup outline traces itself (move: ease-in-out). + static const cup = Interval(0.0, 0.26, curve: Curves.easeInOut); + + /// Two steam curls rise and fade (enter: ease-out), staggered. + static const steamA = Interval(0.21, 0.53, curve: Curves.easeOut); + static const steamB = Interval(0.29, 0.58, curve: Curves.easeOut); + + /// Wordmark fades up beneath the cup (enter: ease-out). + static const wordmark = Interval(0.37, 0.53, curve: Curves.easeOut); + + /// Whole sting fades out into the app (exit: ease-in). + static const exit = Interval(0.87, 1.0, curve: Curves.easeIn); +} diff --git a/app/lib/branding/dc_splash.dart b/app/lib/branding/dc_splash.dart new file mode 100644 index 0000000..21f12e5 --- /dev/null +++ b/app/lib/branding/dc_splash.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../theme/amiro_theme.dart'; +import 'dc_mark_painter.dart'; +import 'dc_motion.dart'; + +/// Developers Coffee brand sting shown once at cold start. Calls [onDone] +/// exactly once, when the sting ends or the user taps to skip. +class DcSplash extends StatefulWidget { + final VoidCallback onDone; + + const DcSplash({super.key, required this.onDone}); + + @override + State createState() => _DcSplashState(); +} + +class _DcSplashState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + var _done = false; + var _started = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: DcMotion.total) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) _finish(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + if (MediaQuery.disableAnimationsOf(context)) { + // Static final frame, then hand off. + _controller.value = DcMotion.wordmark.end; + Future.delayed(DcMotion.reducedHold, _finish); + } else { + _controller.forward(); + } + } + + void _finish() { + if (_done || !mounted) return; + _done = true; + widget.onDone(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wordmarkStyle = Theme.of(context).textTheme.titleLarge?.copyWith( + color: AmiroColors.text, + letterSpacing: 0.01 * 23, + ); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _finish, + child: ColoredBox( + color: AmiroColors.ground, + child: Semantics( + label: 'Developers Coffee', + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final t = _controller.value; + final out = 1.0 - DcMotion.exit.transform(t); + return Opacity( + opacity: out, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: 120, + child: CustomPaint( + painter: DcMarkPainter( + cup: DcMotion.cup.transform(t), + steamA: DcMotion.steamA.transform(t), + steamB: DcMotion.steamB.transform(t), + ), + ), + ), + const SizedBox(height: AmiroSpacing.lg), + Opacity( + opacity: DcMotion.wordmark.transform(t), + child: Text('Developers Coffee', style: wordmarkStyle), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/app/test/branding/dc_splash_test.dart b/app/test/branding/dc_splash_test.dart new file mode 100644 index 0000000..5347c3c --- /dev/null +++ b/app/test/branding/dc_splash_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:amiro_app/branding/dc_motion.dart'; +import 'package:amiro_app/branding/dc_splash.dart'; +import 'package:amiro_app/theme/amiro_theme.dart'; + +Widget _host(VoidCallback onDone, {bool reduced = false}) => MaterialApp( + theme: buildAmiroTheme(loadFonts: false), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Scaffold(body: DcSplash(onDone: onDone)), + ), + ); + +void main() { + testWidgets('calls onDone once after the full duration', (tester) async { + var done = 0; + await tester.pumpWidget(_host(() => done++)); + await tester.pump(DcMotion.total - const Duration(milliseconds: 100)); + expect(done, 0); + await tester.pump(const Duration(milliseconds: 200)); + expect(done, 1); + await tester.pump(const Duration(seconds: 1)); + expect(done, 1); + }); + + testWidgets('tap skips, and onDone still fires only once', (tester) async { + var done = 0; + await tester.pumpWidget(_host(() => done++)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.byType(DcSplash)); + expect(done, 1); + await tester.pump(DcMotion.total); + expect(done, 1); + }); + + testWidgets('reduced motion shows the wordmark statically then hands off', + (tester) async { + var done = 0; + await tester.pumpWidget(_host(() => done++, reduced: true)); + expect(find.text('Developers Coffee'), findsOneWidget); + expect(done, 0); + await tester.pump(DcMotion.reducedHold + const Duration(milliseconds: 50)); + expect(done, 1); + }); +}