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 2e123caa..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ 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 6b19a02b..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png and /dev/null differ 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 c070750f..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ 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 97cc0040..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/drawable-v21/launch_background.xml b/billsplit/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/billsplit/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - 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 c400742e..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 93a46ba8..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 43af3d01..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ 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 f45dec47..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index a7c9121b..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index ffcdd66f..00000000 Binary files a/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ 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 061a3ef0..00000000 Binary files a/billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ 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 88fa56e1..00000000 Binary files a/billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ 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 a42a652f..00000000 Binary files a/billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index bcf7b57f..00000000 Binary files a/billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ 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 35c1f1a8..00000000 Binary files a/billsplit/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ 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 7245aaa7..00000000 Binary files a/billsplit/assets/icon/icon.png and /dev/null differ diff --git a/billsplit/assets/icon/icon.svg b/billsplit/assets/icon/icon.svg deleted file mode 100644 index e4fd6cab..00000000 --- a/billsplit/assets/icon/icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/billsplit/assets/icon/icon_foreground.png b/billsplit/assets/icon/icon_foreground.png deleted file mode 100644 index afcebf38..00000000 Binary files a/billsplit/assets/icon/icon_foreground.png and /dev/null differ diff --git a/billsplit/assets/icon/icon_foreground.svg b/billsplit/assets/icon/icon_foreground.svg deleted file mode 100644 index 823e44fb..00000000 --- a/billsplit/assets/icon/icon_foreground.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/billsplit/assets/icon/icon_monochrome.png b/billsplit/assets/icon/icon_monochrome.png deleted file mode 100644 index 19ce4684..00000000 Binary files a/billsplit/assets/icon/icon_monochrome.png and /dev/null differ 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()