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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
22 changes: 16 additions & 6 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,27 @@ 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
starts.

### 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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions lib/features/companion/domain/roasty_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 32 additions & 18 deletions lib/features/companion/presentation/roasty.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -135,9 +138,10 @@ class _RoastyState extends State<Roasty> 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,
),
),
),
Expand All @@ -154,15 +158,21 @@ class _RoastyState extends State<Roasty> 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;
Expand All @@ -175,36 +185,40 @@ 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();
}

/// 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;
}
33 changes: 14 additions & 19 deletions lib/features/companion/presentation/roasty_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -144,15 +139,15 @@ 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,
);

// 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;
Expand Down
Loading
Loading