diff --git a/analysis_options.yaml b/analysis_options.yaml index 30506205..8e3084b6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 @@ -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 diff --git a/assets/content/generated/companion_gear.json b/assets/content/generated/companion_gear.json new file mode 100644 index 00000000..e06415f7 --- /dev/null +++ b/assets/content/generated/companion_gear.json @@ -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" + } + ] +} diff --git a/assets/content/generated/companion_hats.json b/assets/content/generated/companion_hats.json new file mode 100644 index 00000000..1f0d3336 --- /dev/null +++ b/assets/content/generated/companion_hats.json @@ -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" + } + ] +} diff --git a/assets/content/generated/companion_roasts.json b/assets/content/generated/companion_roasts.json new file mode 100644 index 00000000..de655dc5 --- /dev/null +++ b/assets/content/generated/companion_roasts.json @@ -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" + } + ] +} diff --git a/assets/content/generated/companion_sprouts.json b/assets/content/generated/companion_sprouts.json new file mode 100644 index 00000000..9f6ece98 --- /dev/null +++ b/assets/content/generated/companion_sprouts.json @@ -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" + } + ] +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ce345f4..c265446a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/lib/app/app.dart b/lib/app/app.dart index a5d25cbc..6ee36986 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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'; @@ -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!), + ), ), ), ); diff --git a/lib/app/app_redirect.dart b/lib/app/app_redirect.dart index 5126cc31..b25f3605 100644 --- a/lib/app/app_redirect.dart +++ b/lib/app/app_redirect.dart @@ -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); } diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index 517a4328..3fc3118f 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -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'; @@ -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, diff --git a/lib/app/app_router.g.dart b/lib/app/app_router.g.dart index a080903f..50637a19 100644 --- a/lib/app/app_router.g.dart +++ b/lib/app/app_router.g.dart @@ -52,4 +52,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'51d2cff4c63aa0b811173c1d87af8c05912cde07'; +String _$appRouterHash() => r'3e3c96f8e1aab1e289c6d401655677e5e691811d'; diff --git a/lib/core/constants/app_routes.dart b/lib/core/constants/app_routes.dart index 216f072c..eb9d30fa 100644 --- a/lib/core/constants/app_routes.dart +++ b/lib/core/constants/app_routes.dart @@ -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'); diff --git a/lib/features/companion/application/companion_outfit.dart b/lib/features/companion/application/companion_outfit.dart new file mode 100644 index 00000000..3ffd4381 --- /dev/null +++ b/lib/features/companion/application/companion_outfit.dart @@ -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 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; +} diff --git a/lib/features/companion/application/companion_outfit.g.dart b/lib/features/companion/application/companion_outfit.g.dart new file mode 100644 index 00000000..32ae95ab --- /dev/null +++ b/lib/features/companion/application/companion_outfit.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'companion_outfit.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// 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). + +@ProviderFor(companionOutfit) +final companionOutfitProvider = CompanionOutfitProvider._(); + +/// 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). + +final class CompanionOutfitProvider + extends + $FunctionalProvider< + AsyncValue, + CompanionConfig, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// 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). + CompanionOutfitProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'companionOutfitProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$companionOutfitHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return companionOutfit(ref); + } +} + +String _$companionOutfitHash() => r'c3cc8fcea52c0e5b5c037a3732e98e677506aaea'; diff --git a/lib/features/companion/presentation/companion_outfit_scope.dart b/lib/features/companion/presentation/companion_outfit_scope.dart new file mode 100644 index 00000000..82afb47f --- /dev/null +++ b/lib/features/companion/presentation/companion_outfit_scope.dart @@ -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() + ?.outfit ?? + CompanionConfig.initial; + + @override + bool updateShouldNotify(CompanionOutfitScope oldWidget) => + oldWidget.outfit != outfit; +} diff --git a/lib/features/companion/presentation/roasty.dart b/lib/features/companion/presentation/roasty.dart index cd6826b0..9200a331 100644 --- a/lib/features/companion/presentation/roasty.dart +++ b/lib/features/companion/presentation/roasty.dart @@ -2,14 +2,23 @@ import 'dart:async'; import 'dart:math' as math; import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/companion_outfit_scope.dart'; 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_gear.dart'; +import 'package:brew_path/features/companion/presentation/roasty_hats.dart'; import 'package:brew_path/features/companion/presentation/roasty_particles.dart'; +import 'package:brew_path/features/companion/presentation/roasty_sprouts.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; import 'package:brew_path/shared/theme/mood_colors.dart'; import 'package:brew_path/shared/theme/roasty_colors.dart'; import 'package:flutter/material.dart'; +/// How much taller than wide a Roasty is — the design's 200x280 box, which a +/// host sizing a box around one has to reserve. +const double roastyAspect = 1.4; + /// Animated Roasty mascot. Reproduces the design's geometry + per-state /// animations using Flutter's Canvas + a single [AnimationController]. Public /// API: `Roasty(state: …, size: …, replayKey: …, plate: …, pointsAmount: …)`. @@ -26,6 +35,7 @@ class Roasty extends StatefulWidget { this.animate = true, this.plate = false, this.pointsAmount, + this.outfit, super.key, }) : assert( (state == RoastyState.points) == (pointsAmount != null), @@ -66,6 +76,14 @@ class Roasty extends StatefulWidget { /// it, so wiring the pose to a reaction means giving the amount a way too. final int? pointsAmount; + /// The outfit to draw, overriding what the learner has on. + /// + /// Only the Studio passes one, so it can preview a pick before it is + /// confirmed. Everywhere else this is null and the mascot dresses itself + /// from the ambient [CompanionOutfitScope] — which is what stops a screen + /// showing the wrong Roasty by forgetting to thread it through. + final CompanionConfig? outfit; + @override State createState() => _RoastyState(); } @@ -143,10 +161,11 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { + final outfit = widget.outfit ?? CompanionOutfitScope.of(context); return RepaintBoundary( child: SizedBox( width: widget.size, - height: widget.size * 1.4, + height: widget.size * roastyAspect, child: AnimatedBuilder( animation: _controller, builder: (context, _) => CustomPaint( @@ -156,6 +175,7 @@ class _RoastyState extends State with SingleTickerProviderStateMixin { sproutScale: widget.sproutScale, plate: widget.plate, pointsAmount: widget.pointsAmount, + outfit: outfit, mood: context.mood, ), ), @@ -175,6 +195,7 @@ class _RoastyPainter extends CustomPainter { required this.state, required this.progress, required this.plate, + required this.outfit, required this.mood, this.sproutScale, this.pointsAmount, @@ -184,6 +205,9 @@ class _RoastyPainter extends CustomPainter { final double progress; final bool plate; + /// What the mascot is wearing, already resolved past the gate. + final CompanionConfig outfit; + /// What the points burst says; null for every other state. final int? pointsAmount; @@ -213,13 +237,23 @@ class _RoastyPainter extends CustomPainter { if (plate) paintRoastyPlate(canvas); paintRoastyParticlesBack(canvas, state, progress, mood); - paintRoastySprout(canvas, state, progress, sproutScale); + // Bare-headed, the sprout nestles on the bean and sways on its own. Under + // a hat it moves inside the body group instead, drawn over the crown. + if (hatIsBare(outfit.hat)) { + paintRoastySprout( + canvas, + state, + progress, + sproutScale, + sprout: outfit.sprout, + ); + } _withBodyTransform( canvas, () => paintRoastyShimmer(canvas, state, progress, mood), ); - paintRoastyBody(canvas, state, progress); - _withBodyTransform(canvas, () => paintRoastyFace(canvas, state, mood)); + paintRoastyBody(canvas, state, progress, roast: outfit.roast); + _withBodyTransform(canvas, () => _paintFaceAndOutfit(canvas)); paintRoastyParticlesFront( canvas, state, @@ -231,8 +265,8 @@ class _RoastyPainter extends CustomPainter { canvas.restore(); } - /// The face and the shimmer ride along with the body, so [paint] runs - /// under the body's own transform. + /// The face, the shimmer and everything worn ride along with the body, so + /// [paint] runs under the body's own transform. void _withBodyTransform(Canvas canvas, void Function() paint) { canvas.save(); final offset = roastyBodyOffset(state, progress); @@ -244,6 +278,22 @@ class _RoastyPainter extends CustomPainter { canvas.restore(); } + /// The face and everything worn, in the order the design draws them — run + /// inside [_withBodyTransform], which is what makes a hat ride the hop. + void _paintFaceAndOutfit(Canvas canvas) { + paintRoastyFace(canvas, state, mood); + paintRoastyGear(canvas, outfit.gear); + paintRoastyHat(canvas, outfit.hat); + if (!hatIsBare(outfit.hat)) { + // The design lifts it 13 up so it clears the crown it grows through. + canvas.translate(0, -_sproutOverHat); + paintRoastySproutArt(canvas, outfit.sprout); + } + } + + /// How far the sprout rises to grow through a hat, in canvas units. + static const double _sproutOverHat = 13; + @override bool shouldRepaint(covariant _RoastyPainter old) => old.state != state || @@ -251,5 +301,6 @@ class _RoastyPainter extends CustomPainter { old.sproutScale != sproutScale || old.plate != plate || old.pointsAmount != pointsAmount || + old.outfit != outfit || old.mood != mood; } diff --git a/lib/features/companion/presentation/roasty_body.dart b/lib/features/companion/presentation/roasty_body.dart index 0281afa6..59b45ab5 100644 --- a/lib/features/companion/presentation/roasty_body.dart +++ b/lib/features/companion/presentation/roasty_body.dart @@ -2,7 +2,9 @@ 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/features/companion/presentation/roasty_sprouts.dart'; import 'package:brew_path/shared/theme/roasty_colors.dart'; +import 'package:brew_path/shared/theme/roasty_outfit_colors.dart'; import 'package:flutter/material.dart'; /// Scale origin while the host drives the grow: the stem base sits at the @@ -27,14 +29,16 @@ void paintRoastyPlate(Canvas canvas) { ); } -/// Paints the sprout (stem + leaves) above the bean. [sproutScale] overrides -/// the state-derived scale when non-null (used by the loading wake-up grow). +/// Paints the [sprout] above the bean. [sproutScale] overrides the +/// state-derived scale when non-null (used by the loading wake-up grow). void paintRoastySprout( Canvas canvas, RoastyState state, double t, - double? sproutScale, -) { + double? sproutScale, { + String sprout = 'leaf', +}) { + if (sproutIsBare(sprout)) return; final sleeping = state == RoastyState.sleep || state == RoastyState.awake; final usingGrow = sproutScale != null; final scale = sproutScale ?? (sleeping ? 0.15 : 1.0); @@ -51,52 +55,7 @@ void paintRoastySprout( canvas.scale(scale); canvas.translate(-anchor.dx, -anchor.dy); - - final stem = Paint() - ..color = RoastyColors.leafDeep - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - final stemPath = Path() - ..moveTo(100, 88) - ..quadraticBezierTo(100, 80, 100, 70); - canvas.drawPath(stemPath, stem); - - const leafGradient = RadialGradient( - center: Alignment(-0.3, -0.4), - radius: 0.75, - colors: RoastyColors.leafGradient, - ); - const leafRect = Rect.fromLTWH(60, 55, 80, 30); - final leafPaint = Paint()..shader = leafGradient.createShader(leafRect); - - final leafL = Path() - ..moveTo(100, 72) - ..cubicTo(86, 58, 70, 60, 66, 70) - ..cubicTo(70, 82, 88, 80, 100, 74) - ..close(); - final leafR = Path() - ..moveTo(100, 72) - ..cubicTo(114, 58, 130, 60, 134, 70) - ..cubicTo(130, 82, 112, 80, 100, 74) - ..close(); - canvas.drawPath(leafL, leafPaint); - canvas.drawPath(leafR, leafPaint); - - final vein = Paint() - ..color = RoastyColors.leafDeep.withValues(alpha: 0.6) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - final veinL = Path() - ..moveTo(100, 73) - ..quadraticBezierTo(86, 70, 72, 72); - final veinR = Path() - ..moveTo(100, 73) - ..quadraticBezierTo(114, 70, 128, 72); - canvas.drawPath(veinL, vein); - canvas.drawPath(veinR, vein); - + paintRoastySproutArt(canvas, sprout); canvas.restore(); } @@ -112,7 +71,16 @@ Path roastyBeanOutline() => Path() /// Paints the bean body (shadow, gradient body, highlight, crease) with the /// per-state body transform for [state] at progress [t]. -void paintRoastyBody(Canvas canvas, RoastyState state, double t) { +/// +/// [roast] picks the body gradient. It is threaded through the paint rather +/// than overlaid, because a darker bean is a different bean and not a tinted +/// one — a wash over the highlight and crease would grey both. +void paintRoastyBody( + Canvas canvas, + RoastyState state, + double t, { + String roast = 'medium', +}) { canvas.save(); final offset = roastyBodyOffset(state, t); canvas.translate(100 + offset.dx, 158 + offset.dy); @@ -131,11 +99,11 @@ void paintRoastyBody(Canvas canvas, RoastyState state, double t) { // 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), + final bodyGradient = RadialGradient( + center: const Alignment(-0.36, -0.36), radius: 0.75, - colors: RoastyColors.beanGradient, - stops: [0.0, 0.55, 1.0], + colors: RoastyOutfitColors.roastGradient(roast), + stops: const [0.0, 0.55, 1.0], ); final bodyPaint = Paint()..shader = bodyGradient.createShader(bodyRect); canvas.drawPath(roastyBeanOutline(), bodyPaint); diff --git a/lib/features/companion/presentation/roasty_gear.dart b/lib/features/companion/presentation/roasty_gear.dart new file mode 100644 index 00000000..28f33c3d --- /dev/null +++ b/lib/features/companion/presentation/roasty_gear.dart @@ -0,0 +1,172 @@ +import 'dart:math' as math; + +import 'package:brew_path/shared/theme/roasty_outfit_colors.dart'; +import 'package:flutter/material.dart'; + +/// What Roasty can wear on his face and neck. +/// +/// Drawn inside the body transform and under the hat, which is the order the +/// design draws them in — a beanie's brim sits over a headphone band. An id +/// with no art here draws nothing, which is what `none` is. +void paintRoastyGear(Canvas canvas, String gear) => _gear[gear]?.call(canvas); + +/// Every piece this file draws, by the id the bank ships it under — one table +/// rather than a switch beside a set of the same ids. +const _gear = { + 'glasses': _paintGlasses, + 'sunglasses': _paintShades, + 'scarf': _paintScarf, + 'headphones': _paintHeadphones, +}; + +/// The gear this file can draw, guarded against the bank's ids. +final Set roastyGearIds = _gear.keys.toSet(); + +Paint get _frame => Paint() + ..color = RoastyOutfitColors.gearDark + ..strokeWidth = 2.4 + ..style = PaintingStyle.stroke; + +Paint get _arm => Paint() + ..color = RoastyOutfitColors.gearDark + ..strokeWidth = 2.4 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + +void _paintGlasses(Canvas canvas) { + final lens = Paint() + ..color = RoastyOutfitColors.gearLight.withValues(alpha: 0.16); + canvas + ..drawCircle(const Offset(80, 150), 13, lens) + ..drawCircle(const Offset(80, 150), 13, _frame) + ..drawCircle(const Offset(120, 150), 13, lens) + ..drawCircle(const Offset(120, 150), 13, _frame) + ..drawPath( + Path() + ..moveTo(93, 149) + ..relativeQuadraticBezierTo(7, -4, 14, 0), + _frame, + ) + ..drawPath( + Path() + ..moveTo(67, 149) + ..relativeQuadraticBezierTo(-12, -2, -17, 4), + _arm, + ) + ..drawPath( + Path() + ..moveTo(133, 149) + ..relativeQuadraticBezierTo(12, -2, 17, 4), + _arm, + ); +} + +void _paintShades(Canvas canvas) { + final dark = Paint()..color = RoastyOutfitColors.gearDark; + final glint = Paint() + ..color = RoastyOutfitColors.gearLight.withValues(alpha: 0.28); + canvas + ..drawRRect( + RRect.fromLTRBR(63, 142, 94, 159, const Radius.circular(7.5)), + dark, + ) + ..drawRRect( + RRect.fromLTRBR(106, 142, 137, 159, const Radius.circular(7.5)), + dark, + ) + ..drawLine( + const Offset(94, 147), + const Offset(106, 147), + Paint() + ..color = RoastyOutfitColors.gearDark + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round, + ) + ..drawPath( + Path() + ..moveTo(63, 146) + ..relativeQuadraticBezierTo(-12, -1, -16, 4), + _arm, + ) + ..drawPath( + Path() + ..moveTo(137, 146) + ..relativeQuadraticBezierTo(12, -1, 16, 4), + _arm, + ) + ..drawOval( + Rect.fromCenter(center: const Offset(72, 147), width: 6.4, height: 4), + glint, + ) + ..drawOval( + Rect.fromCenter(center: const Offset(115, 147), width: 6.4, height: 4), + glint, + ); +} + +void _paintScarf(Canvas canvas) { + canvas + ..drawPath( + Path() + ..moveTo(50, 196) + ..quadraticBezierTo(100, 214, 150, 196) + ..lineTo(150, 209) + ..quadraticBezierTo(100, 227, 50, 209) + ..close(), + Paint()..color = RoastyOutfitColors.moss, + ) + ..drawPath( + Path() + ..moveTo(118, 206) + ..relativeQuadraticBezierTo(11, 15, 5, 32) + ..relativeQuadraticBezierTo(-9, 2, -13, -2) + ..relativeQuadraticBezierTo(5, -15, 0, -28) + ..close(), + Paint()..color = RoastyOutfitColors.scarfShade, + ) + ..drawPath( + Path() + ..moveTo(58, 202) + ..quadraticBezierTo(100, 216, 142, 202), + Paint() + ..color = RoastyOutfitColors.scarfShade.withValues(alpha: 0.7) + ..strokeWidth = 1.4 + ..style = PaintingStyle.stroke, + ); +} + +void _paintHeadphones(Canvas canvas) { + final shell = Paint()..color = RoastyOutfitColors.gearDark; + final cushion = Paint()..color = RoastyOutfitColors.cushion; + canvas + ..drawPath( + // The design's `a52 52 0 0 1` sweep from (48,152) to (152,152): a + // half-circle over the head, which its own bounding arc states exactly. + Path()..addArc( + Rect.fromCircle(center: const Offset(100, 152), radius: 52), + math.pi, + math.pi, + ), + Paint() + ..color = RoastyOutfitColors.gearDark + ..strokeWidth = 6 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round, + ) + ..drawRRect( + RRect.fromLTRBR(35, 142, 54, 170, const Radius.circular(8)), + shell, + ) + ..drawRRect( + RRect.fromLTRBR(146, 142, 165, 170, const Radius.circular(8)), + shell, + ) + ..drawRRect( + RRect.fromLTRBR(39, 147, 50, 165, const Radius.circular(5)), + cushion, + ) + ..drawRRect( + RRect.fromLTRBR(150, 147, 161, 165, const Radius.circular(5)), + cushion, + ); +} diff --git a/lib/features/companion/presentation/roasty_hats.dart b/lib/features/companion/presentation/roasty_hats.dart new file mode 100644 index 00000000..9244b560 --- /dev/null +++ b/lib/features/companion/presentation/roasty_hats.dart @@ -0,0 +1,121 @@ +import 'package:brew_path/shared/theme/roasty_outfit_colors.dart'; +import 'package:flutter/material.dart'; + +/// What Roasty can wear on his head. +/// +/// Drawn inside the body transform, so a hat rides the hop and the shake with +/// the head it sits on. An id with no art here draws nothing, which is what +/// `none` is. +void paintRoastyHat(Canvas canvas, String hat) => _hats[hat]?.call(canvas); + +/// Every hat this file draws, by the id the bank ships it under. +/// +/// One table rather than a switch beside a set of the same ids: a drawing this +/// does not list cannot be reached, and an id it lists cannot draw nothing. +const _hats = { + 'beanie': _paintBeanie, + 'field': _paintFieldHat, + 'cap': _paintCap, +}; + +/// The hats this file can draw, guarded against the bank's ids. +final Set roastyHatIds = _hats.keys.toSet(); + +/// Whether [hat] draws anything — which is also whether the sprout has to +/// grow up through it. +bool hatIsBare(String hat) => !_hats.containsKey(hat); + +void _paintBeanie(Canvas canvas) { + canvas + ..drawPath( + Path() + ..moveTo(44, 120) + ..quadraticBezierTo(48, 76, 100, 74) + ..quadraticBezierTo(152, 76, 156, 120) + ..close(), + Paint()..color = RoastyOutfitColors.ember, + ) + ..drawPath( + Path() + ..moveTo(42, 114) + ..quadraticBezierTo(100, 126, 158, 114) + ..lineTo(158, 129) + ..quadraticBezierTo(100, 140, 42, 129) + ..close(), + Paint()..color = RoastyOutfitColors.emberDeep, + ); + + // The two knit lines across the crown, at the design's own opacities. + Paint knit(double opacity) => Paint() + ..color = RoastyOutfitColors.emberDeep.withValues(alpha: opacity) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + canvas + ..drawPath( + Path() + ..moveTo(66, 96) + ..quadraticBezierTo(100, 89, 134, 96), + knit(0.55), + ) + ..drawPath( + Path() + ..moveTo(64, 105) + ..quadraticBezierTo(100, 98, 136, 105), + knit(0.4), + ); +} + +void _paintFieldHat(Canvas canvas) { + const brim = Rect.fromLTRB(20, 101, 180, 133); + canvas + ..drawOval(brim, Paint()..color = RoastyOutfitColors.strawBrim) + ..drawOval( + brim, + Paint() + ..color = RoastyOutfitColors.strawBand + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke, + ) + ..drawPath( + Path() + ..moveTo(48, 116) + ..quadraticBezierTo(50, 80, 100, 78) + ..quadraticBezierTo(150, 80, 152, 116) + ..close(), + Paint()..color = RoastyOutfitColors.strawCrown, + ) + ..drawPath( + Path() + ..moveTo(62, 110) + ..quadraticBezierTo(100, 119, 138, 110), + Paint() + ..color = RoastyOutfitColors.strawBand + ..strokeWidth = 3.5 + ..style = PaintingStyle.stroke, + ); +} + +void _paintCap(Canvas canvas) { + canvas + ..drawPath( + Path() + ..moveTo(54, 118) + ..quadraticBezierTo(58, 80, 100, 78) + ..quadraticBezierTo(142, 80, 146, 118) + ..close(), + Paint()..color = RoastyOutfitColors.moss, + ) + ..drawPath( + Path() + ..moveTo(142, 115) + ..quadraticBezierTo(177, 113, 182, 126) + ..quadraticBezierTo(154, 128, 142, 122) + ..close(), + Paint()..color = RoastyOutfitColors.mossDeep, + ) + ..drawCircle( + const Offset(100, 80), + 3, + Paint()..color = RoastyOutfitColors.mossDeep, + ); +} diff --git a/lib/features/companion/presentation/roasty_sprouts.dart b/lib/features/companion/presentation/roasty_sprouts.dart new file mode 100644 index 00000000..c79c642f --- /dev/null +++ b/lib/features/companion/presentation/roasty_sprouts.dart @@ -0,0 +1,172 @@ +import 'package:brew_path/shared/theme/roasty_colors.dart'; +import 'package:brew_path/shared/theme/roasty_outfit_colors.dart'; +import 'package:flutter/material.dart'; + +/// The four things that can grow from Roasty's crown. +/// +/// All four are transcribed from the running prototype's own table, so the +/// set is internally consistent. `leaf` therefore **drops the mascot design +/// page's stem `M100 88 Q100 80 100 70` and leaf `M100 72 C 86 58…`**, which +/// sit 1–2px higher; the running file wins (ADR-0009). +void paintRoastySproutArt(Canvas canvas, String sprout) => + _sprouts[sprout]?.call(canvas); + +/// Every sprout this file draws, by the id the bank ships it under — one table +/// rather than a switch beside a set of the same ids. +const _sprouts = { + 'leaf': _paintLeaves, + 'flower': _paintBlossom, + 'sprig': _paintSprig, +}; + +/// The sprouts this file can draw, guarded against the bank's ids. +final Set roastySproutIds = _sprouts.keys.toSet(); + +/// Whether [sprout] draws anything at all. +bool sproutIsBare(String sprout) => !_sprouts.containsKey(sprout); + +const _leafRect = Rect.fromLTWH(60, 55, 80, 30); +const _leafGradient = RadialGradient( + center: Alignment(-0.3, -0.4), + radius: 0.75, + colors: RoastyColors.leafGradient, +); + +Paint get _leafPaint => Paint()..shader = _leafGradient.createShader(_leafRect); + +Paint get _stemPaint => Paint() + ..color = RoastyColors.leafDeep + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + +Paint get _veinPaint => Paint() + ..color = RoastyColors.leafDeep.withValues(alpha: 0.6) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + +void _paintLeaves(Canvas canvas) { + canvas.drawPath( + Path() + ..moveTo(100, 87) + ..quadraticBezierTo(100, 79, 100, 72), + _stemPaint, + ); + final leaf = _leafPaint; + canvas + ..drawPath( + Path() + ..moveTo(100, 75) + ..cubicTo(87, 64, 73, 66, 69, 74) + ..cubicTo(73, 83, 89, 81, 100, 77) + ..close(), + leaf, + ) + ..drawPath( + Path() + ..moveTo(100, 75) + ..cubicTo(113, 64, 127, 66, 131, 74) + ..cubicTo(127, 83, 111, 81, 100, 77) + ..close(), + leaf, + ); + final vein = _veinPaint; + canvas + ..drawPath( + Path() + ..moveTo(100, 76) + ..quadraticBezierTo(87, 73, 73, 75), + vein, + ) + ..drawPath( + Path() + ..moveTo(100, 76) + ..quadraticBezierTo(113, 73, 127, 75), + vein, + ); +} + +void _paintBlossom(Canvas canvas) { + canvas.drawPath( + Path() + ..moveTo(100, 89) + ..quadraticBezierTo(100, 81, 100, 74), + _stemPaint, + ); + final leaf = _leafPaint; + canvas + ..drawPath( + Path() + ..moveTo(100, 81) + ..cubicTo(91, 75, 83, 77, 81, 82) + ..cubicTo(85, 87, 94, 85, 100, 82) + ..close(), + leaf, + ) + ..drawPath( + Path() + ..moveTo(100, 81) + ..cubicTo(109, 75, 117, 77, 119, 82) + ..cubicTo(115, 87, 106, 85, 100, 82) + ..close(), + leaf, + ); + + // Five petals around a centre, at the design's translate(100 68). + const petals = [ + Offset(0, -7), + Offset(7, -2), + Offset(4, 6), + Offset(-4, 6), + Offset(-7, -2), + ]; + final petal = Paint()..color = RoastyOutfitColors.gearLight; + for (final at in petals) { + canvas.drawCircle(Offset(100 + at.dx, 68 + at.dy), 4.6, petal); + } + canvas.drawCircle( + const Offset(100, 68), + 3.2, + Paint()..color = RoastyOutfitColors.blossomHeart, + ); +} + +void _paintSprig(Canvas canvas) { + canvas + ..drawPath( + Path() + ..moveTo(100, 88) + ..quadraticBezierTo(100, 80, 100, 72), + _stemPaint, + ) + ..drawPath( + Path() + ..moveTo(100, 76) + ..cubicTo(113, 65, 127, 67, 131, 75) + ..cubicTo(127, 84, 111, 82, 100, 78) + ..close(), + _leafPaint, + ) + ..drawPath( + Path() + ..moveTo(100, 77) + ..quadraticBezierTo(113, 74, 127, 76), + _veinPaint, + ) + ..drawCircle( + const Offset(91, 70), + 5.5, + Paint()..color = RoastyOutfitColors.ember, + ) + ..drawCircle( + const Offset(89, 68), + 1.8, + Paint()..color = RoastyOutfitColors.cherryBlush, + ) + ..drawCircle( + const Offset(99, 65), + 4.5, + Paint()..color = RoastyOutfitColors.emberDeep, + ); +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 550af292..ff6604a0 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -13,6 +13,7 @@ import 'package:brew_path/features/progress/domain/mastery_rollup.dart'; import 'package:brew_path/features/progress/domain/progress_providers.dart'; import 'package:brew_path/features/progress/presentation/coffee_tree.dart'; import 'package:brew_path/features/saved/presentation/saved_entry_card.dart'; +import 'package:brew_path/features/studio/presentation/roasty_door_tile.dart'; import 'package:brew_path/features/studio/presentation/studio_door_tile.dart'; import 'package:brew_path/shared/theme/app_spacing.dart'; import 'package:brew_path/shared/theme/app_text.dart'; @@ -120,6 +121,8 @@ class ProfileScreen extends ConsumerWidget { // are Settings' (#429). const StudioDoorTile(), const SizedBox(height: _cardGap), + const RoastyDoorTile(), + const SizedBox(height: _cardGap), const SavedEntryCard(), if (joined != null) ...[ const SizedBox(height: _joinedGap), diff --git a/lib/features/studio/domain/dress_companion.dart b/lib/features/studio/domain/dress_companion.dart new file mode 100644 index 00000000..984b748e --- /dev/null +++ b/lib/features/studio/domain/dress_companion.dart @@ -0,0 +1,29 @@ +import 'package:brew_path/shared/repositories/snapshot_repository.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; + +/// Dresses Roasty in [outfit], and nothing else. +/// +/// `plantGrove`'s twin, down to the no-op: the outfit is last-writer-wins, so +/// an identical write would move the stamp and beat a real pick made on +/// another device. The screen already disables its confirm when the draft +/// matches, and this is the same rule where it cannot be bypassed. +Future dressCompanion( + SnapshotRepository repository, { + required CompanionConfig outfit, + required DateTime now, +}) async { + final snapshot = await repository.read(); + if (snapshot.clearedByDeleteOnly.companion.value == outfit) return; + + final at = now.millisecondsSinceEpoch; + await repository.write( + snapshot.copyWith( + updatedAt: at, + clearedByDeleteOnly: snapshot.clearedByDeleteOnly.withCompanion( + outfit, + at: at, + writerId: snapshot.deviceId, + ), + ), + ); +} diff --git a/lib/features/studio/domain/roasty_studio_providers.dart b/lib/features/studio/domain/roasty_studio_providers.dart new file mode 100644 index 00000000..d8d07c72 --- /dev/null +++ b/lib/features/studio/domain/roasty_studio_providers.dart @@ -0,0 +1,40 @@ +import 'package:brew_path/shared/models/content/companion_option.dart'; +import 'package:brew_path/shared/repositories/content_repository.dart'; +import 'package:brew_path/shared/repositories/repository_providers.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'roasty_studio_providers.g.dart'; + +/// Everything the wardrobe needs, resolved once. +/// +/// `StudioGrove`'s twin: the four banks and what Roasty has on arrive together +/// because the screen cannot draw a single row without all of them. +@immutable +class RoastyStudio { + /// Creates a [RoastyStudio]. + const RoastyStudio({required this.options, required this.worn}); + + /// The four axes, in the order the banks ship them. + final CompanionOptions options; + + /// What Roasty has on right now — what the confirm is compared against. + final CompanionConfig worn; +} + +/// The wardrobe's data: the four banks and the outfit Roasty has on. +/// +/// Reads the snapshot rather than `companionOutfit`, deliberately: this is the +/// screen that *edits* the outfit, so it must see what is stored even while +/// the gate would hide it — the door that opens it is already Plus-locked. +@riverpod +Future roastyStudio(Ref ref) async { + final content = ref.watch(contentRepositoryProvider); + final snapshot = await ref.watch(snapshotRepositoryProvider).read(); + + return RoastyStudio( + options: await content.getCompanionOptions(), + worn: snapshot.clearedByDeleteOnly.companion.value, + ); +} diff --git a/lib/features/studio/domain/roasty_studio_providers.g.dart b/lib/features/studio/domain/roasty_studio_providers.g.dart new file mode 100644 index 00000000..504a6b02 --- /dev/null +++ b/lib/features/studio/domain/roasty_studio_providers.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'roasty_studio_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// The wardrobe's data: the four banks and the outfit Roasty has on. +/// +/// Reads the snapshot rather than `companionOutfit`, deliberately: this is the +/// screen that *edits* the outfit, so it must see what is stored even while +/// the gate would hide it — the door that opens it is already Plus-locked. + +@ProviderFor(roastyStudio) +final roastyStudioProvider = RoastyStudioProvider._(); + +/// The wardrobe's data: the four banks and the outfit Roasty has on. +/// +/// Reads the snapshot rather than `companionOutfit`, deliberately: this is the +/// screen that *edits* the outfit, so it must see what is stored even while +/// the gate would hide it — the door that opens it is already Plus-locked. + +final class RoastyStudioProvider + extends + $FunctionalProvider< + AsyncValue, + RoastyStudio, + FutureOr + > + with $FutureModifier, $FutureProvider { + /// The wardrobe's data: the four banks and the outfit Roasty has on. + /// + /// Reads the snapshot rather than `companionOutfit`, deliberately: this is the + /// screen that *edits* the outfit, so it must see what is stored even while + /// the gate would hide it — the door that opens it is already Plus-locked. + RoastyStudioProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roastyStudioProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roastyStudioHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return roastyStudio(ref); + } +} + +String _$roastyStudioHash() => r'9248a861eb1c8954d67ac7902845654897b5cef5'; diff --git a/lib/features/studio/presentation/roasty_door_tile.dart b/lib/features/studio/presentation/roasty_door_tile.dart new file mode 100644 index 00000000..92ca9208 --- /dev/null +++ b/lib/features/studio/presentation/roasty_door_tile.dart @@ -0,0 +1,54 @@ +import 'package:brew_path/core/constants/app_routes.dart'; +import 'package:brew_path/features/companion/application/companion_outfit.dart'; +import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; +import 'package:brew_path/features/monetization/domain/course_entitlement.dart'; +import 'package:brew_path/features/profile/presentation/widgets/profile_entry_card.dart'; +import 'package:brew_path/features/studio/presentation/studio_gate.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// The mascot in the well, small enough to read as a thumbnail. +const double _mascotSize = 40; + +/// What the door says it opens. The design writes the axes here rather than +/// the picks — the grove's door names what is planted, this one does not. +const String _support = 'Hat, gear, roast and sprout'; + +/// The way into Dress up Roasty, beside the grove's door. +/// +/// Draws the mascot dressed, as the grove's door draws the planted grove: it +/// says *this is yours and you can change it*, which a glyph cannot. **Locked +/// for a free learner**, and drawn in the *gated* outfit — so the door is the +/// plain bean for them too, rather than the one screen that leaks it. +class RoastyDoorTile extends ConsumerWidget { + /// Creates a [RoastyDoorTile]. + const RoastyDoorTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Unresolved reads as locked, as the grove's door does: a door that opens + // for a frame and then refuses is worse than one that asks twice. + final isPlus = ref.watch(courseEntitlementProvider).asData?.value ?? false; + final worn = ref.watch(companionOutfitProvider).asData?.value; + + return ProfileEntryCard( + // Frozen, and in the gated outfit every other screen draws: a lapsed + // learner meets the same plain bean here as everywhere else. + art: Roasty( + state: RoastyState.idle, + size: _mascotSize, + animate: false, + outfit: worn, + ), + kicker: 'Companion', + title: 'Dress up Roasty', + support: _support, + locked: !isPlus, + onTap: () => isPlus + ? context.goNamed(AppRoutes.roastyStudio.name) + : showStudioLocked(context), + ); + } +} diff --git a/lib/features/studio/presentation/roasty_studio_screen.dart b/lib/features/studio/presentation/roasty_studio_screen.dart new file mode 100644 index 00000000..4a07f5f4 --- /dev/null +++ b/lib/features/studio/presentation/roasty_studio_screen.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:brew_path/core/widgets/primary_button.dart'; +import 'package:brew_path/core/widgets/sub_screen_scaffold.dart'; +import 'package:brew_path/features/companion/application/companion_outfit.dart'; +import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; +import 'package:brew_path/features/studio/domain/dress_companion.dart'; +import 'package:brew_path/features/studio/domain/roasty_studio_providers.dart'; +import 'package:brew_path/features/studio/presentation/widgets/companion_option_row.dart'; +import 'package:brew_path/shared/repositories/repository_providers.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// The loading placeholder's footprint, so the screen does not jump when the +/// banks arrive. +const double _spinnerSize = 48; + +/// The preview's height, at the design's 128 with room for the sprout above. +const double _previewSize = 128; + +/// Copy on the confirm, which names the state rather than only the action. +const String _applyLabel = 'Apply look'; +const String _appliedLabel = 'Looking sharp'; + +/// Where the design opens this page, measured from the top of the screen. +/// +/// 100 rather than 108, for the same reason the grove carries no large title: +/// the live preview of the mascot is what is at the top. +const double _designScrollPad = 100; + +/// Dress up Roasty: four axes, a live preview, and one confirm. +/// +/// The grove chooser's sibling, and built the same way — the draft is local +/// until confirmed, so backing out changes nothing and `dressCompanion` is the +/// feature's only write. +class RoastyStudioScreen extends ConsumerStatefulWidget { + /// Creates a [RoastyStudioScreen]. + const RoastyStudioScreen({super.key}); + + @override + ConsumerState createState() => _RoastyStudioScreenState(); +} + +class _RoastyStudioScreenState extends ConsumerState { + CompanionConfig? _draft; + + @override + Widget build(BuildContext context) { + final studio = ref.watch(roastyStudioProvider); + + return SubScreenScaffold( + title: 'Dress up Roasty', + designScrollPad: _designScrollPad, + body: (context, scrollPadding) => studio.when( + loading: () => const Center(child: _Loading()), + error: (_, _) => Center( + child: Semantics( + label: 'The wardrobe could not be loaded', + excludeSemantics: true, + child: Text( + 'The wardrobe could not be loaded.', + style: AppText.body(mood: context.mood), + ), + ), + ), + data: (bank) => _Wardrobe( + scrollPadding: scrollPadding, + bank: bank, + draft: _draft ?? bank.worn, + onDraft: (next) => setState(() => _draft = next), + onApply: () => _apply(bank.worn), + ), + ), + ); + } + + Future _apply(CompanionConfig worn) async { + final draft = _draft; + if (draft == null || draft == worn) return; + + await dressCompanion( + ref.read(snapshotRepositoryProvider), + outfit: draft, + now: DateTime.now(), + ); + // Every Roasty in the app reads the scope the app root feeds from this, + // so the write only reaches the screens once it is re-derived. + ref + ..invalidate(roastyStudioProvider) + ..invalidate(companionOutfitProvider); + if (mounted) unawaited(Navigator.of(context).maybePop()); + } +} + +/// The wardrobe while its banks are still arriving — a labelled, fixed-size +/// hole rather than a spinner, as the grove chooser uses. +class _Loading extends StatelessWidget { + const _Loading(); + + @override + Widget build(BuildContext context) => Semantics( + label: 'Loading the wardrobe', + child: const SizedBox.square(dimension: _spinnerSize), + ); +} + +class _Wardrobe extends StatelessWidget { + const _Wardrobe({ + required this.scrollPadding, + required this.bank, + required this.draft, + required this.onDraft, + required this.onApply, + }); + + /// The room the bar floating over this list leaves at the top. + final EdgeInsets scrollPadding; + + final RoastyStudio bank; + final CompanionConfig draft; + final ValueChanged onDraft; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + final dirty = draft != bank.worn; + + return ListView( + padding: scrollPadding.copyWith(bottom: AppSpacing.xl), + children: [ + SizedBox( + height: _previewSize * roastyAspect, + child: Center( + child: Roasty( + state: RoastyState.idle, + size: _previewSize, + // The one Roasty in the app that is told what to wear: it shows + // the draft, which is not what anything else should be drawing. + outfit: draft, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CompanionOptionRow( + label: 'Roast', + options: bank.options.roasts, + value: draft.roast, + onSelect: (id) => onDraft(draft.copyWith(roast: id)), + ), + CompanionOptionRow( + label: 'Hat', + options: bank.options.hats, + value: draft.hat, + onSelect: (id) => onDraft(draft.copyWith(hat: id)), + ), + CompanionOptionRow( + label: 'Accessory', + options: bank.options.gear, + value: draft.gear, + onSelect: (id) => onDraft(draft.copyWith(gear: id)), + ), + CompanionOptionRow( + label: 'Sprout', + options: bank.options.sprouts, + value: draft.sprout, + onSelect: (id) => onDraft(draft.copyWith(sprout: id)), + ), + const SizedBox(height: AppSpacing.lg), + PrimaryButton( + label: dirty ? _applyLabel : _appliedLabel, + onPressed: dirty ? onApply : null, + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Roasty wears this everywhere in the app.', + textAlign: TextAlign.center, + style: AppText.support(mood: mood, color: mood.inkMute), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/studio/presentation/studio_door_tile.dart b/lib/features/studio/presentation/studio_door_tile.dart index 512bf2b8..b32e0eea 100644 --- a/lib/features/studio/presentation/studio_door_tile.dart +++ b/lib/features/studio/presentation/studio_door_tile.dart @@ -18,26 +18,10 @@ const int _doorStage = 10; /// The way into Your grove, wired to what it needs. /// -/// Draws the grove it opens rather than an icon: the door's whole job is to say -/// *this is yours and you can change it*, which a glyph cannot. -/// -/// **Locked for a free learner**, and the lock is on the door rather than -/// inside the chooser — a learner should not walk through a door to be told -/// they cannot be there. The design marks that with a Plus pill beside the -/// eyebrow and **keeps the chevron**: the door still goes somewhere, it just -/// asks first. -/// -/// The card around all of that is [ProfileEntryCard], shared with Saved — on -/// Profile the design draws the two entries as one row pattern (#428). This -/// widget used to hand its parts to a `StudioDoor` in between; with the row -/// settled in one place that layer held nothing but three strings, so the -/// wiring and the drawing sit together here, as Saved's do. -/// -/// **It says `GROVE · Choose your plant`, not the design's `STUDIO · Dress up -/// Roasty`.** The design's Studio is a hub of several doors, of which dressing -/// the mascot is one; v1 ships only the grove chooser -/// ([#140](https://github.com/maximsan/brewpath/issues/140)), so the card names -/// what it actually opens. The mascot wardrobe returns with the hub in v2. +/// Draws the grove it opens rather than an icon, and locks on the **door** +/// rather than inside the chooser — Plus pill beside the eyebrow, chevron +/// kept, so it still goes somewhere and just asks first. Its title names what +/// it opens; the wardrobe is the Studio's second door (#367). class StudioDoorTile extends ConsumerWidget { /// Creates a [StudioDoorTile]. const StudioDoorTile({super.key}); diff --git a/lib/features/studio/presentation/widgets/companion_option_row.dart b/lib/features/studio/presentation/widgets/companion_option_row.dart new file mode 100644 index 00000000..f62c93d1 --- /dev/null +++ b/lib/features/studio/presentation/widgets/companion_option_row.dart @@ -0,0 +1,161 @@ +import 'package:brew_path/core/widgets/smallcaps_label.dart'; +import 'package:brew_path/features/studio/domain/grove_swatch.dart'; +import 'package:brew_path/shared/models/content/companion_option.dart'; +import 'package:brew_path/shared/theme/app_spacing.dart'; +import 'package:brew_path/shared/theme/app_text.dart'; +import 'package:brew_path/shared/theme/mood_colors.dart'; +import 'package:flutter/material.dart'; + +/// Wash behind a picked option — the design's `accent 12%` on this screen. +/// +/// **Not the grove's ten.** `GroveSelection` records its own recipe for the +/// plant rows and light pills; the wardrobe states twelve, and its picked +/// label takes the accent where the grove's does not. Two screens, two +/// recipes, both quoted from the design. Recorded so neither is levelled. +const double _pickedWash = 0.12; + +/// The colour dot on a roast, at the design's 16px. +const double _swatchSize = 16; + +/// Alpha on the swatch's hairline, the design's `ink 18%`. +const double _swatchEdgeAlpha = 0.18; + +/// The design's minimum for this control, which is also the platform's. +const double _minTarget = 44; + +/// One axis of Roasty's outfit: its name, and the picks laid out in a row. +/// +/// Scrolls sideways rather than wrapping — the design gives each axis a single +/// line, so five gear options stay one gesture rather than becoming two rows +/// that push the next axis off the screen. +class CompanionOptionRow extends StatelessWidget { + /// Creates a [CompanionOptionRow]. + const CompanionOptionRow({ + required this.label, + required this.options, + required this.value, + required this.onSelect, + super.key, + }); + + /// What the axis is called, in smallcaps above the row. + final String label; + + /// The picks on offer, in the bank's order. + final List options; + + /// The id currently drawn on the preview. + final String value; + + /// Picks one. + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SmallcapsLabel(label, isHeader: true), + const SizedBox(height: AppSpacing.sm), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final option in options) ...[ + _OptionPill( + option: option, + selected: option.id == value, + onSelect: () => onSelect(option.id), + ), + if (option != options.last) + const SizedBox(width: AppSpacing.xs), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _OptionPill extends StatelessWidget { + const _OptionPill({ + required this.option, + required this.selected, + required this.onSelect, + }); + + final CompanionOption option; + final bool selected; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + final mood = context.mood; + final swatch = _swatch; + + return Semantics( + button: true, + selected: selected, + label: option.label, + excludeSemantics: true, + child: Material( + color: selected ? mood.accent.withValues(alpha: _pickedWash) : null, + shape: const StadiumBorder(), + child: InkWell( + onTap: onSelect, + customBorder: const StadiumBorder(), + child: Container( + constraints: const BoxConstraints(minHeight: _minTarget), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: ShapeDecoration( + shape: StadiumBorder( + side: BorderSide(color: selected ? mood.accent : mood.rule), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (swatch != null) ...[ + Container( + width: _swatchSize, + height: _swatchSize, + decoration: BoxDecoration( + color: swatch, + shape: BoxShape.circle, + border: Border.all( + color: mood.ink.withValues(alpha: _swatchEdgeAlpha), + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + ], + Text( + option.label, + style: AppText.support( + mood: mood, + face: selected ? AppFace.control : AppFace.ui, + color: selected ? mood.accent : null, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// The roast's colour dot. Only that axis authors a swatch; the rest of + /// the pills carry their label alone. + Color? get _swatch { + final authored = option.swatch; + return authored == null ? null : swatchColor(authored); + } +} diff --git a/lib/shared/models/content/companion_option.dart b/lib/shared/models/content/companion_option.dart new file mode 100644 index 00000000..2f12e293 --- /dev/null +++ b/lib/shared/models/content/companion_option.dart @@ -0,0 +1,57 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'companion_option.freezed.dart'; +part 'companion_option.g.dart'; + +/// One pick on one of Roasty's four outfit axes, as the extractor emits it. +/// +/// Words only. What each id *looks like* is vector geometry the app draws, so +/// the art is keyed by [id] rather than carried here — see `roastyOutfitArt` +/// and the guard test that holds the two in step. +@freezed +abstract class CompanionOption with _$CompanionOption { + /// Creates a [CompanionOption]. + const factory CompanionOption({ + required String id, + required String label, + + /// Swatch colour for the picker chip, as a CSS hex string. Only the roast + /// axis carries one — a hat is shown by its drawing, not by a colour. + String? swatch, + }) = _CompanionOption; + + /// Creates a [CompanionOption] from decoded JSON. + factory CompanionOption.fromJson(Map json) => + _$CompanionOptionFromJson(json); +} + +/// The four axes together — what the Studio's Roasty section draws its rows +/// from, and the only shape any caller wants them in. +@immutable +class CompanionOptions { + /// Creates a [CompanionOptions]. + const CompanionOptions({ + required this.roasts, + required this.hats, + required this.gear, + required this.sprouts, + }); + + /// Roast levels, lightest first. + final List roasts; + + /// Headwear, `none` first. + final List hats; + + /// Glasses, scarf and friends, `none` first. + final List gear; + + /// What grows from the crown. + final List sprouts; + + /// Every id the four banks ship, for the guard that holds art in step. + Set get allIds => { + for (final axis in [roasts, hats, gear, sprouts]) + for (final option in axis) option.id, + }; +} diff --git a/lib/shared/models/content/companion_option.freezed.dart b/lib/shared/models/content/companion_option.freezed.dart new file mode 100644 index 00000000..74d3c250 --- /dev/null +++ b/lib/shared/models/content/companion_option.freezed.dart @@ -0,0 +1,287 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'companion_option.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CompanionOption { + + String get id; String get label;/// Swatch colour for the picker chip, as a CSS hex string. Only the roast +/// axis carries one — a hat is shown by its drawing, not by a colour. + String? get swatch; +/// Create a copy of CompanionOption +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CompanionOptionCopyWith get copyWith => _$CompanionOptionCopyWithImpl(this as CompanionOption, _$identity); + + /// Serializes this CompanionOption to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CompanionOption&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.swatch, swatch) || other.swatch == swatch)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,swatch); + +@override +String toString() { + return 'CompanionOption(id: $id, label: $label, swatch: $swatch)'; +} + + +} + +/// @nodoc +abstract mixin class $CompanionOptionCopyWith<$Res> { + factory $CompanionOptionCopyWith(CompanionOption value, $Res Function(CompanionOption) _then) = _$CompanionOptionCopyWithImpl; +@useResult +$Res call({ + String id, String label, String? swatch +}); + + + + +} +/// @nodoc +class _$CompanionOptionCopyWithImpl<$Res> + implements $CompanionOptionCopyWith<$Res> { + _$CompanionOptionCopyWithImpl(this._self, this._then); + + final CompanionOption _self; + final $Res Function(CompanionOption) _then; + +/// Create a copy of CompanionOption +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = null,Object? swatch = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,swatch: freezed == swatch ? _self.swatch : swatch // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CompanionOption]. +extension CompanionOptionPatterns on CompanionOption { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CompanionOption value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CompanionOption() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CompanionOption value) $default,){ +final _that = this; +switch (_that) { +case _CompanionOption(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CompanionOption value)? $default,){ +final _that = this; +switch (_that) { +case _CompanionOption() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String label, String? swatch)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CompanionOption() when $default != null: +return $default(_that.id,_that.label,_that.swatch);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String label, String? swatch) $default,) {final _that = this; +switch (_that) { +case _CompanionOption(): +return $default(_that.id,_that.label,_that.swatch);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String label, String? swatch)? $default,) {final _that = this; +switch (_that) { +case _CompanionOption() when $default != null: +return $default(_that.id,_that.label,_that.swatch);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CompanionOption implements CompanionOption { + const _CompanionOption({required this.id, required this.label, this.swatch}); + factory _CompanionOption.fromJson(Map json) => _$CompanionOptionFromJson(json); + +@override final String id; +@override final String label; +/// Swatch colour for the picker chip, as a CSS hex string. Only the roast +/// axis carries one — a hat is shown by its drawing, not by a colour. +@override final String? swatch; + +/// Create a copy of CompanionOption +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CompanionOptionCopyWith<_CompanionOption> get copyWith => __$CompanionOptionCopyWithImpl<_CompanionOption>(this, _$identity); + +@override +Map toJson() { + return _$CompanionOptionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CompanionOption&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.swatch, swatch) || other.swatch == swatch)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,label,swatch); + +@override +String toString() { + return 'CompanionOption(id: $id, label: $label, swatch: $swatch)'; +} + + +} + +/// @nodoc +abstract mixin class _$CompanionOptionCopyWith<$Res> implements $CompanionOptionCopyWith<$Res> { + factory _$CompanionOptionCopyWith(_CompanionOption value, $Res Function(_CompanionOption) _then) = __$CompanionOptionCopyWithImpl; +@override @useResult +$Res call({ + String id, String label, String? swatch +}); + + + + +} +/// @nodoc +class __$CompanionOptionCopyWithImpl<$Res> + implements _$CompanionOptionCopyWith<$Res> { + __$CompanionOptionCopyWithImpl(this._self, this._then); + + final _CompanionOption _self; + final $Res Function(_CompanionOption) _then; + +/// Create a copy of CompanionOption +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = null,Object? swatch = freezed,}) { + return _then(_CompanionOption( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,swatch: freezed == swatch ? _self.swatch : swatch // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/shared/models/content/companion_option.g.dart b/lib/shared/models/content/companion_option.g.dart new file mode 100644 index 00000000..f665f36b --- /dev/null +++ b/lib/shared/models/content/companion_option.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'companion_option.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CompanionOption _$CompanionOptionFromJson(Map json) => + _CompanionOption( + id: json['id'] as String, + label: json['label'] as String, + swatch: json['swatch'] as String?, + ); + +Map _$CompanionOptionToJson(_CompanionOption instance) => + { + 'id': instance.id, + 'label': instance.label, + 'swatch': instance.swatch, + }; diff --git a/lib/shared/repositories/content_repository.dart b/lib/shared/repositories/content_repository.dart index a7578ec4..85b6dff4 100644 --- a/lib/shared/repositories/content_repository.dart +++ b/lib/shared/repositories/content_repository.dart @@ -1,6 +1,7 @@ import 'package:brew_path/shared/models/coffee_card_model.dart'; import 'package:brew_path/shared/models/content/brew_challenge.dart'; import 'package:brew_path/shared/models/content/collectible.dart'; +import 'package:brew_path/shared/models/content/companion_option.dart'; import 'package:brew_path/shared/models/content/content_card.dart'; import 'package:brew_path/shared/models/content/grove_light.dart'; import 'package:brew_path/shared/models/content/grove_variety.dart'; @@ -27,6 +28,7 @@ class ContentRepository { Map>? _miniGameRounds; List? _groveVarieties; List? _groveLights; + CompanionOptions? _companionOptions; List? _challenges; /// Loads and caches the five modules, in course order. @@ -118,6 +120,25 @@ class ContentRepository { return _groveLights!; } + /// Loads and caches Roasty's four outfit axes, in picker order. + /// + /// One bank each, as the grove splits its two: the axes are co-equal and + /// independently chosen, so none is another's auxiliary data. + Future getCompanionOptions() async { + _companionOptions ??= CompanionOptions( + roasts: await _companionBank('companion_roasts'), + hats: await _companionBank('companion_hats'), + gear: await _companionBank('companion_gear'), + sprouts: await _companionBank('companion_sprouts'), + ); + return _companionOptions!; + } + + Future> _companionBank(String name) => loadBank( + 'assets/content/generated/$name.json', + CompanionOption.fromJson, + ); + /// The rounds authored for [formatId], or empty when the bank has none. /// /// The extractor refuses to write a format with no rounds, so an empty list diff --git a/lib/shared/storage/snapshot/snapshot_scopes.dart b/lib/shared/storage/snapshot/snapshot_scopes.dart index cc4ba7fb..ffc83eb4 100644 --- a/lib/shared/storage/snapshot/snapshot_scopes.dart +++ b/lib/shared/storage/snapshot/snapshot_scopes.dart @@ -547,6 +547,21 @@ class ClearedByDeleteOnly { unknown: unknown, ); + /// A copy with [dressed] as the outfit, stamped so a peer holding an older + /// pick loses to it. + /// + /// Last-writer-wins, like the grove beside it: two devices cannot both be + /// right about what Roasty is wearing. + ClearedByDeleteOnly withCompanion( + CompanionConfig dressed, { + required int at, + required String writerId, + }) => ClearedByDeleteOnly( + grove: grove, + companion: Timestamped(value: dressed, updatedAt: at, writerId: writerId), + unknown: unknown, + ); + /// This scope's JSON form, with unrecognised keys written back verbatim. Map toJson() => { ...unknown, diff --git a/lib/shared/storage/snapshot/snapshot_values.dart b/lib/shared/storage/snapshot/snapshot_values.dart index d3fbff2e..b963e413 100644 --- a/lib/shared/storage/snapshot/snapshot_values.dart +++ b/lib/shared/storage/snapshot/snapshot_values.dart @@ -2,11 +2,10 @@ import 'package:flutter/foundation.dart'; /// A logged reaction to a brew challenge. /// -/// The reaction is stored as its **text**, never as an index into the -/// challenge's option list. The design has already rewritten every reaction -/// string once (`Tasted the difference` → `Preferred 1:15`), and a stored index -/// silently changes meaning across such a rewrite where a stored string either -/// still matches or visibly does not. +/// Stored as its **text**, never as an index into the challenge's options. The +/// design has rewritten every reaction string once already, and an index +/// silently changes meaning across such a rewrite where a string either still +/// matches or visibly does not. @immutable class ChallengeReaction { /// Creates a [ChallengeReaction]. @@ -83,10 +82,8 @@ class ActiveChallenge { /// stands in. /// /// Two axes rather than five fixed skins, so the plant reinforces the course -/// (`m1l2` teaches Arabica vs Robusta) instead of decorating around it. The -/// split from a single `tree` field is the snapshot's worked example of a -/// migration, and both halves are last-writer-wins — the first non-monotonic -/// fields on the snapshot. +/// instead of decorating around it. The split from a single `tree` field is +/// the snapshot's worked migration, and both halves are last-writer-wins. @immutable class Grove { /// Creates a [Grove]. @@ -183,6 +180,19 @@ class CompanionConfig { 'sprout': sprout, }; + /// The same outfit with one axis changed — what the wardrobe edits through. + CompanionConfig copyWith({ + String? roast, + String? hat, + String? gear, + String? sprout, + }) => CompanionConfig( + roast: roast ?? this.roast, + hat: hat ?? this.hat, + gear: gear ?? this.gear, + sprout: sprout ?? this.sprout, + ); + @override bool operator ==(Object other) => identical(this, other) || diff --git a/lib/shared/theme/roasty_outfit_colors.dart b/lib/shared/theme/roasty_outfit_colors.dart new file mode 100644 index 00000000..f31e70b9 --- /dev/null +++ b/lib/shared/theme/roasty_outfit_colors.dart @@ -0,0 +1,87 @@ +import 'package:brew_path/shared/theme/roasty_colors.dart'; +import 'package:flutter/painting.dart'; + +/// What Roasty's wardrobe is drawn in — the four roast gradients, the hats, +/// the gear and the sprouts. +/// +/// `RoastyColors`' rules, for its reasons: a beanie is the same red under any +/// theme. A value the design reuses from the mascot's own palette is +/// referenced there rather than restated, so the two cannot drift apart. +abstract final class RoastyOutfitColors { + /// The lightest roast, centre out: lit → mid → edge. + static const List roastLight = [ + Color(0xFFC49A6C), + Color(0xFFA87B4F), + Color(0xFF855B36), + ]; + + /// The default roast — the bean's own gradient, which is what `medium` is. + static const List roastMedium = RoastyColors.beanGradient; + + /// A darker bean. + static const List roastDark = [ + Color(0xFF6E4329), + Color(0xFF4A2C19), + Color(0xFF2C190E), + ]; + + /// The darkest roast, nearly black at the edge. + static const List roastEspresso = [ + Color(0xFF4A2E1C), + Color(0xFF2F1B10), + Color(0xFF180C06), + ]; + + /// The beanie's body, and the cherry on the sprig sprout. + static const Color ember = RoastyColors.confettiEmber; + + /// The beanie's turned brim, and the cherry's shaded side. + static const Color emberDeep = Color(0xFF9E4632); + + /// The highlight on the cherry. + static const Color cherryBlush = Color(0xFFE0997A); + + /// The field hat's brim. + static const Color strawBrim = Color(0xFFC9A35E); + + /// Its crown, lighter than the brim it sits on. + static const Color strawCrown = Color(0xFFD9B873); + + /// Its band, and the line that edges the brim. + static const Color strawBand = Color(0xFFA8823F); + + /// The cap's crown, and the scarf. + static const Color moss = RoastyColors.confettiMoss; + + /// The cap's peak, and its button. + static const Color mossDeep = Color(0xFF5E6857); + + /// The scarf's hanging tail and its fold line. + static const Color scarfShade = Color(0xFF6B7563); + + /// Frames, lenses and headphone shells — the same near-black the face is + /// drawn in. + static const Color gearDark = RoastyColors.mouth; + + /// Glass, the blossom's petals, and the glint on a lens. + static const Color gearLight = RoastyColors.eyeWhite; + + /// The headphone ear cushions. + static const Color cushion = RoastyColors.confettiEmber; + + /// The blossom's centre. + static const Color blossomHeart = RoastyColors.confettiGold; + + /// The four roast gradients by the id the bank ships, so a roast the app has + /// no stops for falls back to the bean's own rather than failing to paint. + static const byRoast = >{ + 'light': roastLight, + 'medium': roastMedium, + 'dark': roastDark, + 'espresso': roastEspresso, + }; + + /// The gradient for [roast], or the default bean when the id is unknown. + static List roastGradient(String roast) => + byRoast[roast] ?? roastMedium; +} diff --git a/test/unit/app/app_redirect_test.dart b/test/unit/app/app_redirect_test.dart index c8cef88c..7c2f15c9 100644 --- a/test/unit/app/app_redirect_test.dart +++ b/test/unit/app/app_redirect_test.dart @@ -159,6 +159,11 @@ void main() { expect(redirect('/profile/studio'), isNull); }); + test('so does the wardrobe behind the second Studio door', () { + expect(redirect('/profile/roasty', entitled: false), '/profile'); + expect(redirect('/profile/roasty'), isNull); + }); + test('the completion moment intercepts Today only', () { expect(redirect('/learn', completionDue: true), '/course-complete'); expect(redirect('/cards', completionDue: true), isNull); diff --git a/test/unit/app_routes_test.dart b/test/unit/app_routes_test.dart index 52179c41..48693ca3 100644 --- a/test/unit/app_routes_test.dart +++ b/test/unit/app_routes_test.dart @@ -138,6 +138,8 @@ void main() { 'profileStreak': HeaderTier.pushed, // Full-screen surface with its own back bar, like Settings. 'studio': HeaderTier.pushed, + // The Studio's second door, and the same chrome as the first. + 'roastyStudio': HeaderTier.pushed, // The four screens the design's ACCOUNT and SUPPORT rows lead to. Each // wears the same back-arrow bar Settings does, which is what pushed // means here. diff --git a/test/unit/features/companion/companion_outfit_test.dart b/test/unit/features/companion/companion_outfit_test.dart new file mode 100644 index 00000000..ab64cfea --- /dev/null +++ b/test/unit/features/companion/companion_outfit_test.dart @@ -0,0 +1,76 @@ +import 'package:brew_path/features/companion/application/companion_outfit.dart'; +import 'package:brew_path/features/monetization/domain/course_entitlement.dart'; +import 'package:brew_path/features/studio/domain/dress_companion.dart'; +import 'package:brew_path/shared/repositories/snapshot_repository.dart'; +import 'package:brew_path/shared/storage/app_database.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// The one place that decides what Roasty wears. The gate is the point: a free +// learner sees the plain mascot everywhere, whatever the snapshot holds — a +// lapsed subscriber must not keep the wardrobe. +const _dressed = CompanionConfig( + roast: 'dark', + hat: 'beanie', + gear: 'scarf', + sprout: 'flower', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late SnapshotRepository snapshots; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + AppDatabaseService.instance = db; + snapshots = SnapshotRepository(); + }); + tearDown(() async => db.close()); + + Future outfitFor({required bool entitled}) async { + final container = ProviderContainer( + overrides: [ + courseEntitlementProvider.overrideWith((ref) async => entitled), + ], + ); + addTearDown(container.dispose); + return container.read(companionOutfitProvider.future); + } + + test('a learner who has dressed Roasty and paid sees the outfit', () async { + await dressCompanion(snapshots, outfit: _dressed, now: DateTime.now()); + + expect(await outfitFor(entitled: true), _dressed); + }); + + test( + 'the same learner without the entitlement sees the plain bean', + () async { + await dressCompanion(snapshots, outfit: _dressed, now: DateTime.now()); + + expect(await outfitFor(entitled: false), CompanionConfig.initial); + }, + ); + + test( + 'the stored outfit survives the gate — it is hidden, not wiped', + () async { + await dressCompanion(snapshots, outfit: _dressed, now: DateTime.now()); + await outfitFor(entitled: false); + + final stored = (await snapshots.read()).clearedByDeleteOnly.companion; + expect(stored.value, _dressed); + }, + ); + + test( + 'an undressed learner with the entitlement sees the plain bean', + () async { + expect(await outfitFor(entitled: true), CompanionConfig.initial); + }, + ); +} diff --git a/test/unit/features/companion/roasty_outfit_art_test.dart b/test/unit/features/companion/roasty_outfit_art_test.dart new file mode 100644 index 00000000..62069a35 --- /dev/null +++ b/test/unit/features/companion/roasty_outfit_art_test.dart @@ -0,0 +1,87 @@ +// The banks name the outfit; the art draws it, keyed by the same ids. Nothing +// holds the two in step at compile time, so this does — a bank id with no +// drawing is a pick that silently changes nothing on screen. +import 'package:brew_path/features/companion/presentation/roasty_gear.dart'; +import 'package:brew_path/features/companion/presentation/roasty_hats.dart'; +import 'package:brew_path/features/companion/presentation/roasty_sprouts.dart'; +import 'package:brew_path/shared/models/content/companion_option.dart'; +import 'package:brew_path/shared/repositories/content_repository.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:brew_path/shared/theme/roasty_outfit_colors.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The id every axis uses for "wearing nothing on this one". +const _bare = 'none'; + +Set _ids(List axis) => + axis.map((option) => option.id).toSet(); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CompanionOptions options; + + setUpAll(() async { + options = await ContentRepository().getCompanionOptions(); + }); + + group('the roast axis', () { + test('every id the bank ships has its own gradient', () { + expect(_ids(options.roasts), RoastyOutfitColors.byRoast.keys.toSet()); + }); + + test('each gradient is the three stops the body paint takes', () { + for (final stops in RoastyOutfitColors.byRoast.values) { + expect(stops, hasLength(3)); + } + }); + + test('an unknown roast falls back rather than failing to paint', () { + expect( + RoastyOutfitColors.roastGradient('charcoal'), + RoastyOutfitColors.roastMedium, + ); + }); + }); + + group('the hat axis', () { + test('every id the bank ships is drawn, bar the bare one', () { + expect(_ids(options.hats), {_bare, ...roastyHatIds}); + }); + + test('only the bare one reads as bare', () { + expect(hatIsBare(_bare), isTrue); + for (final id in roastyHatIds) { + expect(hatIsBare(id), isFalse, reason: '$id draws nothing'); + } + }); + }); + + group('the sprout axis', () { + test('every id the bank ships is drawn, bar the bare one', () { + expect(_ids(options.sprouts), {_bare, ...roastySproutIds}); + }); + + test('only the bare one reads as bare', () { + expect(sproutIsBare(_bare), isTrue); + for (final id in roastySproutIds) { + expect(sproutIsBare(id), isFalse, reason: '$id draws nothing'); + } + }); + }); + + group('the gear axis', () { + test('every id the bank ships is drawn, bar the bare one', () { + expect(_ids(options.gear), {_bare, ...roastyGearIds}); + }); + }); + + test('the plain outfit names an id the bank actually ships', () { + const plain = CompanionConfig.initial; + + expect(_ids(options.roasts), contains(plain.roast)); + expect(_ids(options.hats), contains(plain.hat)); + expect(_ids(options.gear), contains(plain.gear)); + expect(_ids(options.sprouts), contains(plain.sprout)); + }); +} diff --git a/test/unit/features/studio/dress_companion_test.dart b/test/unit/features/studio/dress_companion_test.dart new file mode 100644 index 00000000..c6f99705 --- /dev/null +++ b/test/unit/features/studio/dress_companion_test.dart @@ -0,0 +1,96 @@ +import 'package:brew_path/features/studio/domain/dress_companion.dart'; +import 'package:brew_path/shared/repositories/snapshot_repository.dart'; +import 'package:brew_path/shared/storage/app_database.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// The Roasty section's only write. It rides the same last-writer-wins field +// the grove does, so what matters is that it stamps what the learner picked, +// leaves the grove alone, and writes nothing at all when nothing changed. +const _dressed = CompanionConfig( + roast: 'dark', + hat: 'beanie', + gear: 'scarf', + sprout: 'flower', +); + +void main() { + late AppDatabase db; + late SnapshotRepository snapshots; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + AppDatabaseService.instance = db; + snapshots = SnapshotRepository(); + }); + + tearDown(() async => db.close()); + + final now = DateTime.fromMillisecondsSinceEpoch(1700000000000); + + test('dressing writes the picked outfit, stamped', () async { + await dressCompanion(snapshots, outfit: _dressed, now: now); + + final stored = (await snapshots.read()).clearedByDeleteOnly.companion; + expect(stored.value, _dressed); + expect(stored.updatedAt, now.millisecondsSinceEpoch); + }); + + test('dressing in what is already worn does not move the stamp', () async { + // An identical write moves the last-writer-wins stamp, so it would beat a + // real change made on another device. + await dressCompanion( + snapshots, + outfit: CompanionConfig.initial, + now: now, + ); + + final stored = (await snapshots.read()).clearedByDeleteOnly.companion; + expect(stored.updatedAt, isNot(now.millisecondsSinceEpoch)); + }); + + test('a second change overwrites the first', () async { + await dressCompanion(snapshots, outfit: _dressed, now: now); + await dressCompanion( + snapshots, + outfit: const CompanionConfig( + roast: 'light', + hat: 'none', + gear: 'none', + sprout: 'leaf', + ), + now: now.add(const Duration(minutes: 1)), + ); + + final stored = (await snapshots.read()).clearedByDeleteOnly.companion; + expect(stored.value.roast, 'light'); + expect(stored.value.hat, 'none'); + }); + + test('dressing leaves the grove exactly where it was', () async { + // The two live in one scope, so a writer that rebuilt the scope carelessly + // would silently replant. + const planted = Grove(variety: 'robusta', light: 'moonlit'); + final before = await snapshots.read(); + await snapshots.write( + before.copyWith( + clearedByDeleteOnly: before.clearedByDeleteOnly.withGrove( + planted, + at: now.millisecondsSinceEpoch, + writerId: before.deviceId, + ), + ), + ); + + await dressCompanion( + snapshots, + outfit: _dressed, + now: now.add(const Duration(minutes: 1)), + ); + + final stored = (await snapshots.read()).clearedByDeleteOnly; + expect(stored.grove.value, planted); + expect(stored.companion.value, _dressed); + }); +} diff --git a/test/unit/shared/storage/snapshot/companion_config_test.dart b/test/unit/shared/storage/snapshot/companion_config_test.dart new file mode 100644 index 00000000..73cb19db --- /dev/null +++ b/test/unit/shared/storage/snapshot/companion_config_test.dart @@ -0,0 +1,50 @@ +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _worn = CompanionConfig( + roast: 'dark', + hat: 'beanie', + gear: 'shades', + sprout: 'cherry', +); + +void main() { + group('copyWith — one axis at a time, which is how the wardrobe edits', () { + test('the roast leaves the other three alone', () { + expect( + _worn.copyWith(roast: 'light'), + const CompanionConfig( + roast: 'light', + hat: 'beanie', + gear: 'shades', + sprout: 'cherry', + ), + ); + }); + + test('the hat leaves the other three alone', () { + expect(_worn.copyWith(hat: 'cap').roast, 'dark'); + expect(_worn.copyWith(hat: 'cap').hat, 'cap'); + expect(_worn.copyWith(hat: 'cap').gear, 'shades'); + expect(_worn.copyWith(hat: 'cap').sprout, 'cherry'); + }); + + test('the gear leaves the other three alone', () { + expect(_worn.copyWith(gear: 'scarf').hat, 'beanie'); + expect(_worn.copyWith(gear: 'scarf').gear, 'scarf'); + }); + + test('the sprout leaves the other three alone', () { + expect(_worn.copyWith(sprout: 'bare').gear, 'shades'); + expect(_worn.copyWith(sprout: 'bare').sprout, 'bare'); + }); + + test('naming nothing changes nothing', () { + expect(_worn.copyWith(), _worn); + }); + + test('re-picking what is already on is not a change', () { + expect(_worn.copyWith(hat: 'beanie') == _worn, isTrue); + }); + }); +} diff --git a/test/unit/shared/storage/snapshot/merge_cases_test.dart b/test/unit/shared/storage/snapshot/merge_cases_test.dart index 2f6c4b56..7473bc26 100644 --- a/test/unit/shared/storage/snapshot/merge_cases_test.dart +++ b/test/unit/shared/storage/snapshot/merge_cases_test.dart @@ -103,6 +103,55 @@ void main() { }); }); + group('the companion — last writer wins, both ways round', () { + ClearedByDeleteOnly dressed( + CompanionConfig outfit, { + required int at, + required String by, + }) => ClearedByDeleteOnly( + grove: ClearedByDeleteOnly.empty.grove, + companion: Timestamped(value: outfit, updatedAt: at, writerId: by), + ); + + const beanie = CompanionConfig( + roast: 'dark', + hat: 'beanie', + gear: 'none', + sprout: 'leaf', + ); + const shades = CompanionConfig( + roast: 'light', + hat: 'none', + gear: 'shades', + sprout: 'cherry', + ); + + test('two devices dressing him converge on the later pick', () { + final phone = _snap(account: dressed(beanie, at: 10, by: 'phone')); + final tablet = _snap(account: dressed(shades, at: 20, by: 'tablet')); + + expect( + mergeSnapshot(phone, tablet).clearedByDeleteOnly.companion.value, + shades, + ); + expect( + mergeSnapshot(tablet, phone).clearedByDeleteOnly.companion.value, + shades, + reason: 'whichever device syncs first, both land on one outfit', + ); + }); + + test('a tie is broken by the writer, not by the argument order', () { + final phone = _snap(account: dressed(beanie, at: 10, by: 'phone')); + final tablet = _snap(account: dressed(shades, at: 10, by: 'tablet')); + + expect( + mergeSnapshot(phone, tablet).clearedByDeleteOnly.companion.value, + mergeSnapshot(tablet, phone).clearedByDeleteOnly.companion.value, + ); + }); + }); + group('dailyActivity — a set per day, never a counter', () { test('two devices each playing one game that day combine to two', () { // A counter cannot express this: max gives 1, and sum double-counts on diff --git a/test/unit/shared/theme/unthemed_constants_guard_test.dart b/test/unit/shared/theme/unthemed_constants_guard_test.dart index f242d1c4..64250456 100644 --- a/test/unit/shared/theme/unthemed_constants_guard_test.dart +++ b/test/unit/shared/theme/unthemed_constants_guard_test.dart @@ -12,6 +12,7 @@ const _unthemedFiles = [ 'lib/shared/theme/art_colors.dart', 'lib/shared/theme/overlay_colors.dart', 'lib/shared/theme/roasty_colors.dart', + 'lib/shared/theme/roasty_outfit_colors.dart', 'lib/shared/theme/app_radii.dart', 'lib/shared/theme/app_spacing.dart', 'lib/shared/theme/off_token.dart', diff --git a/test/unit/tool/extract_content_test.dart b/test/unit/tool/extract_content_test.dart index ac6c583f..50a96a47 100644 --- a/test/unit/tool/extract_content_test.dart +++ b/test/unit/tool/extract_content_test.dart @@ -40,6 +40,10 @@ const _expectedBanks = [ 'mini_game_content.json', 'grove_varieties.json', 'grove_lights.json', + 'companion_roasts.json', + 'companion_hats.json', + 'companion_gear.json', + 'companion_sprouts.json', ]; /// The entry whose rounds live in a second source behind a `window` getter — diff --git a/test/widget/features/companion/companion_outfit_scope_test.dart b/test/widget/features/companion/companion_outfit_scope_test.dart new file mode 100644 index 00000000..2c430615 --- /dev/null +++ b/test/widget/features/companion/companion_outfit_scope_test.dart @@ -0,0 +1,134 @@ +import 'package:brew_path/app/app.dart'; +import 'package:brew_path/features/companion/application/companion_outfit.dart'; +import 'package:brew_path/features/companion/domain/roasty_state.dart'; +import 'package:brew_path/features/companion/presentation/companion_outfit_scope.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/widget_harness.dart'; + +// What makes the outfit app-wide: a Roasty three hosts deep reads it without +// anyone threading it through, and a Roasty with no scope over it is the plain +// bean — which is what a free learner, and every unrelated widget test, sees. +const _dressed = CompanionConfig( + roast: 'dark', + hat: 'beanie', + gear: 'scarf', + sprout: 'flower', +); + +/// A mascot buried under hosts that know nothing about outfits. +Widget _buriedRoasty() => const Padding( + padding: EdgeInsets.all(4), + child: Center( + child: DecoratedBox( + decoration: BoxDecoration(), + child: Roasty(state: RoastyState.idle, size: 40, animate: false), + ), + ), +); + +Future _pump(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: child), + ); + await tester.pump(); +} + +void main() { + testWidgets('the app installs the scope over every route', (tester) async { + await useInMemoryDatabase(); + await pumpWithProviders( + tester, + const BrewPathApp(), + container: ProviderContainer( + overrides: [ + companionOutfitProvider.overrideWith((ref) async => _dressed), + ], + ), + ); + + final scope = find.byType(CompanionOutfitScope); + expect( + scope, + findsOneWidget, + reason: + 'delete the scope from the app root and every screen goes plain — ' + 'which is the failure mode this feature exists to prevent', + ); + expect( + tester.widget(scope).outfit, + _dressed, + reason: 'the scope is fed by the provider, not by a hard-coded outfit', + ); + }); + + testWidgets('a mascot under the scope wears what it carries', (tester) async { + await _pump( + tester, + CompanionOutfitScope(outfit: _dressed, child: _buriedRoasty()), + ); + + expect(CompanionOutfitScope.of(_context(tester)), _dressed); + }); + + testWidgets('a mascot with no scope over it is the plain bean', ( + tester, + ) async { + await _pump(tester, _buriedRoasty()); + + expect(CompanionOutfitScope.of(_context(tester)), CompanionConfig.initial); + }); + + testWidgets('an outfit passed to one mascot overrides the scope for it', ( + tester, + ) async { + // The Studio's preview: one dressed mascot inside an app wearing another. + const previewed = CompanionConfig( + roast: 'light', + hat: 'cap', + gear: 'none', + sprout: 'none', + ); + + await _pump( + tester, + const CompanionOutfitScope( + outfit: _dressed, + child: Roasty( + state: RoastyState.idle, + size: 40, + animate: false, + outfit: previewed, + ), + ), + ); + + expect(tester.widget(find.byType(Roasty)).outfit, previewed); + }); + + testWidgets('changing the outfit rebuilds what is under it', (tester) async { + await _pump( + tester, + CompanionOutfitScope( + outfit: CompanionConfig.initial, + child: _buriedRoasty(), + ), + ); + expect(CompanionOutfitScope.of(_context(tester)), CompanionConfig.initial); + + await _pump( + tester, + CompanionOutfitScope(outfit: _dressed, child: _buriedRoasty()), + ); + + expect(CompanionOutfitScope.of(_context(tester)), _dressed); + }); +} + +/// The buried mascot's own context — the one that has to resolve the scope. +BuildContext _context(WidgetTester tester) => + tester.element(find.byType(Roasty)); diff --git a/test/widget/features/profile/profile_entry_cards_test.dart b/test/widget/features/profile/profile_entry_cards_test.dart index 8048afe8..0ccd3013 100644 --- a/test/widget/features/profile/profile_entry_cards_test.dart +++ b/test/widget/features/profile/profile_entry_cards_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:brew_path/app/app.dart'; import 'package:brew_path/core/icons/app_icon.dart'; +import 'package:brew_path/features/companion/presentation/roasty.dart'; import 'package:brew_path/features/monetization/domain/plus_gate_trigger.dart'; import 'package:brew_path/features/monetization/presentation/plus_pill.dart'; import 'package:brew_path/features/profile/presentation/widgets/profile_entry_card.dart'; @@ -10,9 +11,13 @@ import 'package:brew_path/features/saved/domain/saved_providers.dart'; import 'package:brew_path/features/saved/domain/saved_shelf.dart'; import 'package:brew_path/features/saved/presentation/saved_entry_card.dart'; import 'package:brew_path/features/saved/presentation/saved_screen.dart'; +import 'package:brew_path/features/studio/domain/dress_companion.dart'; +import 'package:brew_path/features/studio/presentation/roasty_door_tile.dart'; +import 'package:brew_path/features/studio/presentation/roasty_studio_screen.dart'; import 'package:brew_path/features/studio/presentation/studio_door_tile.dart'; import 'package:brew_path/features/studio/presentation/studio_screen.dart'; import 'package:brew_path/shared/repositories/repository_providers.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -51,16 +56,52 @@ void main() { await settleLoaders(tester); } - testWidgets('the two entries close the screen, in the design order', ( + testWidgets('a free learner meets the plain bean on the wardrobe door', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await dressCompanion( + container.read(snapshotRepositoryProvider), + outfit: const CompanionConfig( + roast: 'espresso', + hat: 'beanie', + gear: 'headphones', + sprout: 'cherry', + ), + now: DateTime(2026, 9, 11), + ); + + await openProfile(tester); + + final door = tester.widget( + find.descendant( + of: find.byType(RoastyDoorTile), + matching: find.byType(Roasty), + ), + ); + expect( + door.outfit, + CompanionConfig.initial, + reason: 'the gate hides the wardrobe on the door as well as behind it', + ); + }); + + testWidgets('the three entries close the screen, in the design order', ( tester, ) async { await openProfile(tester); - expect(find.byType(ProfileEntryCard), findsNWidgets(2)); + expect(find.byType(ProfileEntryCard), findsNWidgets(3)); expect( tester.getTopLeft(find.byType(StudioDoorTile)).dy, + lessThan(tester.getTopLeft(find.byType(RoastyDoorTile)).dy), + reason: 'the grove door opens the Studio, and the wardrobe follows it', + ); + expect( + tester.getTopLeft(find.byType(RoastyDoorTile)).dy, lessThan(tester.getTopLeft(find.byType(SavedEntryCard)).dy), - reason: 'the design stacks the Studio door above Saved', + reason: 'the design stacks the Studio doors above Saved', ); // Neither is owed for v1: the design gates both off. expect(find.text('Challenge a friend'), findsNothing); @@ -155,9 +196,17 @@ void main() { ) async { await openProfile(tester); - // A fresh learner owns nothing, so the Studio is the screen's one gated - // entry — and Saved, which is free for everyone, wears no pill beside it. - expect(find.byType(PlusPill), findsOneWidget); + // A fresh learner owns nothing, so both Studio doors are gated — and + // Saved, which is free for everyone, wears no pill beside it. + for (final door in [ + find.byType(StudioDoorTile), + find.byType(RoastyDoorTile), + ]) { + expect( + find.descendant(of: door, matching: find.byType(PlusPill)), + findsOneWidget, + ); + } expect( find.descendant( of: find.byType(SavedEntryCard), @@ -172,4 +221,16 @@ void main() { expect(find.text(const LockedStudio().header), findsOneWidget); expect(find.byType(StudioScreen), findsNothing); }); + + testWidgets('the wardrobe door raises the same gate, and opens nothing', ( + tester, + ) async { + await openProfile(tester); + + await tester.tap(find.byType(RoastyDoorTile)); + await settleLoaders(tester); + + expect(find.text(const LockedStudio().header), findsOneWidget); + expect(find.byType(RoastyStudioScreen), findsNothing); + }); } diff --git a/test/widget/features/studio/roasty_studio_screen_test.dart b/test/widget/features/studio/roasty_studio_screen_test.dart new file mode 100644 index 00000000..1df60517 --- /dev/null +++ b/test/widget/features/studio/roasty_studio_screen_test.dart @@ -0,0 +1,144 @@ +import 'package:brew_path/features/companion/presentation/roasty.dart'; +import 'package:brew_path/features/studio/domain/dress_companion.dart'; +import 'package:brew_path/features/studio/presentation/roasty_studio_screen.dart'; +import 'package:brew_path/shared/repositories/snapshot_repository.dart'; +import 'package:brew_path/shared/storage/snapshot/snapshot_values.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/widget_harness.dart'; + +// The wardrobe as a learner meets it: pick, see it, confirm. The preview is +// asserted through the outfit the mascot is handed, because what a painter put +// on the canvas is not readable from a widget test. +/// Reduced motion throughout: Roasty's idle loop never settles, so +/// `pumpAndSettle` would time out on the preview rather than on anything the +/// test is about. +Future _pump(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: const RoastyStudioScreen(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + ), + ), + ); + await _settle(tester); +} + +/// Pumps until the banks have arrived, in real time — they come off the +/// bundle, which a fake-async pump cannot advance. +Future _settle(WidgetTester tester) async { + for (var frame = 0; frame < 20; frame++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + if (find.byType(Roasty).evaluate().isNotEmpty) return; + } +} + +CompanionConfig _previewed(WidgetTester tester) => + tester.widget(find.byType(Roasty)).outfit!; + +void main() { + final snapshots = SnapshotRepository(); + + setUp(useInMemoryDatabase); + + testWidgets('opens on the four axes the banks ship', (tester) async { + await _pump(tester); + + expect(find.text('ROAST'), findsOneWidget); + expect(find.text('HAT'), findsOneWidget); + expect(find.text('ACCESSORY'), findsOneWidget); + expect(find.text('SPROUT'), findsOneWidget); + // One label off each axis, to prove the rows are fed from the banks. + expect(find.text('Espresso'), findsOneWidget); + expect(find.text('Field hat'), findsOneWidget); + expect(find.text('Headphones'), findsOneWidget); + expect(find.text('Blossom'), findsOneWidget); + }); + + testWidgets('opens on what Roasty already has on', (tester) async { + await dressCompanion( + snapshots, + outfit: const CompanionConfig( + roast: 'dark', + hat: 'cap', + gear: 'scarf', + sprout: 'sprig', + ), + now: DateTime.now(), + ); + + await _pump(tester); + + expect(_previewed(tester).hat, 'cap'); + expect(find.text('Looking sharp'), findsOneWidget); + }); + + testWidgets('picking an axis updates the preview live', (tester) async { + await _pump(tester); + expect(_previewed(tester).roast, 'medium'); + + await tester.tap(find.text('Espresso')); + await tester.pump(); + + expect(_previewed(tester).roast, 'espresso'); + // The other three are untouched by a pick on one. + expect(_previewed(tester).hat, CompanionConfig.initial.hat); + }); + + testWidgets('the confirm is dead until something changes', (tester) async { + await _pump(tester); + + expect(find.text('Looking sharp'), findsOneWidget); + expect(find.text('Apply look'), findsNothing); + + await tester.tap(find.text('Beanie')); + await tester.pump(); + + expect(find.text('Apply look'), findsOneWidget); + }); + + testWidgets('confirming persists the outfit', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Shades')); + await tester.pump(); + // The confirm sits below the fold on a test-sized screen; a raw tap would + // land outside the viewport and silently miss. + await tester.ensureVisible(find.text('Apply look')); + await tester.pump(); + await tester.tap(find.text('Apply look')); + for (var frame = 0; frame < 20; frame++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + + final stored = (await snapshots.read()).clearedByDeleteOnly.companion; + expect(stored.value.gear, 'sunglasses'); + }); + + testWidgets('picking back to what is worn kills the confirm again', ( + tester, + ) async { + await _pump(tester); + + await tester.tap(find.text('Espresso')); + await tester.pump(); + expect(find.text('Apply look'), findsOneWidget); + + await tester.tap(find.text('Medium')); + await tester.pump(); + + expect(find.text('Looking sharp'), findsOneWidget); + }); +} diff --git a/tool/extract_content.js b/tool/extract_content.js index 1a2985e4..084bbccf 100644 --- a/tool/extract_content.js +++ b/tool/extract_content.js @@ -210,6 +210,18 @@ function main(argv) { "customize.jsx", ), groveLights: evaluateDeclaration(customize, "GROVE_LIGHT", "customize.jsx"), + companionRoasts: evaluateDeclaration( + customize, + "ROAST_OPTS", + "customize.jsx", + ), + companionHats: evaluateDeclaration(customize, "HAT_OPTS", "customize.jsx"), + companionGear: evaluateDeclaration(customize, "GEAR_OPTS", "customize.jsx"), + companionSprouts: evaluateDeclaration( + customize, + "SPROUT_OPTS", + "customize.jsx", + ), visualGuideCards: evaluateDeclaration( data, "VISUAL_GUIDE_CARDS", @@ -270,6 +282,14 @@ function main(argv) { // is the other's auxiliary data, and each validates on its own terms. bank("grove_varieties", "customize.jsx", derived.groveVarieties), bank("grove_lights", "customize.jsx", derived.groveLights), + // One bank per axis, for the reason the grove's two are split: roast, hat, + // gear and sprout are co-equal and independently chosen, so none is + // another's auxiliary data. Labels only — the art is vector geometry the + // app draws, keyed by these ids and guarded against them. + bank("companion_roasts", "customize.jsx", derived.companionRoasts), + bank("companion_hats", "customize.jsx", derived.companionHats), + bank("companion_gear", "customize.jsx", derived.companionGear), + bank("companion_sprouts", "customize.jsx", derived.companionSprouts), // Two sources, honestly: identity, unlock and the meta table are authored // in `data.jsx`, the words in `practical.jsx`, and a guide is only whole // once they are joined.