polish(ux): AA-contrast chip inks, 14-tone palette, calendar month auto-scroll

Blind-build polish pass over the recent sales/chips/calendar work:

- Chip label contrast: 7 palette inks (terracotta, ochre, rose, seedling,
  plant, bulb, cutting) sat below WCAG AA on their own fill; darkened
  (hue preserved) to ≥4.8:1. New category_palette_contrast_test locks it.
- Palette expanded 8 → 14 earthy tones (hues spread around the wheel) so
  family colours rarely collide for a typical 6–10 family inventory,
  keeping the stable-by-name, cross-screen-consistent scheme. Every new
  ink meets AA on its fill.
- Calendar month strip now actually scrolls the selected month into view
  (the comment promised it but it was never wired) — late-year months are
  no longer left off-screen when the screen opens.
- Minor: dead code removed from the month chip.

dart analyze clean; palette/contrast/calendar/quantity/overflow/inventory
suites green.
This commit is contained in:
vjrj 2026-07-11 06:48:19 +02:00
parent a476f17cdf
commit 62ed4f79b3
3 changed files with 97 additions and 16 deletions

View file

@ -102,13 +102,43 @@ class _CalendarScreenState extends State<CalendarScreen> {
} }
/// A horizontally scrolling strip of the twelve months; the picked one is /// A horizontally scrolling strip of the twelve months; the picked one is
/// filled. Scrolls the picked month into view on first build. /// filled. Scrolls the picked month into view (so late-year months aren't left
class _MonthStrip extends StatelessWidget { /// off-screen when the screen opens in, say, November).
class _MonthStrip extends StatefulWidget {
const _MonthStrip({required this.month, required this.onSelect}); const _MonthStrip({required this.month, required this.onSelect});
final int month; final int month;
final ValueChanged<int> onSelect; final ValueChanged<int> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final locale = materialLocaleFor(Localizations.localeOf(context)); final locale = materialLocaleFor(Localizations.localeOf(context));
@ -122,12 +152,16 @@ class _MonthStrip extends StatelessWidget {
separatorBuilder: (_, _) => const SizedBox(width: 8), separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final m = i + 1; final m = i + 1;
final selected = m == month; final selected = m == widget.month;
return ChoiceChip( return ChoiceChip(
key: Key('calendar.month.$m'), 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, selected: selected,
onSelected: (_) => onSelect(m), onSelected: (_) => widget.onSelect(m),
selectedColor: seedPrimaryContainer, selectedColor: seedPrimaryContainer,
labelStyle: TextStyle( labelStyle: TextStyle(
color: selected ? seedOnPrimaryContainer : seedMuted, color: selected ? seedOnPrimaryContainer : seedMuted,

View file

@ -11,19 +11,28 @@ class Swatch {
final Color ink; final Color ink;
} }
/// A small, muted, earthy palette one low-saturation key, different hues so /// A muted, earthy palette one low-saturation key, hues spread around the
/// botanical families (categories) are distinguishable at a glance without /// wheel so botanical families (categories) are distinguishable at a glance
/// shouting over the green brand. Kept calm on purpose (soft fills, legible /// without shouting over the green brand. Kept calm on purpose (soft fills,
/// ink). Order is stable; new entries append only. /// legible ink). 14 tones keep collisions rare for a typical 610 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>[ const _familyPalette = <Swatch>[
Swatch(Color(0xFFE8EEDA), Color(0xFF556B2F)), // olive Swatch(Color(0xFFE8EEDA), Color(0xFF556B2F)), // olive
Swatch(Color(0xFFF3E2D8), Color(0xFF9C5B3B)), // terracotta Swatch(Color(0xFFF3E2D8), Color(0xFF8E5336)), // terracotta
Swatch(Color(0xFFF3EBD3), Color(0xFF8A6D1E)), // ochre Swatch(Color(0xFFF3EBD3), Color(0xFF7C621B)), // ochre
Swatch(Color(0xFFDDEAE6), Color(0xFF3E7168)), // sage teal 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(0xFFE0E6EE), Color(0xFF4A6489)), // slate blue
Swatch(Color(0xFFE9E1EE), Color(0xFF6E5090)), // plum Swatch(Color(0xFFE9E1EE), Color(0xFF6E5090)), // plum
Swatch(Color(0xFFEBE3D8), Color(0xFF7A5A3A)), // warm brown 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. /// 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. /// tones for woody/underground ones.
Swatch lotTypeSwatch(LotType type) => switch (type) { Swatch lotTypeSwatch(LotType type) => switch (type) {
LotType.seed => const Swatch(seedPrimaryContainer, seedOnPrimaryContainer), LotType.seed => const Swatch(seedPrimaryContainer, seedOnPrimaryContainer),
LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF4E7A2E)), LotType.seedling => const Swatch(Color(0xFFDDEBCF), Color(0xFF456D29)),
LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2F7D34)), LotType.plant => const Swatch(Color(0xFFD9EAD9), Color(0xFF2A702F)),
LotType.tree => const Swatch(Color(0xFFE3D9C8), Color(0xFF6B4F2A)), LotType.tree => const Swatch(Color(0xFFE3D9C8), Color(0xFF6B4F2A)),
LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF8A6D1E)), LotType.bulb => const Swatch(Color(0xFFF3E9CF), Color(0xFF7B611B)),
LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF63702F)), LotType.cutting => const Swatch(Color(0xFFE6EAD3), Color(0xFF5D692C)),
}; };

View file

@ -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');
}
});
}