Split every over-cap billsplit Dart file

Four lib files and the 536-line UI flow test, into seven new files. Only
Flutter's generated windows/runner/win32_window.cpp remains, and editing
that would be undone by regeneration.

All four lib splits used part/part of first time, applying the rule
learned in focus_owner. models.dart needed a hand fix: it has a
`library;` directive, which must precede any `part`.

60 tests passing before and after. flutter analyze reports the same 4
pre-existing infos as a detached worktree at HEAD - which is how I
caught that an intermediate run's 7 issues were 3 of my own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 20:57:56 +02:00
parent 34783a5022
commit a68313f236
15 changed files with 1229 additions and 963 deletions

View File

@ -1,6 +1,14 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
language:
strict-casts: true
strict-inference: true

View File

@ -0,0 +1,200 @@
/// Assignment and the split calculation.
///
/// Split out of models.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'models.dart';
/// The assignment of one item: a symbolic mode plus mode-specific data.
class Assignment {
/// Creates an assignment; defaults to [AssignMode.everyone].
Assignment({
this.mode = AssignMode.everyone,
this.groupId,
Set<String>? customIds,
}) : customIds = customIds ?? <String>{};
/// Restores an assignment from its [toJson] map.
factory Assignment.fromJson(Map<String, dynamic> j) => Assignment(
mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone,
groupId: j['groupId'] as String?,
customIds: (j['customIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
);
/// Current mode.
AssignMode mode;
/// Group id when [mode] is [AssignMode.group].
String? groupId;
/// Hand-picked person ids when [mode] is [AssignMode.custom].
final Set<String> customIds;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'mode': mode.name,
if (groupId != null) 'groupId': groupId,
'customIds': customIds.toList(),
};
}
/// Standard item categories. [Item.category] is free text so users can add
/// their own; these constants are the ones the importer produces.
abstract final class Categories {
/// Alcoholic drinks — default to drinkers only.
static const alcohol = 'Alcohol';
/// Mixers (tonic, limes, …) — tagged for visibility, split among everyone.
static const mixer = 'Mixer';
/// Returnable bottle deposits (kaucja).
static const deposit = 'Deposit';
/// Anything else.
static const general = 'General';
}
/// One receipt line.
class Item {
/// Creates an item; [totalGr] is the post-discount line total.
Item({
required this.id,
required this.name,
required this.totalGr,
this.qty = 1,
this.unitPriceGr = 0,
this.discountGr = 0,
this.category = Categories.general,
Assignment? assignment,
}) : assignment = assignment ?? Assignment();
/// Restores an item from its [toJson] map.
factory Item.fromJson(Map<String, dynamic> j) => Item(
id: j['id'] as String,
name: j['name'] as String,
totalGr: j['totalGr'] as int,
qty: (j['qty'] as num? ?? 1).toDouble(),
unitPriceGr: j['unitPriceGr'] as int? ?? 0,
discountGr: j['discountGr'] as int? ?? 0,
category: j['category'] as String? ?? Categories.general,
assignment: Assignment.fromJson(
(j['assignment'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
/// Stable unique id.
final String id;
/// Display name.
String name;
/// Final line total after discount — the ground truth used for splitting.
int totalGr;
/// Quantity (may be fractional for weighed goods).
double qty;
/// Unit price in grosze.
int unitPriceGr;
/// Discount already included in [totalGr]; kept for display (<= 0).
int discountGr;
/// Free-text category; see [Categories] for the standard values.
String category;
/// Who shares this item.
Assignment assignment;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'totalGr': totalGr,
'qty': qty,
'unitPriceGr': unitPriceGr,
'discountGr': discountGr,
'category': category,
'assignment': assignment.toJson(),
};
}
/// One shopping receipt with its items and participants.
class Receipt {
/// Creates a receipt; [participantIds] defaults to empty.
Receipt({
required this.id,
required this.title,
this.store,
DateTime? date,
List<Item>? items,
Set<String>? participantIds,
this.paidTotalGr,
}) : date = date ?? DateTime.now(),
items = items ?? <Item>[],
participantIds = participantIds ?? <String>{};
/// Restores a receipt from its [toJson] map.
factory Receipt.fromJson(Map<String, dynamic> j) => Receipt(
id: j['id'] as String,
title: j['title'] as String,
store: j['store'] as String?,
date: DateTime.tryParse(j['date'] as String? ?? '') ?? DateTime.now(),
items: (j['items'] as List? ?? const <dynamic>[])
.map((e) => Item.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
participantIds: (j['participantIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
paidTotalGr: j['paidTotalGr'] as int?,
);
/// Stable unique id.
final String id;
/// Display title.
String title;
/// Store name, if known.
String? store;
/// Sale date.
DateTime date;
/// Line items.
final List<Item> items;
/// Subset of the roster taking part in this receipt.
final Set<String> participantIds;
/// What was actually paid (incl. deposits) — used for the reconciliation
/// check. Null for manual receipts where the user never entered it.
int? paidTotalGr;
/// Sum of all item totals in grosze.
int get itemsTotalGr => items.fold(0, (s, i) => s + i.totalGr);
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'store': store,
'date': date.toIso8601String(),
'items': items.map((i) => i.toJson()).toList(),
'participantIds': participantIds.toList(),
'paidTotalGr': paidTotalGr,
};
}
/// Simple unique id source: time + counter. Good enough for a local,
/// single-user app.
class Ids {
static int _c = 0;
/// Returns a new unique id.
static String next() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'
'-${(_c++).toRadixString(36)}';
}

View File

@ -0,0 +1,124 @@
/// Field extraction helpers for the e-paragon parser.
///
/// Split out of eparagon_parser.dart to keep it under the 250-line cap.
/// A `part` because Dart privates are library-scoped.
part of 'eparagon_parser.dart';
DateTime _saleDate(
Map<dynamic, dynamic> paragon,
Map<dynamic, dynamic> dokument,
) {
final raw =
paragon['zakSprzed'] ?? (dokument['naglowek'] as Map?)?['dataJPK'];
if (raw is String) {
final d = DateTime.tryParse(raw);
if (d != null) return d.toLocal();
}
return DateTime.now();
}
String _iso(DateTime d) {
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '${d.year}-$m-$day';
}
Map<String, dynamic> _extractPayload(String source) {
dynamic root;
try {
root = jsonDecode(source.trim());
} on FormatException {
// Maybe the user pasted just the JWS token.
final p = _tryJws(source.trim());
if (p != null) return p;
throw EParagonParseException(
'Input is neither valid JSON nor a JWS.',
);
}
if (root is Map) {
if (root['dokument'] is Map) return root.cast<String, dynamic>();
final data = root['data'];
if (data is String) {
final p = _tryJws(data);
if (p != null) return p;
throw EParagonParseException('"data" field is not a decodable JWS.');
}
}
throw EParagonParseException(
'Unrecognized structure: expected e-paragon wrapper or JPK payload.',
);
}
Map<String, dynamic>? _tryJws(String token) {
final parts = token.split('.');
if (parts.length != 3) return null;
try {
final normalized = base64Url.normalize(parts[1]);
final decoded = utf8.decode(base64Url.decode(normalized));
final obj = jsonDecode(decoded);
return obj is Map ? obj.cast<String, dynamic>() : null;
} on FormatException {
return null;
}
}
Item? _itemFromTowar(Map<String, dynamic> t, List<String> warnings) {
var name = (t['nazwa'] as String? ?? '').trim();
// Names end with the VAT rate letter, e.g. "Tonic A".
if (name.length > 2 &&
name[name.length - 2] == ' ' &&
RegExp(r'[A-G]$').hasMatch(name)) {
name = name.substring(0, name.length - 2).trim();
}
name = name.replaceAll(RegExp(r'\s+'), ' ');
if (name.isEmpty) return null;
final total = _asIntOrNull(t['brutto']);
if (total == null) {
warnings.add('Skipped "$name": missing brutto amount.');
return null;
}
final unit = _asIntOrNull(t['cena']) ?? 0;
final qty = _parseQty(t['ilosc']);
final rabat = t['rabat'];
final discount = rabat is Map ? (_asIntOrNull(rabat['wart']) ?? 0) : 0;
final lower = name.toLowerCase();
var category = Categories.general;
var assignment = Assignment();
if (_alcoholKeywords.any(lower.contains)) {
category = Categories.alcohol;
assignment = Assignment(mode: AssignMode.drinkers);
} else if (_mixerKeywords.any(lower.contains)) {
category = Categories.mixer; // still split among everyone by default
}
return Item(
id: Ids.next(),
name: name,
totalGr: total,
qty: qty,
unitPriceGr: unit,
discountGr: discount,
category: category,
assignment: assignment,
);
}
double _parseQty(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) {
return double.tryParse(v.replaceAll(',', '.').trim()) ?? 1;
}
return 1;
}
int _asInt(dynamic v) => _asIntOrNull(v) ?? 0;
int? _asIntOrNull(dynamic v) {
if (v is int) return v;
if (v is num) return v.round();
if (v is String) return int.tryParse(v);
return null;
}

View File

@ -14,6 +14,8 @@ import 'dart:convert';
import 'package:billsplit/domain/models.dart';
part 'eparagon_fields.dart';
/// A parsed receipt plus non-fatal [warnings] collected on the way.
class EParagonResult {
/// Bundles a parsed [receipt] with its [warnings].
@ -173,121 +175,3 @@ String? _storeName(Map<dynamic, dynamic> dokument) {
if (name.toUpperCase().contains('JERONIMO MARTINS')) return 'Biedronka';
return name;
}
DateTime _saleDate(
Map<dynamic, dynamic> paragon,
Map<dynamic, dynamic> dokument,
) {
final raw =
paragon['zakSprzed'] ?? (dokument['naglowek'] as Map?)?['dataJPK'];
if (raw is String) {
final d = DateTime.tryParse(raw);
if (d != null) return d.toLocal();
}
return DateTime.now();
}
String _iso(DateTime d) {
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '${d.year}-$m-$day';
}
Map<String, dynamic> _extractPayload(String source) {
dynamic root;
try {
root = jsonDecode(source.trim());
} on FormatException {
// Maybe the user pasted just the JWS token.
final p = _tryJws(source.trim());
if (p != null) return p;
throw EParagonParseException(
'Input is neither valid JSON nor a JWS.',
);
}
if (root is Map) {
if (root['dokument'] is Map) return root.cast<String, dynamic>();
final data = root['data'];
if (data is String) {
final p = _tryJws(data);
if (p != null) return p;
throw EParagonParseException('"data" field is not a decodable JWS.');
}
}
throw EParagonParseException(
'Unrecognized structure: expected e-paragon wrapper or JPK payload.',
);
}
Map<String, dynamic>? _tryJws(String token) {
final parts = token.split('.');
if (parts.length != 3) return null;
try {
final normalized = base64Url.normalize(parts[1]);
final decoded = utf8.decode(base64Url.decode(normalized));
final obj = jsonDecode(decoded);
return obj is Map ? obj.cast<String, dynamic>() : null;
} on FormatException {
return null;
}
}
Item? _itemFromTowar(Map<String, dynamic> t, List<String> warnings) {
var name = (t['nazwa'] as String? ?? '').trim();
// Names end with the VAT rate letter, e.g. "Tonic A".
if (name.length > 2 &&
name[name.length - 2] == ' ' &&
RegExp(r'[A-G]$').hasMatch(name)) {
name = name.substring(0, name.length - 2).trim();
}
name = name.replaceAll(RegExp(r'\s+'), ' ');
if (name.isEmpty) return null;
final total = _asIntOrNull(t['brutto']);
if (total == null) {
warnings.add('Skipped "$name": missing brutto amount.');
return null;
}
final unit = _asIntOrNull(t['cena']) ?? 0;
final qty = _parseQty(t['ilosc']);
final rabat = t['rabat'];
final discount = rabat is Map ? (_asIntOrNull(rabat['wart']) ?? 0) : 0;
final lower = name.toLowerCase();
var category = Categories.general;
var assignment = Assignment();
if (_alcoholKeywords.any(lower.contains)) {
category = Categories.alcohol;
assignment = Assignment(mode: AssignMode.drinkers);
} else if (_mixerKeywords.any(lower.contains)) {
category = Categories.mixer; // still split among everyone by default
}
return Item(
id: Ids.next(),
name: name,
totalGr: total,
qty: qty,
unitPriceGr: unit,
discountGr: discount,
category: category,
assignment: assignment,
);
}
double _parseQty(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) {
return double.tryParse(v.replaceAll(',', '.').trim()) ?? 1;
}
return 1;
}
int _asInt(dynamic v) => _asIntOrNull(v) ?? 0;
int? _asIntOrNull(dynamic v) {
if (v is int) return v;
if (v is num) return v.round();
if (v is String) return int.tryParse(v);
return null;
}

View File

@ -2,6 +2,8 @@
/// floating point drift; formatting happens only at the UI/export edge.
library;
part 'assignment.dart';
/// Formats [grosze] as a Polish złoty string, e.g. `1234` → `12,34 zł`.
String formatPln(int grosze) {
final neg = grosze < 0;
@ -82,197 +84,3 @@ enum AssignMode {
/// An explicit hand-picked set of people shares the item.
custom,
}
/// The assignment of one item: a symbolic mode plus mode-specific data.
class Assignment {
/// Creates an assignment; defaults to [AssignMode.everyone].
Assignment({
this.mode = AssignMode.everyone,
this.groupId,
Set<String>? customIds,
}) : customIds = customIds ?? <String>{};
/// Restores an assignment from its [toJson] map.
factory Assignment.fromJson(Map<String, dynamic> j) => Assignment(
mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone,
groupId: j['groupId'] as String?,
customIds: (j['customIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
);
/// Current mode.
AssignMode mode;
/// Group id when [mode] is [AssignMode.group].
String? groupId;
/// Hand-picked person ids when [mode] is [AssignMode.custom].
final Set<String> customIds;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'mode': mode.name,
if (groupId != null) 'groupId': groupId,
'customIds': customIds.toList(),
};
}
/// Standard item categories. [Item.category] is free text so users can add
/// their own; these constants are the ones the importer produces.
abstract final class Categories {
/// Alcoholic drinks — default to drinkers only.
static const alcohol = 'Alcohol';
/// Mixers (tonic, limes, …) — tagged for visibility, split among everyone.
static const mixer = 'Mixer';
/// Returnable bottle deposits (kaucja).
static const deposit = 'Deposit';
/// Anything else.
static const general = 'General';
}
/// One receipt line.
class Item {
/// Creates an item; [totalGr] is the post-discount line total.
Item({
required this.id,
required this.name,
required this.totalGr,
this.qty = 1,
this.unitPriceGr = 0,
this.discountGr = 0,
this.category = Categories.general,
Assignment? assignment,
}) : assignment = assignment ?? Assignment();
/// Restores an item from its [toJson] map.
factory Item.fromJson(Map<String, dynamic> j) => Item(
id: j['id'] as String,
name: j['name'] as String,
totalGr: j['totalGr'] as int,
qty: (j['qty'] as num? ?? 1).toDouble(),
unitPriceGr: j['unitPriceGr'] as int? ?? 0,
discountGr: j['discountGr'] as int? ?? 0,
category: j['category'] as String? ?? Categories.general,
assignment: Assignment.fromJson(
(j['assignment'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
/// Stable unique id.
final String id;
/// Display name.
String name;
/// Final line total after discount — the ground truth used for splitting.
int totalGr;
/// Quantity (may be fractional for weighed goods).
double qty;
/// Unit price in grosze.
int unitPriceGr;
/// Discount already included in [totalGr]; kept for display (<= 0).
int discountGr;
/// Free-text category; see [Categories] for the standard values.
String category;
/// Who shares this item.
Assignment assignment;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'totalGr': totalGr,
'qty': qty,
'unitPriceGr': unitPriceGr,
'discountGr': discountGr,
'category': category,
'assignment': assignment.toJson(),
};
}
/// One shopping receipt with its items and participants.
class Receipt {
/// Creates a receipt; [participantIds] defaults to empty.
Receipt({
required this.id,
required this.title,
this.store,
DateTime? date,
List<Item>? items,
Set<String>? participantIds,
this.paidTotalGr,
}) : date = date ?? DateTime.now(),
items = items ?? <Item>[],
participantIds = participantIds ?? <String>{};
/// Restores a receipt from its [toJson] map.
factory Receipt.fromJson(Map<String, dynamic> j) => Receipt(
id: j['id'] as String,
title: j['title'] as String,
store: j['store'] as String?,
date: DateTime.tryParse(j['date'] as String? ?? '') ?? DateTime.now(),
items: (j['items'] as List? ?? const <dynamic>[])
.map((e) => Item.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
participantIds: (j['participantIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
paidTotalGr: j['paidTotalGr'] as int?,
);
/// Stable unique id.
final String id;
/// Display title.
String title;
/// Store name, if known.
String? store;
/// Sale date.
DateTime date;
/// Line items.
final List<Item> items;
/// Subset of the roster taking part in this receipt.
final Set<String> participantIds;
/// What was actually paid (incl. deposits) — used for the reconciliation
/// check. Null for manual receipts where the user never entered it.
int? paidTotalGr;
/// Sum of all item totals in grosze.
int get itemsTotalGr => items.fold(0, (s, i) => s + i.totalGr);
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'store': store,
'date': date.toIso8601String(),
'items': items.map((i) => i.toJson()).toList(),
'participantIds': participantIds.toList(),
'paidTotalGr': paidTotalGr,
};
}
/// Simple unique id source: time + counter. Good enough for a local,
/// single-user app.
class Ids {
static int _c = 0;
/// Returns a new unique id.
static String next() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'
'-${(_c++).toRadixString(36)}';
}

View File

@ -4,6 +4,8 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
part 'people_tiles.dart';
/// Roster management: people with a drinks toggle, plus custom groups.
class PeopleScreen extends StatelessWidget {
/// Creates the people screen.
@ -131,136 +133,3 @@ class PeopleScreen extends StatelessWidget {
);
}
}
class _PersonTile extends StatelessWidget {
const _PersonTile({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
return ListTile(
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: Wrap(
spacing: 6,
children: [
for (final g in state.groups.where(
(g) => g.memberIds.contains(person.id),
))
Chip(
label: Text(g.name, style: const TextStyle(fontSize: 11)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: 'Drinks alcohol',
child: Switch(
value: person.drinks,
onChanged: (v) => state.mutate(() => person.drinks = v),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmRemove(context, state),
),
],
),
onTap: () => _editMembership(context, state),
);
}
Future<void> _confirmRemove(BuildContext context, AppState state) async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Remove ${person.name}?'),
content: const Text(
'They will be removed from all receipts and item assignments.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Remove'),
),
],
),
);
if (ok == true) state.removePerson(person);
}
Future<void> _editMembership(BuildContext context, AppState state) async {
if (state.groups.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, setState) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${person.name} — groups',
style: Theme.of(c).textTheme.titleMedium,
),
),
for (final g in state.groups)
CheckboxListTile(
title: Text(g.name),
value: g.memberIds.contains(person.id),
onChanged: (v) {
state.mutate(() {
if (v == true) {
g.memberIds.add(person.id);
} else {
g.memberIds.remove(person.id);
}
});
setState(() {});
},
),
],
),
),
),
);
}
}
class _GroupTile extends StatelessWidget {
const _GroupTile({required this.group});
final Group group;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
final names = state.people
.where((p) => group.memberIds.contains(p.id))
.map((p) => p.name)
.join(', ');
return ListTile(
leading: const Icon(Icons.label_outline),
title: Text(group.name),
subtitle:
Text(names.isEmpty ? 'No members — tap a person to add' : names),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => state.removeGroup(group),
),
);
}
}

