billsplit: adopt the shared palette, retire the teal seed
Some checks are pending
billsplit CI / quality (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

Last of the four drifted apps. The theme was ColorScheme.fromSeed(teal)
plus fifteen ad hoc colour literals that mostly bypassed it -- not one
widget outside main.dart read colorScheme at all, so the seed barely
affected what you saw. It now uses design_system-v0.1.0's
buildLightTheme(), and the literals that have a real theme role read it
off the scheme.

Light, not dark, deliberately: the receipt screens carry black-on-white
helper text, so inverting brightness would make it unreadable. The
Colors.black54 sites are exactly those, now onSurfaceVariant.

The error family maps cleanly -- Colors.red to error, red.shade50 to
errorContainer with onErrorContainer text on it, and the swipe-to-delete
background+icon to error/onError so the fill and the glyph drawn on it
cannot drift apart.

The four category dots (deepOrange/amber/blueGrey/teal) stay literals,
with a comment saying why: they encode which category an item is, not a
theme role, and a single-accent system has no four distinct hues to lend
them. Mapping them onto primary/secondary/tertiary would make the
categories stop being distinguishable, which is their only job. They are
tracked in nielsen-audit.md as needing a categorical ramp. Note this
decouples the default dot from the old seed -- both were Colors.teal, so
they used to match by coincidence rather than by design.

Verified: 60/60 tests, 100% line coverage across 11 lib files, analyze
clean apart from four pre-existing unnecessary_unawaited infos. A
temporary assertion pumped the real app and read primary=ffb8862e and
surface=fff6f4f3 off a live Scaffold, confirming the palette changed
rather than merely compiling. Colour literals: 15 -> 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 23:35:08 +02:00
parent 01778d4f05
commit 40d2c96a77
10 changed files with 145 additions and 27 deletions

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/home_screen.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -27,9 +28,10 @@ class BillSplitApp extends StatelessWidget {
child: MaterialApp(
title: 'BillSplit',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
// The shared light theme, replacing a teal-seeded scheme. Light, not
// dark: the receipt screens carry black-on-white helper text, so
// switching brightness here would make it unreadable.
theme: buildLightTheme(),
home: const HomeScreen(),
),
);

View File

@ -1,6 +1,7 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Bottom sheet to assign an item: quick modes (everyone / drinkers /
@ -126,8 +127,10 @@ Future<void> showAssignmentSheet(
'totals until someone is assigned.'
: _eachLabel(resolved.length, item.totalGr),
style: TextStyle(
color: resolved.isEmpty ? Colors.red : Colors.black54,
fontSize: 12,
color: resolved.isEmpty
? Theme.of(c).colorScheme.error
: Theme.of(c).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],

View File

@ -6,6 +6,7 @@ import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:design_system/design_system.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -45,11 +46,17 @@ class HomeScreen extends StatelessWidget {
return Dismissible(
key: ValueKey(r.id),
direction: DismissDirection.endToStart,
// The delete affordance revealed by swiping. The fill
// and the icon on top of it must move together, hence
// error/onError rather than two independent colours.
background: Container(
color: Colors.red.shade300,
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
padding: const EdgeInsets.only(right: AppSpacing.md),
child: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.onError,
),
),
confirmDismiss: (_) => _confirmDelete(context, r),
onDismissed: (_) => state.removeReceipt(r),

View File

@ -1,5 +1,6 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Add ([item] == null) or edit an item. The user types amounts in złoty
@ -65,10 +66,13 @@ Future<void> showItemEditDialog(
setState(() => category = v ?? Categories.general),
),
const SizedBox(height: 4),
const Text(
Text(
'Alcohol items default to drinkers only; everything else to '
'everyone. Fine-tune by tapping the item afterwards.',
style: TextStyle(fontSize: 11, color: Colors.black54),
style: TextStyle(
fontSize: AppTextSize.caption,
color: Theme.of(c).colorScheme.onSurfaceVariant,
),
),
],
),
@ -80,7 +84,10 @@ Future<void> showItemEditDialog(
state.mutate(() => receipt.items.remove(item));
Navigator.pop(c);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(
'Delete',
style: TextStyle(color: Theme.of(c).colorScheme.error),
),
),
TextButton(
onPressed: () => Navigator.pop(c),

View File

@ -1,5 +1,6 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -25,12 +26,15 @@ class PeopleScreen extends StatelessWidget {
for (final p in state.people) _PersonTile(person: p),
const Divider(height: 32),
const _SectionHeader('Groups'),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Drinkers / non-drinkers are built in (the toggle on each '
'person). Groups below are extra tags, e.g. "meat eaters".',
style: TextStyle(fontSize: 12, color: Colors.black54),
style: TextStyle(
fontSize: AppTextSize.caption,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
for (final g in state.groups) _GroupTile(group: g),

View File

@ -234,7 +234,7 @@ class _ItemsList extends StatelessWidget {
subtitle: Text(
_assignmentLabel(item, people.length, state),
style: TextStyle(
color: unassigned ? Colors.red : null,
color: unassigned ? Theme.of(context).colorScheme.error : null,
fontWeight: unassigned ? FontWeight.bold : null,
),
),
@ -272,6 +272,12 @@ class _CategoryDot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = switch (category) {
// Categorical encoding, deliberately NOT mapped onto ColorScheme slots.
// These four say "which category", not "which theme role", and a
// single-accent design system has no four distinct hues to lend. Folding
// them into primary/secondary/tertiary would make the categories stop
// being distinguishable, which is the only job they have. Tracked in
// unified-design-system/nielsen-audit.md as needing a categorical ramp.
Categories.alcohol => Colors.deepOrange,
Categories.mixer => Colors.amber,
Categories.deposit => Colors.blueGrey,
@ -300,14 +306,16 @@ class _TotalsView extends StatelessWidget {
children: [
if (split.unassigned.isNotEmpty)
Card(
color: Colors.red.shade50,
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${split.unassigned.length} item(s) have nobody assigned and '
'are excluded from totals:\n'
'${split.unassigned.map((i) => '· ${i.name}').join('\n')}',
style: const TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
),
@ -347,7 +355,7 @@ class _TotalsView extends StatelessWidget {
}) {
final style = TextStyle(
fontWeight: bold || warn ? FontWeight.bold : null,
color: warn ? Colors.red : null,
color: warn ? Theme.of(context).colorScheme.error : null,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),

View File

@ -97,6 +97,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
design_system:
dependency: "direct main"
description:
path: design_system
ref: "design_system-v0.1.0"
resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
equatable:
dependency: transitive
description:
@ -332,10 +341,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@ -348,10 +357,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
nested:
dependency: transitive
description:
@ -529,10 +538,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@ -545,10 +554,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
very_good_analysis:
dependency: "direct dev"
description:
@ -598,5 +607,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"

View File

@ -7,6 +7,13 @@ environment:
sdk: ^3.5.0
dependencies:
# The shared palette and scales.
design_system:
git:
url: https://github.com/kuhyx/utils
ref: design_system-v0.1.0
path: design_system
excel: ^4.0.6
file_selector: ^1.1.0
flutter:

View File

@ -0,0 +1,20 @@
{
"title": "billsplit adopts the shared design_system palette",
"objective": "billsplit is the last of four Flutter apps that had drifted off the frozen palette. It renders from a teal-seeded ColorScheme plus fifteen ad hoc colour literals that largely bypass the theme entirely. Success is billsplit rendering from design_system-v0.1.0's buildLightTheme(), with every literal that carries a theme role reading it off the ColorScheme instead, and with no behavioural, layout or coverage regression.",
"acceptance_criteria": [
"lib/main.dart builds its theme from design_system's buildLightTheme() rather than ColorScheme.fromSeed(seedColor: Colors.teal).",
"The rendered app's colorScheme.primary is ffb8862e and surface is fff6f4f3, proven by pumping the real BillSplitApp and reading the theme off a live element -- not by inspecting source.",
"Error-family literals map to error / onError / errorContainer / onErrorContainer; de-emphasised text maps to onSurfaceVariant.",
"The swipe-to-delete background and the icon drawn on it use error and onError respectively, so they cannot be changed independently.",
"flutter test reports 60/60 passing.",
"python3 -m python_pkg.billsplit_coverage reports 100% line coverage across all lib files.",
"flutter analyze --fatal-infos introduces no new findings beyond the pre-existing unnecessary_unawaited infos."
],
"out_of_scope": [
"Switching billsplit to a dark theme. It is light-only by design; its helper text is black-on-white and would become unreadable.",
"Mapping the four category-dot colours (deepOrange/amber/blueGrey/teal) onto ColorScheme slots. They encode category identity rather than a theme role, and a single-accent system has no four distinct hues for them. Recorded in unified-design-system/nielsen-audit.md as needing a categorical ramp instead.",
"Fixing the pre-existing unnecessary_unawaited analyzer infos, which come from SDK drift and are present across several repos.",
"Any change to split logic, e-paragon parsing, xlsx export, or app state."
],
"verifier": "flutter analyze --fatal-infos && flutter test (60/60) && python3 -m python_pkg.billsplit_coverage (100%, 11 files), plus a temporary widget test asserting primary=ffb8862e and surface=fff6f4f3 on the pumped app, deleted after it passed. CI re-runs the first three via .github/workflows/billsplit-ci.yml."
}

View File

@ -0,0 +1,51 @@
{
"intent": "Put billsplit on the one shared palette every other kuhy Flutter app now uses, instead of a teal-seeded scheme plus fifteen ad hoc colour literals.",
"scope": [
"billsplit/lib/main.dart (theme construction)",
"billsplit/lib/ui/{home_screen,receipt_screen,item_edit,people_screen,assignment_sheet}.dart (literal call sites)",
"billsplit/pubspec.yaml (new design_system git dep)",
"Non-goal: no behaviour, layout or business-logic change; no switch to a dark theme"
],
"changes": [
"theme: ColorScheme.fromSeed(seedColor: Colors.teal) -> buildLightTheme() from design_system-v0.1.0. Light, not dark: the receipt screens carry black-on-white helper text that would be unreadable inverted.",
"Mapped the error-family literals onto ColorScheme slots: Colors.red -> error (4 sites), Colors.red.shade50 -> errorContainer with onErrorContainer text, and the swipe-to-delete Colors.red.shade300 + Colors.white -> error + onError, so the fill and the icon on top of it cannot drift apart.",
"Mapped the de-emphasised helper text Colors.black54 -> onSurfaceVariant (3 sites). These were the literals that would have broken outright on any dark surface.",
"Left the four-colour category ramp (deepOrange/amber/blueGrey/teal for alcohol/mixer/deposit/other) as literals, with a comment saying why: they encode category identity, not a theme role, and a single-accent design system has no four distinct hues to lend. Folding them into ColorScheme slots would destroy the only job they do. Tracked in unified-design-system/nielsen-audit.md as needing a categorical ramp."
],
"verification": [
{
"command": "flutter analyze --fatal-infos (billsplit)",
"result": "pass",
"evidence": "4 issues, all pre-existing unnecessary_unawaited infos from SDK drift (the same class of info also present in epopeja_karta and kuhylog before this work). One real error introduced mid-edit -- const_eval_method_invocation, from putting Theme.of(context) inside a const Padding -- was found by the analyzer and fixed before commit."
},
{
"command": "flutter test (billsplit)",
"result": "pass",
"evidence": "60/60 tests passed"
},
{
"command": "python3 -m python_pkg.billsplit_coverage",
"result": "pass",
"evidence": "100% line coverage across 11 lib files"
},
{
"command": "flutter test (ad-hoc palette assertion, then deleted)",
"result": "pass",
"evidence": "Pumped the real BillSplitApp and read the theme off a Scaffold element: primary=ffb8862e, surface=fff6f4f3 (paper), brightness=light. Confirms the palette actually changed rather than merely compiling."
},
{
"command": "grep -rc 'Colors\\.' billsplit/lib",
"result": "pass",
"evidence": "15 -> 4, and the 4 remaining are exactly the category-dot ramp that was deliberately not mapped"
}
],
"risks": [
"The app moves from a teal-derived Material scheme to the hand-picked gold palette, so every screen changes colour. That is the intent, and it is the last of four apps being brought onto one palette.",
"Colors.teal was both the theme seed and the default category dot. The seed is gone but the dot is deliberately unchanged, so those two are now decoupled -- previously they matched by coincidence, not by design. Noted here so the next reader does not read it as an oversight.",
"billsplit declares sdk ^3.5.0 while design_system declares ^3.12.2. Resolution succeeds on the installed SDK, but a machine on an older Dart could fail pub get; bumping billsplit's floor is the fix if that ever bites."
],
"rollback": [
"git revert the commit and run flutter pub get; there is no build artifact or device state to unwind, since billsplit ships no APK from this change.",
"After rollback, confirm flutter test still reports 60/60 and the coverage gate still reports 100% across 11 files."
]
}