From ec5d3aba3411f0e0148f885e0abf35f949b04bf3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 23 Aug 2026 17:17:22 +0200 Subject: [PATCH] Archive billsplit and everything coupled to it The coupled case the previous archive commit deferred. billsplit is not just a directory: a coverage package exists only to gate it, app_icons holds a registry entry pointing into it, a CI workflow builds it, and .binary-allowlist / .gitignore carry a paired set of rules for its launcher icons. Removing the directory alone would have failed the 100% coverage gate and left three configs naming a missing path. So all of it goes in one commit: billsplit/ 73 files python_pkg/billsplit_coverage/ 5 files, gated billsplit only .github/workflows/billsplit-ci.yml the app_icons AppIcon(key="billsplit") entry the .binary-allowlist patterns and their .gitignore ! overrides the .prettierignore fixture line billsplit/ and billsplit_coverage/ were extracted in a single filter-repo pass (8 commits, matching the monorepo) and pushed to github.com/kuhyx/testsAndMisc-archive, where the coverage package joins the archive's existing python_pkg/ tree. Presence was confirmed through the GitHub API before anything was deleted here. .binary-allowlist is now empty of patterns -- billsplit's icons were its only entries. The policy header stays and records that no exception is in force, and check_no_binaries.sh still exits 0 on a comment-only file. Verified: 2095 tests pass at 100% coverage, down from 2114 by exactly the 19 billsplit_coverage tests. Depth violations 451 -> 417. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp --- .github/workflows/billsplit-ci.yml | 43 -- billsplit/.gitignore | 45 -- billsplit/.metadata | 42 -- billsplit/README.md | 81 --- billsplit/analysis_options.yaml | 15 - billsplit/android/.gitignore | 14 - billsplit/android/app/build.gradle.kts | 45 -- .../android/app/src/debug/AndroidManifest.xml | 7 - .../android/app/src/main/AndroidManifest.xml | 45 -- .../kotlin/dev/kuhy/billsplit/MainActivity.kt | 5 - .../drawable-hdpi/ic_launcher_foreground.png | Bin 2796 -> 0 bytes .../drawable-hdpi/ic_launcher_monochrome.png | Bin 2664 -> 0 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 1823 -> 0 bytes .../drawable-mdpi/ic_launcher_monochrome.png | Bin 1707 -> 0 bytes .../res/drawable-v21/launch_background.xml | 12 - .../drawable-xhdpi/ic_launcher_foreground.png | Bin 3775 -> 0 bytes .../drawable-xhdpi/ic_launcher_monochrome.png | Bin 3358 -> 0 bytes .../ic_launcher_foreground.png | Bin 5627 -> 0 bytes .../ic_launcher_monochrome.png | Bin 4872 -> 0 bytes .../ic_launcher_foreground.png | Bin 8050 -> 0 bytes .../ic_launcher_monochrome.png | Bin 6824 -> 0 bytes .../main/res/drawable/launch_background.xml | 12 - .../res/mipmap-anydpi-v26/ic_launcher.xml | 14 - .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 959 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 519 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 984 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1656 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 2032 -> 0 bytes .../app/src/main/res/values-night/styles.xml | 18 - .../app/src/main/res/values/colors.xml | 4 - .../app/src/main/res/values/styles.xml | 18 - .../app/src/profile/AndroidManifest.xml | 7 - billsplit/android/build.gradle.kts | 24 - billsplit/android/gradle.properties | 24 - .../gradle/wrapper/gradle-wrapper.properties | 5 - billsplit/android/settings.gradle.kts | 26 - billsplit/assets/icon/icon.png | Bin 13540 -> 0 bytes billsplit/assets/icon/icon.svg | 6 - billsplit/assets/icon/icon_foreground.png | Bin 14136 -> 0 bytes billsplit/assets/icon/icon_foreground.svg | 5 - billsplit/assets/icon/icon_monochrome.png | Bin 12557 -> 0 bytes billsplit/assets/icon/icon_monochrome.svg | 5 - billsplit/lib/domain/assignment.dart | 200 ------ billsplit/lib/domain/eparagon_fields.dart | 124 ---- billsplit/lib/domain/eparagon_parser.dart | 177 ----- billsplit/lib/domain/models.dart | 86 --- billsplit/lib/domain/split_engine.dart | 155 ----- billsplit/lib/domain/xlsx_export.dart | 215 ------ billsplit/lib/main.dart | 39 -- billsplit/lib/state/app_state.dart | 166 ----- billsplit/lib/ui/assignment_sheet.dart | 146 ----- billsplit/lib/ui/home_screen.dart | 210 ------ billsplit/lib/ui/item_edit.dart | 137 ---- billsplit/lib/ui/people_screen.dart | 135 ---- billsplit/lib/ui/people_tiles.dart | 139 ---- billsplit/lib/ui/receipt_items.dart | 198 ------ billsplit/lib/ui/receipt_screen.dart | 211 ------ billsplit/pubspec.lock | 611 ------------------ billsplit/pubspec.yaml | 45 -- billsplit/test/app_state_test.dart | 184 ------ billsplit/test/engine_extra_test.dart | 29 - billsplit/test/eparagon_edge_test.dart | 232 ------- billsplit/test/eparagon_parser_test.dart | 86 --- billsplit/test/fixtures/sample_receipt.json | 1 - billsplit/test/helpers.dart | 118 ---- billsplit/test/models_test.dart | 78 --- billsplit/test/people_flows_test.dart | 154 ----- billsplit/test/receipt_flows_test.dart | 140 ---- billsplit/test/receipt_items_test.dart | 195 ------ billsplit/test/split_engine_test.dart | 189 ------ billsplit/test/ui_flows_test.dart | 210 ------ billsplit/test/widget_test.dart | 16 - billsplit/test/xlsx_export_test.dart | 31 - billsplit/test/xlsx_extra_test.dart | 56 -- .../archive-billsplit-2026-08-23.json | 21 + .../archive-billsplit-2026-08-23.json | 64 ++ python_pkg/app_icons/apps.py | 14 - python_pkg/billsplit_coverage/__init__.py | 13 - python_pkg/billsplit_coverage/__main__.py | 14 - python_pkg/billsplit_coverage/checker.py | 130 ---- .../billsplit_coverage/tests/__init__.py | 3 - .../billsplit_coverage/tests/test_checker.py | 191 ------ 82 files changed, 85 insertions(+), 5600 deletions(-) delete mode 100644 .github/workflows/billsplit-ci.yml delete mode 100644 billsplit/.gitignore delete mode 100644 billsplit/.metadata delete mode 100644 billsplit/README.md delete mode 100644 billsplit/analysis_options.yaml delete mode 100644 billsplit/android/.gitignore delete mode 100644 billsplit/android/app/build.gradle.kts delete mode 100644 billsplit/android/app/src/debug/AndroidManifest.xml delete mode 100644 billsplit/android/app/src/main/AndroidManifest.xml delete mode 100644 billsplit/android/app/src/main/kotlin/dev/kuhy/billsplit/MainActivity.kt delete mode 100644 billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png delete mode 100644 billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png delete mode 100644 billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png delete mode 100644 billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png delete mode 100644 billsplit/android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png delete mode 100644 billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png delete mode 100644 billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png delete mode 100644 billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png delete mode 100644 billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png delete mode 100644 billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png delete mode 100644 billsplit/android/app/src/main/res/drawable/launch_background.xml delete mode 100644 billsplit/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 billsplit/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 billsplit/android/app/src/main/res/values-night/styles.xml delete mode 100644 billsplit/android/app/src/main/res/values/colors.xml delete mode 100644 billsplit/android/app/src/main/res/values/styles.xml delete mode 100644 billsplit/android/app/src/profile/AndroidManifest.xml delete mode 100644 billsplit/android/build.gradle.kts delete mode 100644 billsplit/android/gradle.properties delete mode 100644 billsplit/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 billsplit/android/settings.gradle.kts delete mode 100644 billsplit/assets/icon/icon.png delete mode 100644 billsplit/assets/icon/icon.svg delete mode 100644 billsplit/assets/icon/icon_foreground.png delete mode 100644 billsplit/assets/icon/icon_foreground.svg delete mode 100644 billsplit/assets/icon/icon_monochrome.png delete mode 100644 billsplit/assets/icon/icon_monochrome.svg delete mode 100644 billsplit/lib/domain/assignment.dart delete mode 100644 billsplit/lib/domain/eparagon_fields.dart delete mode 100644 billsplit/lib/domain/eparagon_parser.dart delete mode 100644 billsplit/lib/domain/models.dart delete mode 100644 billsplit/lib/domain/split_engine.dart delete mode 100644 billsplit/lib/domain/xlsx_export.dart delete mode 100644 billsplit/lib/main.dart delete mode 100644 billsplit/lib/state/app_state.dart delete mode 100644 billsplit/lib/ui/assignment_sheet.dart delete mode 100644 billsplit/lib/ui/home_screen.dart delete mode 100644 billsplit/lib/ui/item_edit.dart delete mode 100644 billsplit/lib/ui/people_screen.dart delete mode 100644 billsplit/lib/ui/people_tiles.dart delete mode 100644 billsplit/lib/ui/receipt_items.dart delete mode 100644 billsplit/lib/ui/receipt_screen.dart delete mode 100644 billsplit/pubspec.lock delete mode 100644 billsplit/pubspec.yaml delete mode 100644 billsplit/test/app_state_test.dart delete mode 100644 billsplit/test/engine_extra_test.dart delete mode 100644 billsplit/test/eparagon_edge_test.dart delete mode 100644 billsplit/test/eparagon_parser_test.dart delete mode 100644 billsplit/test/fixtures/sample_receipt.json delete mode 100644 billsplit/test/helpers.dart delete mode 100644 billsplit/test/models_test.dart delete mode 100644 billsplit/test/people_flows_test.dart delete mode 100644 billsplit/test/receipt_flows_test.dart delete mode 100644 billsplit/test/receipt_items_test.dart delete mode 100644 billsplit/test/split_engine_test.dart delete mode 100644 billsplit/test/ui_flows_test.dart delete mode 100644 billsplit/test/widget_test.dart delete mode 100644 billsplit/test/xlsx_export_test.dart delete mode 100644 billsplit/test/xlsx_extra_test.dart create mode 100644 docs/superpowers/contracts/archive-billsplit-2026-08-23.json create mode 100644 docs/superpowers/evidence/archive-billsplit-2026-08-23.json delete mode 100644 python_pkg/billsplit_coverage/__init__.py delete mode 100644 python_pkg/billsplit_coverage/__main__.py delete mode 100644 python_pkg/billsplit_coverage/checker.py delete mode 100644 python_pkg/billsplit_coverage/tests/__init__.py delete mode 100644 python_pkg/billsplit_coverage/tests/test_checker.py diff --git a/.github/workflows/billsplit-ci.yml b/.github/workflows/billsplit-ci.yml deleted file mode 100644 index 1d638ead..00000000 --- a/.github/workflows/billsplit-ci.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: billsplit CI - -# Relocated here from billsplit/.github/workflows/ci.yaml when the app moved -# into this monorepo — GitHub only reads workflows from the repository root, -# so the nested copy would have been inert. - -on: - push: - branches: [main] - paths: - - "billsplit/**" - - ".github/workflows/billsplit-ci.yml" - pull_request: - branches: [main] - paths: - - "billsplit/**" - - ".github/workflows/billsplit-ci.yml" - -defaults: - run: - working-directory: billsplit - -jobs: - quality: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - channel: stable - - run: flutter pub get - - name: Format (fails on diff) - run: dart format --set-exit-if-changed . - - name: Analyze (infos are fatal) - run: flutter analyze --fatal-infos - - name: Tests with coverage - run: flutter test --coverage - # Runs from the repository root: every Python file in this repo lives - # under python_pkg/, so the gate is a module there rather than a script - # inside the app. It resolves billsplit/ from its own location. - - name: Enforce 100% line coverage - working-directory: . - run: python3 -m python_pkg.billsplit_coverage diff --git a/billsplit/.gitignore b/billsplit/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/billsplit/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/billsplit/.metadata b/billsplit/.metadata deleted file mode 100644 index c17b9417..00000000 --- a/billsplit/.metadata +++ /dev/null @@ -1,42 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: android - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: ios - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: linux - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: macos - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: windows - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/billsplit/README.md b/billsplit/README.md deleted file mode 100644 index a345d9d6..00000000 --- a/billsplit/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# BillSplit - -Flutter app (mobile + desktop) for splitting shop receipts between people — -including the drinkers vs non-drinkers case — with Biedronka e-paragon import -and .xlsx export. - -## Features - -- **People roster** with a per-person _drinks alcohol_ toggle, plus arbitrary - **groups** (tags) like "meat eaters". -- **Receipts**: import a Polish fiscal e-receipt JSON (`e-paragon`, - JPK_KASA_PARAGON_v2-0 — the file the Biedronka app exports; the embedded JWS - payload is decoded, signature not verified) or build one manually. Multiple - receipts persist locally (`state.json` in app documents). -- **Per-item assignment**: each item is split among _Everyone / Drinkers / - Non-drinkers / a group / hand-picked people_. Alcohol is auto-detected on - import (wódka/piwo/wino/… keywords) and defaults to drinkers only; deposits - (kaucja) become their own line split among everyone. Category and assignment - are always editable — the keyword detection is a default, not a decision. -- **Grosz-exact math**: every item's shares sum to exactly the item total - (largest-remainder distribution), so per-person totals reconcile with the - receipt to the grosz — no floating point drift. -- **Totals view**: per person, with alcohol / rest breakdown, unassigned-item - warnings, and a reconciliation check against the paid amount. -- **Export .xlsx**: one row per item with a 1/0 person matrix plus a summary — - same layout as the hand-made spreadsheet this app grew out of. Matrix and - summary are exported values (the app is the calculator); column totals are - live SUM formulas. - -## Running - -`android/` is the only platform runner folder the repo tracks. Generate the -others locally for whatever you want to run on: - -```sh -cd billsplit -flutter create . --platforms=linux,windows,macos,ios -flutter pub get -flutter run # picks a connected device / desktop -``` - -Those four directories are gitignored, so a regenerated runner stays local -instead of being committed back. CI never builds them: `billsplit-ci.yml` runs -`flutter analyze` and `flutter test` only. - -Tested with Flutter 3.44.8 / Dart 3.10. - -## Tests - -```sh -flutter test -``` - -Covers: parsing the real sample receipt in `test/fixtures/` (49 items + -deposit, totals 590.55 + 7.00 = 597.55 zł), split invariants incl. a 500-case -fuzz of the remainder distribution, assignment-mode resolution, and xlsx -generation (the test writes `build/test_export.xlsx` for external inspection). - -## Design notes / edge cases - -- Money is `int` grosze end to end; formatting happens only at the UI edge. -- An item resolving to zero people (e.g. "non-drinkers" on an all-drinker - receipt) is **excluded from totals and loudly flagged**, never silently - dropped into someone's bill. -- Deleting a person prunes them from groups, receipt participants and custom - assignments; deleting a group falls back affected items to _Everyone_. -- Assignment modes are stored symbolically (not as materialized ID sets), so - adding a person later automatically includes them in _Everyone_ items. - Touching a person checkbox materializes the current set and switches the - item to _custom_. -- `paidTotalGr` (what the card was charged) is kept separately from the item - sum; a mismatch shows red in Totals instead of being "fixed" silently. - -## Quality gates (enforced in CI) - -- `dart format --set-exit-if-changed .` — formatting is canonical -- `flutter analyze --fatal-infos` — `very_good_analysis` + `strict-casts`, - `strict-inference`, `strict-raw-types`; any info fails the build -- `flutter test --coverage` + `tool/coverage_check.py` — **100% line - coverage of `lib/` is mandatory**; the gate also fails if a lib file is - never executed by any test diff --git a/billsplit/analysis_options.yaml b/billsplit/analysis_options.yaml deleted file mode 100644 index c1d2d0f1..00000000 --- a/billsplit/analysis_options.yaml +++ /dev/null @@ -1,15 +0,0 @@ -include: package:very_good_analysis/analysis_options.yaml - -analyzer: - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** - language: - strict-casts: true - strict-inference: true - strict-raw-types: true diff --git a/billsplit/android/.gitignore b/billsplit/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/billsplit/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/billsplit/android/app/build.gradle.kts b/billsplit/android/app/build.gradle.kts deleted file mode 100644 index c42c15b3..00000000 --- a/billsplit/android/app/build.gradle.kts +++ /dev/null @@ -1,45 +0,0 @@ -plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "dev.kuhy.billsplit" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "dev.kuhy.billsplit" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - -flutter { - source = "../.." -} diff --git a/billsplit/android/app/src/debug/AndroidManifest.xml b/billsplit/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/billsplit/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/billsplit/android/app/src/main/AndroidManifest.xml b/billsplit/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 551a6415..00000000 --- a/billsplit/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/billsplit/android/app/src/main/kotlin/dev/kuhy/billsplit/MainActivity.kt b/billsplit/android/app/src/main/kotlin/dev/kuhy/billsplit/MainActivity.kt deleted file mode 100644 index 877a7f13..00000000 --- a/billsplit/android/app/src/main/kotlin/dev/kuhy/billsplit/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.kuhy.billsplit - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 2e123caa799acd80a1657c19d0a0ab21feb3c79d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2796 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 diff --git a/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index 6b19a02b2d8c783b1370a2dd5049ff3d5409f19e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2664 zcmd5;{X5fp9~XyMdCWsc;jBAn)WJDBrI~{oLRgWjGb?Yi$D?yEoGzV6R;eLkNb-q-tmy+5z_>;3w?@18?C zD1&rCGBPsCPL6i&!2RIsR#X5^=4b5#GBO}DC%aP~_+5(=L>I0^uO|u@cjvn8#X#qC z-~aQa0h3DmPSki3cGd2qtD4&bMg0vqNlZU_n6i4=ZFlp*!`Uy#l!Em2lCmwRHZpX- zs*M`Ftmx8^Cp|&$h@<8TGX7xm`udM~WRsZ-E?e8?^$r}z_9@kS@wI2$7xN4jJs>g) z6@0=j8CjCzPe0fN?gGp1QB?Oinfy`~@twBXp@v&(3G)AA6U;7I|B6E6I($KJmE*L~ zmd?7CtY&kV+BrGjkfi%KXOy5vnNb7Z7K8sQU&?B#Y3v--CC@kdI-Aj#WlBO9hs4Ch z5c}EeiOM1Lw#o0AtSdH1XXh<;ciMqixFB9jIW}KwIP8mql9G~TD*gD06R{ZnlTiAr zK(zOfKila%ei;Z%9_Df%4)*qrhDQ*Q$N@oBRh3pWDYaqdd6^2B?BVNsy^Kzeef@U( z{(W#|cD9D6o0~d9a~I$@F>#Y18?+sq@)V{)GRkghTFvn9+K&gU#!x5}e)Q=4`<3q6 zi!D&T*L;+wqFdRmTerFjPhk8j+^dlVe6zeh(Gcs*BO1>zMsWzVTUGxzXz*V-PrJIh zj(3lDbaVu3X=xEFRNFC?AzJ4sGfR!H+}HFx@vV)AUAU#18~ z-3M0K%5+!FZUu$Yr|EA-MxF^pA|lG)zeLBtpq*PXPdP*~awigZY(e2oQKWA7^XFre z5)-BTzP_U`N3K$!BqI)s0@-;zG_(Q$202@D<2(w5D)l(x9NuDDZJT_^uRXqv#o1qs z42jzNLJ>?}zvF#OA@v8lEKQI-9*0|NR&7;RR~I}(Wjs8Ye8|M7AsyKhEa;f|njNVv~3cB$Vo=Hu`^gsrXjSa_61qFUE{Mp|0h zfqeqHzD@jZc3UG;%~9{YBO)T278+sIh~z_Wcszsf&`?Q6i*$Wyg7CnrW)GLkRUM7= z%}!FYf1q|ew;QdPh)$V-?IjsaE+5sEJFWJ>F(D!0l(+}-J}rxN<&-%pz2hrDzK=gl zv|vTsbu=0~FswZ(spkNT#oGAg=Pba3d6wtl?QKBmn*qVGOUEZq+}srNG7N0|g-c86 z^@e1U8tt7(q*KeAug}ZP)doR+d=lz|hoxs^48FvC^4$$avAVi~bC(Tui}c!czYz_{ z{hFG2EIhO>c5}yf^+rIz6uo#^m+y5d*$Xe89V-6t;lswGM~|@GkWGw1k`|{DVI3*>e&`KNP2{cMYMFmCZKXa;-I|rY0 z^exKFZ0O6K&xVtXH0*tXLS6j){FD)zyTRmRFxcq&k?xl%z;mswt+l7#g&YccXAgv! z>C1x*6j`jE1S(!%E#+3Y7G?u0bUwyO5248d>f7}A@#p1H9jrmQdv*N$;-b}*{#Od4 z)WSS^Op5ZFsUb&2Z}AzXf&FR7N|>m4>q2#mD}x@U`z7x@>)~fbg*m) zd-G_Woz_BFU(%T$q4gD55!O<%q5(f#Ow1SPuVSiu_1mK2Vm}LjpuSWp)qq;qR9`=s znUj;Vxh&|YFDNJwUXP30e*1Qm$z-YmfTVLosX*mC;8e*jKESxGtgNh>y$T(7uJY!OP-^58O5vy!-$mB?1(2`}RP*nn4VPvbgL($IYrGzn z-_hu*QpLW+3_51Yzab}|ianQKNuw1eg z4i3%hrU8r_1aX|Fr{_Zy&2;}3<1auat&EM0$4}R*s;F4+6TZN&OzW7Mnl5z*rI5+i zrPrT4;CAT*vjV-rjSZEaU$1@hzIDkTLc+ z*trN<*=mDB@)^R{_iaOpN~Qw5>n>W@4;rlY3^>uxGa?1dh|9>zR{bxGck;^qK VY4X<_GXo|_87F(BT^%Cu_TOjs3hw{_ diff --git a/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index c070750fa74bce1cdb0aad60ba610cd1c03f2062..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1823 zcmb_d`8OMg7G|gfiESupF|8$9Yb-%ibcE4TTP+pKR8ysG>{7J^(MXji5n9x~Co(!h zZM8mQuf4TXt0 zwHP>Pc&}Kqjf{KeRT}ZzoaW{2=7YXbY@i(+^t0r-Io?=20H_EOvyiz!g8eLmj)isb zpozR4Kt7_l!2d8HAg8xn1p&biR)7%)8ra-`_GDc%d*Jz*`8{e(A&1pG& z?6Fv@t^GQ9$w4BLc(JFM8Gh9N#}<3z+$5E1-dZtL-n5WF)f)$zfvVwpIJu`g`jnN> zrhV22s2orjipn5=lc!Q@W*Z_;nl4-WCjiHk7W)6$e`(*Qf5jwhKRKtS^bBB2YMItk zIgW9H=C-F1p=j$6Pp`8H;mj#_W?#r*z?D9>ZP)Jzv zFad$CEt zn<(fmGNG~MIXd8XL}MQX$0;m*sID!ukU`ShF>M^0E3M8Z$e?kr{Nc+0!(!dUoci_Soi}lc`QIeU+0RZ%^a;QmLQ72L zVt${ybpiiV#^}6Z=x$fT&MBx;<*0W_US3lJ0JeJG8Rw2;(bM-8m+XjPgs{)$-_M1h zt_2ULo|pd3Ms~VnAo>7g=NS?(fsrx8F-!M_T8zFp2xQ&?ujN%>X1X_#11r4Zf&9Cg z&ds6|GmUD@=Z{-A9^um`LTmdL>yDe-tRo0W)aT>ueJ*CZ_-}CIsJeJ4`;f*r$=OEe z-^cpQ|3!n@5Go&FBQ+Utcb!l-eWjq>l8FPB8+Is1{?4}FFvv1F@%>3e=h~O>q{BM_ z$zhyfJrapB4&K};&EDz!)aKp51~PghA<#Vv)2P^gB0u{|OVEavSCE)whpa{v);87q zd-XQaQB~^oOU-&+T-c8As7+Pxkam8{Q3gq>CNeLDGa{_;r8LRJy{RgfCDOT1VV?Qg ze_R}iaa5aE7*QM2_8(G<9$wW`ExDvXiDIvx4Xl}SZ+#m{8&?~{OlyMTy2t?E=4K=Jxr4))m9iKEV4i@R)cCjVJwQ>4ZE5UGY7dNXm#8|`Q ze)@{@(um62QF)#!!X#Ndx#v87o2_6`Fz_e$trzMciI zW}}z;JhLY)dsBCN=0S#gjPygr=xvQR?cQ1}091*fjEC-*EjC5rFH SIE`z3JeI%Mnlq4h34a69cU)2c diff --git a/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index 97cc0040e4818e38ae6c7bddabd99701e6283eb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1707 zcmb_d`!^E`93PHnCW^5Yjxw!L8!8vEn_Nb$A{4@oS22&fnQgh0j65oIfzSU~)ea$I!9=yRWodqBBHC0+OSvrmDV71hrAac=Mf?e4+y}kDm z22;bsuC`xlU7E2?N{JT{1{`dz4|&Wkqu2nAWur zovGp;1hq?5GVs_i2tbaM?85ebH^DyF_8be%d5VA7-Q7L<32(dG*s)Zxq6JdBd&bZM zPTeyyJUskpXjXFx9}pn+0|J34aIV0mysgUF6dS-`Fpf_$h(zL%^z`&c;4OKb4j%yZ{mX<3qlk(~??CsXp z)~cqaCeumVM2%@`mX`YIjCdaIgqV?i;f!4SyYN-6X%lW9XrNGOtCOSW9b zd6^U-YkERpE2XckvvYFljzK(WFz=*%YJU=<1bpVqnSBHTVK1C9M>MLeaxumB6%`dR zJLAqC*3~HXdU*Sj-cFBZbu1QZWHzCK?Ca~}19om(d8zGo@rG~Rk=wbsKEbW=J)4_I zthT#>WIR<#)$6Um{yD~^1UbEfIL5$Zdbzm&bIt-JQ8LvN#`WKVFMD_$C`8r`M>!_^ zb8hb4yOJ~pwh^~5KfkGHX5>Hlw#vyAo0pTFtufJLpmmGSsHlhoV8Q1K2|_dQ0rkzr zcid=HlHY((D06ghNIp~T1^uc9J$^u4tOo`k4h{?)m(~})2vg9}(GmTRdby2(rm~)% zp4njE_;{cvnY{k;|G?PjVc_QX#hkf?g$2Bv5if7=q3L$t9(!h?8dOv5^=twZ_t7w{ zM%%MULPv&W!h|VXKfdWC)`ZGgk5f@qy|toc3UyNNAbc6fLyFL86+`u!d+E=&x3}4M z3JRzcZC4nULZKM9hg*h-I%}2Xl8RZzG`$-nexI{cN0wCCNt#nkwlA33s+Kg3WDjZ_lUGB`!D| z?q){}g|czV#U%qY^5?unh4PH^ULX+Mbh$5zi;3AHC|Y;X z`}_O#3_91alKnU(g?BHRi0ivL`bsf1hANj;m2i>wB2mB9jZ_BzkjZ3l`=hdQODn6d z4y9QLCzPs~*#(MLQn&=l`Q(m9XDVhNB@0b}vK|pXAbLG`_6hoaNpq-`ySuypc4ft2=+|C0Rp+Pt ez^MFE5g1GE5}5~?4>ROX2;lYy#<{^M - - - - - - - diff --git a/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index c400742eebe1ad15aa1667f4b343af7ae65e5767..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3775 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 diff --git a/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index f45dec47314cac76da6b5f57bc3c84f38bd9e3ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4872 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 T#+(HIKyeuvnCe$ub$;|;#+#5p diff --git a/billsplit/android/app/src/main/res/drawable/launch_background.xml b/billsplit/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/billsplit/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/billsplit/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/billsplit/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index d5063661..00000000 --- a/billsplit/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - diff --git a/billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 061a3ef0d5d8e06b7b12e96275d8b4f0cdcfadab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 959 zcmV;w13>(VP)j2r%sPw6%>06cs1OmX2(+TNh@gkwdJ&}Pwf7)|D61EvCJ3>A$y^g5lHNP0TSre3*3(x76bLB&Xeijf8tBMmA>8dQulsMzKlFLPRNWCNYUvZ7gPAr4|E%3anQ z*+BPbNYRj$7U)p4#>|r1IuO(mQ(NlvW(gP~;tJ>jsM4-HPyO8eYFa*Uk$1YT*lzA0|(EdD;WfMLQ z8Ej%UjxEj?@x(I0m+sZ*y2RUXyeOOJ#QQ&;IeTIj$K5r-JOtB1W(L^aA! z`T`XNqRXD}{DDIZhM*qH_OPa{mlFyOF~P>xSGphf1z;$iEO(|jlPKLEDRh*1jjZT) z;ILwFW<_jDCYZ!vX&#?fVJH@4@f?>#3v^3Q;O*xy6b|kbFV&=Ch=@ZhqM*L06)-#* zfekT+?II2_-o~UjS-))TJnRDF6q98r!lb%bo%-la01jq#hupB)#}E|L9NUv4TtAnN zCzc7l^RR{-)?a@LLApP@&yku;JCCLgKdfU=7@CY+J>$V}*2EzeQjlo{%q?rHlAf8G z9Csc(leg72y<}YNZu4--bLRmA?{aOLZ$ySOOI490skT&UE)GsEcw(8*biX`(Ph zr~g=y19n(U^hcxTgYp$8SCB2}I)VmO%hv~S5Sv-j+6Lw7Vh3~xmf+Tbpf{V>b>_v8 z3{~)HtoJE{%^CR<)aLzrQWS`r11dhNTStX;vR}Np1(LMc)ssZ@1e0 zQ$ONh#l%Yt9Hti6J7gNJv1aveRSF^aTdPhk#>a}GS&=-b7->*3(x76bLB&Xeijf8t hBMmA>8dQul{s4x)UZZ?aK`Q_N002ovPDHLkV1nw5#9#mb diff --git a/billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 88fa56e133e3de0b20df2d494897cb4a0d166173..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 519 zcmV+i0{H!jP)v;P5=@8$6C%Nc zNH8H1|5>QRYaH#V{Kg*h!PMSqhQ81z+{-HFxSC2q`zQ)RSBd=OauQ>1+;8>NN%{*V zWTc{(5Pv|PI<3sSTzjKY`!otd1_R&QV%9#V00p7EikEj4gz=EiVw8CO zg(4T(?UOVFSFPC9WYS;AWfv_b9?p>4L17w&5jLwZF;Wdd619FWj#e?sPly%c$FO1~ z!HSUtD@GE!6(clF0Tp4vz`qZI@b(ObiY&VOwve(=^fDVysy3@(Wkzhh3m=FmjMB>j0Jp=3a%N(-Tv2*|c002ov JPDHLkV1lCb>e~PS diff --git a/billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index a42a652fd7a244ba63c164edebeee67b097dbb5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 984 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1SE5RJ;-NZV4m;k;uuoF_;%KP@8Cp<`4iVqDXs zdRf}QO2CD|k%dg6igQ=M+dIKomoq6m^hRSCM;O6rcQ!;wU5Py zv(x6MpZiiKB;k6&N1H50ZXzxna9;Fj+r#>pPSXlY&W81m8 z1!-m8g*{3cS!Szht>5;q`*QMsPaw2FCZFpla`TJdT^a*^7vlgmH6 z?n?6hDpdn1da05hjk zfDe0<5+e$Fw8|mij2({(hohk^M;8k!TFvBW_}gHD0?W}71q~row2q7A=o@E!$B9#d zJ-Akv&9l@C@*s%M`sruXxr*Z;nFUj+qdb2cqmF+7Jy|3ykK3zW+HQmze z^v}D7zAh2bC375Qor_#Imi`Z$o$m0r@g(cfAsOx3fC!;d*h-LR#pp-X#ZjR zUh(aR)%Nm_fsd}I&Mul&-Bi^(U*Z?nr}i6j`2wRKp1qUDuk-uHW%JYOCp!EkKWg6M zx_@hwu0MIp=de=leb1@8_Ivmb>d2IgkoSLPA2$$q|9x zsd=A6diPEaJ`%MoApx*jDh@5ALT7LH8>v9`24;G&Wk*F|x^%$kMpE&oM9MM*40CpTohNV4(dz7VK(tP)D=% ziK-r+=6ochzWNs{%4KUvqfOsLfXSMZ&k1?x-@n&-ej!f)~65Hy+&@ok)1Kym8vH zJ`16T?Je4vr1GA4oe(^EI{=kx&-l~oM9t$`##k~+0}13XNwZ;NejWHrR*g?ER&Pj} zsMCCD=b*+yio=|siayulPPS@vEFfux>SAk~r=#T@^!gJ<&Uv1VIXMh#{_)VGwyCai z{SgJoN-VoV6eRSp=Tz2unT+Ta_*WolY0PZfQoxCPUeh$yeQ1s0VXT&GR!l+DF7ka_ z;U6aMS)9syuHUqw#$}KnAdM-tWrjpE_h;MhL$o*qqz zEUHyK#+R1KggxxjU4+WUw8YOdO;J$;2v`gWSktG=riR)?4b}Dh;Nsy_rlHh1l7`0h zL^;!^OISV($kjN%&%^8?{Z*sg8Dpt+Y5=w2UmQVqFvg~wT``cmHIi1NtLqZ><%o91 z*ifcJN)tQasV5Xj4oUsvT$my2ksQFcWi_C|L^+h%o!Z2Hjd5321_LrGXgidoZ%h7H z>YGmXtImS;C`an6?ur5!07y@s1nsy$X}y%RSD&te^!9kmi)|-W1}+vzf;^Mq@wiVp zZKh4+>)coOpM_#jxUr++Mtlt;aH5iHeskOcj!0QPx z&E+bNHOYylM5CIA_lE<)>0;r#1;O5NM?#00;&FXY&IIl}o2=D-kyUHAy3x=@46xx1 zVqBuOPcn92uh!c_V0jx4U;JiQ+Ln}fTFidVu6CneyN`7Iw>KM^ZyFe40}^0d)fZe{ zdwRugewr>5tx)+lToomu%c!u?Qk$HnWvxAHB2)_%%>E}vxG|QjJBgLPx%?;k6lRY$ z#6CmhZOb)-tyiBHvjz$xCFcaqy+^}5w*m$JuJlh1YRU&u{=yuk$?e@Q^U35x2L3-? z0eMLeM*4S=GALkjT=QFb%$&Tj$PcKYz@gn=orH@I(z9|US=h*t&Bom0`z6Fs% z!qbKTalQ;AL-28a&^2FQL5h?TUVr-{n(o5wZ|@UX%Wh)ySUN;*q}l3rAo1`!wxa?u z?6h)Hq-R^-@>oG-z?tRZUHz5Q_s!|i?Ss4py){*ty36^72AkNxuHXAmnNu|fHpLC) zl)6k5UF9Y?iH8efM9MVSd(RnhItn*W2?ckNU_3;8v)nhAw^ng|{Jc-HH)GjaIDG}MGuAsaQs+*Jon$;Jx&retBa;zrQq4Q-C2Kp^HEz`{jR_YZ{ zG*Gb{IP{SSoz0q(5?NcnSMbxheOo>~*#;Z%#qB%({VuTkm*xw7Cg#pt{GE>?;biZM Jcx)Sx@;@j41wa4* diff --git a/billsplit/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/billsplit/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 35c1f1a8e2176a099a93ee279679a3d94d12ad36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2032 zcmd5-`8OMg7M9S;7-}uX-WVK9N?J=3gEV3qgwaq-wNz0mZ4@PzSjrRCDUDOLwx!e( z>#3!&MMg^*ty092wDKs@hR{?aEfUYn>6tgL?+P9GWVZt9z6wz2n~}o|EXwNz+3j6b|WKS5QEKKwv2; zMPNDzaQq+tt1{mgA@5EJu&8XJ6Zj6ynUWo1as!<(pFRjgR((6>tc6<)`*)11{}v`} z$}-u;T57FWy*CNjQCC+-r+P<$hFlmM@4IFSBq?*Fce}`*P&ZA@#Hi)+E7wVuQXzup zYa#5!L>?^9@6k)&V=-d_Hr7C2&n?VSDBA3p@FD0pj~115CWO38#8aLqlB<`(;iaZ= z84kBrva~D&=_1~R(V|R#nkLooKLcbOIKoW^#wrr=ah3X|&#&PzO;@DU#amuXk z09dbH(GmD7$Ev{>rv)$Ni55Ga{d42iB*k3t_L+$?U{>8*H$K$e@x+gW9inw)NhN9j zpzQY_>`4${^yG|!$F4wl*hKrAV-D?C&R){jGc{B?d+005DQDvq$)s!)6@-3@s_>V3 z7zBO=0ghYby;CJ*{jDA{fT}Q+3dzXs|ENgTH&l9l{2S@C^r6mw0S(>AM)`cj|7r-h zScJa%YnmnI$F6}l5B^OA@Pp>PlL5@013w~4End&=kN#GXmn`yp?SU=l!LJT}#Xl$f zTSF&=hN03Mu-+dFMG?aPB631_@6jO<)$Jn_>tCs3Vly?g>r%xrq9kkfq_0p z=wmfm@L7#Mq1NhVlmB^zFtE|t1AEvsgM&_3-ii$|k~Qb6(wKL)Qu$Rx9b)TLFo0)Q z{6!QVvDF_@qggw+{gm-@a^r55-+&=Kmk`1h8-C>8-HLM3{cLXC59aUSVpb$m1~2R$ zEAVzW39)*W@Is&6YC6G5O2*UJ36#g1FGeBR;B5**=Xu#LhA}f|m^WN6T5Mb|_kyqQdwAfZt|-E+>M zJA%)2T%2*(4(x=*LF?=?^rJRPFZ1^uhs#MazKipq+EZ2=2{k@ za{%o}H-{}%k2QBI!#G(88uRpHX3;K!w?(s-&ggY@)7HfP5PElz%lPG_5#_|XmIsm2 zDD636M6!8onDg*0&O~4kE9*HO;Ll5u9&zl^CRe<)cj6i!Bfy0Cn6^c%tIwSP?%3=l zR_E>oik;Ds#+oArg>_f%%hxWx^%9Sm#86~7U(&|t{|`3xmlLH*LcDL5e|*v$zvtu< N&Q9*ACgi#Fe*ws?e{TQ) diff --git a/billsplit/android/app/src/main/res/values-night/styles.xml b/billsplit/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/billsplit/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/billsplit/android/app/src/main/res/values/colors.xml b/billsplit/android/app/src/main/res/values/colors.xml deleted file mode 100644 index 1e7986ad..00000000 --- a/billsplit/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #211D1B - diff --git a/billsplit/android/app/src/main/res/values/styles.xml b/billsplit/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/billsplit/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/billsplit/android/app/src/profile/AndroidManifest.xml b/billsplit/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/billsplit/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/billsplit/android/build.gradle.kts b/billsplit/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/billsplit/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/billsplit/android/gradle.properties b/billsplit/android/gradle.properties deleted file mode 100644 index 04400cf4..00000000 --- a/billsplit/android/gradle.properties +++ /dev/null @@ -1,24 +0,0 @@ -# Memory budget. The Flutter template ships `-Xmx8G -XX:MaxMetaspaceSize=4G` -# here, which asks for more than this 7.6 GB machine has and got a whole -# session OOM-killed. What actually brings the footprint down is cutting the -# NUMBER of JVMs, not just the heap: without these flags Gradle runs a daemon -# plus a separate Kotlin compile daemon plus workers, each with its own heap. -# -# Budget for anything run on this machine is <= 2 GB. Verify with PSS summed -# from /proc//smaps_rollup across the build's own processes — system-wide -# "used" includes everything else, and RSS double-counts shared pages. -org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError -# Compile Kotlin inside the Gradle process instead of spawning a second daemon. -kotlin.compiler.execution.strategy=in-process -# No persistent daemon, no parallel project execution, one worker. -org.gradle.daemon=false -org.gradle.parallel=false -org.gradle.workers.max=1 - -android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template. Kept false to -# match every sibling app (todo, habit_stack, home_inventory, diet_guard_app), -# which all build fine on this same AGP 9.0.1 / Gradle 9.1.0 pair. -android.builtInKotlin=false diff --git a/billsplit/android/gradle/wrapper/gradle-wrapper.properties b/billsplit/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bfb..00000000 --- a/billsplit/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/billsplit/android/settings.gradle.kts b/billsplit/android/settings.gradle.kts deleted file mode 100644 index c21f0c5b..00000000 --- a/billsplit/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false -} - -include(":app") diff --git a/billsplit/assets/icon/icon.png b/billsplit/assets/icon/icon.png deleted file mode 100644 index 7245aaa71eb0d6e16148e97cff1ac811e7b12dba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13540 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 - - - - - diff --git a/billsplit/assets/icon/icon_foreground.png b/billsplit/assets/icon/icon_foreground.png deleted file mode 100644 index afcebf385b9446b426c91c3de277a57f151f8417..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14136 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 - - - - diff --git a/billsplit/assets/icon/icon_monochrome.png b/billsplit/assets/icon/icon_monochrome.png deleted file mode 100644 index 19ce46846713418f185728b2b86cfbbefd1ae112..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12557 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 diff --git a/billsplit/assets/icon/icon_monochrome.svg b/billsplit/assets/icon/icon_monochrome.svg deleted file mode 100644 index 1962d67a..00000000 --- a/billsplit/assets/icon/icon_monochrome.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/billsplit/lib/domain/assignment.dart b/billsplit/lib/domain/assignment.dart deleted file mode 100644 index 9298bdbe..00000000 --- a/billsplit/lib/domain/assignment.dart +++ /dev/null @@ -1,200 +0,0 @@ -/// 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 deleted file mode 100644 index bdeba1b5..00000000 --- a/billsplit/lib/domain/eparagon_fields.dart +++ /dev/null @@ -1,124 +0,0 @@ -/// 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 deleted file mode 100644 index bb960b6c..00000000 --- a/billsplit/lib/domain/eparagon_parser.dart +++ /dev/null @@ -1,177 +0,0 @@ -/// Parser for Polish fiscal e-receipts (e-paragon / JPK_KASA_PARAGON_v2-0), -/// e.g. the JSON files produced by the Biedronka app. -/// -/// Accepted inputs: -/// * the full wrapper JSON containing a `data` field with a JWS -/// (header.payload.signature, base64url) whose payload is the JPK document; -/// * the decoded JPK payload itself (an object with `dokument.paragon`); -/// * a bare JWS token string. -/// -/// The signature is NOT verified — this app only reads the contents. -library; - -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]. - EParagonResult({required this.receipt, required this.warnings}); - - /// The parsed receipt. - final Receipt receipt; - - /// Human-readable warnings (e.g. sum mismatches, skipped lines). - final List warnings; -} - -/// Thrown when the input cannot be interpreted as an e-paragon. -class EParagonParseException implements Exception { - /// Creates the exception with a user-facing [message]. - EParagonParseException(this.message); - - /// User-facing description of what went wrong. - final String message; - - @override - String toString() => 'EParagonParseException: $message'; -} - -const _alcoholKeywords = [ - 'wódk', - 'wodka', - 'vodka', - 'piwo', - 'wino', - 'whisk', - 'likier', - 'nalewk', - 'cydr', - ' rum', - 'gin ', - 'tequil', - 'brandy', - 'koniak', - 'bourbon', -]; - -const _mixerKeywords = [ - 'tonic', - 'tonik', - 'kinley', - 'citrus mix', - 'limonka', - 'cytryna', -]; - -/// Parses [source] and returns just the receipt; see [parseEParagonDetailed]. -Receipt parseEParagon(String source) => parseEParagonDetailed(source).receipt; - -/// Parses [source] (wrapper JSON, bare JPK payload, or bare JWS) into a -/// [Receipt] plus warnings. Throws [EParagonParseException] on unusable input. -EParagonResult parseEParagonDetailed(String source) { - final warnings = []; - final payload = _extractPayload(source); - - final dokument = payload['dokument']; - if (dokument is! Map) { - throw EParagonParseException('No "dokument" object in payload.'); - } - final paragon = dokument['paragon']; - if (paragon is! Map) { - throw EParagonParseException('No "dokument.paragon" object in payload.'); - } - - final store = _storeName(dokument); - final date = _saleDate(paragon, dokument); - - final items = []; - final pozycje = paragon['pozycja']; - if (pozycje is List) { - for (final p in pozycje) { - final towar = (p is Map) ? p['towar'] : null; - if (towar is! Map) continue; - final it = _itemFromTowar(towar.cast(), warnings); - if (it != null) items.add(it); - } - } - if (items.isEmpty) { - throw EParagonParseException('Receipt has no line items.'); - } - - // Bottle deposits ("opakowania zwrotne" / kaucja) are outside sumaBrutto but - // part of what was paid; add each as a regular splittable line. - final opak = paragon['opak']; - if (opak is Map) { - final dane = opak['daneOpak']; - if (dane is List) { - for (final d in dane) { - if (d is! Map) continue; - final cena = _asInt(d['cena']); - final iloscMil = _asInt(d['ilosc']); // thousandths - final qty = iloscMil / 1000; - final total = (cena * iloscMil / 1000).round(); - if (total == 0) continue; - items.add( - Item( - id: Ids.next(), - name: 'Kaucja: ' - '${(d['nazwa'] as String? ?? 'opakowanie').trim()} (deposit)', - totalGr: total, - qty: qty, - unitPriceGr: cena, - category: Categories.deposit, - ), - ); - } - } - } - - // Reconciliation figures. - var paid = _asIntOrNull((paragon['total'] as Map?)?['zaplZwrot']); - final sumaBrutto = _asIntOrNull((paragon['podsum'] as Map?)?['sumaBrutto']); - final opakWart = _asIntOrNull((paragon['opak'] as Map?)?['wart']) ?? 0; - paid ??= sumaBrutto == null ? null : sumaBrutto + opakWart; - - if (sumaBrutto != null) { - final goods = items - .where((i) => i.category != Categories.deposit) - .fold(0, (s, i) => s + i.totalGr); - if (goods != sumaBrutto) { - warnings.add( - 'Sum of parsed items (${formatPln(goods)}) differs from receipt ' - 'sumaBrutto (${formatPln(sumaBrutto)}).', - ); - } - } - - final title = [ - if (store != null) store, - _iso(date), - ].join(' '); - - return EParagonResult( - receipt: Receipt( - id: Ids.next(), - title: title, - store: store, - date: date, - items: items, - paidTotalGr: paid, - ), - warnings: warnings, - ); -} - -String? _storeName(Map dokument) { - final podmiot = dokument['podmiot1']; - if (podmiot is! Map) return null; - final name = (podmiot['nazwaPod'] as String?)?.trim(); - if (name == null || name.isEmpty) return null; - // Friendlier alias for the most common chain. - if (name.toUpperCase().contains('JERONIMO MARTINS')) return 'Biedronka'; - return name; -} diff --git a/billsplit/lib/domain/models.dart b/billsplit/lib/domain/models.dart deleted file mode 100644 index 1f4dae77..00000000 --- a/billsplit/lib/domain/models.dart +++ /dev/null @@ -1,86 +0,0 @@ -/// Domain models. All money values are ints in grosze (1/100 PLN) to avoid -/// 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; - final a = grosze.abs(); - final zl = a ~/ 100; - final gr = (a % 100).toString().padLeft(2, '0'); - return '${neg ? '-' : ''}$zl,$gr zł'; -} - -/// A person on the shared roster. -class Person { - /// Creates a person; [drinks] controls alcohol-item defaults. - Person({required this.id, required this.name, this.drinks = true}); - - /// Restores a person from its [toJson] map. - factory Person.fromJson(Map j) => Person( - id: j['id'] as String, - name: j['name'] as String, - drinks: j['drinks'] as bool? ?? true, - ); - - /// Stable unique id. - final String id; - - /// Display name. - String name; - - /// Whether this person shares alcohol items by default. - bool drinks; - - /// JSON representation for persistence. - Map toJson() => {'id': id, 'name': name, 'drinks': drinks}; -} - -/// A user-defined tag over roster members, e.g. "meat eaters". -class Group { - /// Creates a group with optional initial [memberIds]. - Group({required this.id, required this.name, Set? memberIds}) - : memberIds = memberIds ?? {}; - - /// Restores a group from its [toJson] map. - factory Group.fromJson(Map j) => Group( - id: j['id'] as String, - name: j['name'] as String, - memberIds: (j['memberIds'] as List? ?? const []) - .cast() - .toSet(), - ); - - /// Stable unique id. - final String id; - - /// Display name. - String name; - - /// Ids of [Person]s belonging to this group. - final Set memberIds; - - /// JSON representation for persistence. - Map toJson() => - {'id': id, 'name': name, 'memberIds': memberIds.toList()}; -} - -/// How an item is assigned to people. -enum AssignMode { - /// Everyone on the receipt shares the item. - everyone, - - /// Only participants with [Person.drinks] set share the item. - drinkers, - - /// Only participants without [Person.drinks] share the item. - nonDrinkers, - - /// Members of one [Group] share the item. - group, - - /// An explicit hand-picked set of people shares the item. - custom, -} diff --git a/billsplit/lib/domain/split_engine.dart b/billsplit/lib/domain/split_engine.dart deleted file mode 100644 index cdddf36c..00000000 --- a/billsplit/lib/domain/split_engine.dart +++ /dev/null @@ -1,155 +0,0 @@ -/// Split computation. Grosz-exact: every item's shares sum to exactly the -/// item total (largest-remainder distribution, deterministic order), so the -/// per-person totals always reconcile with the receipt to the grosz. -library; - -import 'package:billsplit/domain/models.dart'; - -/// Accumulated amounts one person owes on a receipt. -class PersonTotal { - /// Creates an empty total for [personId]. - PersonTotal(this.personId); - - /// The person this total belongs to. - final String personId; - - /// Everything owed, in grosze. - int totalGr = 0; - - /// The alcohol portion of [totalGr]. - int alcoholGr = 0; - - /// The non-alcohol portion of [totalGr]. - int get restGr => totalGr - alcoholGr; -} - -/// Result of [computeSplit]. -class SplitResult { - /// Bundles the computed maps. - SplitResult({ - required this.perPerson, - required this.itemShares, - required this.unassigned, - }); - - /// personId → totals. Contains every receipt participant, even if 0. - final Map perPerson; - - /// itemId → (personId → share in grosze). Only resolved participants. - final Map> itemShares; - - /// Items that resolved to zero participants (excluded from totals). - final List unassigned; - - /// Sum of all per-person totals, in grosze. - int get assignedTotalGr => perPerson.values.fold(0, (s, p) => s + p.totalGr); -} - -/// People from [Receipt.participantIds] that still exist in [roster]. -List receiptParticipants(Receipt receipt, List roster) => - roster.where((p) => receipt.participantIds.contains(p.id)).toList(); - -/// Resolves an item's assignment to concrete people, always intersected with -/// the receipt's participant pool. -List resolveParticipants( - Item item, - Receipt receipt, - List roster, - List groups, -) { - final pool = receiptParticipants(receipt, roster); - final a = item.assignment; - switch (a.mode) { - case AssignMode.everyone: - return pool; - case AssignMode.drinkers: - return pool.where((p) => p.drinks).toList(); - case AssignMode.nonDrinkers: - return pool.where((p) => !p.drinks).toList(); - case AssignMode.group: - final g = groups.where((g) => g.id == a.groupId).firstOrNull; - if (g == null) return const []; - return pool.where((p) => g.memberIds.contains(p.id)).toList(); - case AssignMode.custom: - return pool.where((p) => a.customIds.contains(p.id)).toList(); - } -} - -/// Splits [totalGr] between [personIds]. Base share is floor(total/n); the -/// remainder is handed out one grosz at a time starting at a position derived -/// from [rotation] (e.g. a stable hash of the item id). Rotating the start -/// per item avoids systematically overcharging the same person across a whole -/// receipt, while staying fully deterministic. Shares always sum to exactly -/// [totalGr] (also for negative totals, e.g. returns). -Map splitAmount( - int totalGr, - List personIds, { - int rotation = 0, -}) { - if (personIds.isEmpty) return const {}; - final ids = [...personIds]..sort(); - final n = ids.length; - final base = (totalGr / n).floor(); - final remainder = totalGr - base * n; // 0 <= remainder < n, also if negative - final start = ((rotation % n) + n) % n; - final out = {for (final id in ids) id: base}; - for (var k = 0; k < remainder; k++) { - out[ids[(start + k) % n]] = base + 1; - } - return out; -} - -/// Stable string hash (unlike `String.hashCode`, which is not guaranteed -/// identical across Dart VM runs) so splits don't shift between app launches. -int stableHash(String s) { - var h = 0; - for (final c in s.codeUnits) { - h = (h * 31 + c) & 0x7fffffff; - } - return h; -} - -/// Computes the full split of [receipt] between [roster] members using -/// [groups] for group-mode assignments. -SplitResult computeSplit( - Receipt receipt, - List roster, - List groups, -) { - final perPerson = { - for (final p in receiptParticipants(receipt, roster)) - p.id: PersonTotal(p.id), - }; - final itemShares = >{}; - final unassigned = []; - - for (final item in receipt.items) { - final people = resolveParticipants(item, receipt, roster, groups); - if (people.isEmpty) { - unassigned.add(item); - continue; - } - final shares = splitAmount( - item.totalGr, - people.map((p) => p.id).toList(), - rotation: stableHash(item.id), - ); - itemShares[item.id] = shares; - final isAlcohol = item.category == Categories.alcohol; - shares.forEach((pid, amount) { - perPerson[pid]! - ..totalGr += amount - ..alcoholGr += isAlcohol ? amount : 0; - }); - } - - return SplitResult( - perPerson: perPerson, - itemShares: itemShares, - unassigned: unassigned, - ); -} - -extension on Iterable { - T? get firstOrNull => isEmpty ? null : first; -} diff --git a/billsplit/lib/domain/xlsx_export.dart b/billsplit/lib/domain/xlsx_export.dart deleted file mode 100644 index b2231a30..00000000 --- a/billsplit/lib/domain/xlsx_export.dart +++ /dev/null @@ -1,215 +0,0 @@ -/// Exports a receipt + computed split to an .xlsx workbook whose layout -/// mirrors the hand-made "podzial_rachunku" sheet: one row per item, a 1/0 -/// person matrix, per-item cost-per-person, and a per-person summary. -/// -/// Values are written as computed numbers (grosz-exact, matching the in-app -/// engine), plus live SUM formulas for the column totals so the sheet stays -/// consistent when opened in Excel/LibreOffice. -library; - -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/split_engine.dart'; -import 'package:excel/excel.dart'; - -/// Renders [receipt] with its current split into xlsx bytes. -List exportXlsx({ - required Receipt receipt, - required List roster, - required List groups, -}) { - final excel = Excel.createExcel()..rename('Sheet1', 'Split'); - final sheet = excel['Split']; - - final people = receiptParticipants(receipt, roster) - ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - final split = computeSplit(receipt, roster, groups); - - final bold = CellStyle(bold: true); - final boldGrey = CellStyle( - bold: true, - backgroundColorHex: ExcelColor.fromHexString('#D9D9D9'), - ); - final alcoholStyle = - CellStyle(backgroundColorHex: ExcelColor.fromHexString('#FCE4D6')); - final warnStyle = CellStyle( - bold: true, - backgroundColorHex: ExcelColor.fromHexString('#FFC7CE'), - ); - - void put(int col, int row, CellValue value, [CellStyle? style]) { - final index = CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row); - final cell = sheet.cell(index)..value = value; - if (style != null) cell.cellStyle = style; - } - - double zl(int gr) => gr / 100.0; - - var r = 0; - put(0, r, TextCellValue(receipt.title), bold); - r++; - put( - 0, - r, - TextCellValue( - [ - if (receipt.store != null) receipt.store!, - receipt.date.toIso8601String().substring(0, 10), - if (receipt.paidTotalGr != null) - 'paid ${formatPln(receipt.paidTotalGr!)}', - ].join(' · '), - ), - ); - r += 2; - - // Header row. - const fixed = ['Lp', 'Item', 'Category', 'Qty', 'Unit', 'Discount', 'Total']; - for (var c = 0; c < fixed.length; c++) { - put(c, r, TextCellValue(fixed[c]), boldGrey); - } - const p0 = 7; - for (var i = 0; i < people.length; i++) { - put(p0 + i, r, TextCellValue(people[i].name), boldGrey); - } - put(p0 + people.length, r, TextCellValue('People on item'), boldGrey); - put(p0 + people.length + 1, r, TextCellValue('Cost / person'), boldGrey); - r++; - - final firstDataRow = r; - var lp = 1; - for (final item in receipt.items) { - final shares = split.itemShares[item.id] ?? const {}; - final isAlcohol = item.category == Categories.alcohol; - final rowStyle = shares.isEmpty - ? warnStyle - : isAlcohol - ? alcoholStyle - : null; - put(0, r, IntCellValue(lp++), rowStyle); - put(1, r, TextCellValue(item.name), rowStyle); - put(2, r, TextCellValue(item.category), rowStyle); - put(3, r, DoubleCellValue(item.qty), rowStyle); - put(4, r, DoubleCellValue(zl(item.unitPriceGr)), rowStyle); - put(5, r, DoubleCellValue(zl(item.discountGr)), rowStyle); - put(6, r, DoubleCellValue(zl(item.totalGr)), rowStyle); - for (var i = 0; i < people.length; i++) { - put( - p0 + i, - r, - IntCellValue(shares.containsKey(people[i].id) ? 1 : 0), - rowStyle, - ); - } - put(p0 + people.length, r, IntCellValue(shares.length), rowStyle); - if (shares.isNotEmpty) { - // Representative value; the exact grosz split may differ by ±0.01 - // between people — the summary uses the exact per-person numbers. - put( - p0 + people.length + 1, - r, - DoubleCellValue(zl((item.totalGr / shares.length).round())), - rowStyle, - ); - } else { - put(p0 + people.length + 1, r, TextCellValue('UNASSIGNED'), warnStyle); - } - r++; - } - final lastDataRow = r - 1; - - // Totals row with a live formula over the Total column. - put(1, r, TextCellValue('TOTAL'), bold); - final totalCol = colName(6); - put( - 6, - r, - FormulaCellValue( - 'SUM($totalCol${firstDataRow + 1}:$totalCol${lastDataRow + 1})', - ), - bold, - ); - r += 2; - - // Per-person summary (exact grosz numbers from the engine). - put(1, r, TextCellValue('SUMMARY'), bold); - r++; - put(1, r, TextCellValue('Person'), boldGrey); - put(2, r, TextCellValue('Owes'), boldGrey); - put(3, r, TextCellValue('of which alcohol'), boldGrey); - put(4, r, TextCellValue('rest'), boldGrey); - r++; - final firstSummaryRow = r; - for (final p in people) { - final t = split.perPerson[p.id]; - put(1, r, TextCellValue(p.name + (p.drinks ? '' : ' (non-drinker)'))); - put(2, r, DoubleCellValue(zl(t?.totalGr ?? 0))); - put(3, r, DoubleCellValue(zl(t?.alcoholGr ?? 0))); - put(4, r, DoubleCellValue(zl(t?.restGr ?? 0))); - r++; - } - put(1, r, TextCellValue('SUM'), bold); - for (var c = 2; c <= 4; c++) { - final col = colName(c); - put(c, r, FormulaCellValue('SUM($col$firstSummaryRow:$col$r)'), bold); - } - r += 2; - - // Reconciliation note. - final assigned = split.assignedTotalGr; - final itemsTotal = receipt.itemsTotalGr; - put( - 1, - r, - TextCellValue( - _checkLine(receipt, assigned, itemsTotal, split), - ), - split.unassigned.isEmpty && assigned == itemsTotal ? bold : warnStyle, - ); - r += 2; - put( - 0, - r, - TextCellValue( - '1 = person shares the item. Matrix and summary are exported values ' - 'from the app (grosz-exact); edit splits in the app, not here.', - ), - ); - - // Column widths. - sheet - ..setColumnWidth(0, 5) - ..setColumnWidth(1, 34) - ..setColumnWidth(2, 11); - for (var c = 3; c < 7; c++) { - sheet.setColumnWidth(c, 10); - } - for (var i = 0; i < people.length + 2; i++) { - sheet.setColumnWidth(p0 + i, 12); - } - - return excel.encode()!; -} - -String _checkLine( - Receipt receipt, - int assigned, - int itemsTotal, - SplitResult split, -) { - final paid = receipt.paidTotalGr; - final paidPart = paid == null ? '' : ' / paid ${formatPln(paid)}'; - final un = split.unassigned.length; - final unPart = un == 0 ? '' : ' — $un UNASSIGNED item(s) excluded'; - return 'Check: assigned ${formatPln(assigned)} ' - '/ items ${formatPln(itemsTotal)}$paidPart$unPart'; -} - -/// Converts a 0-based column index into its A1-style name (0 → A, 26 → AA). -String colName(int index) { - var i = index; - var s = ''; - while (i >= 0) { - s = String.fromCharCode(65 + (i % 26)) + s; - i = i ~/ 26 - 1; - } - return s; -} diff --git a/billsplit/lib/main.dart b/billsplit/lib/main.dart deleted file mode 100644 index e8ad84e8..00000000 --- a/billsplit/lib/main.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; - -import 'package:billsplit/state/app_state.dart'; -import 'package:billsplit/ui/home_screen.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - final state = AppState(); - unawaited(state.load()); - runApp(BillSplitApp(state: state)); -} - -/// Root widget wiring [AppState] into the tree. -class BillSplitApp extends StatelessWidget { - /// Creates the app around an existing [state]. - const BillSplitApp({required this.state, super.key}); - - /// The single app-wide state instance. - final AppState state; - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: state, - child: MaterialApp( - title: 'BillSplit', - debugShowCheckedModeBanner: false, - // The shared light theme, replacing a teal-seeded scheme. Light, not - // dark: the receipt screens carry black-on-white helper text, so - // switching brightness here would make it unreadable. - theme: buildLightTheme(), - home: const HomeScreen(), - ), - ); - } -} diff --git a/billsplit/lib/state/app_state.dart b/billsplit/lib/state/app_state.dart deleted file mode 100644 index 9c3ba735..00000000 --- a/billsplit/lib/state/app_state.dart +++ /dev/null @@ -1,166 +0,0 @@ -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 people = []; - - /// User-defined groups over [people]. - final List groups = []; - - /// Saved receipts, newest first. - final List receipts = []; - - /// Whether [load] has completed. - bool loaded = false; - - Timer? _saveTimer; - - Future _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 load() async { - try { - final f = await _file(); - if (f.existsSync()) { - final j = jsonDecode(f.readAsStringSync()) as Map; - people - ..clear() - ..addAll( - (j['people'] as List? ?? const []) - .map((e) => Person.fromJson((e as Map).cast())), - ); - groups - ..clear() - ..addAll( - (j['groups'] as List? ?? const []) - .map((e) => Group.fromJson((e as Map).cast())), - ); - receipts - ..clear() - ..addAll( - (j['receipts'] as List? ?? const []) - .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 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(); - } -} diff --git a/billsplit/lib/ui/assignment_sheet.dart b/billsplit/lib/ui/assignment_sheet.dart deleted file mode 100644 index 6dd7c408..00000000 --- a/billsplit/lib/ui/assignment_sheet.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/split_engine.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// Bottom sheet to assign an item: quick modes (everyone / drinkers / -/// non-drinkers / group) or explicit person selection. Toggling a person -/// checkbox on a non-custom mode first materializes the current selection, -/// then switches to custom. -Future showAssignmentSheet( - BuildContext context, - AppState state, - Receipt receipt, - Item item, -) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (c) => StatefulBuilder( - builder: (c, setSheet) { - final pool = receiptParticipants(receipt, state.people) - ..sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - final resolved = - resolveParticipants(item, receipt, state.people, state.groups) - .map((p) => p.id) - .toSet(); - - void setMode(AssignMode mode, {String? groupId}) { - state.mutate(() { - item.assignment - ..mode = mode - ..groupId = groupId; - }); - setSheet(() {}); - } - - void togglePerson(Person p, {required bool selected}) { - state.mutate(() { - if (item.assignment.mode != AssignMode.custom) { - item.assignment - ..customIds.clear() - ..customIds.addAll(resolved) - ..mode = AssignMode.custom - ..groupId = null; - } - if (selected) { - item.assignment.customIds.add(p.id); - } else { - item.assignment.customIds.remove(p.id); - } - }); - setSheet(() {}); - } - - final selectedSeg = switch (item.assignment.mode) { - AssignMode.everyone => 'everyone', - AssignMode.drinkers => 'drinkers', - AssignMode.nonDrinkers => 'nonDrinkers', - AssignMode.group => 'group:${item.assignment.groupId}', - AssignMode.custom => 'custom', - }; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.name, style: Theme.of(c).textTheme.titleMedium), - Text( - '${formatPln(item.totalGr)} · ${item.category}', - style: Theme.of(c).textTheme.bodySmall, - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ChoiceChip( - label: const Text('Everyone'), - selected: selectedSeg == 'everyone', - onSelected: (_) => setMode(AssignMode.everyone), - ), - ChoiceChip( - label: const Text('Drinkers'), - selected: selectedSeg == 'drinkers', - onSelected: (_) => setMode(AssignMode.drinkers), - ), - ChoiceChip( - label: const Text('Non-drinkers'), - selected: selectedSeg == 'nonDrinkers', - onSelected: (_) => setMode(AssignMode.nonDrinkers), - ), - for (final g in state.groups) - ChoiceChip( - label: Text(g.name), - selected: selectedSeg == 'group:${g.id}', - onSelected: (_) => - setMode(AssignMode.group, groupId: g.id), - ), - ], - ), - const SizedBox(height: 8), - Flexible( - child: ListView( - shrinkWrap: true, - children: [ - for (final p in pool) - CheckboxListTile( - dense: true, - title: Text(p.name), - subtitle: p.drinks ? null : const Text('non-drinker'), - value: resolved.contains(p.id), - onChanged: (v) => - togglePerson(p, selected: v ?? false), - ), - ], - ), - ), - Text( - resolved.isEmpty - ? 'Nobody selected — this item will be excluded from ' - 'totals until someone is assigned.' - : _eachLabel(resolved.length, item.totalGr), - style: TextStyle( - color: resolved.isEmpty - ? Theme.of(c).colorScheme.error - : Theme.of(c).colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - ], - ), - ), - ); - }, - ), - ); -} - -String _eachLabel(int n, int totalGr) => - '$n people · ~${formatPln((totalGr / n).round())} each'; diff --git a/billsplit/lib/ui/home_screen.dart b/billsplit/lib/ui/home_screen.dart deleted file mode 100644 index dbf00ea9..00000000 --- a/billsplit/lib/ui/home_screen.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'dart:convert'; - -import 'package:billsplit/domain/eparagon_parser.dart'; -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:billsplit/ui/people_screen.dart'; -import 'package:billsplit/ui/receipt_screen.dart'; -import 'package:design_system/design_system.dart'; -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -/// Receipt list plus entry points for import / manual creation. -class HomeScreen extends StatelessWidget { - /// Creates the home screen. - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context) { - final state = context.watch(); - return Scaffold( - appBar: AppBar( - title: const Text('BillSplit'), - actions: [ - IconButton( - tooltip: 'People & groups', - icon: const Icon(Icons.group), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const PeopleScreen()), - ), - ), - ], - ), - body: !state.loaded - ? const Center(child: CircularProgressIndicator()) - : state.receipts.isEmpty - ? const _EmptyHint() - : ListView.builder( - itemCount: state.receipts.length, - itemBuilder: (context, i) { - final r = state.receipts[i]; - return Dismissible( - key: ValueKey(r.id), - direction: DismissDirection.endToStart, - // The delete affordance revealed by swiping. The fill - // and the icon on top of it must move together, hence - // error/onError rather than two independent colours. - background: Container( - color: Theme.of(context).colorScheme.error, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: AppSpacing.md), - child: Icon( - Icons.delete, - color: Theme.of(context).colorScheme.onError, - ), - ), - confirmDismiss: (_) => _confirmDelete(context, r), - onDismissed: (_) => state.removeReceipt(r), - child: ListTile( - title: Text(r.title), - subtitle: Text( - '${r.items.length} items · ' - '${r.participantIds.length} people', - ), - trailing: Text( - formatPln(r.itemsTotalGr), - style: Theme.of(context).textTheme.titleMedium, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ReceiptScreen(receiptId: r.id), - ), - ), - ), - ); - }, - ), - floatingActionButton: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton.extended( - heroTag: 'import', - icon: const Icon(Icons.file_download), - label: const Text('Import e-paragon'), - onPressed: () => _import(context), - ), - const SizedBox(height: 12), - FloatingActionButton.extended( - heroTag: 'new', - icon: const Icon(Icons.add), - label: const Text('New receipt'), - onPressed: () => _createManual(context), - ), - ], - ), - ); - } - - Future _confirmDelete(BuildContext context, Receipt r) async { - return await showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text('Delete receipt?'), - content: Text('"${r.title}" will be removed permanently.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(c, true), - child: const Text('Delete'), - ), - ], - ), - ) ?? - false; - } - - Future _import(BuildContext context) async { - final state = context.read(); - final messenger = ScaffoldMessenger.of(context); - final nav = Navigator.of(context); - const jsonGroup = XTypeGroup( - label: 'e-paragon', - extensions: ['json'], - // Android matches on MIME rather than extension; without this the - // picker greys out every file. - mimeTypes: ['application/json'], - uniformTypeIdentifiers: ['public.json'], - ); - final picked = await openFile(acceptedTypeGroups: [jsonGroup]); - if (picked == null) return; - final data = await picked.readAsBytes(); - try { - final result = parseEParagonDetailed(utf8.decode(data)); - state.addReceipt(result.receipt); - for (final w in result.warnings) { - messenger.showSnackBar(SnackBar(content: Text(w))); - } - nav.push( - MaterialPageRoute( - builder: (_) => ReceiptScreen(receiptId: result.receipt.id), - ), - ); - } on EParagonParseException catch (e) { - messenger.showSnackBar(SnackBar(content: Text(e.message))); - } on Exception catch (e) { - messenger.showSnackBar(SnackBar(content: Text('Import failed: $e'))); - } - } - - Future _createManual(BuildContext context) async { - final state = context.read(); - final nav = Navigator.of(context); - final controller = TextEditingController(); - final title = await showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text('New receipt'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Title'), - onSubmitted: (v) => Navigator.pop(c, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(c, controller.text), - child: const Text('Create'), - ), - ], - ), - ); - if (title == null || title.trim().isEmpty) return; - final r = Receipt(id: Ids.next(), title: title.trim()); - state.addReceipt(r); - nav.push( - MaterialPageRoute( - builder: (_) => ReceiptScreen(receiptId: r.id), - ), - ); - } -} - -class _EmptyHint extends StatelessWidget { - const _EmptyHint(); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - 'No receipts yet.\n\nAdd people first (top right), then import a ' - 'Biedronka e-paragon JSON or create a receipt manually.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ); - } -} diff --git a/billsplit/lib/ui/item_edit.dart b/billsplit/lib/ui/item_edit.dart deleted file mode 100644 index 40e2ec9c..00000000 --- a/billsplit/lib/ui/item_edit.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -/// Add ([item] == null) or edit an item. The user types amounts in złoty -/// ("12,34" or "12.34"); they are stored as int grosze. -Future showItemEditDialog( - BuildContext context, - AppState state, - Receipt receipt, - Item? item, -) async { - final name = TextEditingController(text: item?.name ?? ''); - final total = TextEditingController( - text: item == null ? '' : _zl(item.totalGr), - ); - final qty = TextEditingController(text: (item?.qty ?? 1).toString()); - var category = item?.category ?? Categories.general; - final categories = { - Categories.general, - Categories.alcohol, - Categories.mixer, - Categories.deposit, - ...receipt.items.map((i) => i.category), - }.toList(); - - await showDialog( - context: context, - builder: (c) => StatefulBuilder( - builder: (c, setState) => AlertDialog( - title: Text(item == null ? 'Add item' : 'Edit item'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: name, - autofocus: item == null, - decoration: const InputDecoration(labelText: 'Name'), - ), - TextField( - controller: total, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: 'Total (zł)', - hintText: 'e.g. 12,34', - ), - ), - TextField( - controller: qty, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: 'Quantity'), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: category, - decoration: const InputDecoration(labelText: 'Category'), - items: [ - for (final cat in categories) - DropdownMenuItem(value: cat, child: Text(cat)), - ], - onChanged: (v) => - setState(() => category = v ?? Categories.general), - ), - const SizedBox(height: 4), - Text( - 'Alcohol items default to drinkers only; everything else to ' - 'everyone. Fine-tune by tapping the item afterwards.', - style: TextStyle( - fontSize: AppTextSize.caption, - color: Theme.of(c).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - actions: [ - if (item != null) - TextButton( - onPressed: () { - state.mutate(() => receipt.items.remove(item)); - Navigator.pop(c); - }, - child: Text( - 'Delete', - style: TextStyle(color: Theme.of(c).colorScheme.error), - ), - ), - TextButton( - onPressed: () => Navigator.pop(c), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final gr = _parseZl(total.text); - if (name.text.trim().isEmpty || gr == null) return; - state.mutate(() { - if (item == null) { - final it = Item( - id: Ids.next(), - name: name.text.trim(), - totalGr: gr, - qty: double.tryParse(qty.text.replaceAll(',', '.')) ?? 1, - category: category, - assignment: category == Categories.alcohol - ? Assignment(mode: AssignMode.drinkers) - : Assignment(), - ); - receipt.items.add(it); - } else { - item - ..name = name.text.trim() - ..totalGr = gr - ..qty = double.tryParse(qty.text.replaceAll(',', '.')) ?? 1 - ..category = category; - } - }); - Navigator.pop(c); - }, - child: const Text('Save'), - ), - ], - ), - ), - ); -} - -String _zl(int gr) => (gr / 100).toStringAsFixed(2); - -int? _parseZl(String s) { - final v = double.tryParse(s.replaceAll(',', '.').replaceAll(' ', '')); - if (v == null) return null; - return (v * 100).round(); -} diff --git a/billsplit/lib/ui/people_screen.dart b/billsplit/lib/ui/people_screen.dart deleted file mode 100644 index c5b0465f..00000000 --- a/billsplit/lib/ui/people_screen.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/state/app_state.dart'; -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. - const PeopleScreen({super.key}); - - @override - Widget build(BuildContext context) { - final state = context.watch(); - return Scaffold( - appBar: AppBar(title: const Text('People & groups')), - body: ListView( - padding: const EdgeInsets.only(bottom: 96), - children: [ - const SectionHeader('People', padding: SectionHeader.defaultPadding), - if (state.people.isEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Text('Nobody yet — add people below.'), - ), - for (final p in state.people) _PersonTile(person: p), - const Divider(height: 32), - const SectionHeader('Groups', padding: SectionHeader.defaultPadding), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Drinkers / non-drinkers are built in (the toggle on each ' - 'person). Groups below are extra tags, e.g. "meat eaters".', - style: TextStyle( - fontSize: AppTextSize.caption, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - for (final g in state.groups) _GroupTile(group: g), - Padding( - padding: const EdgeInsets.all(16), - child: OutlinedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Add group'), - onPressed: () => _addGroup(context), - ), - ), - ], - ), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.person_add), - label: const Text('Add person'), - onPressed: () => _addPerson(context), - ), - ); - } - - Future _addPerson(BuildContext context) async { - final state = context.read(); - final controller = TextEditingController(); - var drinks = true; - await showDialog( - context: context, - builder: (c) => StatefulBuilder( - builder: (c, setState) => AlertDialog( - title: const Text('Add person'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Name'), - ), - SwitchListTile( - title: const Text('Drinks alcohol'), - value: drinks, - onChanged: (v) => setState(() => drinks = v), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - if (controller.text.trim().isNotEmpty) { - state.addPerson(controller.text.trim(), drinks: drinks); - } - Navigator.pop(c); - }, - child: const Text('Add'), - ), - ], - ), - ), - ); - } - - Future _addGroup(BuildContext context) async { - final state = context.read(); - final controller = TextEditingController(); - await showDialog( - context: context, - builder: (c) => AlertDialog( - title: const Text('Add group'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Group name'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(c), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - if (controller.text.trim().isNotEmpty) { - state.addGroup(controller.text.trim()); - } - Navigator.pop(c); - }, - child: const Text('Add'), - ), - ], - ), - ); - } -} diff --git a/billsplit/lib/ui/people_tiles.dart b/billsplit/lib/ui/people_tiles.dart deleted file mode 100644 index ac1b8951..00000000 --- a/billsplit/lib/ui/people_tiles.dart +++ /dev/null @@ -1,139 +0,0 @@ -/// 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 deleted file mode 100644 index 24b4384a..00000000 --- a/billsplit/lib/ui/receipt_items.dart +++ /dev/null @@ -1,198 +0,0 @@ -/// 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 deleted file mode 100644 index 08f476f1..00000000 --- a/billsplit/lib/ui/receipt_screen.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/split_engine.dart'; -import 'package:billsplit/domain/xlsx_export.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:billsplit/ui/assignment_sheet.dart'; -import 'package:billsplit/ui/item_edit.dart'; -import 'package:file_selector/file_selector.dart'; -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 -/// app performs the write itself, where `file_picker` did it inside the -/// plugin. Real `dart:io` futures never complete inside `testWidgets`' -/// fake-async zone, so a widget test that exercised the real write could never -/// observe the resulting snackbar. Tests swap this for an in-memory stub. -typedef ExportWriter = Future Function( - String path, - Uint8List bytes, - String mimeType, -); - -/// The active export writer. Replaced in tests via [debugSetExportWriter]. -ExportWriter writeExport = _writeExportToDisk; - -Future _writeExportToDisk( - String path, - Uint8List bytes, - String mimeType, -) => - XFile.fromData(bytes, mimeType: mimeType).saveTo(path); - -/// Overrides [writeExport]; pass null to restore the real disk writer. -@visibleForTesting -void debugSetExportWriter(ExportWriter? writer) { - writeExport = writer ?? _writeExportToDisk; -} - -/// One receipt: item list with assignments, totals, participants, export. -class ReceiptScreen extends StatefulWidget { - /// Creates the screen for the receipt with [receiptId]. - const ReceiptScreen({required this.receiptId, super.key}); - - /// Id of the receipt to show; resolved against [AppState.receipts]. - final String receiptId; - - @override - State createState() => _ReceiptScreenState(); -} - -class _ReceiptScreenState extends State { - @override - Widget build(BuildContext context) { - final state = context.watch(); - final receipt = - state.receipts.where((r) => r.id == widget.receiptId).firstOrNull; - if (receipt == null) { - return const Scaffold(body: Center(child: Text('Receipt deleted.'))); - } - final split = computeSplit(receipt, state.people, state.groups); - final wide = MediaQuery.of(context).size.width > 800; - - final itemsList = _ItemsList(receipt: receipt, split: split); - final totals = _TotalsView(receipt: receipt, split: split); - - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: Text(receipt.title, overflow: TextOverflow.ellipsis), - actions: [ - IconButton( - tooltip: 'Participants', - icon: const Icon(Icons.group), - onPressed: () => _editParticipants(context, state, receipt), - ), - IconButton( - tooltip: 'Export .xlsx', - icon: const Icon(Icons.table_view), - onPressed: () => _export(context, state, receipt), - ), - ], - bottom: wide - ? null - : const TabBar( - tabs: [ - Tab(text: 'Items'), - Tab(text: 'Totals'), - ], - ), - ), - floatingActionButton: FloatingActionButton( - tooltip: 'Add item', - child: const Icon(Icons.add), - onPressed: () => showItemEditDialog(context, state, receipt, null), - ), - body: wide - ? Row( - children: [ - Expanded(flex: 3, child: itemsList), - const VerticalDivider(width: 1), - Expanded(flex: 2, child: totals), - ], - ) - : TabBarView(children: [itemsList, totals]), - ), - ); - } - - Future _editParticipants( - BuildContext context, - AppState state, - Receipt receipt, - ) async { - if (state.people.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Add people first (People & groups screen).'), - ), - ); - return; - } - await showModalBottomSheet( - context: context, - builder: (c) => StatefulBuilder( - builder: (c, setSheet) => SafeArea( - child: ListView( - shrinkWrap: true, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Who is on this receipt?', - style: Theme.of(c).textTheme.titleMedium, - ), - ), - for (final p in state.people) - CheckboxListTile( - title: Text(p.name), - subtitle: p.drinks ? null : const Text('non-drinker'), - value: receipt.participantIds.contains(p.id), - onChanged: (v) { - state.mutate(() { - if (v == true) { - receipt.participantIds.add(p.id); - } else { - receipt.participantIds.remove(p.id); - } - }); - setSheet(() {}); - }, - ), - ], - ), - ), - ), - ); - } - - Future _export( - BuildContext context, - AppState state, - Receipt receipt, - ) async { - final messenger = ScaffoldMessenger.of(context); - if (receipt.participantIds.isEmpty) { - messenger.showSnackBar( - const SnackBar(content: Text('Add participants before exporting.')), - ); - return; - } - try { - final bytes = Uint8List.fromList( - exportXlsx( - receipt: receipt, - roster: state.people, - groups: state.groups, - ), - ); - final safe = receipt.title.replaceAll( - RegExp(r'[^\w\d ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'), - '_', - ); - const xlsxGroup = XTypeGroup( - label: 'Excel workbook', - extensions: ['xlsx'], - mimeTypes: [ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ], - ); - final location = await getSaveLocation( - suggestedName: '$safe split.xlsx', - acceptedTypeGroups: [xlsxGroup], - ); - if (location != null) { - // file_selector hands back a destination and leaves the writing to the - // caller, unlike file_picker which wrote the bytes inside the plugin. - await writeExport(location.path, bytes, xlsxGroup.mimeTypes!.first); - messenger.showSnackBar( - SnackBar(content: Text('Saved: ${location.path.split('/').last}')), - ); - } - } on Exception catch (e) { - messenger.showSnackBar(SnackBar(content: Text('Export failed: $e'))); - } - } -} diff --git a/billsplit/pubspec.lock b/billsplit/pubspec.lock deleted file mode 100644 index c8ff25d3..00000000 --- a/billsplit/pubspec.lock +++ /dev/null @@ -1,611 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d - url: "https://pub.dev" - source: hosted - version: "3.6.1" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.dev" - source: hosted - version: "0.3.5+4" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - design_system: - dependency: "direct main" - description: - path: design_system - ref: "design_system-v0.1.0" - resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b - url: "https://github.com/kuhyx/utils" - source: git - version: "0.1.0" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - excel: - dependency: "direct main" - description: - name: excel - sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" - url: "https://pub.dev" - source: hosted - version: "4.0.6" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" - url: "https://pub.dev" - source: hosted - version: "0.5.2+9" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca - url: "https://pub.dev" - source: hosted - version: "0.5.3+5" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.dev" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: "direct dev" - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.dev" - source: hosted - version: "0.9.3+5" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d - url: "https://pub.dev" - source: hosted - version: "4.3.0" - jni: - dependency: transitive - description: - name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" - url: "https://pub.dev" - source: hosted - version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" - url: "https://pub.dev" - source: hosted - version: "1.19.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e - url: "https://pub.dev" - source: hosted - version: "9.5.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d - url: "https://pub.dev" - source: hosted - version: "3.0.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" - url: "https://pub.dev" - source: hosted - version: "0.7.12" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - very_good_analysis: - dependency: "direct dev" - description: - name: very_good_analysis - sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" - url: "https://pub.dev" - source: hosted - version: "10.3.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" diff --git a/billsplit/pubspec.yaml b/billsplit/pubspec.yaml deleted file mode 100644 index ed46da07..00000000 --- a/billsplit/pubspec.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: billsplit -description: Receipt bill-splitting with people, groups, per-item assignment and xlsx export. -publish_to: "none" -version: 0.1.0 - -environment: - sdk: ^3.5.0 - -dependencies: - # The shared palette and scales. - design_system: - git: - url: https://github.com/kuhyx/utils - ref: design_system-v0.1.0 - path: design_system - - excel: ^4.0.6 - file_selector: ^1.1.0 - flutter: - sdk: flutter - path_provider: ^2.1.4 - provider: ^6.1.2 - -dev_dependencies: - file_selector_platform_interface: ^2.7.0 - flutter_launcher_icons: ^0.14.4 - flutter_lints: ^6.0.0 - flutter_test: - sdk: flutter - very_good_analysis: ^10.3.0 - -flutter: - uses-material-design: true - -# Launcher icon generation. Source art is produced by the shared generator: -# PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app billsplit -# then run: dart run flutter_launcher_icons -flutter_launcher_icons: - android: true - ios: false - image_path: "assets/icon/icon.png" - adaptive_icon_background: "#211D1B" - adaptive_icon_foreground: "assets/icon/icon_foreground.png" - adaptive_icon_monochrome: "assets/icon/icon_monochrome.png" - min_sdk_android: 21 diff --git a/billsplit/test/app_state_test.dart b/billsplit/test/app_state_test.dart deleted file mode 100644 index 44a4eccd..00000000 --- a/billsplit/test/app_state_test.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'helpers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - Directory temp() { - final d = Directory.systemTemp.createTempSync('billsplit_state'); - addTearDown(() { - if (d.existsSync()) d.deleteSync(recursive: true); - }); - return d; - } - - test('load on a fresh directory yields empty, usable state', () async { - final s = AppState(overrideDir: temp()); - await s.load(); - expect(s.loaded, isTrue); - expect(s.people, isEmpty); - s.dispose(); - }); - - test('save/load roundtrip preserves everything', () async { - final dir = temp(); - final s = AppState(overrideDir: dir); - await s.load(); - final p = s.addPerson('Ala', drinks: false); - final g = s.addGroup('Meat')..memberIds.add(p.id); - s.addReceipt( - Receipt( - id: 'r1', - title: 'T', - items: [ - Item( - id: 'i1', - name: 'x', - totalGr: 100, - assignment: Assignment(mode: AssignMode.group, groupId: g.id), - ), - ], - ), - ); - await s.save(); - s.dispose(); - - final s2 = AppState(overrideDir: dir); - await s2.load(); - expect(s2.people.single.name, 'Ala'); - expect(s2.people.single.drinks, isFalse); - expect(s2.groups.single.memberIds, {p.id}); - expect(s2.receipts.single.participantIds, {p.id}); - expect(s2.receipts.single.items.single.assignment.groupId, g.id); - s2.dispose(); - }); - - test('load prunes dangling person references', () async { - final dir = temp(); - File('${dir.path}/billsplit/state.json') - ..createSync(recursive: true) - ..writeAsStringSync( - jsonEncode({ - 'people': [ - {'id': 'p1', 'name': 'Ala'}, - ], - 'groups': [ - { - 'id': 'g1', - 'name': 'G', - 'memberIds': ['p1', 'ghost'], - }, - ], - 'receipts': [ - { - 'id': 'r1', - 'title': 'T', - 'date': '2026-01-01T00:00:00.000', - 'participantIds': ['p1', 'ghost'], - 'items': [ - { - 'id': 'i1', - 'name': 'x', - 'totalGr': 1, - 'assignment': { - 'mode': 'custom', - 'customIds': ['p1', 'ghost'], - }, - }, - ], - }, - ], - }), - ); - final s = AppState(overrideDir: dir); - await s.load(); - expect(s.groups.single.memberIds, {'p1'}); - expect(s.receipts.single.participantIds, {'p1'}); - expect(s.receipts.single.items.single.assignment.customIds, {'p1'}); - s.dispose(); - }); - - test('corrupt JSON is survived', () async { - final dir = temp(); - File('${dir.path}/billsplit/state.json') - ..createSync(recursive: true) - ..writeAsStringSync('{oops'); - final s = AppState(overrideDir: dir); - await s.load(); - expect(s.loaded, isTrue); - expect(s.people, isEmpty); - s.dispose(); - }); - - test('IO failure on load is survived (billsplit path is a file)', () async { - final dir = temp(); - File('${dir.path}/billsplit').createSync(recursive: true); - final s = AppState(overrideDir: dir); - await s.load(); - expect(s.loaded, isTrue); - s.dispose(); - }); - - test('IO failure on save is survived (state.json is a directory)', () async { - final dir = temp(); - Directory('${dir.path}/billsplit/state.json').createSync(recursive: true); - final s = AppState(overrideDir: dir); - await s.save(); // must not throw - s.dispose(); - }); - - test('mutations debounce into a save', () async { - final dir = temp(); - final s = AppState(overrideDir: dir); - await s.load(); - s.addPerson('Ala'); - expect(File('${dir.path}/billsplit/state.json').existsSync(), isFalse); - await Future.delayed(const Duration(milliseconds: 600)); - expect(File('${dir.path}/billsplit/state.json').existsSync(), isTrue); - s.dispose(); - }); - - test('removePerson / removeGroup fallback / removeReceipt', () async { - final s = AppState(overrideDir: temp()); - await s.load(); - final p = s.addPerson('Ala'); - final g = s.addGroup('G')..memberIds.add(p.id); - final item = Item( - id: 'i1', - name: 'x', - totalGr: 1, - assignment: Assignment(mode: AssignMode.group, groupId: g.id), - ); - final r = Receipt(id: 'r1', title: 'T', items: [item]); - s - ..addReceipt(r) - ..removeGroup(g); - expect(item.assignment.mode, AssignMode.everyone); - expect(item.assignment.groupId, isNull); - - s.removePerson(p); - expect(r.participantIds, isEmpty); - - s.removeReceipt(r); - expect(s.receipts, isEmpty); - s.dispose(); - }); - - test('default documents dir is used when no override is given', () async { - final dir = temp(); - mockPathProvider(dir); - addTearDown(clearChannelMocks); - final s = AppState(); - await s.load(); - s.addPerson('Ala'); - await s.save(); - expect(File('${dir.path}/billsplit/state.json').existsSync(), isTrue); - s.dispose(); - }); -} diff --git a/billsplit/test/engine_extra_test.dart b/billsplit/test/engine_extra_test.dart deleted file mode 100644 index b27d9afa..00000000 --- a/billsplit/test/engine_extra_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:billsplit/domain/split_engine.dart'; -import 'package:billsplit/domain/xlsx_export.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('splitAmount with no people returns empty', () { - expect(splitAmount(100, const []), isEmpty); - }); - - test('splitAmount accepts negative rotation', () { - final s = splitAmount(10, ['a', 'b', 'c'], rotation: -1); - expect(s.values.fold(0, (x, y) => x + y), 10); - }); - - test('stableHash is stable and differs between strings', () { - expect(stableHash(''), 0); - expect(stableHash('abc'), stableHash('abc')); - expect(stableHash('abc'), isNot(stableHash('abd'))); - }); - - test('colName covers single and multi letter columns', () { - expect(colName(0), 'A'); - expect(colName(25), 'Z'); - expect(colName(26), 'AA'); - expect(colName(27), 'AB'); - expect(colName(701), 'ZZ'); - expect(colName(702), 'AAA'); - }); -} diff --git a/billsplit/test/eparagon_edge_test.dart b/billsplit/test/eparagon_edge_test.dart deleted file mode 100644 index 755c3088..00000000 --- a/billsplit/test/eparagon_edge_test.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:convert'; - -import 'package:billsplit/domain/eparagon_parser.dart'; -import 'package:billsplit/domain/models.dart'; -import 'package:flutter_test/flutter_test.dart'; - -Map minimalPayload({ - List? pozycja, - Map? extraParagon, - Map? dokumentExtra, -}) { - return { - 'dokument': { - ...?dokumentExtra, - 'paragon': { - if (pozycja != null) 'pozycja': pozycja, - ...?extraParagon, - }, - }, - }; -} - -Map towar(String name, int brutto) => { - 'towar': { - 'nazwa': name, - 'ilosc': '1,000', - 'cena': brutto, - 'brutto': brutto, - }, - }; - -String asJws(Map payload) { - final body = base64Url.encode(utf8.encode(jsonEncode(payload))); - return 'eyJh.$body.sig'; -} - -void main() { - test('exception toString carries the message', () { - expect( - EParagonParseException('boom').toString(), - 'EParagonParseException: boom', - ); - }); - - test('bare JWS with non-map payload is rejected', () { - final body = base64Url.encode(utf8.encode('123')); - expect( - () => parseEParagon('h.$body.s'), - throwsA(isA()), - ); - }); - - test('three-part token with invalid base64 payload is rejected', () { - expect( - () => parseEParagon('aa.§§§.bb'), - throwsA(isA()), - ); - }); - - test('JSON root that is not a map is rejected', () { - expect( - () => parseEParagon('[1, 2]'), - throwsA(isA()), - ); - }); - - test('wrapper with undecodable data field is rejected', () { - expect( - () => parseEParagon('{"data": "a.b"}'), - throwsA( - predicate( - (e) => e is EParagonParseException && e.message.contains('"data"'), - ), - ), - ); - }); - - test('missing dokument / paragon / items are rejected with clear messages', - () { - expect( - () => parseEParagon(asJws({'dokument': 1})), - throwsA( - predicate( - (e) => e is EParagonParseException && e.message.contains('dokument'), - ), - ), - ); - expect( - () => parseEParagon(jsonEncode({'dokument': {}})), - throwsA( - predicate( - (e) => e is EParagonParseException && e.message.contains('paragon'), - ), - ), - ); - expect( - () => parseEParagon(jsonEncode(minimalPayload())), - throwsA( - predicate( - (e) => - e is EParagonParseException && e.message.contains('line items'), - ), - ), - ); - }); - - test('malformed pozycja entries are skipped, missing brutto warns', () { - final res = parseEParagonDetailed( - jsonEncode( - minimalPayload( - pozycja: [ - 1, - {'towar': 2}, - { - 'towar': {'nazwa': 'NoPrice', 'ilosc': 'x'}, - }, - { - 'towar': {'nazwa': ' ', 'brutto': 5}, - }, - towar('Chleb', 500), - ], - ), - ), - ); - expect(res.receipt.items.single.name, 'Chleb'); - expect(res.warnings.single, contains('NoPrice')); - expect(res.receipt.items.single.qty, 1); - }); - - test('sumaBrutto mismatch produces a warning; paid falls back to sum', () { - final res = parseEParagonDetailed( - jsonEncode( - minimalPayload( - pozycja: [towar('Chleb', 500)], - extraParagon: { - 'podsum': {'sumaBrutto': 600}, - 'opak': { - 'wart': 100, - 'daneOpak': [ - {'nazwa': 'butelka', 'cena': 50, 'ilosc': 2000}, - {'nazwa': 'zero', 'cena': 50, 'ilosc': 0}, - 1, - ], - }, - }, - ), - ), - ); - expect(res.warnings.single, contains('differs')); - expect(res.receipt.paidTotalGr, 700); // 600 declared + 100 deposit - final deposit = - res.receipt.items.where((i) => i.category == Categories.deposit).single; - expect(deposit.totalGr, 100); - expect(deposit.qty, 2); - }); - - test('store aliasing, plain store names and date fallbacks', () { - final jm = parseEParagon( - asJws( - minimalPayload( - pozycja: [towar('Chleb', 500)], - dokumentExtra: { - 'podmiot1': {'nazwaPod': ' JERONIMO MARTINS POLSKA S.A. '}, - }, - ), - ), - ); - expect(jm.store, 'Biedronka'); - - final other = parseEParagon( - jsonEncode( - minimalPayload( - pozycja: [towar('Chleb', 500)], - dokumentExtra: { - 'podmiot1': {'nazwaPod': 'Żabka'}, - 'naglowek': {'dataJPK': '2026-05-01T10:00:00'}, - }, - ), - ), - ); - expect(other.store, 'Żabka'); - expect(other.date.year, 2026); - expect(other.title, contains('Żabka 2026-05-01')); - - final noStore = parseEParagon( - jsonEncode( - minimalPayload( - pozycja: [towar('Chleb', 500)], - dokumentExtra: { - 'podmiot1': {'nazwaPod': ' '}, - }, - ), - ), - ); - expect(noStore.store, isNull); - expect(noStore.date.year, DateTime.now().year); - }); - - test('quantity and int parsing variants', () { - final r = parseEParagon( - jsonEncode( - minimalPayload( - pozycja: [ - { - 'towar': { - 'nazwa': 'Ser', - 'ilosc': 2, - 'cena': '100', - 'brutto': 200.4, - }, - }, - { - 'towar': {'nazwa': 'Mleko', 'ilosc': 'abc', 'brutto': 300}, - }, - ], - ), - ), - ); - expect(r.items[0].qty, 2); - expect(r.items[0].unitPriceGr, 100); - expect(r.items[0].totalGr, 200); - expect(r.items[1].qty, 1); - }); - - test('mixer keyword tagging without alcohol', () { - final r = parseEParagon( - jsonEncode(minimalPayload(pozycja: [towar('Tonik cytrynowy', 400)])), - ); - expect(r.items.single.category, Categories.mixer); - expect(r.items.single.assignment.mode, AssignMode.everyone); - }); -} diff --git a/billsplit/test/eparagon_parser_test.dart b/billsplit/test/eparagon_parser_test.dart deleted file mode 100644 index 4df10c63..00000000 --- a/billsplit/test/eparagon_parser_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'dart:io'; - -import 'package:billsplit/domain/eparagon_parser.dart'; -import 'package:billsplit/domain/models.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final fixture = File('test/fixtures/sample_receipt.json').readAsStringSync(); - - test('parses the real Biedronka e-paragon wrapper', () { - final res = parseEParagonDetailed(fixture); - final r = res.receipt; - - // 49 goods lines + 1 deposit line. - expect(r.items.length, 50); - final deposit = - r.items.where((i) => i.category == Categories.deposit).toList(); - expect(deposit.length, 1); - expect(deposit.single.totalGr, 700); - expect(deposit.single.qty, 14); - - // Goods sum matches receipt sumaBrutto; paid total includes deposit. - final goods = r.items - .where((i) => i.category != Categories.deposit) - .fold(0, (s, i) => s + i.totalGr); - expect(goods, 59055); - expect(r.paidTotalGr, 59755); - expect(res.warnings, isEmpty); - - // Alcohol detection: exactly vodka + beer, drinkers-only by default. - final alcohol = - r.items.where((i) => i.category == Categories.alcohol).toList(); - expect(alcohol.length, 2); - expect( - alcohol.map((i) => i.name).join(' | ').toLowerCase(), - allOf(contains('wódka'), contains('piwo')), - ); - for (final a in alcohol) { - expect(a.assignment.mode, AssignMode.drinkers); - } - expect(alcohol.fold(0, (s, i) => s + i.totalGr), 10872); - - // Mixers tagged but split among everyone. - final mixers = - r.items.where((i) => i.category == Categories.mixer).toList(); - expect(mixers.length, greaterThanOrEqualTo(4)); - for (final m in mixers) { - expect(m.assignment.mode, AssignMode.everyone); - } - - // Name cleanup: VAT letter stripped, spaces collapsed. - expect(r.items.first.name, 'Tonic'); - // Weight-based quantity with comma decimal. - final kielbasa = - r.items.firstWhere((i) => i.name.startsWith('KiełbBrocka')); - expect(kielbasa.qty, closeTo(0.871, 1e-9)); - // Discount captured (vodka -28.00). - final vodka = r.items.firstWhere((i) => i.name.startsWith('WódkaB')); - expect(vodka.discountGr, -2800); - expect(vodka.totalGr, 7998); - }); - - test('accepts bare JPK payload and bare JWS token', () { - final res = parseEParagonDetailed(fixture); - expect(res.receipt.items, isNotEmpty); - // Raw JWS from the wrapper should parse identically. Tolerate whitespace - // around the colon: the fixture is minified as the device emits it, but a - // pretty-printed payload is equally valid JSON and must not fail here. - final match = RegExp(r'"data"\s*:\s*"([^"]+)"').firstMatch(fixture); - expect(match, isNotNull, reason: 'fixture must carry a "data" JWS field'); - final jws = match!.group(1)!; - final fromJws = parseEParagon(jws); - expect(fromJws.items.length, res.receipt.items.length); - }); - - test('rejects garbage input with a clear error', () { - expect( - () => parseEParagon('not json'), - throwsA(isA()), - ); - expect( - () => parseEParagon('{"foo": 1}'), - throwsA(isA()), - ); - }); -} diff --git a/billsplit/test/fixtures/sample_receipt.json b/billsplit/test/fixtures/sample_receipt.json deleted file mode 100644 index de6f402c..00000000 --- a/billsplit/test/fixtures/sample_receipt.json +++ /dev/null @@ -1 +0,0 @@ -{"protoVersion":"000","IDZ":"c=068b8822a7d1459697c518bacdc26abf|g={b6b16726-f935-4e67-b756-bca881c12124}|s=5834|p=1|t=4320","deviceType":2,"printed":false,"data":"eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGUVRDQ0F5bWdBd0lCQWdJRERxR0dNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1JR2JNUXN3Q1FZRFZRUUdFd0pRVERFVU1CSUdBMVVFQ0F3TFRXRjZiM2RwWldOcmFXVXhFVEFQQmdOVkJBY01DRmRoY25ONllYZGhNU1F3SWdZRFZRUUxEQnREWlc1MGNuVnRJRU5sY25SNVptbHJZV05xYVNCbExVdGhjM2t4R3pBWkJnTlZCQW9NRWxCdmMyNWxkQ0JRYjJ4emEyRWdVeTVCTGpFZ01CNEdBMVVFQXd3WFVFOVRUa1ZVSUdVdFMyRnplU0JTYjI5MElFTkJJREV3SWhnUE1qQXlNekV4TWprd05qRXlNakZhR0E4eU1ETXpNVEV5T0RBMk1USXlNVm93T3pFTE1Ba0dBMVVFQmhNQ1VFd3hGakFVQmdOVkJBTVREVVZCV2pJeU1ESXhOakF4T1RZeEZEQVNCZ05WQkFvVEMxQnZjMjVsZENCVExrRXVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXQrSUxlZlJWNjhqcFdDa2RveitZZWprRnU1dkh3N01vYjgxSFIyQnhqUVp6eENhN2drMjExTEs5T1J6Y2ZIcW1YeU5aV3c3U0tpcUp1U0ZhM1Z5R29CaDFFMmF6VWxvcU5Db3g0TFNaM0FwQ3U1d0NIOVFaaS94YW5nQ2pReUtybG44bFFZaWtkNENTYTJyYlQycWVUZytRckJEdHBreVpmc3hQTWF6aFFuek9MNTRWMlZqQ2ZWUzFTajRNQ3lHeHVwQnl5RFY3UzlaKzRPOEdqZ0JYL3RSa1pxRW1SQmpELy83N2o4cjJWTkIyTHlPUXM3alN6RE1zbFo0VUFFN1lzMm1WNHhnZmtLYlh5TUhQK013VjFoZGpMMDJzR3E4ckFUK25aWXFtdURQNGJZKzJiWG5LNzZWQk1uT0pLVEQ0M0NQamhEVXErYjJsd2k4RUZmSlNxUUlEQVFBQm80SG9NSUhsTUF3R0ExVWRFd0VCL3dRQ01BQXdIUVlEVlIwT0JCWUVGQkRiZUErelZVTCtQYnBXam5sVFNlcmlrN0FkTUI4R0ExVWRJd1FZTUJhQUZFNElhYlJWR0FHTFJkSGlwZVNaeldHQmo0MUpNQTRHQTFVZER3RUIvd1FFQXdJRjREQTFCZ05WSFI4RUxqQXNNQ3FnS0tBbWhpUm9kSFJ3T2k4dlpXc3VjRzl6Ym1WMExtTnZiUzV3YkM5amNtd3ZjbU5oTVM1amNtd3dUZ1lEVlIwZ0JFY3dSVEJEQmdzckJnRUVBWU9mYmdFQkFUQTBNRElHQ0NzR0FRVUZCd0lCRmlab2RIUndjem92TDJWckxuQnZjMjVsZEM1amIyMHVjR3d2WTNCekwzSmpZVEV1YUhSdGJEQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FnRUFrUk5nNEhuMXUrM2tzeEZ2SmFTMHplYnBqQ3pVb2hyYldTZ3dHaGZWTUk1ZnJaZG5Id0V0SytPL1FuT2FIZXdzblZ0am5Samx0NWIrWHA3bFZNVFdMYnpYNjRVTUZQZkhzVER1OER6Zml6NVZjRUJ0MkNnSTlacFVSNHgyR0VJTDlSQmJzTzRqanFZajh1YnA3OWdsckgxMjF6Y3BhNmhSYkpZOHFReHNSTnkxZkNTL3ppazlQTTFla214eWc5b3pxOUJ4WmxKMnREZW12Y0pxbUhMaWNTUCsxWkRZWGR2SnJiNVJHMmFWL2JLeFJmTm15NGZxUVRNSFlucC94K2FBRlByTFdjV3lDbUMya1FodnNFYVJ1cWZDdlJ0TEtsT0t0UC9zdzRxQzlXbVdzbG8zeXl4YW9WRkRsYk5NZ3lWWWpicmtWZ0U1SXBPRWJpUG1GZTY4cUNKMk5KL0tsNFNWUGJiN2RSWXJyeHdYYVAzN2VUSXJKQ3NwbUMrWC9oNzQ3ak5mYTQxeXd3OCsrb1BMblNpby9PcFpNT0IzZmtBaWdjODlnNEtRalJLNDFsOGRJQnYvRmpMZm9XM3JidW5YQTM4R0VKT1ZhY1ZYM3ZQcUNoWExVYTN3dHBQTWRWbm9NbkFRMUJCOEU4RUs1YUtrK0tyQ1NzVnVPdmE0dTlsbURPdXFaMGt1Snd1M2RhM0c2TUpzSm90cloyTWNpMTV0S215UkU1VVViYlhBcXN6a2hXZTZ2bUdMa0ZTemliemZ2TXZmQ0xoMkxvQTBDakNCMGJLVHFydUdsSUpGZ0NhNFpKTDRnTHBiV2d0K2lMZDg3UEZwdGtBMUVHSldHSXNSVFBzVnhLNFQvWE5MME5td3hzTHdPTHhXZ2JxK1hjNDlZNUhYb0MzNDRSND0iXSwianBrbWV0YWRhdGEiOiJleUpqYjNKeVpXeGhkR2x2Ymtsa0lqb2lWRVpFTGtWQldqSXlNREl4TmpBeE9UWXVNakF5TkMwd05pMHdOVlF3T0RveE1Eb3dNQzQyTkRGYUluMCIsImVQYXJhZ29uLm1mLmdvdi5wbCI6ImV5SjNaWEp6YW1FaU9pSktVRXRmUzBGVFFWOVFRVkpCUjA5T1gzWXlMVEFpTENKS1VFdEpSQ0k2SWpBd01UQXdNREF3TURBd01EWXhNREl4TXlJc0ltUmhkR0ZLVUVzaU9pSXlNREkyTFRBM0xUTXdWREUzT2pJeE9qRTVMakF3TUZvaUxDSktVRXRTUlVZaU9uc2lTbEJMU1VRaU9pSXdNREV3TURBd01EQXdNREEyTVRBeU1URWlMQ0pUU0VFeU5UWWlPaUl6TXpVNVFrRkVRa0kzTURSRE5UZzBNVVk1T1RBd01ERkdOREE1T1VGRVJUSkZSVVF6TUVFME4wRkZSa1JDT1RNd05rVTFRVUZEUlRoRk0wVTJOak00SW4xOSJ9.eyJkb2t1bWVudCI6eyJuYWdsb3dlayI6eyJ3ZXJzamEiOiJKUEtfS0FTQV9QQVJBR09OX3YyLTAiLCJkYXRhSlBLIjoiMjAyNi0wNy0zMFQxNzoyMToxOS4wMDBaIn0sInBvZG1pb3QxIjp7Ik5JUCI6Ijc3OTEwMTEzMjciLCJuYXp3YVBvZCI6IkpFUk9OSU1PIE1BUlRJTlMgUE9MU0tBIFMuQS4iLCJhZHJlc1BvZCI6eyJrb2RQb2N6dCI6IjM0LTMyNSIsIm1pZWpzYyI6IsWBT0RZR09XSUNFIiwidWxpY2EiOiJCRVNLSURaS0EgOCJ9LCJuclVuaWsiOiJFQVoyMjAyMTYwMTk2IiwibnJGYWJyIjoiUE8xMTQwNTQzOTEiLCJuckV3aWQiOiIyMDI0LzAwMDA5MjEyNiJ9LCJwYXJhZ29uIjp7IkpQS0lEIjo2MTAyMTMsInBhbWllY0NociI6MSwibnJEb2siOjYwODgyNywicG96eWNqYSI6W3sidG93YXIiOnsiYnJ1dHRvIjo1NDksImNlbmEiOjU0OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlRvbmljICAgICAgICAgICAgICAgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTQ5LCJjZW5hIjo1NDksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDaXRydXMgbWl4ICAgICAgICAgICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjE3OTgsImNlbmEiOjg5OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjIiLCJuYXp3YSI6Ik5hcEdhekNvY2FDb01JWDIsNWwgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTc5OCwiY2VuYSI6ODk5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMiIsIm5hendhIjoiTmFwR2F6Q29jYUNvTUlYMiw1bCAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo0NDksImNlbmEiOjQ0OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6Ik9zaGVlVml0YW1NYWduZXoxLDFsICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NzE0LCJjZW5hIjoxMTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiI2IiwibmF6d2EiOiJQb2xhcmlzIE5HYXogIDEsNWwgICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjE1OTYsImNlbmEiOjM5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjQiLCJuYXp3YSI6IlRvc3QgTWHFm2xhbnkgNTAwZyAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjc5OTgsImNlbmEiOjUzOTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJXw7Nka2FCIEJvY2lhbiAwLDdsICAgICAgIEEiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjgwMH19fSx7InRvd2FyIjp7ImJydXR0byI6OTk5LCJjZW5hIjo5OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJPbGVqUnplcFpPbGl3V3liMWwgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjY0OSwiY2VuYSI6NjQ5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiR3Jhbm9sYU1sZWN6Q3plazM1MGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjozOTksImNlbmEiOjM5OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlBvZHBhxYJOYXR1cjY0S29zdGtpICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjk5OSwiY2VuYSI6OTk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiR3Jhbm9sYUZpdE5lc09hdDMwMGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo5OTksImNlbmEiOjk5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkdyYW5vbGFGaXROZXNRdWkzMDBnICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6Nzk4LCJjZW5hIjozOTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJETyBNT0pFSiBJRMSYICAgICAgICAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjoyMTY5LCJjZW5hIjozMjkwLCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4NzEiLCJuYXp3YSI6IktpZcWCYkJyb2NrYVNva2_FgmtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNjk3fX19LHsidG93YXIiOnsiYnJ1dHRvIjoyMDcyLCJjZW5hIjozMjkwLCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4MzIiLCJuYXp3YSI6IktpZcWCYkJyb2NrYVNva2_FgmtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNjY1fX19LHsidG93YXIiOnsiYnJ1dHRvIjozNzQ1LCJjZW5hIjo3NDksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiI1IiwibmF6d2EiOiJHdWFuY2lhbGVDdWJHQjE1MGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjExMzcsImNlbmEiOjM3OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjMiLCJuYXp3YSI6IkJhZ2lldGthIDE3NWcgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTk5LCJjZW5hIjo1OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJNYXNsb0Vrc3RyYTIwMGcgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjc0OSwiY2VuYSI6NzQ5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMSIsIm5hendhIjoiTmFwR2F6S2lubGV5TWl4MWwgICAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo0OTksImNlbmEiOjQ5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkNlYnVsYSAxa2cgb3BhayAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NzM4LCJjZW5hIjozNjksImlkU3RQVFUiOiJCIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJNdXN6U2FyS2FtMTg1ZyAgICAgICAgICAgQiIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjI5OSwiY2VuYSI6Mjk5LCJpZFN0UFRVIjoiQiIsImlsb3NjIjoiMSIsIm5hendhIjoiRElQIE9SSUVOVEFMTlkgQ1pPU04gICAgIEIiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjoyOTksImNlbmEiOjI5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlByenlwRG9Tb0FtZXJLYW1pMjBnICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5OSwiY2VuYSI6MTI5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IktldFBpa2FudG55UHVkIDk5MCBnICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTA5OSwiY2VuYSI6MTA5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlNvc0N6b3NNYWRYWEw4NjBnICAgICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5LCJjZW5hIjoxMjksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJaYXBhbG5pY3prU29sZWJvMXN6dCAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjg0OSwiY2VuYSI6ODQ5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMSIsIm5hendhIjoiUsSZY3puaWsgUXVlZW4gMiA0ICAgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6Mjc5OCwiY2VuYSI6MTM5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjIiLCJuYXp3YSI6IkphamEgVyBXeWIgTCAxMHN6dCAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5NywiY2VuYSI6MjE5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjAsOTI3IiwibmF6d2EiOiJNacSZc1pub2dpS3UgYiBrIGtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNzQxfX19LHsidG93YXIiOnsiYnJ1dHRvIjoyOTg5LCJjZW5hIjoyOTg5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiU2VyIEdvdWRhIMWad2lhdDFrZyAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTQ5LCJjZW5hIjo1NDksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJNaXhTYcWCYXRSb3N6cG9WZjE1MGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjozOTgsImNlbmEiOjE5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjIiLCJuYXp3YSI6IkxpbW9ua2Egc3p0ICAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTk5LCJjZW5hIjo1OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDeXRyeW5hIDUwMCBnICAgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjEyOTksImNlbmEiOjEyOTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJQb21pZG9yUGFwcnljejUwMGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjU5OCwiY2VuYSI6Njk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4NTYiLCJuYXp3YSI6IkJhbmFuIEx1eiAgICAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MzQwLCJjZW5hIjo4OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIwLDM3OCIsIm5hendhIjoiT2fDs3Jla1N6a0x1eiAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTQ5OCwiY2VuYSI6MTQ5OCwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkJvcsOzd2thQW1lcnlrIDMwMGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NiwiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHN5Q3IxMzAtMTQwZyAgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQzfX19LHsidG93YXIiOnsiYnJ1dHRvIjozNDUsImNlbmEiOjQ5NSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkNoaXBzeVRvcEZyb21hZ2UxNTBnICAgICBDIiwib3BlciI6ZmFsc2UsInJhYmF0Ijp7Im9waXMiOiIxIiwid2FydCI6LTE1MH19fSx7InRvd2FyIjp7ImJydXR0byI6NjA1LCJjZW5hIjo4NjksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDaGlwc3lMYXkgczEyMGcgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi0yNjR9fX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NywiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHN5Q3J1bjEzMC0xNDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQyfX19LHsidG93YXIiOnsiYnJ1dHRvIjozNDcsImNlbmEiOjQ5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlByYcW8eW5raU5pZXRLZXQxMDBnICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi0xNTJ9fX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NiwiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHlDcnVuMTMwLTE0MGcgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQzfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk5LCJjZW5hIjoxNjk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiUGl6emFLdXJQaWVjejYwMGcgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzAwfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk5LCJjZW5hIjoxNjk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiUGl6Um9kelN6eW5TZXI2MDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzAwfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk4LCJjZW5hIjo2OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIzIiwibmF6d2EiOiJTenlua2FLb25zZXJ3b0tXMjAwICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi02OTl9fX0seyJ0b3dhciI6eyJicnV0dG8iOjY3OCwiY2VuYSI6MzM5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMyIsIm5hendhIjoiTWFrYXJvblBlbm5lUGE1MDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzM5fX19LHsidG93YXIiOnsiYnJ1dHRvIjoyODc0LCJjZW5hIjo0NzksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxMiIsIm5hendhIjoiUElXTyBIRUlORUtFTiAwLDVCICAgICAgIEEiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjg3NH19fV0sInN0UFRVIjpbeyJpZCI6IkEiLCJ3YXJ0IjoyMzAwfSx7ImlkIjoiQiIsIndhcnQiOjgwMH0seyJpZCI6IkMiLCJ3YXJ0Ijo1MDB9LHsiaWQiOiJEIiwid2FydCI6MH0seyJpZCI6IkUiLCJ3YXJ0IjoiWlcifSx7ImlkIjoiRiIsIndhcnQiOjE3MDB9LHsiaWQiOiJHIiwid2FydCI6MH1dLCJwb2RzdW0iOnsid2FsdXRhIjoiUExOIiwic3VtYUJydXR0byI6NTkwNTUsInN1bWFQb2QiOjU1ODgsInN1bWFPcHVzdCI6LTEwNzA5LCJzdW1hTmV0dG8iOlt7ImlkU3RQVFUiOiJBIiwiYnJ1dHRvIjoxOTIwNCwidmF0IjozNTkxfSx7ImlkU3RQVFUiOiJCIiwiYnJ1dHRvIjozNzM0LCJ2YXQiOjI3N30seyJpZFN0UFRVIjoiQyIsImJydXR0byI6MzYxMTcsInZhdCI6MTcyMH1dfSwidG90YWwiOnsiemFwbFp3cm90Ijo1OTc1NX0sIm9wYWsiOnsiZGFuZU9wYWsiOlt7ImNlbmEiOjUwLCJpbG9zYyI6MTQwMDAsIm5hendhIjoiQnV0IFBsYXN0aWsga2F1Y2phIn1dLCJ3YXJ0Ijo3MDB9LCJwbGF0bm9zYyI6W3sicmVzenRhIjpmYWxzZSwiZm9ybWEiOiIyIiwid2FydCI6NTk3NTUsIm5hendhIjoiVklTQSBDQVJEIDA3IDEifV0sInpha1NwcnplZCI6IjIwMjYtMDctMzBUMTc6MjE6MTkuMDAwWiIsIm5yS2FzeSI6Ikthc2EgMSIsImthc2plciI6Ikthc2plciBuciAyNyIsIm5yUGFyYWciOjQxOSwiZ3JhZmlrYSI6MX19fQ.mISQbxs3V0GxEIPLaUviW3n2bgAewkMHOV3dmMo64RFMBXPQin3nMg5BUtX_irNEAjaKqpfHJTXy9sxDncqgo1KbynblPschfOeZWuJmY4wyBHjLkdEJYcqAATGBorl4Awo2kTAoSK0_fiRD6EkOk5ooMS5CiSDueV2t-AJW4POmuhq_KNX7TXyxqGDvKcrFHe2hPoGx4cmayC59ymDJKWcSOyStFkUWm_hpui2IObLmXgPxCj8KjTVtWA6_Qsq8JDLuAcAyCzWvi23AFbCelDUo846IUVe9m-DSUCgiuDDCMLmYCBnul0Q049tY5kMc2IhOOCl5F_Qy9SHTi_k4SQ","header":[{"image":{"id":"EAZ2202160196-1-445785","hash":"DmwwvSQRHLaIxnrFwFSH2ZMvlLZHRrslUP5n/gwzXpU=","data":"Qk02FwAAAAAAAD4AAAAoAAAAMAEAAJMAAAABAAEAAAAAAPgWAAAjLgAAIy4AAAAAAAAAAAAAAAAAAP///wD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////wAAf////////////////////////////////////////8AAP/////AAAAf////////////////////////////////////////AAD////+AAAAA////////////////////////////////////////wAA////+ABVVAD///////////////////////////////////////8AAP///+AAAAAAD///////////////////////////////////////AAD///+AVaqqqAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////wAA////AAAAAAAFVVVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVQD////8AAP///wqqVVVVQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////AAD///8AAAAAAAAqqqqqqqVVVVVVVVVVVVWqqqqqqqqqqqqlUf///wAA////FVJUqqqoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB///8AAP///wAFqgAAABVVVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqqn///AAD//x8KqlVAFVUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//wAA//4fFVWqAAAAA////////////////////////////////VVX//8AAP/8HwqqVAACqoFVVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqAAP//AAD//A8VVagAAAAB////////////////////////////////+qp//wAA//gPCqpQAAVVUKqqqqqlVVVVVVVVVVVVqqqqqqqqqqqqpVYAP/8AAP/wDxVVoAACAAB/////////////////////////////////1V//AAD/4IYKqlAAAVKoVVVVVVqqqqqqqqqqqqpVVVVVVVVVVVVaqqAP/wAA/8EAFVWgAAKoAD/////////////////////////////////6p/8AAP/CgAqqUAABVVUKqqqqpVVVVVVVVVVVVaqqqqqqqqqqqqVVUAP/AAD/hUAVVagAAqoAH///////////////////////////////x/1V/wAA/wqACqpUAAVVKoVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqKqAf8AAP8FQBVVqgAKqoAH//////////H////8f///x///x/8fD//j/6r/AAD/CoAKqlVAVVUBQqqqD4FgEBjAYxjEcBWjEYOYxwGqCgY40VUA/wAA/hVAFVWqqqqqgAf//AcAwDAYxmMYzGMf4xmZmMYx/ExmePH/1X8AAP4VQBVVqqqqqoAH//wHAMAwGMZjGMxjH+MZmZjGMfxMZnjx/9V/AAD+KqAKqlVVVVVAoVVYZjxGKOiP4xjEY+pjEXkBRn9U+P44oKqAfwAA/BVABVWqqqqqgAP/+P48Rjx4gGMYzGAf4xnhh8YB/PgGeOD/6r8AAPwqoAqqVVVVVUBQqrGqPEY2KI5jGMRjlaMRgoPGOajs5jjEVUA/AAD4VVAFVaqqqqqAAf/x/jzGPzjOYxjMc5/jGY+Rxzn8TOZ4jH/1XwAA+CqgCqpVVVVVQAhVUVUAoDAIwKAoBHAqYBFBGMcDVgYOAIoqoB8AAPhVUAVVqqqqqqAA//H/gfEwH+DgeB/4P+B/45//h/8fHgP///qvAADwqqgCqlVVVVVAFKqouqVVNVVVVVVVVVWqqqqaqqqqqqqlVVVQDwAA8VVQBVWqqqqqoAD/+Gf//z///////v//////n////////////U8AAPCKqAKqVVVVVVAIVVQFWqoqqKqqqqxqqlVRVRVFVVVVVVqqqqgHAADxFVQFVaqqqqqoAH/+D///P/j////8f///+f+fx//////////+pwAA4AqoAqpVFVVVVAQqqqqlVVVVVVVVVFVVqqiqqqqqqqqqpVVVVAcAAOAFVAFVqAKqqqoIf/////////////////////////////////9TAADiCqoCqlAAVVVUBFVVVVqqqqqqqqqqqqpVVVVVVVVVVVVaqqqqAwAA4AVUAVWgACqqqqp//////////////////////////////////qsAAOIKqgCqQABVVVVUKqgABVAVwo1UIQNBVawA6wKoLAqgKoIQVVUDAADgBVUBVYAAKqqqqj/4AAP4PwAD+AADwf/wAD8D+DwPwD8AAH//UwAA4gqqAKoAABVVVVQVWqqkqSpVU6VVUtWqaqqVKVK0pSpUpVVqqgMAAOAFVQFVgAAqqqqqP/gAAPg+AAHgAAPB/8AADwP4PA8A/AAAf/+rAADiCqqAqgAAFVVVVCqpX1patKVBSoKrSVXVAUtSqS1KVKlH6lVVAwAA4AVVQFWAACqqqqo/+BuAeDwf/8B+A8H/gPwHA/g8DAP4HqB//1MAAOIKqoAqAAAVVVVUFVq/5WkpWqrV1VLVq6uqpSlStKKlUr/1aqoDAADhFVVAVYAAKqqqqj/4FUB4OB//wP8Dwf+B/gcD+DwAD/AlUH//qwAA4rqqoCoAABVVVVQqqV/aWrqgAMqrK0tVlVdXUqktVUq1f/pVVQMAAOF1VUAVgAAqqqqqf/gawHg4AADA/wPB/4H+BwP4PAAf8Dqwf/9TAADguqqgKkAAVVVVVFVav6VpKVVU1dVS1KurqqWpUrSipVK/9WqqAwAA4X1VUBWgAKqqqqp/+AAA+DwfwMB+A8B/gPwHA/A8CAf4HWB//qcAAPC+qqgKUAFVVVVUKqlVWVq0uulKnqtKlZVVS1SlLUlSqUfqVVQHAADxfUFUBagCqqqCqH/4AAf4PA+B4AADwAHAAA8AADwOAfwAAH/9VwAA8L6AKApVVVVUAVRVWqqiqSpVUqVUUtRUSqqVKKq0pSpUpUVqqA8AAPBfABQFqqqqqgKo//gcA/g/AAf4AQPBgfAAPwIAfA+AfgAQf/qvAAD4vgAKAlVVVVQBUKqpX1lQFQqFVKErQUGsAOsCAK1KgCsAMFVQDwAA+F4ABQGqqqqoAqj/+BUB///gP/8PA//////////8D///////9V8AAPgsAAKCVVVVVAFRVVq/paqqqqqqqlKqqlVVVVVVVKVVVVqqqqAfAAD8XAAFAaqqqqgCof/4GwH///////8D//////////wP///////qvwAA/CwAAoBVVVVUAVKqqV9ZVFVVVVVVK1VVqqqqqqqtSqqqpVVVQD8AAP4UAAFAKqqqqAKj//gAA/g//////wP//////////A///////9U/AAD+CgACoBVVVVQFRVVaqqK1WqqqqqpSqqpVVVVVVVSlVVVaqqqAfwAA/hYAAVAqqqqqAoP/+AAH8B//////A//////////8D///////qn8AAP8LAAKgFVVVVRUCqqgABVKVVVVVVStVVaqqqqqqrAqqqqVVVQD/AAD/BYAFUAqqqqqqj//////4P/////+H//////////////////9U/wAA/4KAAqgFVVVQFRVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqqoAf8AAP+BQAVUAqqoAAIf////////////////////////////////+qv/AAD/wrAKqgFVUAAAKqqqqqVVVVVVVVVVVVWqqqqqqqqqqqqlVVAH/wAA/+FcFVWAqoAAAA/////////////////////////////////1T/8AAP/wr+qqQFUB//wFVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqqgB//AAD/+Ff1VaAoB///gf///////////////////////////////qq//wAA//wr+qpAUB/4D8CqqqqlVVVVVVVVVVVVqqqqqqqqqqqqpVQAf/8AAP/+Ff1VoCB/gAHwf//////////////////////////////VVP//AAD//wqCqlAA/wAAeBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//wAA//+FAFWoAfwAADwFVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqqn//8AAP//wIAKVAP4AAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///AAD//+BAD6oH8AAAD4KqqqVVVVVVVVVVVVWqqqqqqqqqqqqlVD///wAA///4IAfVD/AAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///8AAP///BQH+h/gAAADwVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqH////AAD///4Cq/wfwAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf////wAA////AFWoP8AH/AHg//////////////////////////////////8AAP///8AKVH+A///g8P//////////////////////////////////AAD////wACh8g/////B//////////////////////////////////wAA/////AAA/g/////4f/////////////////////////////////8AAP/////gAP8f///B+H//////////////////////////////////AAD//////gH/j///gPw//////////////////////////////////wAA///////h/+///5j8P/////////////////////////////////8AAP//////4f////+I/D//////////////////////////////////AAD//////+H///H/w/4//////////////////////////////////wAA///////D///h///+H/////////////////////////////////8AAP//////w///wP///h//////////////////////////////////AAD//////8P//4D///4f/////////////////////////////////wAA///////D//+A///+H/////////////////////////////////8AAP//////w///AP///h//////////////////////////////////AAD//////8P//wH/+H4f/////////////////////////////////wAA///////D//8B//B+H/////////////////////////////////8AAP//////4//+Af/gfh//////////////////////////////////AAD//////+P//gH/4H4f/////////////////////////////////wAA///////h//4D/+B+P/////////////////////////////////8AAP//////4f/+A//AfD//////////////////////////////////AAD//////+H//gf/wPw//////////////////////////////////wAA///////h//+H/8D8P/////////////////////////////////8AAP//////8P//D/+B+H//////////////////////////////////AAD///////D//x//gfh//////////////////////////////////wAA///////4f////6Pwf/////////////////////////////////8AAP//////+H/////H8P//////////////////////////////////AAD///////g/////h+D//////////////////////////////////wAA///////4H////9/A//////////////////////////////////8AAP///////B//////wf//////////////////////////////////AAD///////4P/////4P//////////////////////////////////wAA///////+B/////8D//////////////////////////////////8AAP///////wP////+B///////////////////////////////////AAD///////+B/////A///////////////////////////////////wAA////////AP////gf//////////////////////////////////8AAP///////gA////gP///////////////////////////////////AAD///////wYD///gH///////////////////////////////////wAA///////8HAH//gH///////////////////////////////////8AAP//////+D4Af/AD////////////////////////////////////AAD///////B/AAAAB////////////////////////////////////wAA///////g/8AAAB////////////////////////////////////8AAP/////wAf/wAAB/////////////////////////////////////AAD/////4Af//AAf/////////////////////////////////////wAA/////+Af//wf//////////////////////////////////////8AAP/////gP//+H///////////////////////////////////////AAD/////8H///h///////////////////////////////////////wAA//////////8f//////////////////////////////////////8AAP//////////D///////////////////////////////////////AAD//////////4f//////////////////////////////////////wAA///////////D//////////////////////////////////////8AAP//////////4MP/////////////////////////////////////AAD///////////AB/////////////////////////////////////wAA///////////8Af////////////////////////////////////8AAP///////////4H/////////////////////////////////////AAD////////////D/////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAA="}},{"headerText":{"headerTextLines":"
BIEDRONKA "CODZIENNIE NISKIE CENY" 5834
34-325 ŁODYGOWICE UL. BESKIDZKA 8
JERONIMO MARTINS POLSKA S.A.
62-025 KOSTRZYN UL.ŻNIWNA 5
"}},{"headerData":{"tin":"7791011327","docNumber":608827,"date":"2026-07-30T17:21:19.000Z","CPS":56}}],"body":[{"sellLine":{"name":"Tonic A","vatId":"A","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Citrus mix A","vatId":"A","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"NapGazCocaCoMIX2,5l A","vatId":"A","price":899,"total":1798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"NapGazCocaCoMIX2,5l A","vatId":"A","price":899,"total":1798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"OsheeVitamMagnez1,1l C","vatId":"C","price":449,"total":449,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Polaris NGaz 1,5l A","vatId":"A","price":119,"total":714,"quantity":"6","isStorno":false}},{"sellLine":{"name":"Tost Maślany 500g C","vatId":"C","price":399,"total":1596,"quantity":"4","isStorno":false}},{"sellLine":{"name":"WódkaB Bocian 0,7l A","vatId":"A","price":5399,"total":10798,"quantity":"2","isStorno":false}},{"discountLine":{"base":10798,"value":2800,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"A"}},{"sellLine":{"name":"OlejRzepZOliwWyb1l C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaMleczCzek350g C","vatId":"C","price":649,"total":649,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PodpałNatur64Kostki A","vatId":"A","price":399,"total":399,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaFitNesOat300g C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaFitNesQui300g C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"DO MOJEJ IDĘ A","vatId":"A","price":399,"total":798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"KiełbBrockaSokołkg C","vatId":"C","price":3290,"total":2866,"quantity":"0,871","isStorno":false}},{"discountLine":{"base":2866,"value":697,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"KiełbBrockaSokołkg C","vatId":"C","price":3290,"total":2737,"quantity":"0,832","isStorno":false}},{"discountLine":{"base":2737,"value":665,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"GuancialeCubGB150g C","vatId":"C","price":749,"total":3745,"quantity":"5","isStorno":false}},{"sellLine":{"name":"Bagietka 175g C","vatId":"C","price":379,"total":1137,"quantity":"3","isStorno":false}},{"sellLine":{"name":"MasloEkstra200g C","vatId":"C","price":599,"total":599,"quantity":"1","isStorno":false}},{"sellLine":{"name":"NapGazKinleyMix1l A","vatId":"A","price":749,"total":749,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Cebula 1kg opak C","vatId":"C","price":499,"total":499,"quantity":"1","isStorno":false}},{"sellLine":{"name":"MuszSarKam185g B","vatId":"B","price":369,"total":738,"quantity":"2","isStorno":false}},{"sellLine":{"name":"DIP ORIENTALNY CZOSN B","vatId":"B","price":299,"total":299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PrzypDoSoAmerKami20g B","vatId":"B","price":299,"total":299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"KetPikantnyPud 990 g B","vatId":"B","price":1299,"total":1299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"SosCzosMadXXL860g B","vatId":"B","price":1099,"total":1099,"quantity":"1","isStorno":false}},{"sellLine":{"name":"ZapalniczkSolebo1szt A","vatId":"A","price":129,"total":129,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Ręcznik Queen 2 4 A","vatId":"A","price":849,"total":849,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Jaja W Wyb L 10szt C","vatId":"C","price":1399,"total":2798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"MięsZnogiKu b k kg C","vatId":"C","price":2199,"total":2038,"quantity":"0,927","isStorno":false}},{"discountLine":{"base":2038,"value":741,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"Ser Gouda Świat1kg C","vatId":"C","price":2989,"total":2989,"quantity":"1","isStorno":false}},{"sellLine":{"name":"MixSałatRoszpoVf150g C","vatId":"C","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Limonka szt C","vatId":"C","price":199,"total":398,"quantity":"2","isStorno":false}},{"sellLine":{"name":"Cytryna 500 g C","vatId":"C","price":599,"total":599,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PomidorPaprycz500g C","vatId":"C","price":1299,"total":1299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Banan Luz C","vatId":"C","price":699,"total":598,"quantity":"0,856","isStorno":false}},{"sellLine":{"name":"OgórekSzkLuz C","vatId":"C","price":899,"total":340,"quantity":"0,378","isStorno":false}},{"sellLine":{"name":"BorówkaAmeryk 300g C","vatId":"C","price":1498,"total":1498,"quantity":"1","isStorno":false}},{"sellLine":{"name":"ChipsyCr130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":243,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyTopFromage150g C","vatId":"C","price":495,"total":495,"quantity":"1","isStorno":false}},{"discountLine":{"base":495,"value":150,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyLay s120g C","vatId":"C","price":869,"total":869,"quantity":"1","isStorno":false}},{"discountLine":{"base":869,"value":264,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyCrun130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":242,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PrażynkiNietKet100g C","vatId":"C","price":499,"total":499,"quantity":"1","isStorno":false}},{"discountLine":{"base":499,"value":152,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipyCrun130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":243,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PizzaKurPiecz600g C","vatId":"C","price":1699,"total":1699,"quantity":"1","isStorno":false}},{"discountLine":{"base":1699,"value":300,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PizRodzSzynSer600g C","vatId":"C","price":1699,"total":1699,"quantity":"1","isStorno":false}},{"discountLine":{"base":1699,"value":300,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"SzynkaKonserwoKW200 C","vatId":"C","price":699,"total":2097,"quantity":"3","isStorno":false}},{"discountLine":{"base":2097,"value":699,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"MakaronPennePa500g C","vatId":"C","price":339,"total":1017,"quantity":"3","isStorno":false}},{"discountLine":{"base":1017,"value":339,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PIWO HEINEKEN 0,5B A","vatId":"A","price":479,"total":5748,"quantity":"12","isStorno":false}},{"discountLine":{"base":5748,"value":2874,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"A"}},{"discountSummary":{"discounts":10709}},{"vatSummary":{"currency":"PLN","vatRatesSummary":[{"vatId":"A","vatRate":2300,"vatSale":19204,"vatAmount":3591},{"vatId":"B","vatRate":800,"vatSale":3734,"vatAmount":277},{"vatId":"C","vatRate":500,"vatSale":36117,"vatAmount":1720}]}},{"sumInCurrency":{"fiscalTotal":59055,"totalWithPacks":59755,"currency":"PLN","printBig":true,"printable":true}},{"section":{"type":0}},{"section":{"type":1}},{"pack":{"name":"But Plastik kaucja","price":50,"quantity":"14","total":700,"isNegative":false}},{"section":{"type":100,"amount":700,"currency":"PLN"}},{"section":{"type":101,"amount":59755,"currency":"PLN"}},{"section":{"type":3}},{"payment":{"type":"2","amount":59755,"name":"VISA CARD 07 1","currency":"PLN"}},{"fiscalFooter":{"billNumber":419,"uniqueNumber":"EAZ2202160196","cashNumber":"Kasa 1","cashier":"Kasjer nr 27","CPS":56,"date":"2026-07-30T17:21:19.000Z"}},{"addLine":{"id":0,"data":"
Nr transakcji:4320
","width":80,"CPS":56}},{"addLine":{"id":30,"data":"
Numer:5834260730432001
","width":80,"CPS":56}},{"addLine":{"id":41,"data":"
Udzielono łącznie opustów                   107,09
","width":80,"CPS":56}},{"addLine":{"id":41,"data":"
w tym:
","width":80,"CPS":56}},{"addLine":{"id":41,"data":"
  Promocje                                  107,09
","width":80,"CPS":56}},{"addLine":{"id":6,"data":"
Numer karty:99001*****936
","width":80,"CPS":56}},{"barcode":{"id":5,"data":"MTAwMDA1ODM0NDMyMDI0MDIwMTgxOA=="}},{"sysNumber":{"data":"4320","width":80,"CPS":56}}],"sign":"9884906F1B375741B11083CB694BE25B79F66E001EC24307395DDD98CA3AE1114C0573D08A7DE7320E4152D5FF8AB34402368AAA97C72535F2F6CC439DCAA0A3529BCA76E53EC7217CE7995AE266638C320478CB91D10961CA80013181A2B978030A3691302848AD3F7E2443E8490E939A28312E428920EE795DADF80256E0F3A6BA1ABF28D5FB4D7CB1A860EF29CAC51DEDA13E81B1E1C99AC82E7DCA60C92967123B24AD1645169BF869BA2D8839B2E65E03F10A3F0A8D356D580EBF42CABC2432EE01C0320B35AF8B6DC015B09E943528F38E885157BD9BE0D2502822B830C230B9980819EE974434E3DB58E6431CD8884E38297917F432F521D38BF93849"} diff --git a/billsplit/test/helpers.dart b/billsplit/test/helpers.dart deleted file mode 100644 index d341bb1c..00000000 --- a/billsplit/test/helpers.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:io'; - -import 'package:billsplit/state/app_state.dart'; -import 'package:billsplit/ui/receipt_screen.dart'; -import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; - -const _pathChannel = MethodChannel('plugins.flutter.io/path_provider'); - -/// Fake [FileSelectorPlatform] driving open/save dialogs in tests. -/// -/// file_selector is swapped at the platform-interface level rather than at the -/// method channel: the real implementation talks Pigeon, so a MethodChannel -/// mock would not intercept it. -class _FakeFileSelector extends FileSelectorPlatform { - _FakeFileSelector({ - this.pickResult, - this.pickName, - this.saveResult, - this.saveThrows = false, - }); - - final Uint8List? pickResult; - final String? pickName; - final String? saveResult; - final bool saveThrows; - - @override - Future openFile({ - List? acceptedTypeGroups, - String? initialDirectory, - String? confirmButtonText, - }) async { - if (pickResult == null) return null; - return XFile.fromData(pickResult!, name: pickName ?? 'picked.json'); - } - - @override - Future getSaveLocation({ - List? acceptedTypeGroups, - SaveDialogOptions options = const SaveDialogOptions(), - }) async { - if (saveThrows) { - throw PlatformException(code: 'boom', message: 'disk full'); - } - if (saveResult == null) return null; - return FileSaveLocation(saveResult!); - } -} - -/// Installs a fake file_selector platform for the current test. -/// -/// `pickResult`: bytes returned by openFile (null → user cancelled). -/// `saveResult`: path returned by getSaveLocation (null → user cancelled). -/// `saveThrows`: getSaveLocation raises a PlatformException. -void mockFilePicker({ - Uint8List? pickResult, - String? pickName, - String? saveResult, - bool saveThrows = false, -}) { - TestWidgetsFlutterBinding.ensureInitialized(); - FileSelectorPlatform.instance = _FakeFileSelector( - pickResult: pickResult, - pickName: pickName, - saveResult: saveResult, - saveThrows: saveThrows, - ); - // Record the write instead of touching the disk: a real dart:io future never - // completes inside testWidgets' fake-async zone, so the export flow would - // stall before showing its snackbar. - exportedFiles.clear(); - debugSetExportWriter((path, bytes, mimeType) async { - exportedFiles[path] = bytes; - }); -} - -/// Paths and payloads captured by the stub export writer. -final Map exportedFiles = {}; - -/// Points path_provider's documents directory at [dir]. -void mockPathProvider(Directory dir) { - TestWidgetsFlutterBinding.ensureInitialized(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_pathChannel, (call) async => dir.path); -} - -/// Clears the path_provider channel mock and the file_selector fake. -void clearChannelMocks() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_pathChannel, null); - FileSelectorPlatform.instance = _FakeFileSelector(); - debugSetExportWriter(null); -} - -/// Creates an [AppState] persisted under a fresh temp directory. -AppState tempState() { - final dir = Directory.systemTemp.createTempSync('billsplit_ui'); - addTearDown(() => dir.deleteSync(recursive: true)); - return AppState(overrideDir: dir)..loaded = true; -} - -/// Pumps [home] inside the app shell with [state] provided. -Future pumpApp(WidgetTester tester, AppState state, Widget home) { - return tester.pumpWidget( - ChangeNotifierProvider.value( - value: state, - child: MaterialApp(home: home), - ), - ); -} - -/// Fires the debounced save timer so tests end with no pending timers. -Future flushSaves(WidgetTester tester) => - tester.pump(const Duration(milliseconds: 450)); diff --git a/billsplit/test/models_test.dart b/billsplit/test/models_test.dart deleted file mode 100644 index 0cfbb723..00000000 --- a/billsplit/test/models_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:convert'; - -import 'package:billsplit/domain/models.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('formatPln handles zero, cents padding and negatives', () { - expect(formatPln(0), '0,00 zł'); - expect(formatPln(5), '0,05 zł'); - expect(formatPln(123456), '1234,56 zł'); - expect(formatPln(-7), '-0,07 zł'); - }); - - test('Ids are unique and ordered enough', () { - final a = Ids.next(); - final b = Ids.next(); - expect(a, isNot(b)); - }); - - test('Person/Group/Assignment/Item/Receipt JSON roundtrips', () { - final p = Person(id: 'p1', name: 'Ala', drinks: false); - expect(Person.fromJson(p.toJson()).drinks, isFalse); - - final g = Group(id: 'g1', name: 'Meat', memberIds: {'p1'}); - expect(Group.fromJson(g.toJson()).memberIds, {'p1'}); - - final a = Assignment(mode: AssignMode.group, groupId: 'g1'); - final a2 = Assignment.fromJson(a.toJson()); - expect(a2.mode, AssignMode.group); - expect(a2.groupId, 'g1'); - - final i = Item( - id: 'i1', - name: 'Piwo', - totalGr: 500, - qty: 2, - unitPriceGr: 250, - discountGr: -100, - category: Categories.alcohol, - assignment: Assignment(mode: AssignMode.custom, customIds: {'p1'}), - ); - final i2 = Item.fromJson( - jsonDecode(jsonEncode(i.toJson())) as Map, - ); - expect(i2.assignment.customIds, {'p1'}); - expect(i2.discountGr, -100); - - final r = Receipt( - id: 'r1', - title: 't', - store: 's', - items: [i], - participantIds: {'p1'}, - paidTotalGr: 500, - ); - final r2 = Receipt.fromJson( - jsonDecode(jsonEncode(r.toJson())) as Map, - ); - expect(r2.itemsTotalGr, 500); - expect(r2.participantIds, {'p1'}); - expect(r2.store, 's'); - }); - - test('fromJson tolerates missing optional fields', () { - final p = Person.fromJson(const {'id': 'x', 'name': 'n'}); - expect(p.drinks, isTrue); - final g = Group.fromJson(const {'id': 'x', 'name': 'n'}); - expect(g.memberIds, isEmpty); - final a = Assignment.fromJson(const {}); - expect(a.mode, AssignMode.everyone); - final i = Item.fromJson(const {'id': 'x', 'name': 'n', 'totalGr': 1}); - expect(i.qty, 1); - expect(i.category, Categories.general); - final r = Receipt.fromJson(const {'id': 'x', 'title': 't', 'date': 'bad'}); - expect(r.items, isEmpty); - expect(r.paidTotalGr, isNull); - }); -} diff --git a/billsplit/test/people_flows_test.dart b/billsplit/test/people_flows_test.dart deleted file mode 100644 index 7c297b12..00000000 --- a/billsplit/test/people_flows_test.dart +++ /dev/null @@ -1,154 +0,0 @@ -// 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 deleted file mode 100644 index 09f58809..00000000 --- a/billsplit/test/receipt_flows_test.dart +++ /dev/null @@ -1,140 +0,0 @@ -// 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 deleted file mode 100644 index 07059e91..00000000 --- a/billsplit/test/receipt_items_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -// 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/split_engine_test.dart b/billsplit/test/split_engine_test.dart deleted file mode 100644 index 9451f673..00000000 --- a/billsplit/test/split_engine_test.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:billsplit/domain/eparagon_parser.dart'; -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/split_engine.dart'; -import 'package:flutter_test/flutter_test.dart'; - -List makePeople(int n, {Set nonDrinkers = const {}}) => [ - for (var i = 0; i < n; i++) - Person(id: 'p$i', name: 'P$i', drinks: !nonDrinkers.contains(i)), - ]; - -void main() { - test('splitAmount: exact sum, deterministic, rotation moves the extra', () { - final shares = splitAmount(100, ['b', 'a', 'c']); // rotation 0 - expect(shares.values.reduce((a, b) => a + b), 100); - expect(shares['a'], 34); // sorted order, start at index 0 - expect(shares['b'], 33); - expect(shares['c'], 33); - final rotated = splitAmount(100, ['b', 'a', 'c'], rotation: 1); - expect(rotated.values.reduce((a, b) => a + b), 100); - expect(rotated['b'], 34); // start shifted by one - // Same inputs -> same output (deterministic). - expect(splitAmount(100, ['b', 'a', 'c'], rotation: 1), rotated); - }); - - test('splitAmount: negative totals (returns) also sum exactly', () { - final shares = splitAmount(-100, ['a', 'b', 'c']); - expect(shares.values.reduce((a, b) => a + b), -100); - expect(shares.values.every((v) => v == -33 || v == -34), isTrue); - }); - - test('splitAmount fuzz: sum invariant over random cases', () { - final rnd = Random(42); - for (var i = 0; i < 500; i++) { - final n = 1 + rnd.nextInt(9); - final total = rnd.nextInt(100000) - 5000; - final ids = List.generate(n, (j) => 'p$j'); - final shares = splitAmount(total, ids, rotation: rnd.nextInt(1000)); - expect( - shares.values.fold(0, (a, b) => a + b), - total, - reason: 'total=$total n=$n', - ); - final values = shares.values.toList(); - expect( - values.reduce(max) - values.reduce(min), - lessThanOrEqualTo(1), - reason: 'shares differ by more than 1 grosz', - ); - } - }); - - test('modes resolve correctly and intersect with receipt participants', () { - final people = makePeople(4, nonDrinkers: {3}); - final group = Group(id: 'g1', name: 'Meat', memberIds: {'p0', 'p2'}); - final receipt = Receipt( - id: 'r', - title: 't', - participantIds: {'p0', 'p1', 'p3'}, // p2 not on this receipt - ); - Item mk(Assignment a) => - Item(id: Ids.next(), name: 'x', totalGr: 300, assignment: a); - - expect( - resolveParticipants(mk(Assignment()), receipt, people, [group]) - .map((p) => p.id), - ['p0', 'p1', 'p3'], - ); - expect( - resolveParticipants( - mk(Assignment(mode: AssignMode.drinkers)), - receipt, - people, - [group], - ).map((p) => p.id), - ['p0', 'p1'], - ); - expect( - resolveParticipants( - mk(Assignment(mode: AssignMode.nonDrinkers)), - receipt, - people, - [group], - ).map((p) => p.id), - ['p3'], - ); - // Group intersected with receipt pool: p2 excluded. - expect( - resolveParticipants( - mk(Assignment(mode: AssignMode.group, groupId: 'g1')), - receipt, - people, - [group], - ).map((p) => p.id), - ['p0'], - ); - expect( - resolveParticipants( - mk(Assignment(mode: AssignMode.custom, customIds: {'p1', 'p2'})), - receipt, - people, - [group], - ).map((p) => p.id), - ['p1'], - ); - // Missing group -> unassigned. - expect( - resolveParticipants( - mk(Assignment(mode: AssignMode.group, groupId: 'gone')), - receipt, - people, - [group], - ), - isEmpty, - ); - }); - - test('real receipt: 8 people, all drinkers — reconciles to 597.55', () { - final receipt = parseEParagon( - File('test/fixtures/sample_receipt.json').readAsStringSync(), - ); - final people = makePeople(8); - receipt.participantIds.addAll(people.map((p) => p.id)); - - final split = computeSplit(receipt, people, []); - expect(split.unassigned, isEmpty); - // Exact reconciliation is the hard invariant. - expect(split.assignedTotalGr, 59755); - // 59755 / 8 = 7469.375 -> everyone lands within a few grosze of that; - // remainder rotation spreads the extras instead of stacking them on the - // alphabetically-first person. - final totals = split.perPerson.values.map((t) => t.totalGr).toList(); - for (final t in totals) { - expect(t, inInclusiveRange(7469 - 15, 7470 + 15)); - } - // Alcohol: 79.98 + 28.74 = 108.72 split over 8 -> 13.59 each ±2 gr. - final alcohol = split.perPerson.values.map((t) => t.alcoholGr).toList(); - expect(alcohol.fold(0, (a, b) => a + b), 10872); - for (final a in alcohol) { - expect(a, inInclusiveRange(1357, 1361)); - } - }); - - test('real receipt: 2 non-drinkers pay no alcohol, totals still exact', () { - final receipt = parseEParagon( - File('test/fixtures/sample_receipt.json').readAsStringSync(), - ); - final people = makePeople(8, nonDrinkers: {6, 7}); - receipt.participantIds.addAll(people.map((p) => p.id)); - - final split = computeSplit(receipt, people, []); - expect(split.assignedTotalGr, 59755); - expect(split.perPerson['p6']!.alcoholGr, 0); - expect(split.perPerson['p7']!.alcoholGr, 0); - // 10872 over 6 drinkers = 1812 each ±2 gr, summing exactly. - final drinkerAlcohol = [ - for (var i = 0; i < 6; i++) split.perPerson['p$i']!.alcoholGr, - ]; - expect(drinkerAlcohol.fold(0, (a, b) => a + b), 10872); - for (final a in drinkerAlcohol) { - expect(a, inInclusiveRange(1810, 1814)); - } - // Non-drinkers pay strictly less. - expect( - split.perPerson['p7']!.totalGr, - lessThan(split.perPerson['p0']!.totalGr), - ); - }); - - test('items with no possible participants are reported, not lost', () { - final people = makePeople(2); // both drinkers - final receipt = Receipt(id: 'r', title: 't', participantIds: {'p0', 'p1'}); - receipt.items.add( - Item( - id: 'i1', - name: 'water', - totalGr: 500, - assignment: Assignment(mode: AssignMode.nonDrinkers), - ), - ); - receipt.items.add(Item(id: 'i2', name: 'bread', totalGr: 300)); - - final split = computeSplit(receipt, people, []); - expect(split.unassigned.map((i) => i.id), ['i1']); - expect(split.assignedTotalGr, 300); - }); -} diff --git a/billsplit/test/ui_flows_test.dart b/billsplit/test/ui_flows_test.dart deleted file mode 100644 index 42452909..00000000 --- a/billsplit/test/ui_flows_test.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/main.dart' as app; -import 'package:billsplit/state/app_state.dart'; -import 'package:billsplit/ui/home_screen.dart'; -import 'package:billsplit/ui/people_screen.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() { - tearDown(clearChannelMocks); - - group('main()', () { - testWidgets('boots with the real load path', (tester) async { - final dir = Directory.systemTemp.createTempSync('billsplit_main'); - addTearDown(() => dir.deleteSync(recursive: true)); - mockPathProvider(dir); - app.main(); - await tester.pump(); - await tester.pump(); - expect(find.text('BillSplit'), findsOneWidget); - }); - }); - - group('home', () { - testWidgets('import cancel does nothing', (tester) async { - mockFilePicker(); - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.text('Import e-paragon')); - await tester.pumpAndSettle(); - expect(s.receipts, isEmpty); - }); - - testWidgets('import parse error shows message', (tester) async { - mockFilePicker(pickResult: Uint8List.fromList('zzz'.codeUnits)); - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.text('Import e-paragon')); - await tester.pumpAndSettle(); - expect(find.textContaining('neither valid JSON'), findsOneWidget); - await flushSaves(tester); - }); - - testWidgets('import invalid UTF-8 hits the generic handler', - (tester) async { - mockFilePicker(pickResult: Uint8List.fromList([0xFF, 0xFE, 0x00])); - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.text('Import e-paragon')); - await tester.pumpAndSettle(); - expect(find.textContaining('Import failed'), findsOneWidget); - }); - - testWidgets('import success navigates; warning shows a snackbar', - (tester) async { - final fixture = - File('test/fixtures/sample_receipt.json').readAsBytesSync(); - mockFilePicker(pickResult: fixture); - final s = tempState()..addPerson('Ala'); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.text('Import e-paragon')); - await tester.pumpAndSettle(); - expect(s.receipts, hasLength(1)); - expect(find.byType(ReceiptScreen), findsOneWidget); - await flushSaves(tester); - }); - - testWidgets('import surfaces parser warnings', (tester) async { - final payload = { - 'dokument': { - 'paragon': { - 'pozycja': [ - { - 'towar': {'nazwa': 'Chleb', 'ilosc': 1, 'brutto': 500}, - }, - ], - 'podsum': {'sumaBrutto': 999}, - }, - }, - }; - final body = base64Url.encode(utf8.encode(jsonEncode(payload))); - final wrapper = jsonEncode({'data': 'h.$body.s'}); - mockFilePicker(pickResult: Uint8List.fromList(utf8.encode(wrapper))); - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.text('Import e-paragon')); - await tester.pumpAndSettle(); - expect(find.textContaining('differs'), findsOneWidget); - await flushSaves(tester); - }); - - testWidgets('manual create: cancel, empty and valid', (tester) async { - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - - await tester.tap(find.text('New receipt')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(s.receipts, isEmpty); - - await tester.tap(find.text('New receipt')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Create')); - await tester.pumpAndSettle(); - expect(s.receipts, isEmpty); - - await tester.tap(find.text('New receipt')); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Pizza night'); - await tester.tap(find.text('Create')); - await tester.pumpAndSettle(); - expect(s.receipts.single.title, 'Pizza night'); - expect(find.byType(ReceiptScreen), findsOneWidget); - await tester.pageBack(); - await tester.pumpAndSettle(); - - await tester.tap(find.text('New receipt')); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Kebab'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - expect(s.receipts.first.title, 'Kebab'); - await flushSaves(tester); - }); - - testWidgets('list tile navigates; dismiss asks and deletes', - (tester) async { - final s = tempState(); - demoReceipt(s); - await pumpApp(tester, s, const HomeScreen()); - await flushSaves(tester); - - expect(find.textContaining('4 items'), findsOneWidget); - await tester.tap(find.text('Demo')); - await tester.pumpAndSettle(); - expect(find.byType(ReceiptScreen), findsOneWidget); - await tester.pageBack(); - await tester.pumpAndSettle(); - - await tester.drag(find.byType(Dismissible), const Offset(-600, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - expect(s.receipts, hasLength(1)); - - await tester.drag(find.byType(Dismissible), const Offset(-600, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Delete')); - await tester.pumpAndSettle(); - expect(s.receipts, isEmpty); - await flushSaves(tester); - }); - - testWidgets('people button opens roster', (tester) async { - final s = tempState(); - await pumpApp(tester, s, const HomeScreen()); - await tester.tap(find.byIcon(Icons.group)); - await tester.pumpAndSettle(); - expect(find.byType(PeopleScreen), findsOneWidget); - }); - }); -} diff --git a/billsplit/test/widget_test.dart b/billsplit/test/widget_test.dart deleted file mode 100644 index e37c0aba..00000000 --- a/billsplit/test/widget_test.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:billsplit/main.dart'; -import 'package:billsplit/state/app_state.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('app builds and shows empty state', (tester) async { - // Real file IO never completes inside the fake-async test zone, so skip - // load() and mark the state ready directly; this is a pure widget smoke - // test (persistence is exercised by the domain tests / at runtime). - final state = AppState()..loaded = true; - await tester.pumpWidget(BillSplitApp(state: state)); - await tester.pump(); - expect(find.text('BillSplit'), findsOneWidget); - expect(find.textContaining('No receipts yet'), findsOneWidget); - }); -} diff --git a/billsplit/test/xlsx_export_test.dart b/billsplit/test/xlsx_export_test.dart deleted file mode 100644 index f9be9de2..00000000 --- a/billsplit/test/xlsx_export_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:io'; - -import 'package:billsplit/domain/eparagon_parser.dart'; -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/xlsx_export.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('exports a non-trivial xlsx for the real receipt', () { - final receipt = parseEParagon( - File('test/fixtures/sample_receipt.json').readAsStringSync(), - ); - final people = [ - for (var i = 0; i < 8; i++) - Person(id: 'p$i', name: 'Person ${i + 1}', drinks: i < 6), - ]; - receipt.participantIds.addAll(people.map((p) => p.id)); - - final bytes = exportXlsx(receipt: receipt, roster: people, groups: []); - expect(bytes.length, greaterThan(4000)); - // PK zip magic. - expect(bytes[0], 0x50); - expect(bytes[1], 0x4B); - - // Write out for external validation (recalc via LibreOffice + openpyxl). - final out = File('build/test_export.xlsx') - ..createSync(recursive: true) - ..writeAsBytesSync(bytes); - expect(out.existsSync(), isTrue); - }); -} diff --git a/billsplit/test/xlsx_extra_test.dart b/billsplit/test/xlsx_extra_test.dart deleted file mode 100644 index fcf912d9..00000000 --- a/billsplit/test/xlsx_extra_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:billsplit/domain/models.dart'; -import 'package:billsplit/domain/xlsx_export.dart'; -import 'package:billsplit/ui/receipt_screen.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('export handles no store/paid, unassigned items and non-drinkers', () { - final people = [ - Person(id: 'a', name: 'Ala'), - Person(id: 'b', name: 'Bob', drinks: false), - ]; - final receipt = Receipt( - id: 'r', - title: 'Manual', - participantIds: {'a', 'b'}, - 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: 'Ghost', - totalGr: 100, - assignment: Assignment(mode: AssignMode.custom), - ), - ], - ); - final bytes = exportXlsx(receipt: receipt, roster: people, groups: []); - expect(bytes.length, greaterThan(1000)); - expect(bytes[0], 0x50); - }); - - // Exercises the REAL disk writer, deliberately as a plain `test` rather than - // a `testWidgets`: a real dart:io future never completes inside the - // fake-async zone, which is the whole reason the export flow writes through - // an injectable seam. - test('the real export writer puts the bytes on disk', () async { - final dir = Directory.systemTemp.createTempSync('billsplit_export'); - addTearDown(() => dir.deleteSync(recursive: true)); - final target = '${dir.path}/split.xlsx'; - final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]); - - debugSetExportWriter(null); // restore the real writer - await writeExport(target, payload, 'application/vnd.ms-excel'); - - expect(File(target).readAsBytesSync(), payload); - }); -} diff --git a/docs/superpowers/contracts/archive-billsplit-2026-08-23.json b/docs/superpowers/contracts/archive-billsplit-2026-08-23.json new file mode 100644 index 00000000..6bdea775 --- /dev/null +++ b/docs/superpowers/contracts/archive-billsplit-2026-08-23.json @@ -0,0 +1,21 @@ +{ + "title": "Archive billsplit together with every artifact coupled to it", + "objective": "Remove the billsplit Flutter app from testsAndMisc without leaving the repo in a state where a gate fails or a config rule points at a missing path. billsplit is the coupled case the earlier archive commit deliberately deferred: a coverage package, an app_icons registry entry, a CI workflow and two paired binary rules all name it.", + "acceptance_criteria": [ + "billsplit/ and python_pkg/billsplit_coverage/ are both present in kuhyx/testsAndMisc-archive, confirmed via the GitHub API before the local delete.", + "Archived commit count equals the monorepo count for those paths (8), and tracked file counts match exactly (73 and 5).", + "python_pkg/app_icons/apps.py no longer contains a billsplit AppIcon entry, and the app_icons registry still imports cleanly.", + "The repo-wide pytest gate still reports 100% coverage, with the test count reduced by exactly the 19 billsplit_coverage tests and nothing else.", + "check_no_binaries.sh exits 0 with a .binary-allowlist that now carries no patterns.", + ".gitignore contains no billsplit rule; the ! overrides are removed in the same commit as the allowlist patterns they paired with, so the two halves never disagree.", + "No file under .github/workflows/ references billsplit or billsplit_coverage.", + "AGENTS.md no longer presents billsplit as a live app, while keeping the both-halves guidance for any future Flutter app.", + "pre-commit passes without --no-verify." + ], + "out_of_scope": [ + "Removing python_pkg/app_icons itself, which serves eleven other apps.", + "Changing how check_no_binaries.sh or the coverage gate work; this commit only removes data they read.", + "Historical planning documents that mention billsplit as past work." + ], + "verifier": "gh api confirms both paths in the archive; git ls-files counts match 73/5; diff -r clean; python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py reports 2095 passed at 100%; ./meta/scripts/check_no_binaries.sh exits 0; ./meta/scripts/check_directory_depth.sh --all drops 451 -> 417; git grep billsplit finds only intentional prose." +} diff --git a/docs/superpowers/evidence/archive-billsplit-2026-08-23.json b/docs/superpowers/evidence/archive-billsplit-2026-08-23.json new file mode 100644 index 00000000..3db05fc6 --- /dev/null +++ b/docs/superpowers/evidence/archive-billsplit-2026-08-23.json @@ -0,0 +1,64 @@ +{ + "intent": "Archive the billsplit Flutter app to testsAndMisc-archive together with everything coupled to it, so the monorepo's 100% coverage gate and binary-allowlist stay consistent.", + "scope": [ + "billsplit/ (73 files) - removed", + "python_pkg/billsplit_coverage/ (5 files) - removed, its only purpose was gating billsplit", + ".github/workflows/billsplit-ci.yml - removed", + "python_pkg/app_icons/apps.py - billsplit registry entry removed", + ".binary-allowlist, .gitignore, .prettierignore, AGENTS.md - billsplit rules removed", + "Non-goal: app_icons itself stays; only its billsplit entry goes" + ], + "changes": [ + "Extracted billsplit/ and python_pkg/billsplit_coverage/ in one filter-repo pass (8 commits, matching the monorepo's count for those paths) and merged into kuhyx/testsAndMisc-archive, where billsplit_coverage joins the existing python_pkg/ tree.", + "Deleted the app_icons AppIcon entry keyed 'billsplit', whose repo= pointed at _HOME/testsAndMisc/billsplit. app_icons stays in the repo and its tests iterate APPS generically, so a dangling entry would have failed the suite.", + "Emptied .binary-allowlist: billsplit's launcher icons were its only entries. The policy header is kept and now records that no exception is in force.", + "Removed the 27-line billsplit block from .gitignore (the ! overrides that paired with the allowlist) and the .prettierignore fixture line.", + "Rewrote the AGENTS.md 'Adding a Flutter app' section: the both-halves guidance is still correct for a future app, but no longer claims billsplit exists, and notes a Flutter app now belongs in its own repo." + ], + "verification": [ + { + "command": "git ls-files | wc -l, archive vs source", + "result": "pass", + "evidence": "billsplit 73 = 73, python_pkg/billsplit_coverage 5 = 5; 8 commits, equal to git log --oneline -- billsplit python_pkg/billsplit_coverage." + }, + { + "command": "diff -r --brief between archived copy and working tree", + "result": "pass", + "evidence": "No content differences for either path." + }, + { + "command": "gh api repos/kuhyx/testsAndMisc-archive/contents --jq '.[].name'", + "result": "pass", + "evidence": "billsplit listed; billsplit_coverage confirmed under the archive's python_pkg/ alongside the previously archived packages." + }, + { + "command": "python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py", + "result": "pass", + "evidence": "2095 passed, TOTAL coverage 100.00% - the gate holds after removing the registry entry. Count fell from 2114 by exactly the 19 billsplit_coverage tests." + }, + { + "command": "PYTHONPATH=. python3 -c 'from python_pkg.app_icons import apps; ...'", + "result": "pass", + "evidence": "Registry loads with 12 apps and 'billsplit' in apps.APPS is False. Seven other apps still carry linux=False, so no cli.py branch lost its only exercise." + }, + { + "command": "./meta/scripts/check_no_binaries.sh", + "result": "pass", + "evidence": "Exit 0 with an entry-free .binary-allowlist - the checker tolerates a comment-only file." + }, + { + "command": "./meta/scripts/check_directory_depth.sh --all", + "result": "pass (improvement)", + "evidence": "451 -> 417 violations, matching billsplit's 34 deep files." + } + ], + "risks": [ + "app_icons' generator can no longer produce billsplit's icons; that is intended, since the app and its assets now live in the archive.", + ".binary-allowlist is now empty of patterns. If a future Flutter app is added, both it and the .gitignore ! overrides must be repopulated - AGENTS.md still documents that requirement.", + "The archive push happened before the local delete; presence was confirmed through the GitHub API rather than assumed." + ], + "rollback": [ + "git revert the removal commit to restore all four trees and the config blocks.", + "Re-run meta/scripts/pytest_changed_packages.py to confirm the coverage gate is still satisfied after the revert." + ] +} diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py index b6fd4008..bf7777ce 100644 --- a/python_pkg/app_icons/apps.py +++ b/python_pkg/app_icons/apps.py @@ -115,20 +115,6 @@ APPS: Final[dict[str, AppIcon]] = { icon_name="home-inventory", linux=True, ), - AppIcon( - key="billsplit", - # The only app in this registry that lives inside the testsAndMisc - # monorepo rather than in its own repo, so its path carries a - # directory component the others do not. - repo=_HOME / "testsAndMisc" / "billsplit", - accent="#B8862E", - glyph="receipt-split", - icon_name="billsplit", - # `flutter create` scaffolded a linux/ target, but the app is - # phone-first and ships no desktop entry, so the hicolor icon set - # is not generated for it. - linux=False, - ), AppIcon( key="epopeja_karta", repo=_HOME / "epopeja_karta", diff --git a/python_pkg/billsplit_coverage/__init__.py b/python_pkg/billsplit_coverage/__init__.py deleted file mode 100644 index 3f7e0065..00000000 --- a/python_pkg/billsplit_coverage/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Coverage gate for the ``billsplit/`` Flutter app. - -``flutter test --coverage`` reports success while leaving whole files untouched, -so this package re-reads the lcov report it produces and fails unless every -``lib/`` file is present *and* fully covered. It lives here rather than beside -the app because every Python file in this repository belongs under -``python_pkg/``; the app itself holds only Dart. - -Usage: - PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage -""" - -from __future__ import annotations diff --git a/python_pkg/billsplit_coverage/__main__.py b/python_pkg/billsplit_coverage/__main__.py deleted file mode 100644 index 9e959078..00000000 --- a/python_pkg/billsplit_coverage/__main__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Module entry point for the billsplit coverage gate. - -Usage: - PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage -""" - -from __future__ import annotations - -import sys - -from python_pkg.billsplit_coverage.checker import main - -if __name__ == "__main__": - sys.exit(main()) diff --git a/python_pkg/billsplit_coverage/checker.py b/python_pkg/billsplit_coverage/checker.py deleted file mode 100644 index d0766712..00000000 --- a/python_pkg/billsplit_coverage/checker.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Fail unless lcov reports 100% line coverage for every ``billsplit/lib`` file. - -Two separate checks, because they catch different mistakes: a file present in -the report but with unhit lines, and a file missing from the report entirely. -The second is the one that catches a newly added Dart file nothing imports — -partial coverage is visible in the report, absence is not. - -The default project root is resolved from this file's location rather than the -working directory, so the CI job can invoke the module from anywhere in the -repository. -""" - -from __future__ import annotations - -import argparse -import pathlib -import sys -from typing import Final - -# python_pkg/billsplit_coverage/checker.py -> repo root -> billsplit/ -_DEFAULT_PROJECT: Final[pathlib.Path] = ( - pathlib.Path(__file__).resolve().parents[2] / "billsplit" -) - -EXIT_INCOMPLETE: Final[int] = 1 -EXIT_NO_REPORT: Final[int] = 2 - - -def parse_lcov(lcov: pathlib.Path) -> dict[str, list[int]]: - """Map each file in an lcov report to its uncovered line numbers. - - Parameters: - lcov (pathlib.Path): Path to an ``lcov.info`` report. - - Returns: - dict[str, list[int]]: Source path to the lines with a zero hit count. A - fully covered file maps to an empty list, which is what lets the caller - tell "covered completely" apart from "absent from the report". - """ - uncovered: dict[str, list[int]] = {} - current: str | None = None - for line in lcov.read_text(encoding="utf-8").splitlines(): - if line.startswith("SF:"): - # lcov quotes the path verbatim; normalise Windows separators. - current = line[3:].replace("\\", "/") - uncovered.setdefault(current, []) - elif line.startswith("DA:") and current is not None: - number, hits = line[3:].split(",") - if int(hits) == 0: - uncovered[current].append(int(number)) - return uncovered - - -def find_failures( - uncovered: dict[str, list[int]], - project: pathlib.Path, -) -> list[str]: - """Describe every coverage gap, as lines ready to print. - - Parameters: - uncovered (dict[str, list[int]]): Output of :func:`parse_lcov`. - project (pathlib.Path): Flutter project root holding ``lib/``. - - Returns: - list[str]: One message per gap; empty when coverage is complete. - """ - failures = [ - f"{path}: uncovered lines {misses}" - for path, misses in sorted(uncovered.items()) - if misses - ] - lib_files = { - str(path.relative_to(project)).replace("\\", "/") - for path in (project / "lib").rglob("*.dart") - } - failures.extend( - f"{path}: not executed by any test (0% coverage)" - for path in sorted(lib_files - set(uncovered)) - ) - return failures - - -def check(project: pathlib.Path) -> int: - """Run the gate for one Flutter project and report to stdout/stderr. - - Parameters: - project (pathlib.Path): Flutter project root, i.e. the directory holding - ``lib/`` and ``coverage/lcov.info``. - - Returns: - int: 0 when coverage is complete, :data:`EXIT_INCOMPLETE` when it is not, - :data:`EXIT_NO_REPORT` when the lcov report is missing. - """ - lcov = project / "coverage" / "lcov.info" - if not lcov.exists(): - sys.stderr.write(f"{lcov} missing — run: flutter test --coverage\n") - return EXIT_NO_REPORT - - uncovered = parse_lcov(lcov) - failures = find_failures(uncovered, project) - if failures: - sys.stderr.write("COVERAGE < 100%:\n") - for failure in failures: - sys.stderr.write(f" {failure}\n") - return EXIT_INCOMPLETE - - sys.stdout.write(f"100% line coverage across {len(uncovered)} lib files.\n") - return 0 - - -def main(argv: list[str] | None = None) -> int: - """Parse arguments and run the gate. - - Parameters: - argv (list[str] | None): Argument list, or None to read ``sys.argv``. - - Returns: - int: Process exit status. - """ - parser = argparse.ArgumentParser( - description="Fail unless billsplit has 100% line coverage.", - ) - parser.add_argument( - "--project", - type=pathlib.Path, - default=_DEFAULT_PROJECT, - help="Flutter project root (defaults to the billsplit/ app in this repo)", - ) - args = parser.parse_args(argv) - return check(args.project) diff --git a/python_pkg/billsplit_coverage/tests/__init__.py b/python_pkg/billsplit_coverage/tests/__init__.py deleted file mode 100644 index b20c2352..00000000 --- a/python_pkg/billsplit_coverage/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for the billsplit coverage gate.""" - -from __future__ import annotations diff --git a/python_pkg/billsplit_coverage/tests/test_checker.py b/python_pkg/billsplit_coverage/tests/test_checker.py deleted file mode 100644 index cba107f0..00000000 --- a/python_pkg/billsplit_coverage/tests/test_checker.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for the billsplit coverage gate.""" - -from __future__ import annotations - -import runpy -import sys -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -from python_pkg.billsplit_coverage import checker - -if TYPE_CHECKING: - import pathlib - - -def make_project( - root: pathlib.Path, - lcov: str | None = None, - dart_files: tuple[str, ...] = ("lib/main.dart",), -) -> pathlib.Path: - """Build a throwaway Flutter project tree. - - Parameters: - root (pathlib.Path): Directory to build the project in. - lcov (str | None): Contents of ``coverage/lcov.info``; None writes no - report at all, which is the missing-report case. - dart_files (tuple[str, ...]): Project-relative Dart files to create. - - Returns: - pathlib.Path: The project root. - """ - for relative in dart_files: - path = root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("void main() {}\n", encoding="utf-8") - if lcov is not None: - report = root / "coverage" / "lcov.info" - report.parent.mkdir(parents=True, exist_ok=True) - report.write_text(lcov, encoding="utf-8") - return root - - -class TestParseLcov: - def test_records_only_unhit_lines(self, tmp_path: pathlib.Path) -> None: - report = tmp_path / "lcov.info" - report.write_text( - "SF:lib/main.dart\nDA:1,3\nDA:2,0\nDA:9,0\nend_of_record\n", - encoding="utf-8", - ) - assert checker.parse_lcov(report) == {"lib/main.dart": [2, 9]} - - def test_fully_covered_file_maps_to_empty_list( - self, tmp_path: pathlib.Path - ) -> None: - """Present-and-covered must be distinguishable from absent.""" - report = tmp_path / "lcov.info" - report.write_text("SF:lib/a.dart\nDA:1,1\nend_of_record\n", encoding="utf-8") - assert checker.parse_lcov(report) == {"lib/a.dart": []} - - def test_normalises_windows_separators(self, tmp_path: pathlib.Path) -> None: - report = tmp_path / "lcov.info" - report.write_text("SF:lib\\ui\\home.dart\nDA:1,1\n", encoding="utf-8") - assert "lib/ui/home.dart" in checker.parse_lcov(report) - - def test_da_before_any_sf_is_ignored(self, tmp_path: pathlib.Path) -> None: - """A DA: line with no preceding SF: has no file to attribute to.""" - report = tmp_path / "lcov.info" - report.write_text("DA:1,0\nSF:lib/a.dart\nDA:2,0\n", encoding="utf-8") - assert checker.parse_lcov(report) == {"lib/a.dart": [2]} - - def test_unrelated_lines_are_skipped(self, tmp_path: pathlib.Path) -> None: - report = tmp_path / "lcov.info" - report.write_text( - "TN:\nSF:lib/a.dart\nLF:2\nLH:1\nDA:1,0\nend_of_record\n", - encoding="utf-8", - ) - assert checker.parse_lcov(report) == {"lib/a.dart": [1]} - - def test_empty_report_yields_nothing(self, tmp_path: pathlib.Path) -> None: - report = tmp_path / "lcov.info" - report.write_text("", encoding="utf-8") - assert checker.parse_lcov(report) == {} - - -class TestFindFailures: - def test_no_gaps_returns_empty(self, tmp_path: pathlib.Path) -> None: - project = make_project(tmp_path, lcov="") - assert checker.find_failures({"lib/main.dart": []}, project) == [] - - def test_reports_uncovered_lines(self, tmp_path: pathlib.Path) -> None: - project = make_project(tmp_path, lcov="") - failures = checker.find_failures({"lib/main.dart": [2, 7]}, project) - assert failures == ["lib/main.dart: uncovered lines [2, 7]"] - - def test_reports_file_absent_from_report(self, tmp_path: pathlib.Path) -> None: - project = make_project( - tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart") - ) - failures = checker.find_failures({"lib/main.dart": []}, project) - assert failures == ["lib/orphan.dart: not executed by any test (0% coverage)"] - - def test_reports_both_kinds_together(self, tmp_path: pathlib.Path) -> None: - project = make_project( - tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart") - ) - failures = checker.find_failures({"lib/main.dart": [4]}, project) - assert failures == [ - "lib/main.dart: uncovered lines [4]", - "lib/orphan.dart: not executed by any test (0% coverage)", - ] - - def test_finds_dart_files_in_subdirectories(self, tmp_path: pathlib.Path) -> None: - project = make_project( - tmp_path, lcov="", dart_files=("lib/domain/split_engine.dart",) - ) - failures = checker.find_failures({}, project) - assert failures == [ - "lib/domain/split_engine.dart: not executed by any test (0% coverage)" - ] - - -class TestCheck: - def test_missing_report_exits_two( - self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] - ) -> None: - project = make_project(tmp_path) - assert checker.check(project) == checker.EXIT_NO_REPORT - assert "flutter test --coverage" in capsys.readouterr().err - - def test_incomplete_coverage_exits_one_and_names_files( - self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] - ) -> None: - project = make_project( - tmp_path, - lcov="SF:lib/main.dart\nDA:1,1\nDA:2,0\n", - dart_files=("lib/main.dart", "lib/orphan.dart"), - ) - assert checker.check(project) == checker.EXIT_INCOMPLETE - err = capsys.readouterr().err - assert "COVERAGE < 100%:" in err - assert "lib/main.dart: uncovered lines [2]" in err - assert "lib/orphan.dart: not executed by any test" in err - - def test_full_coverage_exits_zero( - self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] - ) -> None: - project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n") - assert checker.check(project) == 0 - assert "100% line coverage across 1 lib files." in capsys.readouterr().out - - -class TestMain: - def test_project_flag_selects_the_tree(self, tmp_path: pathlib.Path) -> None: - project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n") - assert checker.main(["--project", str(project)]) == 0 - - def test_defaults_to_the_repo_billsplit_app(self) -> None: - """With no --project, the gate resolves billsplit/ from this file.""" - with patch.object(checker, "check", return_value=0) as mock_check: - assert checker.main([]) == 0 - assert mock_check.call_args[0][0].name == "billsplit" - - def test_propagates_failure_status(self, tmp_path: pathlib.Path) -> None: - project = make_project(tmp_path) - assert checker.main(["--project", str(project)]) == checker.EXIT_NO_REPORT - - -class TestModuleEntry: - def test_main_called_when_run_as_module(self) -> None: - """python3 -m python_pkg.billsplit_coverage runs main() and exits.""" - mock_main = MagicMock(return_value=0) - # runpy warns if __main__ is already imported, and the suite may run in - # a random order, so another test could have imported it first. - sys.modules.pop("python_pkg.billsplit_coverage.__main__", None) - with ( - patch("python_pkg.billsplit_coverage.checker.main", mock_main), - pytest.raises(SystemExit) as excinfo, - ): - runpy.run_module("python_pkg.billsplit_coverage", run_name="__main__") - assert excinfo.value.code == 0 - mock_main.assert_called_once() - - def test_main_not_called_on_plain_import(self) -> None: - """Importing __main__ must not run the gate as a side effect.""" - mock_main = MagicMock() - with patch("python_pkg.billsplit_coverage.checker.main", mock_main): - sys.modules.pop("python_pkg.billsplit_coverage.__main__", None) - runpy.run_module("python_pkg.billsplit_coverage.__main__") - mock_main.assert_not_called()