View File

@ -0,0 +1,139 @@
/// Person and group tile widgets.
///
/// Split out of people_screen.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'people_screen.dart';
class _PersonTile extends StatelessWidget {
const _PersonTile({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
return ListTile(
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: Wrap(
spacing: 6,
children: [
for (final g in state.groups.where(
(g) => g.memberIds.contains(person.id),
))
Chip(
label: Text(g.name, style: const TextStyle(fontSize: 11)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: 'Drinks alcohol',
child: Switch(
value: person.drinks,
onChanged: (v) => state.mutate(() => person.drinks = v),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmRemove(context, state),
),
],
),
onTap: () => _editMembership(context, state),
);
}
Future<void> _confirmRemove(BuildContext context, AppState state) async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Remove ${person.name}?'),
content: const Text(
'They will be removed from all receipts and item assignments.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Remove'),
),
],
),
);
if (ok == true) state.removePerson(person);
}
Future<void> _editMembership(BuildContext context, AppState state) async {
if (state.groups.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, setState) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${person.name} — groups',
style: Theme.of(c).textTheme.titleMedium,
),
),
for (final g in state.groups)
CheckboxListTile(
title: Text(g.name),
value: g.memberIds.contains(person.id),
onChanged: (v) {
state.mutate(() {
if (v == true) {
g.memberIds.add(person.id);
} else {
g.memberIds.remove(person.id);
}
});
setState(() {});
},
),
],
),
),
),
);
}
}
class _GroupTile extends StatelessWidget {
const _GroupTile({required this.group});
final Group group;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
final names = state.people
.where((p) => group.memberIds.contains(p.id))
.map((p) => p.name)
.join(', ');
return ListTile(
leading: const Icon(Icons.label_outline),
title: Text(group.name),
subtitle:
Text(names.isEmpty ? 'No members — tap a person to add' : names),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => state.removeGroup(group),
),
);
}
}

View File

@ -0,0 +1,198 @@
/// The receipt item list and its row widgets.
///
/// Split out of receipt_screen.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'receipt_screen.dart';
class _ItemsList extends StatelessWidget {
const _ItemsList({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
if (receipt.items.isEmpty) {
return const Center(child: Text('No items — add with +.'));
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 96),
itemCount: receipt.items.length,
itemBuilder: (context, i) {
final item = receipt.items[i];
final people =
resolveParticipants(item, receipt, state.people, state.groups);
final unassigned = people.isEmpty;
return ListTile(
dense: true,
leading: _CategoryDot(category: item.category),
title: Text(item.name),
subtitle: Text(
_assignmentLabel(item, people.length, state),
style: TextStyle(
color: unassigned ? Theme.of(context).colorScheme.error : null,
fontWeight: unassigned ? FontWeight.bold : null,
),
),
trailing: Text(formatPln(item.totalGr)),
onTap: () => showAssignmentSheet(context, state, receipt, item),
onLongPress: () => showItemEditDialog(context, state, receipt, item),
);
},
);
}
String _assignmentLabel(Item item, int n, AppState state) {
switch (item.assignment.mode) {
case AssignMode.everyone:
return 'Everyone ($n)';
case AssignMode.drinkers:
return 'Drinkers only ($n)';
case AssignMode.nonDrinkers:
return 'Non-drinkers ($n)';
case AssignMode.group:
final g = state.groups
.where((g) => g.id == item.assignment.groupId)
.firstOrNull;
return 'Group: ${g?.name ?? '?'} ($n)';
case AssignMode.custom:
return n == 0 ? 'UNASSIGNED — tap to fix' : 'Selected people ($n)';
}
}
}
class _CategoryDot extends StatelessWidget {
const _CategoryDot({required this.category});
final String category;
@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,
_ => Colors.teal,
};
return CircleAvatar(radius: 6, backgroundColor: color);
}
}
class _TotalsView extends StatelessWidget {
const _TotalsView({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
final people = receiptParticipants(receipt, state.people)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final itemsTotal = receipt.itemsTotalGr;
final assigned = split.assignedTotalGr;
final paid = receipt.paidTotalGr;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (split.unassigned.isNotEmpty)
Card(
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: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
),
for (final p in people)
_PersonRow(person: p, total: split.perPerson[p.id]),
const Divider(height: 32),
_kv(context, 'Items total', formatPln(itemsTotal), bold: true),
_kv(
context,
'Assigned to people',
formatPln(assigned),
warn: assigned != itemsTotal,
),
if (paid != null)
_kv(
context,
'Paid (receipt)',
formatPln(paid),
warn: paid != itemsTotal,
),
const SizedBox(height: 8),
Text(
'Splits are grosz-exact: shares always add up to the item total, '
'so no fractional grosze are lost.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _kv(
BuildContext context,
String k,
String v, {
bool bold = false,
bool warn = false,
}) {
final style = TextStyle(
fontWeight: bold || warn ? FontWeight.bold : null,
color: warn ? Theme.of(context).colorScheme.error : null,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(k, style: style), Text(v, style: style)],
),
);
}
}
class _PersonRow extends StatelessWidget {
const _PersonRow({required this.person, required this.total});
final Person person;
final PersonTotal? total;
@override
Widget build(BuildContext context) {
final t = total;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: t == null || t.alcoholGr == 0
? null
: Text(
'alcohol ${formatPln(t.alcoholGr)} · rest ${formatPln(t.restGr)}',
),
trailing: Text(
formatPln(t?.totalGr ?? 0),
style: Theme.of(context).textTheme.titleMedium,
),
);
}
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View File

@ -9,6 +9,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
part 'receipt_items.dart';
/// Writes exported [bytes] to [path]; the seam the export flow writes through.
///
/// Kept injectable because `file_selector` only returns a destination — the
@ -207,195 +209,3 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
}
}
}
class _ItemsList extends StatelessWidget {
const _ItemsList({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
if (receipt.items.isEmpty) {
return const Center(child: Text('No items — add with +.'));
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 96),
itemCount: receipt.items.length,
itemBuilder: (context, i) {
final item = receipt.items[i];
final people =
resolveParticipants(item, receipt, state.people, state.groups);
final unassigned = people.isEmpty;
return ListTile(
dense: true,
leading: _CategoryDot(category: item.category),
title: Text(item.name),
subtitle: Text(
_assignmentLabel(item, people.length, state),
style: TextStyle(
color: unassigned ? Theme.of(context).colorScheme.error : null,
fontWeight: unassigned ? FontWeight.bold : null,
),
),
trailing: Text(formatPln(item.totalGr)),
onTap: () => showAssignmentSheet(context, state, receipt, item),
onLongPress: () => showItemEditDialog(context, state, receipt, item),
);
},
);
}
String _assignmentLabel(Item item, int n, AppState state) {
switch (item.assignment.mode) {
case AssignMode.everyone:
return 'Everyone ($n)';
case AssignMode.drinkers:
return 'Drinkers only ($n)';
case AssignMode.nonDrinkers:
return 'Non-drinkers ($n)';
case AssignMode.group:
final g = state.groups
.where((g) => g.id == item.assignment.groupId)
.firstOrNull;
return 'Group: ${g?.name ?? '?'} ($n)';
case AssignMode.custom:
return n == 0 ? 'UNASSIGNED — tap to fix' : 'Selected people ($n)';
}
}
}
class _CategoryDot extends StatelessWidget {
const _CategoryDot({required this.category});
final String category;
@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,
_ => Colors.teal,
};
return CircleAvatar(radius: 6, backgroundColor: color);
}
}
class _TotalsView extends StatelessWidget {
const _TotalsView({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
final people = receiptParticipants(receipt, state.people)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final itemsTotal = receipt.itemsTotalGr;
final assigned = split.assignedTotalGr;
final paid = receipt.paidTotalGr;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (split.unassigned.isNotEmpty)
Card(
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: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
),
for (final p in people)
_PersonRow(person: p, total: split.perPerson[p.id]),
const Divider(height: 32),
_kv(context, 'Items total', formatPln(itemsTotal), bold: true),
_kv(
context,
'Assigned to people',
formatPln(assigned),
warn: assigned != itemsTotal,
),
if (paid != null)
_kv(
context,
'Paid (receipt)',
formatPln(paid),
warn: paid != itemsTotal,
),
const SizedBox(height: 8),
Text(
'Splits are grosz-exact: shares always add up to the item total, '
'so no fractional grosze are lost.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _kv(
BuildContext context,
String k,
String v, {
bool bold = false,
bool warn = false,
}) {
final style = TextStyle(
fontWeight: bold || warn ? FontWeight.bold : null,
color: warn ? Theme.of(context).colorScheme.error : null,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(k, style: style), Text(v, style: style)],
),
);
}
}
class _PersonRow extends StatelessWidget {
const _PersonRow({required this.person, required this.total});
final Person person;
final PersonTotal? total;
@override
Widget build(BuildContext context) {
final t = total;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: t == null || t.alcoholGr == 0
? null
: Text(
'alcohol ${formatPln(t.alcoholGr)} · rest ${formatPln(t.restGr)}',
),
trailing: Text(
formatPln(t?.totalGr ?? 0),
style: Theme.of(context).textTheme.titleMedium,
),
);
}
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View File

