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 78327da7d6
commit 917b8fbf0b
13 changed files with 1169 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);
});
});
}