diff --git a/billsplit/analysis_options.yaml b/billsplit/analysis_options.yaml index ae2b39e..c1d2d0f 100644 --- a/billsplit/analysis_options.yaml +++ b/billsplit/analysis_options.yaml @@ -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 diff --git a/billsplit/lib/domain/assignment.dart b/billsplit/lib/domain/assignment.dart new file mode 100644 index 0000000..9298bdb --- /dev/null +++ b/billsplit/lib/domain/assignment.dart @@ -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? customIds, + }) : customIds = customIds ?? {}; + + /// Restores an assignment from its [toJson] map. + factory Assignment.fromJson(Map j) => Assignment( + mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone, + groupId: j['groupId'] as String?, + customIds: (j['customIds'] as List? ?? const []) + .cast() + .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 customIds; + + /// JSON representation for persistence. + Map 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 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() ?? 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 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? items, + Set? participantIds, + this.paidTotalGr, + }) : date = date ?? DateTime.now(), + items = items ?? [], + participantIds = participantIds ?? {}; + + /// Restores a receipt from its [toJson] map. + factory Receipt.fromJson(Map 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 []) + .map((e) => Item.fromJson((e as Map).cast())) + .toList(), + participantIds: (j['participantIds'] as List? ?? const []) + .cast() + .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 items; + + /// Subset of the roster taking part in this receipt. + final Set 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 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)}'; +} diff --git a/billsplit/lib/domain/eparagon_fields.dart b/billsplit/lib/domain/eparagon_fields.dart new file mode 100644 index 0000000..bdeba1b --- /dev/null +++ b/billsplit/lib/domain/eparagon_fields.dart @@ -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 paragon, + Map 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 _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(); + 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? _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() : null; + } on FormatException { + return null; + } +} + +Item? _itemFromTowar(Map t, List 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; +} diff --git a/billsplit/lib/domain/eparagon_parser.dart b/billsplit/lib/domain/eparagon_parser.dart index 35aa51b..bb960b6 100644 --- a/billsplit/lib/domain/eparagon_parser.dart +++ b/billsplit/lib/domain/eparagon_parser.dart @@ -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 dokument) { if (name.toUpperCase().contains('JERONIMO MARTINS')) return 'Biedronka'; return name; } - -DateTime _saleDate( - Map paragon, - Map 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 _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(); - 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? _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() : null; - } on FormatException { - return null; - } -} - -Item? _itemFromTowar(Map t, List 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; -} diff --git a/billsplit/lib/domain/models.dart b/billsplit/lib/domain/models.dart index 771e512..1f4dae7 100644 --- a/billsplit/lib/domain/models.dart +++ b/billsplit/lib/domain/models.dart @@ -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? customIds, - }) : customIds = customIds ?? {}; - - /// Restores an assignment from its [toJson] map. - factory Assignment.fromJson(Map j) => Assignment( - mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone, - groupId: j['groupId'] as String?, - customIds: (j['customIds'] as List? ?? const []) - .cast() - .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 customIds; - - /// JSON representation for persistence. - Map 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 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() ?? 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 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? items, - Set? participantIds, - this.paidTotalGr, - }) : date = date ?? DateTime.now(), - items = items ?? [], - participantIds = participantIds ?? {}; - - /// Restores a receipt from its [toJson] map. - factory Receipt.fromJson(Map 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 []) - .map((e) => Item.fromJson((e as Map).cast())) - .toList(), - participantIds: (j['participantIds'] as List? ?? const []) - .cast() - .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 items; - - /// Subset of the roster taking part in this receipt. - final Set 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 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)}'; -} diff --git a/billsplit/lib/ui/people_screen.dart b/billsplit/lib/ui/people_screen.dart index e1874bd..c5b0465 100644 --- a/billsplit/lib/ui/people_screen.dart +++ b/billsplit/lib/ui/people_screen.dart @@ -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(); - 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 _confirmRemove(BuildContext context, AppState state) async { - final ok = await showDialog( - 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 _editMembership(BuildContext context, AppState state) async { - if (state.groups.isEmpty) return; - await showModalBottomSheet( - 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(); - 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), - ), - ); - } -} diff --git a/billsplit/lib/ui/people_tiles.dart b/billsplit/lib/ui/people_tiles.dart new file mode 100644 index 0000000..ac1b895 --- /dev/null +++ b/billsplit/lib/ui/people_tiles.dart @@ -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(); + 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 _confirmRemove(BuildContext context, AppState state) async { + final ok = await showDialog( + 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 _editMembership(BuildContext context, AppState state) async { + if (state.groups.isEmpty) return; + await showModalBottomSheet( + 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(); + 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), + ), + ); + } +} diff --git a/billsplit/lib/ui/receipt_items.dart b/billsplit/lib/ui/receipt_items.dart new file mode 100644 index 0000000..24b4384 --- /dev/null +++ b/billsplit/lib/ui/receipt_items.dart @@ -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(); + 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(); + 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 on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/billsplit/lib/ui/receipt_screen.dart b/billsplit/lib/ui/receipt_screen.dart index d7e06bf..08f476f 100644 --- a/billsplit/lib/ui/receipt_screen.dart +++ b/billsplit/lib/ui/receipt_screen.dart @@ -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 { } } } - -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(); - 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(); - 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 on Iterable { - T? get firstOrNull => isEmpty ? null : first; -} diff --git a/billsplit/test/people_flows_test.dart b/billsplit/test/people_flows_test.dart new file mode 100644 index 0000000..081ceca --- /dev/null +++ b/billsplit/test/people_flows_test.dart @@ -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); + }); + }); +} diff --git a/billsplit/test/receipt_flows_test.dart b/billsplit/test/receipt_flows_test.dart new file mode 100644 index 0000000..740b14b --- /dev/null +++ b/billsplit/test/receipt_flows_test.dart @@ -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); + }); + }); +} diff --git a/billsplit/test/receipt_items_test.dart b/billsplit/test/receipt_items_test.dart new file mode 100644 index 0000000..b48fdc2 --- /dev/null +++ b/billsplit/test/receipt_items_test.dart @@ -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)); + 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); + }); + }); +} diff --git a/billsplit/test/ui_flows_test.dart b/billsplit/test/ui_flows_test.dart index 1b24602..4245290 100644 --- a/billsplit/test/ui_flows_test.dart +++ b/billsplit/test/ui_flows_test.dart @@ -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)); - 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); - }); - }); }