Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
24 changes: 23 additions & 1 deletion app/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down
89 changes: 89 additions & 0 deletions app/lib/branding/dc_mark_painter.dart
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions app/lib/branding/dc_motion.dart
Original file line number Diff line number Diff line change
@@ -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);
}
108 changes: 108 additions & 0 deletions app/lib/branding/dc_splash.dart
Original file line number Diff line number Diff line change
@@ -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<DcSplash> createState() => _DcSplashState();
}

class _DcSplashState extends State<DcSplash>
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<void>.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),
),
],
),
),
);
},
),
),
),
);
}
}
47 changes: 47 additions & 0 deletions app/test/branding/dc_splash_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading