mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 19:07:06 +01:00
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>
144 lines
5.1 KiB
Dart
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';
|