diff --git a/CLAUDE.md b/CLAUDE.md index 4b8545f8..83b20582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ explanations — plus test and iOS/SPM build notes — lives in ## Code Conventions - **Imports:** always `package:brew_path/…` within `lib/`; never relative `../` imports -- **Colours:** read the mood tokens via `context.mood` (`MoodColors`, a `ThemeExtension` with a Cupping and a Dark Roast instance); never `Theme.of(context).colorScheme` — it is populated for stock Material widgets only. Everything that must **not** flip with the mood is `static const` on an `abstract final class` with no `of(context)` accessor — `ArtColors` (illustration palette), `OverlayColors` (scrim, scrim ink, modal dim), `AppSpacing`, `AppRadii` — so mood-dependence is unrepresentable, and painters can read them with no `BuildContext`. An **overlay** is an `AppOverlay`, not a colour: it carries the design's blur radius beside its tint, and only `OverlayBarrier` renders one, so the pair cannot be split at a call site. A value that is deliberately off-token goes in the `OffTokens` register with its reason, never as a bare literal. +- **Colours:** read the mood tokens via `context.mood` (`MoodColors`, a `ThemeExtension` with a Cupping and a Dark Roast instance); never `Theme.of(context).colorScheme` — it is populated for stock Material widgets only. Everything that must **not** flip with the mood is `static const` on an `abstract final class` with no `of(context)` accessor — `ArtColors` (illustration palette), `RoastyColors` (the mascot's palette), `OverlayColors` (scrim, scrim ink, modal dim), `AppSpacing`, `AppRadii` — so mood-dependence is unrepresentable, and painters can read them with no `BuildContext`. An **overlay** is an `AppOverlay`, not a colour: it carries the design's blur radius beside its tint, and only `OverlayBarrier` renders one, so the pair cannot be split at a call site. A value that is deliberately off-token goes in the `OffTokens` register with its reason, never as a bare literal. - **Comments:** TSDoc only for complex logic or third-party integrations; skip self-evident code - **Never cite `prototype/` from `lib/`** — no file names, no line numbers. The prototype is replaced wholesale, so both go stale silently and nothing checks diff --git a/analysis_options.yaml b/analysis_options.yaml index 0ab8d110..dc07455d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -129,6 +129,10 @@ dart_code_linter: # only useful property is that it still matches the design source. # Same reason `bean_shape.dart` and the guide marks are here. - lib/features/dictionary/presentation/vocab/vocab_mark.dart + # The mascot's drawings, on the design's own 200x280 box. The + # literals are that box's coordinates and the design's own alphas; + # the colours are not among them — they live on `RoastyColors`, and + # `roasty_colors_test.dart` fails the painters on any colour literal. - lib/features/companion/presentation/roasty.dart - lib/features/companion/presentation/roasty_animation.dart - lib/features/companion/presentation/roasty_body.dart @@ -141,5 +145,6 @@ dart_code_linter: - lib/shared/theme/app_text.dart - lib/shared/theme/mood_colors.dart - lib/shared/theme/art_colors.dart + - lib/shared/theme/roasty_colors.dart - lib/shared/theme/overlay_colors.dart - lib/shared/theme/off_token.dart diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b3ca9deb..0495096c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -42,14 +42,16 @@ You can always edit this file by hand instead — the helpers just save effort. ## [Unreleased] -### Added - -- **Roasty keeps you company on the Keep Sharp card.** Once you have finished - the course, the day's recommendation shows him resting beside it, on a paper - plate, before you start. He used to appear only after you had done it. - ### Fixed +- **Roasty's sleeping z's and wrong-answer badge follow the mood.** In Dark + Roast the sleeping `z`s were drawn in the light mood's grey and all but + vanished against the dark background, and the badge on a wrong answer kept + the light mood's red. Both, plus the stars and rays of a finished module and + two of the four sparkles, now take the colours of whichever mood is on, as + the design draws them. The happy face's right cheek also blushes as strongly + as the left, as the design draws it. + - **Screen readers read the Keep Sharp card once, not three times.** The card announced its heading and its texts and then the same words again as a sentence. It now reads one sentence, and the Start button says what it @@ -57,6 +59,10 @@ You can always edit this file by hand instead — the helpers just save effort. ### Changed +- **Roasty's colours live in one place.** The mascot's browns, greens and face + colours are one named palette pinned to the design, instead of 37 loose + values across three drawings, so a retone reaches every drawing at once. + - **The dictionary now reads by tier.** Without the course, a term opens on its short explanation and stops there: the deep explanation, the example, the self-check and the sources sit behind a *Read the full entry* row that @@ -98,6 +104,10 @@ You can always edit this file by hand instead — the helpers just save effort. ### Added +- **Roasty keeps you company on the Keep Sharp card.** Once you have finished + the course, the day's recommendation shows him resting beside it, on a paper + plate, before you start. He used to appear only after you had done it. + - **You can change your name in Settings.** The name Roasty greets you by was asked once, on the setup screen, and then nowhere: a typo or a skipped step stuck until you restarted the whole intro. Settings now carries a *Name* row diff --git a/lib/features/companion/domain/roasty_state.dart b/lib/features/companion/domain/roasty_state.dart index 2a44afbf..c6ea5433 100644 --- a/lib/features/companion/domain/roasty_state.dart +++ b/lib/features/companion/domain/roasty_state.dart @@ -2,8 +2,7 @@ // ignore_for_file: public_member_api_docs /// All visual states the Roasty mascot can render. Mirrors the -/// `data-state="…"` enum used by the design-bundle prototype -/// (prototype/roasty.jsx). +/// `data-state="…"` values the design's mascot component switches on. enum RoastyState { idle, correct, diff --git a/lib/features/companion/presentation/roasty.dart b/lib/features/companion/presentation/roasty.dart index 27f8f0ea..3d83e284 100644 --- a/lib/features/companion/presentation/roasty.dart +++ b/lib/features/companion/presentation/roasty.dart @@ -6,6 +6,8 @@ import 'package:brew_path/features/companion/presentation/roasty_animation.dart' import 'package:brew_path/features/companion/presentation/roasty_body.dart'; import 'package:brew_path/features/companion/presentation/roasty_faces.dart'; import 'package:brew_path/features/companion/presentation/roasty_particles.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; /// Animated Roasty mascot. Reproduces the design's geometry + per-state @@ -31,7 +33,7 @@ class Roasty extends StatefulWidget { /// Rendered width/height in logical pixels. final double size; - /// Changing this restarts one-shot animations (mirrors the prototype key). + /// Changing this restarts one-shot animations (mirrors the design's key). final Object? replayKey; /// Whether the mascot animates. When false — or when the platform requests @@ -46,7 +48,8 @@ class Roasty extends StatefulWidget { /// Whether Roasty sits on a paper plate. The plate keeps the bean readable /// on a dark or accent-filled ground, and is pinned to one tone - /// ([roastyPlate]) so it never follows the mood into the bean's own browns. + /// ([RoastyColors.plate]) so it never follows the mood into the bean's own + /// browns. final bool plate; @override @@ -135,9 +138,10 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { builder: (context, _) => CustomPaint( painter: _RoastyPainter( state: widget.state, - t: _controller.value, + progress: _controller.value, sproutScale: widget.sproutScale, plate: widget.plate, + mood: context.mood, ), ), ), @@ -154,15 +158,21 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { class _RoastyPainter extends CustomPainter { _RoastyPainter({ required this.state, - required this.t, + required this.progress, required this.plate, + required this.mood, this.sproutScale, }); final RoastyState state; - final double t; + final double progress; final bool plate; + /// The ambient mood, for the marks the design gives to the theme rather + /// than to the mascot's palette: the celebration warn, the wrong badge's + /// berry, the sleeping `z`s' muted ink. + final MoodColors mood; + /// When non-null, overrides the state-derived sprout scale (used by the /// loading screen to grow the sprout out of Roasty's head during wake-up). final double? sproutScale; @@ -175,16 +185,19 @@ class _RoastyPainter extends CustomPainter { canvas.save(); final sx = size.width / _vbW; final sy = size.height / _vbH; - final s = math.min(sx, sy); - canvas.translate((size.width - _vbW * s) / 2, (size.height - _vbH * s) / 2); - canvas.scale(s, s); + final scale = math.min(sx, sy); + canvas.translate( + (size.width - _vbW * scale) / 2, + (size.height - _vbH * scale) / 2, + ); + canvas.scale(scale, scale); if (plate) paintRoastyPlate(canvas); - paintRoastyParticlesBack(canvas, state, t); - paintRoastySprout(canvas, state, t, sproutScale); - paintRoastyBody(canvas, state, t); + paintRoastyParticlesBack(canvas, state, progress, mood); + paintRoastySprout(canvas, state, progress, sproutScale); + paintRoastyBody(canvas, state, progress); _paintFace(canvas); - paintRoastyParticlesFront(canvas, state, t); + paintRoastyParticlesFront(canvas, state, progress, mood); canvas.restore(); } @@ -192,19 +205,20 @@ class _RoastyPainter extends CustomPainter { /// Faces ride along with the body transform, so apply it before drawing. void _paintFace(Canvas canvas) { canvas.save(); - final offset = roastyBodyOffset(state, t); + final offset = roastyBodyOffset(state, progress); canvas.translate(100 + offset.dx, 158 + offset.dy); - canvas.rotate(roastyBodyRotation(state, t)); - canvas.scale(roastyBodyScale(state, t)); + canvas.rotate(roastyBodyRotation(state, progress)); + canvas.scale(roastyBodyScale(state, progress)); canvas.translate(-100, -158); - paintRoastyFace(canvas, state); + paintRoastyFace(canvas, state, mood); canvas.restore(); } @override bool shouldRepaint(covariant _RoastyPainter old) => old.state != state || - old.t != t || + old.progress != progress || old.sproutScale != sproutScale || - old.plate != plate; + old.plate != plate || + old.mood != mood; } diff --git a/lib/features/companion/presentation/roasty_body.dart b/lib/features/companion/presentation/roasty_body.dart index d55f6c71..f9724748 100644 --- a/lib/features/companion/presentation/roasty_body.dart +++ b/lib/features/companion/presentation/roasty_body.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:brew_path/features/companion/domain/roasty_state.dart'; import 'package:brew_path/features/companion/presentation/roasty_animation.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; /// Scale origin while the host drives the grow: the stem base sits at the @@ -10,16 +11,6 @@ import 'package:flutter/material.dart'; const Offset _sproutGrowAnchor = Offset(100, 88); const Offset _sproutDefaultAnchor = Offset(100, 75); -/// The paper plate's tone, pinned rather than read from the mood. Every -/// surface token inverts with the mood while the bean stays brown, so a -/// mood-following plate would merge into the mascot in Dark Roast. The -/// design's `--roasty-plate: #FBF7EE`. -/// -/// Not an `ArtColors` token: it is Cupping's surface value held still, and -/// that palette's guard forbids any art colour that equals a mood token. It -/// lives with the mascot's other draw colours instead. -const Color roastyPlate = Color(0xFFFBF7EE); - const Offset _plateCenter = Offset(100, 140); /// Clears the contact shadow at y 232, so the bean never hangs off its plate. @@ -29,7 +20,11 @@ const double _plateRadius = 112; /// ground, where the roast browns otherwise merge into it. Goes under /// everything else Roasty draws. void paintRoastyPlate(Canvas canvas) { - canvas.drawCircle(_plateCenter, _plateRadius, Paint()..color = roastyPlate); + canvas.drawCircle( + _plateCenter, + _plateRadius, + Paint()..color = RoastyColors.plate, + ); } /// Paints the sprout (stem + leaves) above the bean. [sproutScale] overrides @@ -58,7 +53,7 @@ void paintRoastySprout( canvas.translate(-anchor.dx, -anchor.dy); final stem = Paint() - ..color = const Color(0xFF5E7148) + ..color = RoastyColors.leafDeep ..strokeWidth = 3 ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; @@ -70,7 +65,7 @@ void paintRoastySprout( const leafGradient = RadialGradient( center: Alignment(-0.3, -0.4), radius: 0.75, - colors: [Color(0xFFB5C497), Color(0xFF5E7148)], + colors: RoastyColors.leafGradient, ); const leafRect = Rect.fromLTWH(60, 55, 80, 30); final leafPaint = Paint()..shader = leafGradient.createShader(leafRect); @@ -89,7 +84,7 @@ void paintRoastySprout( canvas.drawPath(leafR, leafPaint); final vein = Paint() - ..color = const Color(0xFF5E7148).withValues(alpha: 0.6) + ..color = RoastyColors.leafDeep.withValues(alpha: 0.6) ..strokeWidth = 1 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; @@ -118,18 +113,18 @@ void paintRoastyBody(Canvas canvas, RoastyState state, double t) { // contact shadow final shadow = Paint() - ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.18); + ..color = RoastyColors.beanShadow.withValues(alpha: 0.18); canvas.drawOval( Rect.fromCenter(center: const Offset(100, 232), width: 112, height: 12), shadow, ); - // bean body — radial gradient #8C5634 → #6B3E22 → #4A2B19 + // bean body — the design's radial gradient, lit side to edge const bodyRect = Rect.fromLTWH(38, 90, 124, 136); const bodyGradient = RadialGradient( center: Alignment(-0.36, -0.36), radius: 0.75, - colors: [Color(0xFF8C5634), Color(0xFF6B3E22), Color(0xFF4A2B19)], + colors: RoastyColors.beanGradient, stops: [0.0, 0.55, 1.0], ); final bodyPaint = Paint()..shader = bodyGradient.createShader(bodyRect); @@ -144,7 +139,7 @@ void paintRoastyBody(Canvas canvas, RoastyState state, double t) { // top highlight final highlight = Paint() - ..color = const Color(0xFFA26945).withValues(alpha: 0.45); + ..color = RoastyColors.beanHighlight.withValues(alpha: 0.45); canvas.drawOval( Rect.fromCenter(center: const Offset(78, 115), width: 44, height: 28), highlight, @@ -152,7 +147,7 @@ void paintRoastyBody(Canvas canvas, RoastyState state, double t) { // bean crease final crease = Paint() - ..color = const Color(0xFF2F1A0E).withValues(alpha: 0.55) + ..color = RoastyColors.beanShadow.withValues(alpha: 0.55) ..style = PaintingStyle.stroke ..strokeWidth = 2.5 ..strokeCap = StrokeCap.round; diff --git a/lib/features/companion/presentation/roasty_faces.dart b/lib/features/companion/presentation/roasty_faces.dart index 7d4742b9..e1fab0ef 100644 --- a/lib/features/companion/presentation/roasty_faces.dart +++ b/lib/features/companion/presentation/roasty_faces.dart @@ -1,10 +1,15 @@ import 'dart:math' as math; import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; /// Paints the state-specific face onto the already-transformed bean canvas. -void paintRoastyFace(Canvas canvas, RoastyState state) { +/// +/// The face is palette-fixed except the module face's star eyes, which the +/// design gives to the mood's warn — so the host passes [mood] in. +void paintRoastyFace(Canvas canvas, RoastyState state, MoodColors mood) { switch (state) { case RoastyState.idle: _paintIdleFace(canvas); @@ -14,7 +19,7 @@ void paintRoastyFace(Canvas canvas, RoastyState state) { case RoastyState.wrong: _paintWrongFace(canvas); case RoastyState.module: - _paintModuleFace(canvas); + _paintModuleFace(canvas, mood); case RoastyState.card: _paintCardFace(canvas); case RoastyState.sleep: @@ -25,197 +30,203 @@ void paintRoastyFace(Canvas canvas, RoastyState state) { } // ── Face primitives ──────────────────────────────────────────────────── -Paint get _eyeWhite => Paint()..color = const Color(0xFFFBF7EE); -Paint get _pupil => Paint()..color = const Color(0xFF2A1B12); -Paint get _cheek => - Paint()..color = const Color(0xFFC47654).withValues(alpha: 0.45); +Paint get _eyeWhite => Paint()..color = RoastyColors.eyeWhite; +Paint get _pupil => Paint()..color = RoastyColors.mouth; +Paint get _cheek => Paint()..color = RoastyColors.blush.withValues(alpha: 0.45); Paint get _mouthStroke => Paint() - ..color = const Color(0xFF2A1B12) + ..color = RoastyColors.mouth ..style = PaintingStyle.stroke ..strokeWidth = 2.5 ..strokeCap = StrokeCap.round; -void _paintEyeOpen(Canvas c, double cx, double cy) { - c.drawOval( +void _paintEyeOpen(Canvas canvas, double cx, double cy) { + canvas.drawOval( Rect.fromCenter(center: Offset(cx, cy), width: 20, height: 24), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: Offset(cx, cy + 3), width: 10, height: 12), _pupil, ); - c.drawCircle(Offset(cx + 2, cy), 1.7, _eyeWhite); + canvas.drawCircle(Offset(cx + 2, cy), 1.7, _eyeWhite); } -void _paintEyeArchUp(Canvas c, double cx, double cy) { - final p = Path() +void _paintEyeArchUp(Canvas canvas, double cx, double cy) { + final arch = Path() ..moveTo(cx - 10, cy) ..quadraticBezierTo(cx, cy - 10, cx + 10, cy); - c.drawPath(p, _mouthStroke..strokeWidth = 3); + canvas.drawPath(arch, _mouthStroke..strokeWidth = 3); } -void _paintIdleFace(Canvas c) { - _paintEyeOpen(c, 80, 148); - _paintEyeOpen(c, 120, 148); - c.drawOval( +void _paintIdleFace(Canvas canvas) { + _paintEyeOpen(canvas, 80, 148); + _paintEyeOpen(canvas, 120, 148); + canvas.drawOval( Rect.fromCenter(center: const Offset(68, 172), width: 12, height: 6), _cheek, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(132, 172), width: 12, height: 6), _cheek, ); final mouth = Path() ..moveTo(90, 180) ..quadraticBezierTo(100, 188, 110, 180); - c.drawPath(mouth, _mouthStroke); + canvas.drawPath(mouth, _mouthStroke); } -void _paintHappyFace(Canvas c) { - _paintEyeArchUp(c, 80, 148); - _paintEyeArchUp(c, 120, 148); - c.drawOval( +void _paintHappyFace(Canvas canvas) { + _paintEyeArchUp(canvas, 80, 148); + _paintEyeArchUp(canvas, 120, 148); + final cheek = _cheek..color = RoastyColors.blush.withValues(alpha: 0.55); + canvas.drawOval( Rect.fromCenter(center: const Offset(68, 170), width: 14, height: 7), - _cheek..color = const Color(0xFFC47654).withValues(alpha: 0.55), + cheek, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(132, 170), width: 14, height: 7), - _cheek, + cheek, ); final mouth = Path() ..moveTo(86, 178) ..quadraticBezierTo(100, 192, 114, 178); - c.drawPath(mouth, _mouthStroke..strokeWidth = 3); + canvas.drawPath(mouth, _mouthStroke..strokeWidth = 3); } -void _paintWrongFace(Canvas c) { +void _paintWrongFace(Canvas canvas) { // closed-low eyes - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 148), width: 20, height: 24), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 155), width: 10, height: 10), _pupil, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 148), width: 20, height: 24), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 155), width: 10, height: 10), _pupil, ); final brow = Paint() - ..color = const Color(0xFF2A1B12) + ..color = RoastyColors.mouth ..style = PaintingStyle.stroke ..strokeWidth = 2 ..strokeCap = StrokeCap.round; - c.drawLine(const Offset(71, 138), const Offset(84, 135), brow); - c.drawLine(const Offset(129, 138), const Offset(116, 135), brow); + canvas.drawLine(const Offset(71, 138), const Offset(84, 135), brow); + canvas.drawLine(const Offset(129, 138), const Offset(116, 135), brow); final mouth = Path() ..moveTo(91, 184) ..quadraticBezierTo(100, 180, 109, 184); - c.drawPath(mouth, _mouthStroke); + canvas.drawPath(mouth, _mouthStroke); } /// Draws a five-pointed star centered at (cx, cy). Shared by the module face /// and the correct-state sparkle particles. -void paintStar(Canvas c, double cx, double cy, double r, Color color) { +void paintStar( + Canvas canvas, + double cx, + double cy, + double radius, + Color color, +) { final path = Path(); for (var i = 0; i < 10; i++) { final angle = -math.pi / 2 + i * math.pi / 5; - final rad = i.isEven ? r : r * 0.42; - final x = cx + math.cos(angle) * rad; - final y = cy + math.sin(angle) * rad; + final pointRadius = i.isEven ? radius : radius * 0.42; + final pointX = cx + math.cos(angle) * pointRadius; + final pointY = cy + math.sin(angle) * pointRadius; if (i == 0) { - path.moveTo(x, y); + path.moveTo(pointX, pointY); } else { - path.lineTo(x, y); + path.lineTo(pointX, pointY); } } path.close(); - c.drawPath(path, Paint()..color = color); + canvas.drawPath(path, Paint()..color = color); } -void _paintModuleFace(Canvas c) { - paintStar(c, 80, 148, 11, const Color(0xFFC8843A)); - paintStar(c, 120, 148, 11, const Color(0xFFC8843A)); - c.drawOval( +void _paintModuleFace(Canvas canvas, MoodColors mood) { + paintStar(canvas, 80, 148, 11, mood.warn); + paintStar(canvas, 120, 148, 11, mood.warn); + canvas.drawOval( Rect.fromCenter(center: const Offset(68, 172), width: 14, height: 7), _cheek, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(132, 172), width: 14, height: 7), _cheek, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(100, 185), width: 16, height: 18), _pupil, ); } -void _paintCardFace(Canvas c) { +void _paintCardFace(Canvas canvas) { // wide-eyed O - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 26), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), _pupil, ); - c.drawCircle(const Offset(83, 145), 2, _eyeWhite); - c.drawOval( + canvas.drawCircle(const Offset(83, 145), 2, _eyeWhite); + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 26), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), _pupil, ); - c.drawCircle(const Offset(123, 145), 2, _eyeWhite); - c.drawOval( + canvas.drawCircle(const Offset(123, 145), 2, _eyeWhite); + canvas.drawOval( Rect.fromCenter(center: const Offset(100, 184), width: 10, height: 12), _pupil, ); } -void _paintSleepFace(Canvas c) { +void _paintSleepFace(Canvas canvas) { final closed = Paint() - ..color = const Color(0xFF2A1B12) + ..color = RoastyColors.mouth ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round; - c.drawLine(const Offset(71, 150), const Offset(89, 150), closed); - c.drawLine(const Offset(111, 150), const Offset(129, 150), closed); + canvas.drawLine(const Offset(71, 150), const Offset(89, 150), closed); + canvas.drawLine(const Offset(111, 150), const Offset(129, 150), closed); final mouth = Path() ..moveTo(95, 182) ..quadraticBezierTo(100, 184, 105, 182); - c.drawPath(mouth, _mouthStroke); + canvas.drawPath(mouth, _mouthStroke); } -void _paintAwakeFace(Canvas c) { +void _paintAwakeFace(Canvas canvas) { // big O-eyes (like card but smaller) - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 146), width: 22, height: 28), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(80, 148), width: 12, height: 14), _pupil, ); - c.drawCircle(const Offset(83, 145), 2, _eyeWhite); - c.drawOval( + canvas.drawCircle(const Offset(83, 145), 2, _eyeWhite); + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 146), width: 22, height: 28), _eyeWhite, ); - c.drawOval( + canvas.drawOval( Rect.fromCenter(center: const Offset(120, 148), width: 12, height: 14), _pupil, ); - c.drawCircle(const Offset(123, 145), 2, _eyeWhite); - c.drawOval( + canvas.drawCircle(const Offset(123, 145), 2, _eyeWhite); + canvas.drawOval( Rect.fromCenter(center: const Offset(100, 184), width: 8, height: 10), _pupil, ); diff --git a/lib/features/companion/presentation/roasty_particles.dart b/lib/features/companion/presentation/roasty_particles.dart index b9e966ab..bd215b23 100644 --- a/lib/features/companion/presentation/roasty_particles.dart +++ b/lib/features/companion/presentation/roasty_particles.dart @@ -3,6 +3,8 @@ import 'dart:math' as math; import 'package:brew_path/features/companion/domain/roasty_state.dart'; import 'package:brew_path/features/companion/presentation/roasty_faces.dart'; import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; // The front particle dispatch handles the states with particles and defaults @@ -10,62 +12,78 @@ import 'package:flutter/material.dart'; // ignore_for_file: no_default_cases /// Particle layer painted behind the bean body (rays / glow). -void paintRoastyParticlesBack(Canvas canvas, RoastyState state, double t) { +/// +/// The rays are the mood's warn in the design, so the host passes [mood] in; +/// the glow is palette-fixed. +void paintRoastyParticlesBack( + Canvas canvas, + RoastyState state, + double progress, + MoodColors mood, +) { if (state == RoastyState.module) { - _paintModuleRays(canvas, t); + _paintModuleRays(canvas, progress, mood); } if (state == RoastyState.card) { - _paintCardGlow(canvas, t); + _paintCardGlow(canvas, progress); } } /// Particle layer painted in front of the bean body (sparkles, confetti, /// wrong badge, sleep zzz). -void paintRoastyParticlesFront(Canvas canvas, RoastyState state, double t) { +/// +/// Two sparkles, the wrong badge and the sleeping `z`s follow the mood in the +/// design (warn, berry and muted ink); the confetti is palette-fixed. +void paintRoastyParticlesFront( + Canvas canvas, + RoastyState state, + double progress, + MoodColors mood, +) { switch (state) { case RoastyState.correct: - _paintSparkles(canvas, t); + _paintSparkles(canvas, progress, mood); case RoastyState.lesson: case RoastyState.module: - _paintConfetti(canvas, t); + _paintConfetti(canvas, progress); case RoastyState.wrong: - _paintWrongBadge(canvas); + _paintWrongBadge(canvas, mood); case RoastyState.sleep: - _paintSleepZzz(canvas, t); + _paintSleepZzz(canvas, progress, mood); default: break; } } // ── Particles back (behind body) ─────────────────────────────────────── -void _paintModuleRays(Canvas canvas, double t) { +void _paintModuleRays(Canvas canvas, double progress, MoodColors mood) { canvas.save(); const cx = 100.0; const cy = 158.0; canvas.translate(cx, cy); - canvas.rotate(t * math.pi * 2); + canvas.rotate(progress * math.pi * 2); final paint = Paint() - ..color = const Color(0xFFC8843A).withValues(alpha: 0.55) + ..color = mood.warn.withValues(alpha: 0.55) ..strokeWidth = 2 ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; for (var i = 0; i < 8; i++) { - final a = (i / 8) * math.pi * 2; - final x1 = math.cos(a) * 80; - final y1 = math.sin(a) * 80; - final x2 = math.cos(a) * 62; - final y2 = math.sin(a) * 62; + final angle = (i / 8) * math.pi * 2; + final x1 = math.cos(angle) * 80; + final y1 = math.sin(angle) * 80; + final x2 = math.cos(angle) * 62; + final y2 = math.sin(angle) * 62; canvas.drawLine(Offset(x1, y1), Offset(x2, y2), paint); } canvas.restore(); } -void _paintCardGlow(Canvas canvas, double t) { - final pulse = math.sin(t * math.pi * 2) * 0.5 + 0.5; +void _paintCardGlow(Canvas canvas, double progress) { + final pulse = math.sin(progress * math.pi * 2) * 0.5 + 0.5; final gradient = RadialGradient( colors: [ - const Color(0xFFE6C68A).withValues(alpha: 0.6 * pulse), - const Color(0xFFE6C68A).withValues(alpha: 0), + RoastyColors.cardGlow.withValues(alpha: 0.6 * pulse), + RoastyColors.cardGlow.withValues(alpha: 0), ], ); final rect = Rect.fromCenter( @@ -78,7 +96,7 @@ void _paintCardGlow(Canvas canvas, double t) { } // ── Particles in front ───────────────────────────────────────────────── -void _paintSparkles(Canvas c, double t) { +void _paintSparkles(Canvas canvas, double progress, MoodColors mood) { const centers = [ Offset(36, 80), Offset(168, 100), @@ -86,27 +104,27 @@ void _paintSparkles(Canvas c, double t) { Offset(170, 200), ]; const delays = [0.0, 0.25, 0.5, 0.75]; - const colors = [ - Color(0xFFC8843A), - Color(0xFF7A8471), - Color(0xFFB8533A), - Color(0xFFC8843A), + final colors = [ + mood.warn, + RoastyColors.confettiMoss, + RoastyColors.confettiEmber, + mood.warn, ]; for (var i = 0; i < centers.length; i++) { - final phase = (t - delays[i]) % 1.0; + final phase = (progress - delays[i]) % 1.0; final wave = phase < 0 ? 0.0 : math.sin(phase * math.pi).abs(); final opacity = wave; final scale = 0.3 + wave * 0.7; - final p = Paint()..color = colors[i].withValues(alpha: opacity); - c.save(); - c.translate(centers[i].dx, centers[i].dy); - c.scale(scale); - paintStar(c, 0, 0, 5, p.color); - c.restore(); + final sparkle = Paint()..color = colors[i].withValues(alpha: opacity); + canvas.save(); + canvas.translate(centers[i].dx, centers[i].dy); + canvas.scale(scale); + paintStar(canvas, 0, 0, 5, sparkle.color); + canvas.restore(); } } -void _paintConfetti(Canvas c, double t) { +void _paintConfetti(Canvas canvas, double progress) { const pieces = [ [40.0, 60.0, 0.0], [158.0, 50.0, 0.2], @@ -118,18 +136,18 @@ void _paintConfetti(Canvas c, double t) { [140.0, 40.0, 0.7], ]; const colors = [ - Color(0xFFB8533A), - Color(0xFF7A8471), - Color(0xFFC8843A), - Color(0xFFB8533A), - Color(0xFF7A8471), - Color(0xFFC8843A), - Color(0xFF7A8471), - Color(0xFFB8533A), + RoastyColors.confettiEmber, + RoastyColors.confettiMoss, + RoastyColors.confettiGold, + RoastyColors.confettiEmber, + RoastyColors.confettiMoss, + RoastyColors.confettiGold, + RoastyColors.confettiMoss, + RoastyColors.confettiEmber, ]; for (var i = 0; i < pieces.length; i++) { final piece = pieces[i]; - final phase = (t + piece[2]) % 1.0; + final phase = (progress + piece[2]) % 1.0; final dy = -30 + phase * 210; final rotate = phase * 540 * math.pi / 180; final opacity = phase < 0.2 @@ -137,67 +155,80 @@ void _paintConfetti(Canvas c, double t) { : (phase > 0.95 ? (1 - phase) / 0.05 : 1.0); final paint = Paint() ..color = colors[i].withValues(alpha: opacity.clamp(0, 1)); - c.save(); - c.translate(piece[0], piece[1] + dy); - c.rotate(rotate); + canvas.save(); + canvas.translate(piece[0], piece[1] + dy); + canvas.rotate(rotate); if (i.isEven) { - c.drawRect(const Rect.fromLTWH(-3, -4, 6, 8), paint); + canvas.drawRect(const Rect.fromLTWH(-3, -4, 6, 8), paint); } else { - c.drawCircle(Offset.zero, 3, paint); + canvas.drawCircle(Offset.zero, 3, paint); } - c.restore(); + canvas.restore(); } } -void _paintWrongBadge(Canvas c) { - final fill = Paint()..color = const Color(0xFFFBF7EE); +void _paintWrongBadge(Canvas canvas, MoodColors mood) { + final fill = Paint()..color = RoastyColors.eyeWhite; final stroke = Paint() - ..color = const Color(0xFFB8533A) + ..color = mood.berry ..style = PaintingStyle.stroke ..strokeWidth = 2; - c.drawCircle(const Offset(148, 76), 13, fill); - c.drawCircle(const Offset(148, 76), 13, stroke); - final bar = Paint()..color = const Color(0xFFB8533A); - c.drawRRect( + canvas.drawCircle(const Offset(148, 76), 13, fill); + canvas.drawCircle(const Offset(148, 76), 13, stroke); + final bar = Paint()..color = mood.berry; + canvas.drawRRect( RRect.fromRectAndRadius( const Rect.fromLTWH(146, 68, 4, 9), const Radius.circular(1), ), bar, ); - c.drawCircle(const Offset(148, 82), 1.6, bar); + canvas.drawCircle(const Offset(148, 82), 1.6, bar); } -void _paintSleepZzz(Canvas c, double t) { +void _paintSleepZzz(Canvas canvas, double progress, MoodColors mood) { const letters = <({double x, double y, double size, double delay})>[ (x: 148, y: 80, size: 18, delay: 0.0), (x: 158, y: 68, size: 14, delay: 0.6 / 2.6), (x: 166, y: 58, size: 11, delay: 1.2 / 2.6), ]; - for (final l in letters) { - final raw = (t - l.delay) % 1.0; - final p = raw < 0 ? raw + 1 : raw; - final opacity = p < 0.3 ? p / 0.3 : (p > 0.7 ? (1 - p) / 0.3 : 1.0); - final dx = p * 8; - final dy = -p * 12; - final tp = TextPainter( + for (final letter in letters) { + final raw = (progress - letter.delay) % 1.0; + final phase = raw < 0 ? raw + 1 : raw; + final opacity = phase < 0.3 + ? phase / 0.3 + : (phase > 0.7 ? (1 - phase) / 0.3 : 1.0); + final dx = phase * 8; + final dy = -phase * 12; + final painter = TextPainter( text: TextSpan( text: 'z', - // Face from the ladder, size from the drawing — the same split - // `grinder_dial_view.dart` makes, so a rename in the pubspec reaches - // this `z` too. - style: TextStyle( - color: const Color( - 0xFF6B5F54, - ).withValues(alpha: opacity.clamp(0.0, 1.0)), - fontStyle: FontStyle.italic, - fontSize: l.size, - fontFamily: AppFace.display.family, - fontWeight: AppFace.display.weight, + style: roastySleepZStyle( + mood: mood, + size: letter.size, + opacity: opacity, ), ), textDirection: TextDirection.ltr, )..layout(); - tp.paint(c, Offset(l.x + dx, l.y + dy)); + painter.paint(canvas, Offset(letter.x + dx, letter.y + dy)); } } + +/// The style of one sleeping `z`: the mood's muted ink at [opacity], italic, +/// at the drawing's own [size]. +/// +/// Face from the ladder, size from the drawing — the same split +/// `grinder_dial_view.dart` makes, so a rename in the pubspec reaches this `z` +/// too. +TextStyle roastySleepZStyle({ + required MoodColors mood, + required double size, + required double opacity, +}) => TextStyle( + color: mood.inkMute.withValues(alpha: opacity.clamp(0.0, 1.0)), + fontStyle: FontStyle.italic, + fontSize: size, + fontFamily: AppFace.display.family, + fontWeight: AppFace.display.weight, +); diff --git a/lib/shared/theme/roasty_colors.dart b/lib/shared/theme/roasty_colors.dart new file mode 100644 index 00000000..c57ce380 --- /dev/null +++ b/lib/shared/theme/roasty_colors.dart @@ -0,0 +1,100 @@ +import 'package:flutter/painting.dart'; + +/// The mascot's palette — Roasty's bean, sprout and face. +/// +/// A bean is the same brown under any theme, so these are identical in both +/// moods, and they follow the rule `ArtColors` set: `static const` on a class +/// that cannot be extended, implemented or instantiated, with no `of(context)` +/// accessor. Mood-dependence is unrepresentable, and the painters can read +/// them from inside `CustomPainter.paint()`, which has no `BuildContext`. +/// +/// The named tokens are transcribed 1:1 from the `/* Roasty palette */` block +/// of the mascot's design page; the five below it are the colours the drawings +/// use that the block does not name, which the mascot component writes as the +/// same hex literal in both moods. The drift guard in +/// `test/unit/shared/theme/roasty_colors_test.dart` pins both to the source. +/// +/// **Not everything Roasty draws with is here.** The mascot component gives +/// its celebration marks to the mood: the module stars and rays and two of the +/// four sparkles are `--warn`, the wrong badge is `--berry`, and the sleeping +/// `z`s are `--ink-mute`. Those flip with the theme, so the painters read them +/// off the `MoodColors` their host hands them rather than from this class. +abstract final class RoastyColors { + /// The bean's edge — the last stop of the body gradient. + static const beanDeep = Color(0xFF4A2B19); + + /// The bean's mid-tone. + static const beanBody = Color(0xFF6B3E22); + + /// The lit side of the bean — the first stop of the body gradient. + static const beanWarm = Color(0xFF8C5634); + + /// The contact shadow under the bean, and the crease down its front. + static const beanShadow = Color(0xFF2F1A0E); + + /// The sprout's leaf green. Declared by the design; the drawings shade the + /// leaf with [leafHilite] and [leafDeep] instead. + static const leaf = Color(0xFF8A9D6B); + + /// The stem, the leaf veins, and the dark end of the leaf gradient. + static const leafDeep = Color(0xFF5E7148); + + /// The lit end of the leaf gradient. + static const leafHilite = Color(0xFFB5C497); + + /// The whites of the eyes, the catchlights, and the wrong badge's fill. + static const eyeWhite = Color(0xFFFBF7EE); + + /// Pupils, mouths, brows and closed eyes. + static const mouth = Color(0xFF2A1B12); + + /// The cheeks. + static const blush = Color(0xFFC47654); + + /// The soft top highlight on the bean. + static const beanHighlight = Color(0xFFA26945); + + /// The glow behind the bean on the card state. + static const cardGlow = Color(0xFFE6C68A); + + /// The red confetti pieces, and the small sparkle. + static const confettiEmber = Color(0xFFB8533A); + + /// The grey-green confetti pieces, and the medium sparkle. + static const confettiMoss = Color(0xFF7A8471); + + /// The gold confetti pieces. + static const confettiGold = Color(0xFFC8843A); + + /// The paper plate under the bean — the design's `--roasty-plate`, the one + /// mascot colour the component itself names. + /// + /// Pinned rather than read from the mood: every surface token inverts with + /// the mood while the bean stays brown, so a mood-following plate would + /// merge into the mascot in Dark Roast. It is Cupping's surface value held + /// still, which is why it is here and not on `ArtColors`, whose guard + /// forbids an art colour that equals a mood token. Guarded by + /// `test/unit/features/companion/roasty_plate_token_test.dart`. + static const plate = Color(0xFFFBF7EE); + + /// The bean's radial gradient, centre out: lit → mid → edge. + static const beanGradient = [beanWarm, beanBody, beanDeep]; + + /// The leaf's radial gradient, centre out: lit → dark. + static const leafGradient = [leafHilite, leafDeep]; + + /// Every named token under the name the design page calls it, for the drift + /// guard. + static const byTokenName = { + '--bean-deep': beanDeep, + '--bean-body': beanBody, + '--bean-warm': beanWarm, + '--bean-shadow': beanShadow, + '--leaf': leaf, + '--leaf-deep': leafDeep, + '--leaf-hilite': leafHilite, + '--eye-white': eyeWhite, + '--mouth': mouth, + '--blush': blush, + }; +} diff --git a/test/unit/features/companion/roasty_painters_mood_test.dart b/test/unit/features/companion/roasty_painters_mood_test.dart new file mode 100644 index 00000000..c12fe681 --- /dev/null +++ b/test/unit/features/companion/roasty_painters_mood_test.dart @@ -0,0 +1,117 @@ +import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/roasty_faces.dart'; +import 'package:brew_path/features/companion/presentation/roasty_particles.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Records the colour of every paint handed to it, and ignores the rest. +/// +/// The painters draw onto a real canvas in the app; here the question is only +/// *which colour* each mark asked for, and a recorded picture cannot answer +/// that. Every other canvas call is a no-op. +class _RecordingCanvas implements Canvas { + final colours = []; + + void _record(Paint paint) => colours.add(paint.color); + + @override + void drawPath(Path path, Paint paint) => _record(paint); + + @override + void drawLine(Offset p1, Offset p2, Paint paint) => _record(paint); + + @override + void drawCircle(Offset c, double radius, Paint paint) => _record(paint); + + @override + void drawOval(Rect rect, Paint paint) => _record(paint); + + @override + void drawRect(Rect rect, Paint paint) => _record(paint); + + @override + void drawRRect(RRect rrect, Paint paint) => _record(paint); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// The RGB bits of a colour, without its alpha. +const _rgbMask = 0x00FFFFFF; + +/// Whether any recorded mark is [colour] at any opacity — the design sets its +/// alphas per mark, and only the hue is under test here. Compared as 8-bit +/// channels because `Paint.color` reads back float32-rounded components that +/// no longer equal the `Color` the painter set. +bool _uses(List colours, Color colour) => colours.any( + (recorded) => recorded.toARGB32() & _rgbMask == colour.toARGB32() & _rgbMask, +); + +const _moods = <(String, MoodColors)>[ + ('cupping', MoodColors.cupping), + ('darkRoast', MoodColors.darkRoast), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// The mascot component gives these marks to the mood: the module stars and + /// rays and two sparkles are `--warn`, the wrong badge `--berry`, the + /// sleeping `z`s `--ink-mute`. The other mood's value must be absent too, or + /// a painter pinned to Cupping would pass under Cupping. + for (final (name, mood) in _moods) { + final other = mood == MoodColors.cupping + ? MoodColors.darkRoast + : MoodColors.cupping; + + group('under $name', () { + test("the module face stars are the mood's warn", () { + final canvas = _RecordingCanvas(); + paintRoastyFace(canvas, RoastyState.module, mood); + expect(_uses(canvas.colours, mood.warn), isTrue); + expect(_uses(canvas.colours, other.warn), isFalse); + }); + + test("the module rays are the mood's warn", () { + final canvas = _RecordingCanvas(); + paintRoastyParticlesBack(canvas, RoastyState.module, 0, mood); + expect(_uses(canvas.colours, mood.warn), isTrue); + expect(_uses(canvas.colours, other.warn), isFalse); + }); + + test("the wrong badge is the mood's berry on the eye white", () { + final canvas = _RecordingCanvas(); + paintRoastyParticlesFront(canvas, RoastyState.wrong, 0, mood); + expect(_uses(canvas.colours, mood.berry), isTrue); + expect(_uses(canvas.colours, other.berry), isFalse); + expect(_uses(canvas.colours, RoastyColors.eyeWhite), isTrue); + }); + + test("the sparkles are the mood's warn beside two fixed confetti", () { + final canvas = _RecordingCanvas(); + paintRoastyParticlesFront(canvas, RoastyState.correct, 0, mood); + expect(_uses(canvas.colours, mood.warn), isTrue); + expect(_uses(canvas.colours, other.warn), isFalse); + expect(_uses(canvas.colours, RoastyColors.confettiMoss), isTrue); + expect(_uses(canvas.colours, RoastyColors.confettiEmber), isTrue); + }); + + test("the sleeping z is the mood's muted ink", () { + final style = roastySleepZStyle(mood: mood, size: 18, opacity: 0.5); + expect(style.color, mood.inkMute.withValues(alpha: 0.5)); + }); + }); + } + + test('the confetti is fixed in both moods', () { + for (final (_, mood) in _moods) { + final canvas = _RecordingCanvas(); + paintRoastyParticlesFront(canvas, RoastyState.lesson, 0, mood); + expect(_uses(canvas.colours, RoastyColors.confettiEmber), isTrue); + expect(_uses(canvas.colours, RoastyColors.confettiMoss), isTrue); + expect(_uses(canvas.colours, RoastyColors.confettiGold), isTrue); + } + }); +} diff --git a/test/unit/features/companion/roasty_plate_token_test.dart b/test/unit/features/companion/roasty_plate_token_test.dart index d14affcb..70434d4a 100644 --- a/test/unit/features/companion/roasty_plate_token_test.dart +++ b/test/unit/features/companion/roasty_plate_token_test.dart @@ -1,7 +1,7 @@ import 'dart:io'; -import 'package:brew_path/features/companion/presentation/roasty_body.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,7 +18,7 @@ void main() { expect(declared, isNotNull, reason: 'the design no longer pins a plate'); expect( - roastyPlate, + RoastyColors.plate, Color(0xFF000000 | int.parse(declared!.group(1)!, radix: 16)), ); }); @@ -26,7 +26,7 @@ void main() { test('the plate does not follow the mood into Dark Roast', () { // The point of pinning it: on the dark surface the bean would otherwise // sit on a plate the same colour as the ground behind it. - expect(roastyPlate, isNot(MoodColors.darkRoast.surface)); - expect(roastyPlate, isNot(MoodColors.darkRoast.accent)); + expect(RoastyColors.plate, isNot(MoodColors.darkRoast.surface)); + expect(RoastyColors.plate, isNot(MoodColors.darkRoast.accent)); }); } diff --git a/test/unit/shared/theme/roasty_colors_test.dart b/test/unit/shared/theme/roasty_colors_test.dart new file mode 100644 index 00000000..b7c4ec2d --- /dev/null +++ b/test/unit/shared/theme/roasty_colors_test.dart @@ -0,0 +1,220 @@ +import 'dart:io'; + +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/dart_sources.dart'; + +/// The mascot's palette, transcribed from the `/* Roasty palette */` block of +/// the mascot's design page (`prototype/Mascot - Roasty.html`). A bean is the +/// same brown under any theme, so these are identical in both moods. Any drift +/// between this table and [RoastyColors] is a bug in the app, never in the +/// table — and `design page` below proves the table itself has not drifted +/// from the source it was transcribed from. +const _spec = { + '--bean-deep': Color(0xFF4A2B19), + '--bean-body': Color(0xFF6B3E22), + '--bean-warm': Color(0xFF8C5634), + '--bean-shadow': Color(0xFF2F1A0E), + '--leaf': Color(0xFF8A9D6B), + '--leaf-deep': Color(0xFF5E7148), + '--leaf-hilite': Color(0xFFB5C497), + '--eye-white': Color(0xFFFBF7EE), + '--mouth': Color(0xFF2A1B12), + '--blush': Color(0xFFC47654), +}; + +/// The colours the drawings use that the palette block does not name. The +/// mascot component (`prototype/roasty.jsx`) writes each as a hex literal in +/// both moods, which is what makes them palette rather than theme. +const _unnamed = { + 'beanHighlight': Color(0xFFA26945), + 'cardGlow': Color(0xFFE6C68A), + 'confettiEmber': Color(0xFFB8533A), + 'confettiMoss': Color(0xFF7A8471), + 'confettiGold': Color(0xFFC8843A), +}; + +/// The palette as the app states it, by the design source's own token names. +Map get _tokens => RoastyColors.byTokenName; + +void main() { + group('the mascot palette', () { + test('is the 10 tokens the design page names, valued 1:1', () { + expect(_tokens, _spec); + }); + + test('shares no value with the marks the painters read off the mood', () { + // roasty_painters_mood_test.dart asserts the *other* mood's warn, berry + // and muted ink are absent from a painted frame. That only proves the + // painter read the mood if no palette colour happens to equal one. + for (final mood in [MoodColors.cupping, MoodColors.darkRoast]) { + for (final colour in [..._spec.values, ..._unnamed.values]) { + expect([ + mood.warn, + mood.berry, + mood.inkMute, + ], isNot(contains(colour))); + } + } + }); + + test('carries the five colours the drawings use unnamed', () { + expect({ + 'beanHighlight': RoastyColors.beanHighlight, + 'cardGlow': RoastyColors.cardGlow, + 'confettiEmber': RoastyColors.confettiEmber, + 'confettiMoss': RoastyColors.confettiMoss, + 'confettiGold': RoastyColors.confettiGold, + }, _unnamed); + }); + }); + + group('the gradients', () { + test('the bean runs warm → body → deep, the design gradient order', () { + expect(RoastyColors.beanGradient, [ + RoastyColors.beanWarm, + RoastyColors.beanBody, + RoastyColors.beanDeep, + ]); + }); + + test('the leaf runs highlight → deep', () { + expect(RoastyColors.leafGradient, [ + RoastyColors.leafHilite, + RoastyColors.leafDeep, + ]); + }); + }); + + group('the design page', () { + test('still declares the palette block this test transcribes', () { + final css = File('prototype/Mascot - Roasty.html').readAsStringSync(); + final block = RegExp( + r'/\* Roasty palette \*/(.*?)\}', + dotAll: true, + ).firstMatch(css); + expect(block, isNotNull, reason: 'the /* Roasty palette */ block moved'); + + final declared = {}; + for (final match in RegExp( + r'(--[a-z-]+)\s*:\s*#([0-9A-Fa-f]{6})', + ).allMatches(block!.group(1)!)) { + declared[match.group(1)!] = Color( + 0xFF000000 | int.parse(match.group(2)!, radix: 16), + ); + } + + expect( + declared, + _spec, + reason: + 'the design page declares a different mascot palette than this ' + 'test transcribes — update the table, then the tokens.', + ); + }); + + test('the mascot component still draws the unnamed five where it did', () { + // `contains('#B8533A')` anywhere in the file would pass on a hat that + // happens to share the confetti's red, so each colour is read off the + // drawing that uses it. + expect(_fillsIn(_group('confetti')), { + _hex(RoastyColors.confettiEmber), + _hex(RoastyColors.confettiMoss), + _hex(RoastyColors.confettiGold), + }); + expect(_fillsIn(_group('sparkles')), { + _hex(RoastyColors.confettiMoss), + _hex(RoastyColors.confettiEmber), + }); + + final glow = RegExp( + 'gr-glow-.*?', + dotAll: true, + ).firstMatch(_jsx); + expect(glow, isNotNull, reason: 'the card glow gradient moved'); + expect( + RegExp( + 'stopColor="(#[0-9A-Fa-f]{6})"', + ).allMatches(glow!.group(0)!).map((match) => match.group(1)).toSet(), + {_hex(RoastyColors.cardGlow)}, + ); + + expect( + _jsx, + contains( + RegExp( + ']*fill="' + '${_hex(RoastyColors.beanHighlight)}"', + ), + ), + reason: 'the bean highlight has moved or been retoned', + ); + }); + + test('the mascot component still gives the mood marks to the theme', () { + // The painters read these off the mood rather than the palette because + // the component draws them with theme tokens. If the design ever pins + // one of them, it belongs on RoastyColors and this says so. + expect(_group('module-rays'), contains('stroke="var(--warn)"')); + expect( + 'fill="var(--warn)"'.allMatches(_group('face-module')), + hasLength(2), + ); + expect('fill="var(--warn)"'.allMatches(_group('sparkles')), hasLength(2)); + expect('var(--berry)'.allMatches(_group('wrong-x')), hasLength(3)); + expect(_group('sleep-zzz'), contains('fill="var(--ink-mute)"')); + }); + }); + + test( + 'no Roasty painter spells a colour — it reads the palette or the mood', + () { + // The whole reason the palette exists: 37 literals across three painters + // were correct only until the design retoned the bean, and nothing would + // have said so. A colour is either on `RoastyColors` (fixed in both + // moods) or read off the mood the host hands the painter — never spelled + // inline. + final offenders = []; + + for (final file in dartSourcesUnder('lib/features/companion')) { + for (final match in RegExp( + r'Color\(0x[0-9A-Fa-f]+\)|Color\.from(ARGB|RGBO)\(|\bColors\.\w+', + ).allMatches(withoutComments(file.readAsStringSync()))) { + offenders.add('${file.path} spells ${match.group(0)}'); + } + } + + expect( + offenders, + isEmpty, + reason: + 'put the colour on RoastyColors, or read it from the mood:\n' + '${offenders.join('\n')}', + ); + }, + ); +} + +/// The mascot component the app screens render, in both moods. +final String _jsx = File('prototype/roasty.jsx').readAsStringSync(); + +/// The markup of the `className="…"` group in [_jsx], up to the comment that +/// opens the next section. +String _group(String className) { + final start = _jsx.indexOf('className="$className"'); + expect(start, isNot(-1), reason: 'roasty.jsx has no $className group'); + final end = _jsx.indexOf('{/*', start); + return _jsx.substring(start, end == -1 ? _jsx.length : end); +} + +/// Every hex fill in [markup]. +Set _fillsIn(String markup) => RegExp( + 'fill="(#[0-9A-Fa-f]{6})"', +).allMatches(markup).map((match) => match.group(1)!).toSet(); + +/// [colour] the way the component spells it. +String _hex(Color colour) => + '#${colour.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; diff --git a/test/unit/shared/theme/unthemed_constants_guard_test.dart b/test/unit/shared/theme/unthemed_constants_guard_test.dart index 3622e60a..2bf9f829 100644 --- a/test/unit/shared/theme/unthemed_constants_guard_test.dart +++ b/test/unit/shared/theme/unthemed_constants_guard_test.dart @@ -17,6 +17,7 @@ import 'package:flutter_test/flutter_test.dart'; const _unthemedFiles = [ 'lib/shared/theme/art_colors.dart', 'lib/shared/theme/overlay_colors.dart', + 'lib/shared/theme/roasty_colors.dart', 'lib/shared/theme/app_radii.dart', 'lib/shared/theme/app_spacing.dart', 'lib/shared/theme/off_token.dart', diff --git a/test/widget/features/companion/roasty_plate_test.dart b/test/widget/features/companion/roasty_plate_test.dart index ca01467b..1937f790 100644 --- a/test/widget/features/companion/roasty_plate_test.dart +++ b/test/widget/features/companion/roasty_plate_test.dart @@ -2,7 +2,7 @@ import 'dart:typed_data'; import 'package:brew_path/features/companion/domain/roasty_state.dart'; import 'package:brew_path/features/companion/presentation/roasty.dart'; -import 'package:brew_path/features/companion/presentation/roasty_body.dart'; +import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -77,13 +77,13 @@ void main() { testWidgets('with a plate he sits on pinned paper', (tester) async { final colour = await _sampleAt(tester, _plated, _aboveTheSprout); - expect(colour, roastyPlate); + expect(colour, RoastyColors.plate); }); testWidgets('the plate goes under the bean, not over it', (tester) async { final colour = await _sampleAt(tester, _plated, _onTheBean); expect(colour.a, _opaque); - expect(colour, isNot(roastyPlate)); + expect(colour, isNot(RoastyColors.plate)); }); }