diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart index 458f8f5..29c7c9d 100644 --- a/apps/app_seeds/lib/ui/calendar_screen.dart +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -102,13 +102,43 @@ class _CalendarScreenState extends State { } /// A horizontally scrolling strip of the twelve months; the picked one is -/// filled. Scrolls the picked month into view on first build. -class _MonthStrip extends StatelessWidget { +/// filled. Scrolls the picked month into view (so late-year months aren't left +/// off-screen when the screen opens in, say, November). +class _MonthStrip extends StatefulWidget { const _MonthStrip({required this.month, required this.onSelect}); final int month; final ValueChanged onSelect; + @override + State<_MonthStrip> createState() => _MonthStripState(); +} + +class _MonthStripState extends State<_MonthStrip> { + final _selectedKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _revealSelected(); + } + + @override + void didUpdateWidget(_MonthStrip old) { + super.didUpdateWidget(old); + if (old.month != widget.month) _revealSelected(); + } + + void _revealSelected() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _selectedKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible(ctx, + alignment: 0.5, duration: const Duration(milliseconds: 250)); + } + }); + } + @override Widget build(BuildContext context) { final locale = materialLocaleFor(Localizations.localeOf(context)); @@ -122,12 +152,16 @@ class _MonthStrip extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, i) { final m = i + 1; - final selected = m == month; + final selected = m == widget.month; return ChoiceChip( key: Key('calendar.month.$m'), - label: Text(_capitalise(fmt.format(DateTime(2000, m)))), + labelPadding: const EdgeInsets.symmetric(horizontal: 6), + label: Text( + _capitalise(fmt.format(DateTime(2000, m))), + key: selected ? _selectedKey : null, + ), selected: selected, - onSelected: (_) => onSelect(m), + onSelected: (_) => widget.onSelect(m), selectedColor: seedPrimaryContainer, labelStyle: TextStyle( color: selected ? seedOnPrimaryContainer : seedMuted, diff --git a/apps/app_seeds/lib/ui/category_palette.dart b/apps/app_seeds/lib/ui/category_palette.dart index ce1f225..d0006f8 100644 --- a/apps/app_seeds/lib/ui/category_palette.dart +++ b/apps/app_seeds/lib/ui/category_palette.dart @@ -11,19 +11,28 @@ class Swatch { final Color ink; } -/// A small, muted, earthy palette — one low-saturation key, different hues — so -/// botanical families (categories) are distinguishable at a glance without -/// shouting over the green brand. Kept calm on purpose (soft fills, legible -/// ink). Order is stable; new entries append only. +/// A muted, earthy palette — one low-saturation key, hues spread around the +/// wheel — so botanical families (categories) are distinguishable at a glance +/// without shouting over the green brand. Kept calm on purpose (soft fills, +/// legible ink). 14 tones keep collisions rare for a typical 6–10 family +/// inventory. Order is stable; new entries append only. +// Inks are tuned so the label meets WCAG AA (≥4.5:1) on its own fill — see +// test/ui/category_palette_contrast_test.dart. const _familyPalette = [ Swatch(Color(0xFFE8EEDA), Color(0xFF556B2F)), // olive - Swatch(Color(0xFFF3E2D8), Color(0xFF9C5B3B)), // terracotta - Swatch(Color(0xFFF3EBD3), Color(0xFF8A6D1E)), // ochre + Swatch(Color(0xFFF3E2D8), Color(0xFF8E5336)), // terracotta + Swatch(Color(0xFFF3EBD3), Color(0xFF7C621B)), // ochre Swatch(Color(0xFFDDEAE6), Color(0xFF3E7168)), // sage teal - Swatch(Color(0xFFF0E1E4), Color(0xFF9B5566)), // dusty rose + Swatch(Color(0xFFF0E1E4), Color(0xFF8F4E5E)), // dusty rose Swatch(Color(0xFFE0E6EE), Color(0xFF4A6489)), // slate blue Swatch(Color(0xFFE9E1EE), Color(0xFF6E5090)), // plum Swatch(Color(0xFFEBE3D8), Color(0xFF7A5A3A)), // warm brown + Swatch(Color(0xFFDCEAD6), Color(0xFF3E6B3A)), // forest green + Swatch(Color(0xFFD6E7EA), Color(0xFF2C6A73)), // petrol + Swatch(Color(0xFFF3DEDB), Color(0xFFA14234)), // brick red + Swatch(Color(0xFFEFDCEA), Color(0xFF864C7C)), // mauve + Swatch(Color(0xFFE1E1F1), Color(0xFF4E5199)), // indigo + Swatch(Color(0xFFDFE4E9), Color(0xFF4C5E70)), // steel ]; /// A stable (launch-to-launch, platform-independent) hash of a category name. @@ -48,9 +57,9 @@ Swatch categorySwatch(String category) => /// tones for woody/underground ones. Swatch lotTypeSwatch(LotType type) => switch (type) { LotType.seed => const Swatch(seedPrimaryContainer, seedOnPrimaryContainer), - LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF4E7A2E)), - LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2F7D34)), + LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF456D29)), + LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2A702F)), LotType.tree => const Swatch(Color(0xFFE3D9C8), Color(0xFF6B4F2A)), - LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF8A6D1E)), - LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF63702F)), + LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF7B611B)), + LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF5D692C)), }; diff --git a/apps/app_seeds/test/ui/category_palette_contrast_test.dart b/apps/app_seeds/test/ui/category_palette_contrast_test.dart new file mode 100644 index 0000000..c751b96 --- /dev/null +++ b/apps/app_seeds/test/ui/category_palette_contrast_test.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/ui/category_palette.dart'; + +/// Chip labels are drawn in [Swatch.ink] over [Swatch.fill]; that pairing must +/// clear WCAG AA for normal text (≥4.5:1), like the rest of the palette +/// (see theme_contrast_test.dart). Locks the tuned inks against regressions. +void main() { + double lin(double c) => + c <= 0.03928 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + double luminance(Color c) => + 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b); + double contrast(Color a, Color b) { + final la = luminance(a), lb = luminance(b); + final hi = math.max(la, lb), lo = math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); + } + + test('every category swatch label meets AA on its fill', () { + // Cover the whole palette by probing many distinct category names. + for (var i = 0; i < 60; i++) { + final s = categorySwatch('family-$i'); + expect(contrast(s.ink, s.fill), greaterThanOrEqualTo(4.5), + reason: 'category-$i ink on fill'); + } + }); + + test('every lot-form swatch label meets AA on its fill', () { + for (final type in LotType.values) { + final s = lotTypeSwatch(type); + expect(contrast(s.ink, s.fill), greaterThanOrEqualTo(4.5), + reason: '$type ink on fill'); + } + }); +}