Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/screens/drink_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,10 @@ class _SimilarDrinkCard extends StatelessWidget {
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: CategoryColorHelper.getAccentColor(drink.category),
color: CategoryColorHelper.getAccentColor(
drink.category,
theme.brightness,
),
width: 4,
),
),
Expand Down
26 changes: 19 additions & 7 deletions lib/screens/my_festival_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,10 @@ class _MyFestivalScreenState extends State<MyFestivalScreen> {
final availabilityPhrase = _availabilityPhrase(availability);
return _buildRowCard(
context,
accent: CategoryColorHelper.getAccentColor(drink.category),
accent: CategoryColorHelper.getAccentColor(
drink.category,
Theme.of(context).brightness,
),
child: Semantics(
label:
'${drink.name}, ${drink.abv.toStringAsFixed(1)}% ABV'
Expand Down Expand Up @@ -313,7 +316,10 @@ class _MyFestivalScreenState extends State<MyFestivalScreen> {
final note = _noteText(entry);
return _buildRowCard(
context,
accent: CategoryColorHelper.getAccentColor(drink.category),
accent: CategoryColorHelper.getAccentColor(
drink.category,
Theme.of(context).brightness,
),
child: Semantics(
label:
'${drink.name}, by ${drink.breweryName}, $tastedLabel, '
Expand Down Expand Up @@ -471,21 +477,22 @@ class _MyFestivalScreenState extends State<MyFestivalScreen> {
AvailabilityStatus? status,
) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final Color color;
// Bound in the switch so the at-risk status is non-null below; the calm
// states all return early.
final AvailabilityStatus atRisk;
final IconData icon;
final String label;
switch (status) {
case AvailabilityStatus.out:
color = theme.colorScheme.error;
atRisk = AvailabilityStatus.out;
icon = Icons.cancel;
label = 'Sold Out';
case AvailabilityStatus.veryLow:
color = isDark ? const Color(0xFFFF7043) : const Color(0xFFBF360C);
atRisk = AvailabilityStatus.veryLow;
icon = Icons.warning_amber;
label = 'Nearly Gone';
case AvailabilityStatus.low:
color = isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00);
atRisk = AvailabilityStatus.low;
icon = Icons.warning;
label = 'Low';
case AvailabilityStatus.plenty:
Comment on lines 498 to 511

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not acting on this one — Dart 3.0 removed the break requirement for non-empty switch cases, which no longer fall through. The surrounding code already relied on that: the case AvailabilityStatus.out: / veryLow / low bodies in this same method had no break before this PR either, and this project is on Dart >=3.10.

./bin/mise run check is green on this branch — analyzer clean and 1303 tests pass — so the code demonstrably compiles.


Generated by Claude Code

Expand All @@ -494,6 +501,11 @@ class _MyFestivalScreenState extends State<MyFestivalScreen> {
case null:
return null;
}
final color = CategoryColorHelper.getAvailabilityColor(
atRisk,
theme.colorScheme,
theme.brightness,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
Expand Down
183 changes: 107 additions & 76 deletions lib/utils/category_color_helper.dart
Original file line number Diff line number Diff line change
@@ -1,38 +1,80 @@
import 'package:flutter/material.dart';
import '../models/models.dart';

/// Helper class for getting category-specific colors
/// The app's colour system: the single source of truth for every colour used
/// as a *signal* rather than as chrome.
///
/// Provides consistent theme-aware colors for different beverage categories.
/// Colors are supplementary visual aids, not primary indicators (accessibility).
/// Three independent signals live here, deliberately kept separate so they can
/// evolve without dragging each other along:
///
/// | Signal | Accessor | Derivation |
/// |-------------------|--------------------------|--------------------------------|
/// | Beverage category | [getAccentColor] | fixed hue, lightened for dark |
/// | Stock level | [getAvailabilityColor] | fixed pair + theme error |
/// | Personal status | [getTastedColor] | fixed pair |
///
/// Colour is always a *supplementary* aid here — never the sole carrier of
/// meaning. Every surface that uses these also carries an icon or a text label,
/// so none of these values is subject to WCAG text-contrast minima (none has
/// text drawn on top of it; accents are 4px decorative edges).
///
/// Do not hardcode any of these hex values at a call site. Adding one here and
/// referencing it is the whole point of this class.
class CategoryColorHelper {
CategoryColorHelper._();

/// Solid accent colour for a beverage [category], used for the coloured left
/// edge of drink cards — both the list card and the similar-drinks carousel.
/// A fixed palette (independent of theme) so a category reads at a glance;
/// falls back to CBF navy for unknown categories.
static Color getAccentColor(String category) {
switch (category) {
case BeverageCategories.beer:
return const Color(0xFFF59E0B); // amber
case BeverageCategories.internationalBeer:
return const Color(0xFFEF4444); // red
case BeverageCategories.cider:
return const Color(0xFF22C55E); // green
case BeverageCategories.perry:
return const Color(0xFF84CC16); // lime
case BeverageCategories.mead:
return const Color(0xFFD97706); // honey gold
case BeverageCategories.wine:
return const Color(0xFF9333EA); // purple
case BeverageCategories.lowNo:
return const Color(0xFF06B6D4); // cyan
case BeverageCategories.appleJuice:
return const Color(0xFF65A30D); // apple green
default:
return const Color(0xFF2B3170); // CBF navy
}
/// Source hue per beverage category — also the literal light-mode accent.
///
/// These are hand-picked to stay mutually distinguishable at a 4px width:
/// the closest pair (beer/mead) sits ~72 units apart in summed RGB distance.
/// If you add a category, check it does not collide with an existing hue —
/// `category_color_helper_test.dart` pins a minimum separation.
static const Map<String, Color> _categoryHues = {
BeverageCategories.beer: Color(0xFFF59E0B), // amber
BeverageCategories.internationalBeer: Color(0xFFEF4444), // red
BeverageCategories.cider: Color(0xFF22C55E), // green
BeverageCategories.perry: Color(0xFF84CC16), // lime
BeverageCategories.mead: Color(0xFFD97706), // honey gold
BeverageCategories.wine: Color(0xFF9333EA), // purple
BeverageCategories.lowNo: Color(0xFF06B6D4), // cyan
BeverageCategories.appleJuice: Color(0xFF65A30D), // apple green
};

/// Accent for an unrecognised category — CBF poster navy, matching
/// `appSeedColor`.
static const Color _fallbackHue = Color(0xFF2B3170);

/// Lightness added / saturation retained when adapting a hue for a dark
/// surface. Tuned so the lifted palette keeps roughly the separation of the
/// light one rather than washing out to pastel.
static const double _darkLightnessLift = 0.18;
static const double _darkSaturationScale = 0.92;

/// Adapt a light-mode hue for a dark surface by lifting its lightness while
/// preserving hue.
///
/// Deliberately *not* `ColorScheme.fromSeed(...).primary`: that maps every
/// hue onto a Material tonal role, which desaturates the palette and — as
/// measured — collapses perry and apple juice onto the same colour, which
/// defeats the point of a per-category accent.
static Color _liftForDark(Color hue) {
final hsl = HSLColor.fromColor(hue);
return hsl
.withLightness((hsl.lightness + _darkLightnessLift).clamp(0.0, 1.0))
.withSaturation((hsl.saturation * _darkSaturationScale).clamp(0.0, 1.0))
.toColor();
}

/// Solid accent colour for a beverage [category], used for the 4px coloured
/// left edge shared by the drink list card, the hero panels, the My Festival
/// rows and the similar-drinks carousel.
///
/// Derived from [brightness]: the fixed hue in light mode, a lightness-lifted
/// variant in dark mode so the edge reads against a dark surface. Falls back
/// to CBF navy for unknown categories.
static Color getAccentColor(String category, Brightness brightness) {
final hue = _categoryHues[category] ?? _fallbackHue;
return brightness == Brightness.dark ? _liftForDark(hue) : hue;
Comment on lines +72 to +80
}

/// The most common category among [drinks] (by drink count) — used to pick
Expand All @@ -59,57 +101,46 @@ class CategoryColorHelper {
return dominant;
}

/// The "tasted" indicator green, shared by the drink card status badge and
/// the similar-drinks carousel card. Darker in light mode for contrast,
/// lighter in dark mode.
/// Colour for a stock-level [status], shared by the drinks list availability
/// chip and the My Festival at-risk hint.
///
/// [AvailabilityStatus.out] resolves to the theme's semantic error colour
/// rather than a fixed hex — "sold out" is the one availability state that
/// should track the app's error language, so [colorScheme] is required.
/// Every other state uses a fixed light/dark pair chosen for legibility on
/// both surfaces.
static Color getAvailabilityColor(
AvailabilityStatus status,
ColorScheme colorScheme,
Brightness brightness,
) {
final isDark = brightness == Brightness.dark;
switch (status) {
case AvailabilityStatus.plenty:
return isDark ? const Color(0xFF4CAF50) : const Color(0xFF2E7D32);
case AvailabilityStatus.good:
return isDark ? const Color(0xFF8BC34A) : const Color(0xFF558B2F);
case AvailabilityStatus.low:
return isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00);
case AvailabilityStatus.veryLow:
return isDark ? const Color(0xFFFF7043) : const Color(0xFFBF360C);
case AvailabilityStatus.out:
return colorScheme.error;
case AvailabilityStatus.unknown:
return isDark ? const Color(0xFF90A4AE) : const Color(0xFF546E7A);
}
}

/// The "tasted" indicator green, shared by the drink card status badge, the
/// drink detail hero and the My Festival rows. Darker in light mode for
/// contrast, lighter in dark mode.
///
/// Independent of [getAvailabilityColor] by design: personal status and
/// stock level are separate signals and may diverge visually later, even
/// though `plenty` happens to use the same green today.
static Color getTastedColor(Brightness brightness) {
return brightness == Brightness.dark
? const Color(0xFF4CAF50)
: const Color(0xFF2E7D32);
}

/// Get color for a drink category
///
/// Returns a theme-aware color based on the category name.
/// Falls back to outline color if category is not recognized.
static Color getCategoryColor(BuildContext context, String category) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final brightness = theme.brightness;
final cat = category.toLowerCase();

if (cat.contains('beer')) {
// Amber-like color
return brightness == Brightness.dark
? colorScheme.secondary.withValues(alpha: 0.8)
: colorScheme.secondary;
} else if (cat.contains('cider')) {
// Green-ish color
return brightness == Brightness.dark
? const Color(0xFF8BC34A).withValues(alpha: 0.8)
: const Color(0xFF689F38);
} else if (cat.contains('perry')) {
// Lime-ish color
return brightness == Brightness.dark
? const Color(0xFFCDDC39).withValues(alpha: 0.8)
: const Color(0xFFAFB42B);
} else if (cat.contains('mead')) {
// Yellow-ish color
return brightness == Brightness.dark
? const Color(0xFFFFEB3B).withValues(alpha: 0.8)
: const Color(0xFFF9A825);
} else if (cat.contains('wine')) {
// Deep purple/red color
return brightness == Brightness.dark
? const Color(0xFF9C27B0).withValues(alpha: 0.8)
: const Color(0xFF7B1FA2);
} else if (cat.contains('low') || cat.contains('no')) {
// Blue-ish color
return brightness == Brightness.dark
? colorScheme.primary.withValues(alpha: 0.8)
: colorScheme.primary;
}
// Default fallback
return colorScheme.outline;
}
}
5 changes: 4 additions & 1 deletion lib/widgets/brewery_hero_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ class BreweryHeroPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = CategoryColorHelper.getAccentColor(accentCategory);
final accent = CategoryColorHelper.getAccentColor(
accentCategory,
theme.brightness,
);
final hasNotes = producer.notes != null && producer.notes!.isNotEmpty;

final cells = <FactCell>[
Expand Down
18 changes: 9 additions & 9 deletions lib/widgets/drink_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ class DrinkCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final accent = CategoryColorHelper.getAccentColor(drink.category);
final accent = CategoryColorHelper.getAccentColor(
drink.category,
theme.brightness,
);
final excerpt = searchQuery.trim().isEmpty
? null
: const SearchMatchService().hiddenFieldExcerpt(drink, searchQuery);
Expand Down Expand Up @@ -266,40 +269,37 @@ class _AvailabilityChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;

Color color;
final color = CategoryColorHelper.getAvailabilityColor(
status,
theme.colorScheme,
theme.brightness,
);
String label;
IconData icon;

switch (status) {
case AvailabilityStatus.plenty:
color = isDark ? const Color(0xFF4CAF50) : const Color(0xFF2E7D32);
label = 'Available';
icon = Icons.check_circle;
break;
case AvailabilityStatus.good:
color = isDark ? const Color(0xFF8BC34A) : const Color(0xFF558B2F);
label = 'Some Left';
icon = Icons.check_circle_outline;
break;
case AvailabilityStatus.low:
color = isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00);
label = 'Low';
icon = Icons.warning;
break;
case AvailabilityStatus.veryLow:
color = isDark ? const Color(0xFFFF7043) : const Color(0xFFBF360C);
label = 'Nearly Gone';
icon = Icons.warning_amber;
break;
case AvailabilityStatus.out:
color = theme.colorScheme.error;
label = 'Sold Out';
icon = Icons.cancel;
break;
case AvailabilityStatus.unknown:
color = isDark ? const Color(0xFF90A4AE) : const Color(0xFF546E7A);
label = rawText ?? 'Unknown';
icon = Icons.info_outline;
break;
Expand Down
5 changes: 4 additions & 1 deletion lib/widgets/drink_hero_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ class DrinkHeroPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = CategoryColorHelper.getAccentColor(drink.category);
final accent = CategoryColorHelper.getAccentColor(
drink.category,
theme.brightness,
);

return Card(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
Expand Down
5 changes: 4 additions & 1 deletion lib/widgets/style_hero_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ class StyleHeroPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = CategoryColorHelper.getAccentColor(category);
final accent = CategoryColorHelper.getAccentColor(
category,
theme.brightness,
);
final hasDescription = description != null && description!.isNotEmpty;

final cells = <FactCell>[
Expand Down
Loading
Loading