@ -0,0 +1,155 @@
// People screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('people', () {
testWidgets('add person dialog: toggle, cancel, empty, valid',
(tester) async {
final s = tempState();
await pumpApp(tester, s, const PeopleScreen());
expect(find.textContaining('Nobody yet'), findsOneWidget);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Kasia');
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people.single.name, 'Kasia');
expect(s.people.single.drinks, isFalse);
await flushSaves(tester);
});
testWidgets('drinks switch, membership early-return, delete flows',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.byType(Switch).first);
await tester.pumpAndSettle();
expect(p.drinks, isFalse);
// No groups yet → tapping the person is a no-op branch.
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.text('Group membership'), findsNothing);
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, hasLength(1));
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await flushSaves(tester);
});
testWidgets('groups: add, membership sheet, delete', (tester) async {
final s = tempState()..addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Meat');
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.groups.single.name, 'Meat');
expect(find.textContaining('No members'), findsOneWidget);
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, hasLength(1));
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, isEmpty);
await tester.tapAt(const Offset(10, 10)); // close sheet
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete_outline).last);
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await flushSaves(tester);
});
});
}

View File

@ -0,0 +1,141 @@
// Receipt screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('receipt screen', () {
testWidgets('unknown id shows deleted note', (tester) async {
final s = tempState();
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope'));
expect(find.text('Receipt deleted.'), findsOneWidget);
});
testWidgets('tabs on narrow, panes on wide', (tester) async {
final s = tempState()..addPerson('Ala');
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.byType(TabBar), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('grosz-exact'), findsOneWidget);
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpAndSettle();
expect(find.byType(TabBar), findsNothing);
expect(find.byType(VerticalDivider), findsOneWidget);
});
testWidgets('participants: empty roster snackbar, then editing',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.textContaining('Add people first'), findsOneWidget);
final p = s.addPerson('Ala');
s.addPerson('Bob');
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.text('Who is on this receipt?'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(s.receipts.single.participantIds.contains(p.id), isTrue);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
await flushSaves(tester);
});
testWidgets('export: no participants, success, cancel, failure',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Add participants'), findsOneWidget);
s.addPerson('Ala');
s.receipts.single.participantIds.addAll(s.people.map((p) => p.id));
mockFilePicker(saveResult: '/tmp/out.xlsx');
await tester.pump(const Duration(seconds: 5)); // expire prior snackbar
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Saved: out.xlsx'), findsOneWidget);
mockFilePicker();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
mockFilePicker(saveThrows: true);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Export failed'), findsOneWidget);
await flushSaves(tester);
});
});
}

View File

@ -0,0 +1,196 @@
// Receipt item list and assignment sheet flows.
// Receipt screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('receipt items', () {
testWidgets('items list: labels, unassigned, edit dialog paths',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
s.addPerson('Bob', drinks: false);
final g = s.addGroup('Meat')..memberIds.add(p.id);
final r = demoReceipt(s, withUnassigned: true);
r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id);
r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers);
r.items[3].assignment = Assignment(
mode: AssignMode.group,
groupId: 'gone',
);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.textContaining('Group: Meat'), findsOneWidget);
expect(find.textContaining('Group: ?'), findsOneWidget);
expect(find.textContaining('Drinkers'), findsOneWidget);
expect(find.textContaining('Non-drinkers'), findsOneWidget);
expect(find.textContaining('UNASSIGNED'), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('excluded from totals'), findsOneWidget);
await tester.tap(find.text('Items'));
await tester.pumpAndSettle();
// Edit via long-press: invalid total keeps dialog open, then fix+save.
await tester.longPress(find.text('Bread'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'abc',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Save'), findsOneWidget); // still open
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'6,00',
);
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(r.items[0].totalGr, 600);
expect(r.items[0].name, 'Rolls');
// Delete an item from the edit dialog.
await tester.longPress(find.text('Ghost'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(r.items.any((i) => i.name == 'Ghost'), isFalse);
// Add a new alcohol item via the FAB: cancel first, then create.
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine');
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'30',
);
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Alcohol').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
final wine = r.items.singleWhere((i) => i.name == 'Wine');
expect(wine.totalGr, 3000);
expect(wine.assignment.mode, AssignMode.drinkers);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Chips');
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'5',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(
r.items.singleWhere((i) => i.name == 'Chips').assignment.mode,
AssignMode.everyone,
);
await flushSaves(tester);
});
testWidgets('assignment sheet: modes, materialization, nobody',
(tester) async {
final s = tempState();
final ala = s.addPerson('Ala');
s.addPerson('Bob');
final g = s.addGroup('Meat')..memberIds.add(ala.id);
final r = demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.text('Bread'));
await tester.pumpAndSettle();
final item = r.items[0];
await tester.tap(find.widgetWithText(ChoiceChip, 'Drinkers'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.drinkers);
await tester.tap(find.widgetWithText(ChoiceChip, 'Non-drinkers'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.nonDrinkers);
expect(find.textContaining('Nobody selected'), findsOneWidget);
await tester.tap(find.widgetWithText(ChoiceChip, 'Meat'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.group);
expect(item.assignment.groupId, g.id);
await tester.tap(find.widgetWithText(ChoiceChip, 'Everyone'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.everyone);
// Toggling a person on a symbolic mode materializes to custom.
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.custom);
expect(item.assignment.customIds, hasLength(1));
await tester.tap(find.byType(CheckboxListTile).last);
await tester.pumpAndSettle();
expect(item.assignment.customIds, isEmpty);
expect(find.textContaining('Nobody selected'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(item.assignment.customIds, hasLength(1));
await flushSaves(tester);
});
});
}

View File

@ -207,330 +207,4 @@ void main() {
expect(find.byType(PeopleScreen), findsOneWidget);
});
});
group('people', () {
testWidgets('add person dialog: toggle, cancel, empty, valid',
(tester) async {
final s = tempState();
await pumpApp(tester, s, const PeopleScreen());
expect(find.textContaining('Nobody yet'), findsOneWidget);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Kasia');
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people.single.name, 'Kasia');
expect(s.people.single.drinks, isFalse);
await flushSaves(tester);
});
testWidgets('drinks switch, membership early-return, delete flows',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.byType(Switch).first);
await tester.pumpAndSettle();
expect(p.drinks, isFalse);
// No groups yet → tapping the person is a no-op branch.
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.text('Group membership'), findsNothing);
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, hasLength(1));
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await flushSaves(tester);
});
testWidgets('groups: add, membership sheet, delete', (tester) async {
final s = tempState()..addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Meat');
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.groups.single.name, 'Meat');
expect(find.textContaining('No members'), findsOneWidget);
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, hasLength(1));
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, isEmpty);
await tester.tapAt(const Offset(10, 10)); // close sheet
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete_outline).last);
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await flushSaves(tester);
});
});
group('receipt screen', () {
testWidgets('unknown id shows deleted note', (tester) async {
final s = tempState();
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope'));
expect(find.text('Receipt deleted.'), findsOneWidget);
});
testWidgets('tabs on narrow, panes on wide', (tester) async {
final s = tempState()..addPerson('Ala');
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.byType(TabBar), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('grosz-exact'), findsOneWidget);
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpAndSettle();
expect(find.byType(TabBar), findsNothing);
expect(find.byType(VerticalDivider), findsOneWidget);
});
testWidgets('participants: empty roster snackbar, then editing',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.textContaining('Add people first'), findsOneWidget);
final p = s.addPerson('Ala');
s.addPerson('Bob');
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.text('Who is on this receipt?'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(s.receipts.single.participantIds.contains(p.id), isTrue);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
await flushSaves(tester);
});
testWidgets('export: no participants, success, cancel, failure',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Add participants'), findsOneWidget);
s.addPerson('Ala');
s.receipts.single.participantIds.addAll(s.people.map((p) => p.id));
mockFilePicker(saveResult: '/tmp/out.xlsx');
await tester.pump(const Duration(seconds: 5)); // expire prior snackbar
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Saved: out.xlsx'), findsOneWidget);
mockFilePicker();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
mockFilePicker(saveThrows: true);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Export failed'), findsOneWidget);
await flushSaves(tester);
});
testWidgets('items list: labels, unassigned, edit dialog paths',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
s.addPerson('Bob', drinks: false);
final g = s.addGroup('Meat')..memberIds.add(p.id);
final r = demoReceipt(s, withUnassigned: true);
r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id);
r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers);
r.items[3].assignment = Assignment(
mode: AssignMode.group,
groupId: 'gone',
);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.textContaining('Group: Meat'), findsOneWidget);
expect(find.textContaining('Group: ?'), findsOneWidget);
expect(find.textContaining('Drinkers'), findsOneWidget);
expect(find.textContaining('Non-drinkers'), findsOneWidget);
expect(find.textContaining('UNASSIGNED'), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('excluded from totals'), findsOneWidget);
await tester.tap(find.text('Items'));
await tester.pumpAndSettle();
// Edit via long-press: invalid total keeps dialog open, then fix+save.
await tester.longPress(find.text('Bread'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'abc',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Save'), findsOneWidget); // still open
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'6,00',
);
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(r.items[0].totalGr, 600);
expect(r.items[0].name, 'Rolls');
// Delete an item from the edit dialog.
await tester.longPress(find.text('Ghost'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(r.items.any((i) => i.name == 'Ghost'), isFalse);
// Add a new alcohol item via the FAB: cancel first, then create.
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine');
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'30',
);
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Alcohol').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
final wine = r.items.singleWhere((i) => i.name == 'Wine');
expect(wine.totalGr, 3000);
expect(wine.assignment.mode, AssignMode.drinkers);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Chips');
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'5',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(
r.items.singleWhere((i) => i.name == 'Chips').assignment.mode,
AssignMode.everyone,
);
await flushSaves(tester);
});
testWidgets('assignment sheet: modes, materialization, nobody',
(tester) async {
final s = tempState();
final ala = s.addPerson('Ala');
s.addPerson('Bob');
final g = s.addGroup('Meat')..memberIds.add(ala.id);
final r = demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.text('Bread'));
await tester.pumpAndSettle();
final item = r.items[0];
await tester.tap(find.widgetWithText(ChoiceChip, 'Drinkers'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.drinkers);
await tester.tap(find.widgetWithText(ChoiceChip, 'Non-drinkers'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.nonDrinkers);
expect(find.textContaining('Nobody selected'), findsOneWidget);
await tester.tap(find.widgetWithText(ChoiceChip, 'Meat'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.group);
expect(item.assignment.groupId, g.id);
await tester.tap(find.widgetWithText(ChoiceChip, 'Everyone'));
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.everyone);
// Toggling a person on a symbolic mode materializes to custom.
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(item.assignment.mode, AssignMode.custom);
expect(item.assignment.customIds, hasLength(1));
await tester.tap(find.byType(CheckboxListTile).last);
await tester.pumpAndSettle();
expect(item.assignment.customIds, isEmpty);
expect(find.textContaining('Nobody selected'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(item.assignment.customIds, hasLength(1));
await flushSaves(tester);
});
});
}

View File

@ -0,0 +1,18 @@
{
"title": "Split the billsplit Dart files under the 250-line cap",
"objective": "Bring all four over-cap billsplit Dart sources and its 536-line UI flow test under the cap, leaving only Flutter's generated Windows runner.",
"acceptance_criteria": [
"Every billsplit Dart file is at most 250 lines.",
"flutter test reports exactly 60 passing tests, the same as before the split.",
"flutter analyze reports no MORE issues than the pre-split baseline. That baseline is 4 pre-existing unnecessary_unawaited infos, measured from a detached worktree at HEAD rather than assumed.",
"Library-private declarations that cross a seam use part/part of. A `library;` directive must come before any `part`.",
"Split widget-test halves each call TestWidgetsFlutterBinding.ensureInitialized().",
"No test is deleted, skipped or weakened."
],
"out_of_scope": [
"windows/runner/win32_window.cpp, which is Flutter-generated scaffolding that regeneration would overwrite.",
"Changing what any test asserts.",
"The 4 pre-existing unnecessary_unawaited infos, which are not this task's to fix."
],
"verifier": "flutter test (expects 60 passed), flutter analyze compared against the same command run in a detached worktree at HEAD, and check_file_length.sh over billsplit."
}

View File

@ -0,0 +1,42 @@
{
"intent": "Clear every over-cap billsplit Dart file, leaving only Flutter's generated Windows runner.",
"scope": [
"lib/ui/receipt_screen.dart (401 -> 216) + receipt_items.dart",
"lib/domain/eparagon_parser.dart (293 -> 182) + eparagon_fields.dart",
"lib/domain/models.dart (278 -> 194) + assignment.dart",
"lib/ui/people_screen.dart (266 -> 139) + people_tiles.dart",
"test/ui_flows_test.dart (536 -> 214) + people_flows_test.dart, receipt_flows_test.dart, receipt_items_test.dart",
"Not touched: windows/runner/win32_window.cpp (288)",
"Non-goal: no assertion changed"
],
"changes": [
"All four lib splits use part/part of, applying the rule learned in focus_owner rather than rediscovering it - none of them needed a second attempt.",
"models.dart has a `library;` directive, and the splitter inserted `part` above it, which is an error: the library directive must come first. Fixed by hand, and it also revealed that the insertion had split the file's doc comment in two.",
"The test file split three ways, each half declaring the test binding explicitly and keeping only the imports it uses. One aliased import (`as app`) survived the first pruning pass because the filter matched exact text - worth remembering that unused-import cleanup must handle aliases."
],
"verification": [
{
"command": "flutter test",
"result": "pass",
"evidence": "60 passed, matching the baseline measured before any edit."
},
{
"command": "flutter analyze, compared against the same command in a detached worktree at HEAD",
"result": "pass",
"evidence": "4 issues after, 4 issues before - all pre-existing unnecessary_unawaited infos. Comparing against a worktree rather than assuming a clean baseline is what showed the intermediate run's 7 issues were 3 of my own making, including one real error."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all | grep billsplit",
"result": "pass",
"evidence": "Only windows/runner/win32_window.cpp remains, and it is Flutter-generated scaffolding: editing it would be undone the next time the Windows target is regenerated."
}
],
"risks": [
"part/part of puts the split files in one library namespace, so two parts cannot declare the same private name. Not an issue today, but invisible until it collides.",
"win32_window.cpp stays over the cap. It is vendored Flutter scaffolding, so the honest options are an exemption or leaving it; splitting it would be undone by `flutter create`."
],
"rollback": [
"git revert the commit; the five originals return and the seven new files disappear.",
"After rollback, re-run flutter test and expect 60 passed."
]
}