diff --git a/lib/app_theme.dart b/lib/app_theme.dart index a2a236e0..f493fef5 100644 --- a/lib/app_theme.dart +++ b/lib/app_theme.dart @@ -95,21 +95,19 @@ ThemeData buildAppTheme(Brightness brightness) { colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true, + // The app bar is a plain Material 3 surface in both themes. Light mode + // previously used the poster navy as a solid slab, which made it the only + // dark surface in an otherwise light UI; the brand colour still leads + // through `primary`, the nav bar indicator and the category accents. appBarTheme: AppBarTheme( - backgroundColor: brightness == Brightness.light - ? appSeedColor - : colorScheme.surface, - foregroundColor: brightness == Brightness.light - ? Colors.white - : colorScheme.onSurface, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, elevation: 0, centerTitle: false, titleTextStyle: GoogleFonts.playfairDisplay( fontSize: 20, fontWeight: FontWeight.w700, - color: brightness == Brightness.light - ? Colors.white - : colorScheme.onSurface, + color: colorScheme.onSurface, ), ), navigationBarTheme: NavigationBarThemeData( diff --git a/lib/screens/drink_detail_screen.dart b/lib/screens/drink_detail_screen.dart index 892d5054..1927bafd 100644 --- a/lib/screens/drink_detail_screen.dart +++ b/lib/screens/drink_detail_screen.dart @@ -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, ), ), diff --git a/lib/screens/my_festival_screen.dart b/lib/screens/my_festival_screen.dart index 17ead4a8..48ad39e2 100644 --- a/lib/screens/my_festival_screen.dart +++ b/lib/screens/my_festival_screen.dart @@ -88,6 +88,8 @@ class _MyFestivalScreenState extends State { .toList(); final tasted = myFestivalEntries.tasted; final theme = Theme.of(context); + final appBarForeground = + theme.appBarTheme.foregroundColor ?? theme.colorScheme.onSurface; final totalCount = wantToTry.length + tasted.length; return PageTitle( @@ -95,16 +97,27 @@ class _MyFestivalScreenState extends State { contextLabel: provider.currentFestival.name, child: Scaffold( appBar: AppBar( + // The text theme bakes `colorScheme.onSurface` into every style, so + // using titleMedium/bodySmall unmodified here paints near-black text + // on the navy app bar (1.45:1 and 1.27:1 — far below WCAG AA). Force + // the app bar's own foreground colour back on. title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( provider.currentFestival.name, - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleMedium?.copyWith( + color: appBarForeground, + ), ), Text( '$totalCount in My Festival', - style: theme.textTheme.bodySmall, + // Same colour as the title, not a muted variant: the size and + // weight difference already carries the hierarchy, and a + // translucent variant would erode contrast on the navy bar. + style: theme.textTheme.bodySmall?.copyWith( + color: appBarForeground, + ), ), ], ), @@ -266,7 +279,10 @@ class _MyFestivalScreenState extends State { 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' @@ -313,7 +329,10 @@ class _MyFestivalScreenState extends State { 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, ' @@ -471,21 +490,22 @@ class _MyFestivalScreenState extends State { 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: @@ -494,6 +514,10 @@ class _MyFestivalScreenState extends State { case null: return null; } + final color = CategoryColorHelper.getAvailabilityColor( + atRisk, + theme.colorScheme, + ); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( diff --git a/lib/utils/category_color_helper.dart b/lib/utils/category_color_helper.dart index a2358ff8..21c3d700 100644 --- a/lib/utils/category_color_helper.dart +++ b/lib/utils/category_color_helper.dart @@ -1,38 +1,83 @@ 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 _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. + /// + /// An unrecognised category falls back to CBF navy, which is adapted for + /// dark surfaces the same way a real category is — so the dark fallback is a + /// lifted navy, not the navy literal. + static Color getAccentColor(String category, Brightness brightness) { + final hue = _categoryHues[category] ?? _fallbackHue; + return brightness == Brightness.dark ? _liftForDark(hue) : hue; } /// The most common category among [drinks] (by drink count) — used to pick @@ -59,57 +104,48 @@ 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. + /// + /// Brightness is read from [colorScheme] rather than taken separately, so a + /// caller cannot pass a dark scheme alongside a light brightness. + static Color getAvailabilityColor( + AvailabilityStatus status, + ColorScheme colorScheme, + ) { + final isDark = colorScheme.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; - } } diff --git a/lib/widgets/brewery_hero_panel.dart b/lib/widgets/brewery_hero_panel.dart index 150db9c9..d03f2793 100644 --- a/lib/widgets/brewery_hero_panel.dart +++ b/lib/widgets/brewery_hero_panel.dart @@ -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 = [ diff --git a/lib/widgets/drink_card.dart b/lib/widgets/drink_card.dart index 2d415fbf..eaa0859d 100644 --- a/lib/widgets/drink_card.dart +++ b/lib/widgets/drink_card.dart @@ -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); @@ -266,40 +269,36 @@ 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, + ); 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; diff --git a/lib/widgets/drink_hero_panel.dart b/lib/widgets/drink_hero_panel.dart index 1d05e757..6eef7c4e 100644 --- a/lib/widgets/drink_hero_panel.dart +++ b/lib/widgets/drink_hero_panel.dart @@ -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), diff --git a/lib/widgets/style_hero_panel.dart b/lib/widgets/style_hero_panel.dart index 44a2bc48..fe43bdff 100644 --- a/lib/widgets/style_hero_panel.dart +++ b/lib/widgets/style_hero_panel.dart @@ -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 = [ diff --git a/test/app_theme_test.dart b/test/app_theme_test.dart index ab565dad..d9d86637 100644 --- a/test/app_theme_test.dart +++ b/test/app_theme_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -9,18 +11,58 @@ void main() { }); group('buildAppTheme', () { - testWidgets('light theme uses navy seed colour as AppBar background', ( + // The light app bar used to be a solid navy slab, which made it the only + // dark surface in an otherwise light UI. It is now a plain Material 3 + // surface in both themes; the seed colour still leads via `primary`, the + // nav bar indicator and the category accents. + testWidgets('light theme AppBar background is surface (not navy)', ( WidgetTester tester, ) async { final theme = buildAppTheme(Brightness.light); - expect(theme.appBarTheme.backgroundColor, equals(appSeedColor)); + expect(theme.appBarTheme.backgroundColor, isNot(equals(appSeedColor))); + expect( + theme.appBarTheme.backgroundColor, + equals(theme.colorScheme.surface), + ); }); - testWidgets('light theme uses white as AppBar foreground', ( + testWidgets('light theme AppBar foreground is onSurface', ( WidgetTester tester, ) async { final theme = buildAppTheme(Brightness.light); - expect(theme.appBarTheme.foregroundColor, equals(Colors.white)); + expect(theme.appBarTheme.foregroundColor, isNot(equals(Colors.white))); + expect( + theme.appBarTheme.foregroundColor, + equals(theme.colorScheme.onSurface), + ); + }); + + testWidgets('AppBar title contrasts with its background in both themes', ( + WidgetTester tester, + ) async { + for (final brightness in Brightness.values) { + final theme = buildAppTheme(brightness); + final background = theme.appBarTheme.backgroundColor!; + final title = theme.appBarTheme.titleTextStyle!.color!; + double channel(double v) => v <= 0.03928 + ? v / 12.92 + : math.pow((v + 0.055) / 1.055, 2.4).toDouble(); + double luminance(Color c) => + 0.2126 * channel(c.r) + + 0.7152 * channel(c.g) + + 0.0722 * channel(c.b); + final lt = luminance(title); + final lb = luminance(background); + final ratio = + ((lt > lb ? lt : lb) + 0.05) / ((lt > lb ? lb : lt) + 0.05); + expect( + ratio, + greaterThanOrEqualTo(4.5), + reason: + '$brightness app bar title is only ' + '${ratio.toStringAsFixed(2)}:1', + ); + } }); testWidgets('light theme primary colour equals seed colour', ( diff --git a/test/category_color_helper_test.dart b/test/category_color_helper_test.dart index eaef4094..b0914b4d 100644 --- a/test/category_color_helper_test.dart +++ b/test/category_color_helper_test.dart @@ -1,96 +1,203 @@ -import 'package:cambridge_beer_festival/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:cambridge_beer_festival/models/models.dart'; +import 'package:cambridge_beer_festival/utils/utils.dart'; + +/// Summed per-channel RGB distance between two colours, in 0-255 units. +/// A crude but stable proxy for "can a user tell these apart at 4px wide". +int _distance(Color a, Color b) { + return (((a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs()) * 255) + .round(); +} + +const _categories = [ + BeverageCategories.beer, + BeverageCategories.internationalBeer, + BeverageCategories.cider, + BeverageCategories.perry, + BeverageCategories.mead, + BeverageCategories.wine, + BeverageCategories.lowNo, + BeverageCategories.appleJuice, +]; void main() { - group('CategoryColorHelper', () { - /// Pumps a [Builder] under [brightness] and captures the colour the helper - /// returns for [category] alongside the active colour scheme. - Future<(Color result, ColorScheme scheme)> resolve( - WidgetTester tester, - Brightness brightness, - String category, - ) async { - late Color result; - late ColorScheme scheme; - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: brightness), - home: Builder( - builder: (context) { - scheme = Theme.of(context).colorScheme; - result = CategoryColorHelper.getCategoryColor(context, category); - return const SizedBox(); - }, - ), + group('getAccentColor', () { + test('light mode returns the fixed category hue', () { + // Pinned so a derivation change cannot silently restyle light mode — + // these are the values the committed light goldens were rendered from. + expect( + CategoryColorHelper.getAccentColor( + BeverageCategories.beer, + Brightness.light, ), + const Color(0xFFF59E0B), ); - await tester.pumpAndSettle(); - return (result, scheme); - } + expect( + CategoryColorHelper.getAccentColor( + BeverageCategories.wine, + Brightness.light, + ), + const Color(0xFF9333EA), + ); + }); - testWidgets('beer categories use the secondary colour', (tester) async { - final (result, scheme) = await resolve(tester, Brightness.light, 'beer'); - expect(result, scheme.secondary); + test('dark mode lightens every category relative to light mode', () { + for (final category in _categories) { + final light = CategoryColorHelper.getAccentColor( + category, + Brightness.light, + ); + final dark = CategoryColorHelper.getAccentColor( + category, + Brightness.dark, + ); + expect( + HSLColor.fromColor(dark).lightness, + greaterThan(HSLColor.fromColor(light).lightness), + reason: '$category should be lifted for a dark surface', + ); + } }); - testWidgets('international-beer still matches the beer branch', ( - tester, - ) async { - final (result, scheme) = await resolve( - tester, - Brightness.light, - 'international-beer', - ); - expect(result, scheme.secondary); + test('dark mode preserves hue, so a category stays recognisable', () { + for (final category in _categories) { + final lightHue = HSLColor.fromColor( + CategoryColorHelper.getAccentColor(category, Brightness.light), + ).hue; + final darkHue = HSLColor.fromColor( + CategoryColorHelper.getAccentColor(category, Brightness.dark), + ).hue; + expect( + (lightHue - darkHue).abs(), + lessThan(1.0), + reason: '$category changed hue between light and dark', + ); + } }); - testWidgets('cider has a dedicated colour per theme', (tester) async { - final (light, _) = await resolve(tester, Brightness.light, 'cider'); - expect(light, const Color(0xFF689F38)); + // The regression this whole design exists to prevent: a derivation that + // maps distinct categories onto the same colour. ColorScheme.fromSeed(...) + // .primary was rejected precisely because it collapsed perry and apple + // juice to a distance of 2. + for (final brightness in Brightness.values) { + test('all categories stay mutually distinguishable in $brightness', () { + for (var i = 0; i < _categories.length; i++) { + for (var j = i + 1; j < _categories.length; j++) { + final a = CategoryColorHelper.getAccentColor( + _categories[i], + brightness, + ); + final b = CategoryColorHelper.getAccentColor( + _categories[j], + brightness, + ); + expect( + _distance(a, b), + greaterThan(50), + reason: + '${_categories[i]} and ${_categories[j]} are too close to ' + 'tell apart in $brightness', + ); + } + } + }); + } - final (dark, _) = await resolve(tester, Brightness.dark, 'cider'); - expect(dark, const Color(0xFF8BC34A).withValues(alpha: 0.8)); + test('unknown category falls back to CBF navy, adapted per brightness', () { + const navy = Color(0xFF2B3170); + expect( + CategoryColorHelper.getAccentColor('not-a-drink', Brightness.light), + navy, + ); + expect( + CategoryColorHelper.getAccentColor('not-a-drink', Brightness.dark), + isNot(navy), + ); }); - testWidgets('perry has a dedicated colour', (tester) async { - final (result, _) = await resolve(tester, Brightness.light, 'perry'); - expect(result, const Color(0xFFAFB42B)); + test('is pure — repeated calls agree', () { + for (final category in _categories) { + for (final brightness in Brightness.values) { + expect( + CategoryColorHelper.getAccentColor(category, brightness), + CategoryColorHelper.getAccentColor(category, brightness), + ); + } + } }); + }); - testWidgets('mead has a dedicated colour', (tester) async { - final (result, _) = await resolve(tester, Brightness.light, 'mead'); - expect(result, const Color(0xFFF9A825)); - }); + group('getAvailabilityColor', () { + final lightScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF2B3170), + ); + final darkScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF2B3170), + brightness: Brightness.dark, + ); - testWidgets('wine has a dedicated colour', (tester) async { - final (result, _) = await resolve(tester, Brightness.light, 'wine'); - expect(result, const Color(0xFF7B1FA2)); + test('returns the pinned pair for each fixed status', () { + // These are the exact values previously hardcoded in _AvailabilityChip; + // pinned so the consolidation stays a pure move, not a restyle. + const expected = { + AvailabilityStatus.plenty: (Color(0xFF2E7D32), Color(0xFF4CAF50)), + AvailabilityStatus.good: (Color(0xFF558B2F), Color(0xFF8BC34A)), + AvailabilityStatus.low: (Color(0xFFEF6C00), Color(0xFFFF9800)), + AvailabilityStatus.veryLow: (Color(0xFFBF360C), Color(0xFFFF7043)), + AvailabilityStatus.unknown: (Color(0xFF546E7A), Color(0xFF90A4AE)), + }; + for (final entry in expected.entries) { + expect( + CategoryColorHelper.getAvailabilityColor(entry.key, lightScheme), + entry.value.$1, + reason: '${entry.key} light', + ); + expect( + CategoryColorHelper.getAvailabilityColor(entry.key, darkScheme), + entry.value.$2, + reason: '${entry.key} dark', + ); + } }); - testWidgets('low-no categories use the primary colour', (tester) async { - final (result, scheme) = await resolve( - tester, - Brightness.light, - 'low-no', + test('sold out tracks the theme error colour, not a fixed hex', () { + expect( + CategoryColorHelper.getAvailabilityColor( + AvailabilityStatus.out, + lightScheme, + ), + lightScheme.error, + ); + expect( + CategoryColorHelper.getAvailabilityColor( + AvailabilityStatus.out, + darkScheme, + ), + darkScheme.error, ); - expect(result, scheme.primary); }); - testWidgets('matching is case-insensitive', (tester) async { - final (result, scheme) = await resolve(tester, Brightness.light, 'BEER'); - expect(result, scheme.secondary); + test('covers every AvailabilityStatus', () { + for (final status in AvailabilityStatus.values) { + expect( + () => CategoryColorHelper.getAvailabilityColor(status, lightScheme), + returnsNormally, + ); + } }); + }); - testWidgets('unknown categories fall back to the outline colour', ( - tester, - ) async { - final (result, scheme) = await resolve( - tester, - Brightness.light, - 'spirits', + group('getTastedColor', () { + test('differs by brightness', () { + expect( + CategoryColorHelper.getTastedColor(Brightness.light), + const Color(0xFF2E7D32), + ); + expect( + CategoryColorHelper.getTastedColor(Brightness.dark), + const Color(0xFF4CAF50), ); - expect(result, scheme.outline); }); }); } diff --git a/test/goldens/brewery_screen_dark.png b/test/goldens/brewery_screen_dark.png index a5f822d3..6e426175 100644 Binary files a/test/goldens/brewery_screen_dark.png and b/test/goldens/brewery_screen_dark.png differ diff --git a/test/goldens/drink_detail_screen_medium_name_dark.png b/test/goldens/drink_detail_screen_medium_name_dark.png index eccffada..401cc4d9 100644 Binary files a/test/goldens/drink_detail_screen_medium_name_dark.png and b/test/goldens/drink_detail_screen_medium_name_dark.png differ diff --git a/test/goldens/style_screen_with_description_dark.png b/test/goldens/style_screen_with_description_dark.png index 7a87715b..12a961c3 100644 Binary files a/test/goldens/style_screen_with_description_dark.png and b/test/goldens/style_screen_with_description_dark.png differ diff --git a/test/screens/goldens/my_festival_screen_dark.png b/test/screens/goldens/my_festival_screen_dark.png index ef0289da..69fb383f 100644 Binary files a/test/screens/goldens/my_festival_screen_dark.png and b/test/screens/goldens/my_festival_screen_dark.png differ diff --git a/test/screens/my_festival_screen_test.dart b/test/screens/my_festival_screen_test.dart index a51b11e1..5cd5965e 100644 --- a/test/screens/my_festival_screen_test.dart +++ b/test/screens/my_festival_screen_test.dart @@ -1,5 +1,8 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:cambridge_beer_festival/app_theme.dart'; import 'package:cambridge_beer_festival/screens/screens.dart'; import 'package:cambridge_beer_festival/models/models.dart'; import 'package:cambridge_beer_festival/providers/providers.dart'; @@ -509,6 +512,73 @@ void main() { }); }); + group('app bar contrast', () { + // Regression: the text theme bakes colorScheme.onSurface into every + // style, so passing titleMedium/bodySmall straight into the AppBar + // painted near-black text on the navy bar — 1.45:1 and 1.27:1 against + // WCAG AA's 4.5:1 minimum. Assert the rendered text is light, and that + // it actually clears AA against the bar it sits on. + double relativeLuminance(Color c) { + double channel(double v) => v <= 0.03928 + ? v / 12.92 + : math.pow((v + 0.055) / 1.055, 2.4).toDouble(); + return 0.2126 * channel(c.r) + + 0.7152 * channel(c.g) + + 0.0722 * channel(c.b); + } + + double contrastRatio(Color a, Color b) { + final la = relativeLuminance(a); + final lb = relativeLuminance(b); + final lighter = la > lb ? la : lb; + final darker = la > lb ? lb : la; + return (lighter + 0.05) / (darker + 0.05); + } + + testWidgets('title and subtitle meet WCAG AA on the app bar', ( + tester, + ) async { + await setUpProvider(); + // Must pump the real app theme: with the default theme the app bar is + // not navy and the regression cannot reproduce. + await tester.pumpWidget( + createTestWidget(theme: buildAppTheme(Brightness.light)), + ); + await tester.pumpAndSettle(); + + final appBar = tester.widget(find.byType(AppBar)); + final context = tester.element(find.byType(MyFestivalScreen)); + final theme = Theme.of(context); + final background = + appBar.backgroundColor ?? + theme.appBarTheme.backgroundColor ?? + theme.colorScheme.surface; + + final titles = find.descendant( + of: find.byType(AppBar), + matching: find.byType(Text), + ); + expect(titles, findsWidgets); + + for (final text in tester.widgetList(titles)) { + final color = text.style?.color; + expect( + color, + isNotNull, + reason: 'app bar text should pin an explicit colour', + ); + expect( + contrastRatio(color!, background), + greaterThanOrEqualTo(4.5), + reason: + '"${text.data}" only reaches ' + '${contrastRatio(color, background).toStringAsFixed(2)}:1 ' + 'against the app bar', + ); + } + }); + }); + group('card styling', () { // A want-to-try drink with a style and a low-stock status, so the goldens // capture the enriched facts line and the availability hint together. @@ -570,7 +640,12 @@ void main() { ); expect(edgeFinder, findsOneWidget); - final accent = CategoryColorHelper.getAccentColor('beer'); + // Read brightness from the pumped tree rather than assuming light, so + // this assertion holds if the harness theme ever changes. + final accent = CategoryColorHelper.getAccentColor( + 'beer', + Theme.of(tester.element(edgeFinder)).brightness, + ); final border = (tester.widget(edgeFinder).decoration as BoxDecoration) diff --git a/test/utils_test.dart b/test/utils_test.dart index 81c4d453..1498fc59 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -3,271 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:cambridge_beer_festival/utils/utils.dart'; void main() { - group('CategoryColorHelper', () { - testWidgets('returns correct color for beer category', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'beer', - ); - expect(color, isNotNull); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for cider category in light theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'cider', - ); - expect(color, const Color(0xFF689F38)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for cider category in dark theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'cider', - ); - expect(color, const Color(0xFF8BC34A).withValues(alpha: 0.8)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for perry category in light theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'perry', - ); - expect(color, const Color(0xFFAFB42B)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for perry category in dark theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'perry', - ); - expect(color, const Color(0xFFCDDC39).withValues(alpha: 0.8)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for mead category in light theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'mead', - ); - expect(color, const Color(0xFFF9A825)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for mead category in dark theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'mead', - ); - expect(color, const Color(0xFFFFEB3B).withValues(alpha: 0.8)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for wine category in light theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'wine', - ); - expect(color, const Color(0xFF7B1FA2)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for wine category in dark theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'wine', - ); - expect(color, const Color(0xFF9C27B0).withValues(alpha: 0.8)); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for low-no category in light theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'low-no', - ); - expect(color, isNotNull); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns correct color for low-no category in dark theme', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.dark), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'low-no', - ); - expect(color, isNotNull); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('returns valid color for unknown category', (tester) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: Brightness.light), - home: Builder( - builder: (context) { - final color = CategoryColorHelper.getCategoryColor( - context, - 'unknown', - ); - expect(color, isNotNull); - expect(color, isA()); - return Container(); - }, - ), - ), - ); - }); - - testWidgets('handles case-insensitive matching', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) { - final colorLower = CategoryColorHelper.getCategoryColor( - context, - 'beer', - ); - final colorUpper = CategoryColorHelper.getCategoryColor( - context, - 'BEER', - ); - final colorMixed = CategoryColorHelper.getCategoryColor( - context, - 'BeEr', - ); - - expect(colorLower, colorUpper); - expect(colorUpper, colorMixed); - return Container(); - }, - ), - ), - ); - }); - }); - group('BeverageTypeHelper', () { test('formatBeverageType formats dash-separated strings', () { expect(BeverageTypeHelper.formatBeverageType('beer'), 'Beer'); diff --git a/test/widgets/goldens/drink_card_search_excerpt_dark.png b/test/widgets/goldens/drink_card_search_excerpt_dark.png index 08a4aeaf..efbc11bd 100644 Binary files a/test/widgets/goldens/drink_card_search_excerpt_dark.png and b/test/widgets/goldens/drink_card_search_excerpt_dark.png differ diff --git a/test/widgets/goldens/drink_card_tasted_multiple_dark.png b/test/widgets/goldens/drink_card_tasted_multiple_dark.png index 05ae48fc..6eb31d16 100644 Binary files a/test/widgets/goldens/drink_card_tasted_multiple_dark.png and b/test/widgets/goldens/drink_card_tasted_multiple_dark.png differ