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:
parent
aec14f6518
commit
dcda349878
3 changed files with 97 additions and 16 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 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>[
|
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)),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
38
apps/app_seeds/test/ui/category_palette_contrast_test.dart
Normal file
38
apps/app_seeds/test/ui/category_palette_contrast_test.dart
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue