testsAndMisc-archive/billsplit/lib/ui/assignment_sheet.dart
Krzysztof Rudnicki 4dd582a1df Move billsplit Flutter app into the monorepo
billsplit was a standalone Flutter app in ~/Downloads with one commit and no
git remote — that directory was the only copy of its history.

Three things the move needed beyond copying the tree:

- The root .gitignore silently swallowed 32 of its files (*.png, *.ico,
  *.manifest). Ignored files do not error on `git add`, they just vanish, so
  this would have committed an app that cannot build. Added a trailing block
  of ! overrides plus .binary-allowlist (which did not exist yet) — the
  documented in-repo exception for binaries a build genuinely needs. The
  ../testsAndMisc_binaries/ symlink scheme does not apply: those directories
  are gone, and a symlinked PNG breaks a fresh clone.

- billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads
  workflows at the repository root. Relocated with a working-directory and a
  billsplit/** path filter.

- The app's coverage gate moved to python_pkg/billsplit_coverage/ so every
  Python file stays under python_pkg/, and gained a test suite at 100% branch
  coverage as that location requires. billsplit/ now holds only Dart.

Also replaces the stock Flutter icon with a family icon from the shared
generator. The glyph is a filled silhouette because the family's 72px stroke
cannot carry a fine torn edge, and the split is an evenodd-cut perforation so
the holes stay transparent on the foreground and monochrome layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:22:22 +02:00

144 lines
5.1 KiB
Dart

import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/state/app_state.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 ? Colors.red : Colors.black54,
fontSize: 12,
),
),
],
),
),
);
},
),
);
}
String _eachLabel(int n, int totalGr) =>
'$n people · ~${formatPln((totalGr / n).round())} each';