testsAndMisc-archive/billsplit/lib/state/app_state.dart
Krzysztof Rudnicki 4dd582a1df Move billsplit Flutter app into the monorepo
billsplit was a standalone Flutter app in ~/Downloads with one commit and no
git remote — that directory was the only copy of its history.

Three things the move needed beyond copying the tree:

- The root .gitignore silently swallowed 32 of its files (*.png, *.ico,
  *.manifest). Ignored files do not error on `git add`, they just vanish, so
  this would have committed an app that cannot build. Added a trailing block
  of ! overrides plus .binary-allowlist (which did not exist yet) — the
  documented in-repo exception for binaries a build genuinely needs. The
  ../testsAndMisc_binaries/ symlink scheme does not apply: those directories
  are gone, and a symlinked PNG breaks a fresh clone.

- billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads
  workflows at the repository root. Relocated with a working-directory and a
  billsplit/** path filter.

- The app's coverage gate moved to python_pkg/billsplit_coverage/ so every
  Python file stays under python_pkg/, and gained a test suite at 100% branch
  coverage as that location requires. billsplit/ now holds only Dart.

Also replaces the stock Flutter icon with a family icon from the shared
generator. The glyph is a filled silhouette because the family's 72px stroke
cannot carry a fine torn edge, and the split is an evenodd-cut perforation so
the holes stay transparent on the foreground and monochrome layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:22:22 +02:00

167 lines
4.8 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:billsplit/domain/models.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Single source of truth: roster, groups, receipts. Persisted as one JSON
/// file in the app documents directory; saves are debounced.
class AppState extends ChangeNotifier {
/// Creates the state; [overrideDir] replaces the platform documents
/// directory (used by tests).
AppState({Directory? overrideDir}) : _overrideDir = overrideDir;
final Directory? _overrideDir;
/// The shared roster of people.
final List<Person> people = [];
/// User-defined groups over [people].
final List<Group> groups = [];
/// Saved receipts, newest first.
final List<Receipt> receipts = [];
/// Whether [load] has completed.
bool loaded = false;
Timer? _saveTimer;
Future<File> _file() async {
final dir = _overrideDir ?? await getApplicationDocumentsDirectory();
final d = Directory('${dir.path}/billsplit');
if (!d.existsSync()) d.createSync(recursive: true);
return File('${d.path}/state.json');
}
/// Loads persisted state; failures leave the state empty but usable.
Future<void> load() async {
try {
final f = await _file();
if (f.existsSync()) {
final j = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
people
..clear()
..addAll(
(j['people'] as List? ?? const <dynamic>[])
.map((e) => Person.fromJson((e as Map).cast())),
);
groups
..clear()
..addAll(
(j['groups'] as List? ?? const <dynamic>[])
.map((e) => Group.fromJson((e as Map).cast())),
);
receipts
..clear()
..addAll(
(j['receipts'] as List? ?? const <dynamic>[])
.map((e) => Receipt.fromJson((e as Map).cast())),
);
_prune();
}
} on FormatException catch (e) {
debugPrint('State load failed (corrupt JSON): $e');
} on FileSystemException catch (e) {
debugPrint('State load failed (IO): $e');
}
loaded = true;
notifyListeners();
}
/// Drop references to people that no longer exist.
void _prune() {
final ids = people.map((p) => p.id).toSet();
for (final g in groups) {
g.memberIds.removeWhere((id) => !ids.contains(id));
}
for (final r in receipts) {
r.participantIds.removeWhere((id) => !ids.contains(id));
for (final i in r.items) {
i.assignment.customIds.removeWhere((id) => !ids.contains(id));
}
}
}
void _scheduleSave() {
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 400), save);
}
/// Writes the current state to disk immediately.
Future<void> save() async {
try {
final f = await _file();
f.writeAsStringSync(
jsonEncode({
'people': people.map((p) => p.toJson()).toList(),
'groups': groups.map((g) => g.toJson()).toList(),
'receipts': receipts.map((r) => r.toJson()).toList(),
}),
);
} on FileSystemException catch (e) {
debugPrint('State save failed: $e');
}
}
/// Runs [fn], prunes dangling references, notifies, schedules a save.
void mutate(void Function() fn) {
fn();
_prune();
notifyListeners();
_scheduleSave();
}
// Convenience CRUD -------------------------------------------------------
/// Adds a person to the roster and returns it.
Person addPerson(String name, {bool drinks = true}) {
final p = Person(id: Ids.next(), name: name, drinks: drinks);
mutate(() => people.add(p));
return p;
}
/// Removes [p] everywhere (roster, groups, receipts, assignments).
void removePerson(Person p) => mutate(() => people.remove(p));
/// Adds an empty group and returns it.
Group addGroup(String name) {
final g = Group(id: Ids.next(), name: name);
mutate(() => groups.add(g));
return g;
}
/// Removes [g]; items assigned to it fall back to everyone.
void removeGroup(Group g) => mutate(() {
groups.remove(g);
for (final r in receipts) {
for (final i in r.items) {
if (i.assignment.groupId == g.id) {
i.assignment
..mode = AssignMode.everyone
..groupId = null;
}
}
}
});
/// Inserts [r] at the top; empty participants default to the whole roster.
void addReceipt(Receipt r) => mutate(() {
if (r.participantIds.isEmpty) {
r.participantIds.addAll(people.map((p) => p.id));
}
receipts.insert(0, r);
});
/// Deletes [r].
void removeReceipt(Receipt r) => mutate(() => receipts.remove(r));
@override
void dispose() {
_saveTimer?.cancel();
super.dispose();
}
}