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
9 changes: 9 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ dart_code_linter:
metrics-exclude:
- test/**
- integration_test/**
# A registry of bank loaders — one method per bank, by design, the same way
# the painters below are one method per drawing.
- lib/shared/repositories/content_repository.dart
# CustomPainter with many small _paintX helpers — method count is by design.
- lib/features/companion/presentation/roasty.dart
- lib/features/companion/presentation/roasty_body.dart
- lib/features/companion/presentation/roasty_faces.dart
- lib/features/companion/presentation/roasty_particles.dart
- lib/features/companion/presentation/roasty_sprouts.dart
- lib/features/companion/presentation/roasty_hats.dart
- lib/features/companion/presentation/roasty_gear.dart
# Declarative GoRouter route table — long by nature, low logic complexity.
- lib/app/app_router.dart
# A flat 14-token colour table. Its constructor, copyWith, lerp, == and
Expand Down Expand Up @@ -142,6 +148,9 @@ dart_code_linter:
- lib/features/companion/presentation/roasty_body.dart
- lib/features/companion/presentation/roasty_faces.dart
- lib/features/companion/presentation/roasty_particles.dart
- lib/features/companion/presentation/roasty_sprouts.dart
- lib/features/companion/presentation/roasty_hats.dart
- lib/features/companion/presentation/roasty_gear.dart
- lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart
- lib/features/onboarding/presentation/loading/loading_animation.dart
- lib/shared/theme/app_spacing.dart
Expand Down
28 changes: 28 additions & 0 deletions assets/content/generated/companion_gear.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"generated": "Generated by tool/extract_content.js from the prototype source. Do not edit by hand — rerun the script instead.",
"source": "customize.jsx",
"bank": "companion_gear",
"schemaVersion": 2,
"items": [
{
"id": "none",
"label": "None"
},
{
"id": "glasses",
"label": "Glasses"
},
{
"id": "sunglasses",
"label": "Shades"
},
{
"id": "scarf",
"label": "Scarf"
},
{
"id": "headphones",
"label": "Headphones"
}
]
}
24 changes: 24 additions & 0 deletions assets/content/generated/companion_hats.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated": "Generated by tool/extract_content.js from the prototype source. Do not edit by hand — rerun the script instead.",
"source": "customize.jsx",
"bank": "companion_hats",
"schemaVersion": 2,
"items": [
{
"id": "none",
"label": "None"
},
{
"id": "beanie",
"label": "Beanie"
},
{
"id": "field",
"label": "Field hat"
},
{
"id": "cap",
"label": "Cap"
}
]
}
28 changes: 28 additions & 0 deletions assets/content/generated/companion_roasts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"generated": "Generated by tool/extract_content.js from the prototype source. Do not edit by hand — rerun the script instead.",
"source": "customize.jsx",
"bank": "companion_roasts",
"schemaVersion": 2,
"items": [
{
"id": "light",
"label": "Light",
"swatch": "#A87B4F"
},
{
"id": "medium",
"label": "Medium",
"swatch": "#6B3E22"
},
{
"id": "dark",
"label": "Dark",
"swatch": "#4A2C19"
},
{
"id": "espresso",
"label": "Espresso",
"swatch": "#2F1B10"
}
]
}
24 changes: 24 additions & 0 deletions assets/content/generated/companion_sprouts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated": "Generated by tool/extract_content.js from the prototype source. Do not edit by hand — rerun the script instead.",
"source": "customize.jsx",
"bank": "companion_sprouts",
"schemaVersion": 2,
"items": [
{
"id": "leaf",
"label": "Leaves"
},
{
"id": "flower",
"label": "Blossom"
},
{
"id": "sprig",
"label": "Cherry"
},
{
"id": "none",
"label": "Bare"
}
]
}
8 changes: 8 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ You can always edit this file by hand instead — the helpers just save effort.

### Added

- **Roasty can be dressed, and he stays dressed.** A second Studio door opens
a wardrobe with four axes — roast, hat, accessory and sprout — over a live
preview: pick one and the mascot changes under your thumb, confirm and he
wears it on every screen in the app, through a restart and across devices.
Free learners see the plain bean everywhere and the door asks for Plus, and
Reset Progress leaves the outfit alone: it is something the learner chose,
not something they did.

- **The match board is a drag board, and it draws what you paired.** Its cue
has said *drag to pair* since the formats got their names, but the board only
took taps and drew nothing between its two columns, so a cleared board was
Expand Down
13 changes: 12 additions & 1 deletion lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'package:brew_path/app/app_router.dart';
import 'package:brew_path/app/app_theme.dart';
import 'package:brew_path/app/day_rollover_watcher.dart';
import 'package:brew_path/features/challenges/presentation/challenge_expiry_watcher.dart';
import 'package:brew_path/features/companion/application/companion_outfit.dart';
import 'package:brew_path/features/companion/presentation/companion_outfit_scope.dart';
import 'package:brew_path/features/tour/presentation/micro_tip_host.dart';
import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart';
import 'package:brew_path/shared/theme/theme_mode_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Expand Down Expand Up @@ -31,7 +34,15 @@ class BrewPathApp extends ConsumerWidget {
// The guide layer's micro-tips draw over the whole app: two of the
// screens they appear on are pushed over the tab bar, so no shell or
// screen can host them all. See `MicroTipHost`.
builder: (context, child) => MicroTipHost(child: child!),
//
// The outfit is installed here, above every route, because a Roasty
// on a pushed screen has no other common ancestor to read it from.
builder: (context, child) => CompanionOutfitScope(
outfit:
ref.watch(companionOutfitProvider).value ??
CompanionConfig.initial,
child: MicroTipHost(child: child!),
),
),
),
);
Expand Down
13 changes: 8 additions & 5 deletions lib/app/app_redirect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,14 @@ GateDecision redirectFor({
return GateDecision.to(AppRoutes.learn.path);
}

// The Studio is behind the entitlement, and the door is not the only way to
// reach it — a deep link is. The gate belongs here for the same reason every
// other gate→destination decision does: a screen that guards itself is a
// guard one route can be added around.
if (path.endsWith('/${AppRoutes.studio.path}') && !gates.courseEntitled) {
// Both Studio doors are behind the entitlement, and neither door is the only
// way to reach what it opens — a deep link is. The gate belongs here for the
// same reason every other gate→destination decision does: a screen that
// guards itself is a guard one route can be added around, which is exactly
// what the wardrobe did to the grove's.
const studioPaths = [AppRoutes.studio, AppRoutes.roastyStudio];
if (!gates.courseEntitled &&
studioPaths.any((route) => path.endsWith('/${route.path}'))) {
return GateDecision.to(AppRoutes.profile.path);
}

Expand Down
7 changes: 7 additions & 0 deletions lib/app/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import 'package:brew_path/features/progress/domain/progress_providers.dart';
import 'package:brew_path/features/progress/presentation/streak_screen.dart';
import 'package:brew_path/features/progress/presentation/tree_screen.dart';
import 'package:brew_path/features/saved/presentation/saved_screen.dart';
import 'package:brew_path/features/studio/presentation/roasty_studio_screen.dart';
import 'package:brew_path/features/studio/presentation/studio_screen.dart';
import 'package:brew_path/features/tour/presentation/app_guide_screen.dart';
import 'package:brew_path/services/analytics/analytics_provider.dart';
Expand Down Expand Up @@ -353,6 +354,12 @@ GoRouter appRouter(Ref ref) {
parentNavigatorKey: _rootKey,
builder: (context, state) => const StudioScreen(),
),
GoRoute(
path: AppRoutes.roastyStudio.path,
name: AppRoutes.roastyStudio.name,
parentNavigatorKey: _rootKey,
builder: (context, state) => const RoastyStudioScreen(),
),
GoRoute(
path: AppRoutes.profileSettings.path,
name: AppRoutes.profileSettings.name,
Expand Down
2 changes: 1 addition & 1 deletion lib/app/app_router.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lib/core/constants/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ abstract class AppRoutes {
static const profileSettings = AppRoute('profileSettings', 'settings');
static const profileStreak = AppRoute('profileStreak', 'streak');
static const studio = AppRoute('studio', 'studio');

/// Dress up Roasty — the Studio's second door (#367), beside the grove's.
static const roastyStudio = AppRoute('roastyStudio', 'roasty');
static const appGuide = AppRoute('appGuide', 'app-guide');
static const profileTree = AppRoute('profileTree', 'tree');

Expand Down
27 changes: 27 additions & 0 deletions lib/features/companion/application/companion_outfit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// The one place that decides what Roasty is wearing.
library;

import 'package:brew_path/features/monetization/domain/course_entitlement.dart';
import 'package:brew_path/shared/repositories/repository_providers.dart';
import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'companion_outfit.g.dart';

/// What every Roasty in the app draws itself in.
///
/// **Hidden, not wiped.** A free learner reads the plain bean while the
/// snapshot keeps whatever they once picked, so a lapsed entitlement hides the
/// wardrobe and a returning one brings it back. Awaited rather than read as an
/// `AsyncValue`, so this emits once (#89's rule for value providers).
@riverpod
Future<CompanionConfig> companionOutfit(Ref ref) async {
// Watches before awaits: a rebuild mid-flight must not reach a watch across
// an async gap on a disposed ref.
final entitledFuture = ref.watch(courseEntitlementProvider.future);
final snapshots = ref.watch(snapshotRepositoryProvider);

final snapshot = await snapshots.read();
if (!await entitledFuture) return CompanionConfig.initial;
return snapshot.clearedByDeleteOnly.companion.value;
}
68 changes: 68 additions & 0 deletions lib/features/companion/application/companion_outfit.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions lib/features/companion/presentation/companion_outfit_scope.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart';
import 'package:flutter/widgets.dart';

/// What Roasty is wearing, handed down the tree the way his colours are.
///
/// Ambient rather than a parameter, for the reason `context.mood` is: every
/// Roasty in the app has to wear it, and a widget that reaches the mascot
/// through three hosts cannot be asked to thread it. The app installs one of
/// these at the root; nothing else does.
class CompanionOutfitScope extends InheritedWidget {
/// Creates a [CompanionOutfitScope].
const CompanionOutfitScope({
required this.outfit,
required super.child,
super.key,
});

/// The outfit every `Roasty` below this draws itself in.
final CompanionConfig outfit;

/// What Roasty wears here — the plain bean where no scope is installed.
///
/// Absent is not an error: a widget test that pumps the mascot on its own
/// gets the undressed mascot, which is what it has always drawn.
static CompanionConfig of(BuildContext context) =>
context
.dependOnInheritedWidgetOfExactType<CompanionOutfitScope>()
?.outfit ??
CompanionConfig.initial;

@override
bool updateShouldNotify(CompanionOutfitScope oldWidget) =>
oldWidget.outfit != outfit;
}
Loading
Loading