mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 18:07:09 +01:00
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:
parent
78327da7d6
commit
917b8fbf0b
@ -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
|
||||
|
||||
200
billsplit/lib/domain/assignment.dart
Normal file
200
billsplit/lib/domain/assignment.dart
Normal 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)}';
|
||||
}
|
||||
124
billsplit/lib/domain/eparagon_fields.dart
Normal file
124
billsplit/lib/domain/eparagon_fields.dart
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
139
billsplit/lib/ui/people_tiles.dart
Normal file
139
billsplit/lib/ui/people_tiles.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
billsplit/lib/ui/receipt_items.dart
Normal file
198
billsplit/lib/ui/receipt_items.dart
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
155
billsplit/test/people_flows_test.dart
Normal file
155
billsplit/test/people_flows_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
141
billsplit/test/receipt_flows_test.dart
Normal file
141
billsplit/test/receipt_flows_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
196
billsplit/test/receipt_items_test.dart
Normal file
196
billsplit/test/receipt_items_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user