testsAndMisc/billsplit/lib/ui/assignment_sheet.dart
Krzysztof kuhy Rudnicki 40d2c96a77
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
billsplit: adopt the shared palette, retire the teal seed
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
2026-08-14 23:35:08 +02:00

147 lines
5.3 KiB
Dart

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 /
/// non-drinkers / group) or explicit person selection. Toggling a person
/// checkbox on a non-custom mode first materializes the current selection,
/// then switches to custom.
Future<void> showAssignmentSheet(
BuildContext context,
AppState state,
Receipt receipt,
Item item,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (c) => StatefulBuilder(
builder: (c, setSheet) {
final pool = receiptParticipants(receipt, state.people)
..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
final resolved =
resolveParticipants(item, receipt, state.people, state.groups)
.map((p) => p.id)
.toSet();
void setMode(AssignMode mode, {String? groupId}) {
state.mutate(() {
item.assignment
..mode = mode
..groupId = groupId;
});
setSheet(() {});
}
void togglePerson(Person p, {required bool selected}) {
state.mutate(() {
if (item.assignment.mode != AssignMode.custom) {
item.assignment
..customIds.clear()
..customIds.addAll(resolved)
..mode = AssignMode.custom
..groupId = null;
}
if (selected) {
item.assignment.customIds.add(p.id);
} else {
item.assignment.customIds.remove(p.id);
}
});
setSheet(() {});
}
final selectedSeg = switch (item.assignment.mode) {
AssignMode.everyone => 'everyone',
AssignMode.drinkers => 'drinkers',
AssignMode.nonDrinkers => 'nonDrinkers',
AssignMode.group => 'group:${item.assignment.groupId}',
AssignMode.custom => 'custom',
};
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: Theme.of(c).textTheme.titleMedium),
Text(
'${formatPln(item.totalGr)} · ${item.category}',
style: Theme.of(c).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('Everyone'),
selected: selectedSeg == 'everyone',
onSelected: (_) => setMode(AssignMode.everyone),
),
ChoiceChip(
label: const Text('Drinkers'),
selected: selectedSeg == 'drinkers',
onSelected: (_) => setMode(AssignMode.drinkers),
),
ChoiceChip(
label: const Text('Non-drinkers'),
selected: selectedSeg == 'nonDrinkers',
onSelected: (_) => setMode(AssignMode.nonDrinkers),
),
for (final g in state.groups)
ChoiceChip(
label: Text(g.name),
selected: selectedSeg == 'group:${g.id}',
onSelected: (_) =>
setMode(AssignMode.group, groupId: g.id),
),
],
),
const SizedBox(height: 8),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final p in pool)
CheckboxListTile(
dense: true,
title: Text(p.name),
subtitle: p.drinks ? null : const Text('non-drinker'),
value: resolved.contains(p.id),
onChanged: (v) =>
togglePerson(p, selected: v ?? false),
),
],
),
),
Text(
resolved.isEmpty
? 'Nobody selected — this item will be excluded from '
'totals until someone is assigned.'
: _eachLabel(resolved.length, item.totalGr),
style: TextStyle(
color: resolved.isEmpty
? Theme.of(c).colorScheme.error
: Theme.of(c).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
),
);
},
),
);
}
String _eachLabel(int n, int totalGr) =>
'$n people · ~${formatPln((totalGr / n).round())} each';