diff --git a/billsplit/.gitignore b/billsplit/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/billsplit/.gitignore @@ -0,0 +1,45 @@ +# 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 new file mode 100644 index 0000000..c17b941 --- /dev/null +++ b/billsplit/.metadata @@ -0,0 +1,42 @@ +# 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 new file mode 100644 index 0000000..a345d9d --- /dev/null +++ b/billsplit/README.md @@ -0,0 +1,81 @@ +# 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 new file mode 100644 index 0000000..c1d2d0f --- /dev/null +++ b/billsplit/analysis_options.yaml @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/billsplit/android/.gitignore @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..c42c15b --- /dev/null +++ b/billsplit/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/billsplit/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/billsplit/android/app/src/main/AndroidManifest.xml b/billsplit/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..551a641 --- /dev/null +++ b/billsplit/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..877a7f1 --- /dev/null +++ b/billsplit/android/app/src/main/kotlin/dev/kuhy/billsplit/MainActivity.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..2e123ca Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png 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 new file mode 100644 index 0000000..6b19a02 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png 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 new file mode 100644 index 0000000..c070750 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png 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 new file mode 100644 index 0000000..97cc004 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png 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 new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/billsplit/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + 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 new file mode 100644 index 0000000..c400742 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png 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 new file mode 100644 index 0000000..93a46ba Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png 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 new file mode 100644 index 0000000..43af3d0 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png 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 new file mode 100644 index 0000000..f45dec4 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png 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 new file mode 100644 index 0000000..a7c9121 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png 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 new file mode 100644 index 0000000..ffcdd66 Binary files /dev/null and b/billsplit/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png 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 new file mode 100644 index 0000000..304732f --- /dev/null +++ b/billsplit/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + 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 new file mode 100644 index 0000000..d506366 --- /dev/null +++ b/billsplit/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,14 @@ + + + + + + + + + + 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 new file mode 100644 index 0000000..061a3ef Binary files /dev/null and b/billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 new file mode 100644 index 0000000..88fa56e Binary files /dev/null and b/billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 new file mode 100644 index 0000000..a42a652 Binary files /dev/null and b/billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..bcf7b57 Binary files /dev/null and b/billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..35c1f1a Binary files /dev/null and b/billsplit/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 new file mode 100644 index 0000000..06952be --- /dev/null +++ b/billsplit/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/billsplit/android/app/src/main/res/values/colors.xml b/billsplit/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1e7986a --- /dev/null +++ b/billsplit/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #211D1B + diff --git a/billsplit/android/app/src/main/res/values/styles.xml b/billsplit/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/billsplit/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/billsplit/android/app/src/profile/AndroidManifest.xml b/billsplit/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/billsplit/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/billsplit/android/build.gradle.kts b/billsplit/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/billsplit/android/build.gradle.kts @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..04400cf --- /dev/null +++ b/billsplit/android/gradle.properties @@ -0,0 +1,24 @@ +# 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 new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/billsplit/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/billsplit/android/settings.gradle.kts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..7245aaa Binary files /dev/null and b/billsplit/assets/icon/icon.png differ diff --git a/billsplit/assets/icon/icon.svg b/billsplit/assets/icon/icon.svg new file mode 100644 index 0000000..e4fd6ca --- /dev/null +++ b/billsplit/assets/icon/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/billsplit/assets/icon/icon_foreground.png b/billsplit/assets/icon/icon_foreground.png new file mode 100644 index 0000000..afcebf3 Binary files /dev/null and b/billsplit/assets/icon/icon_foreground.png differ diff --git a/billsplit/assets/icon/icon_foreground.svg b/billsplit/assets/icon/icon_foreground.svg new file mode 100644 index 0000000..823e44f --- /dev/null +++ b/billsplit/assets/icon/icon_foreground.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/billsplit/assets/icon/icon_monochrome.png b/billsplit/assets/icon/icon_monochrome.png new file mode 100644 index 0000000..19ce468 Binary files /dev/null and b/billsplit/assets/icon/icon_monochrome.png differ diff --git a/billsplit/assets/icon/icon_monochrome.svg b/billsplit/assets/icon/icon_monochrome.svg new file mode 100644 index 0000000..1962d67 --- /dev/null +++ b/billsplit/assets/icon/icon_monochrome.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/billsplit/lib/domain/assignment.dart b/billsplit/lib/domain/assignment.dart new file mode 100644 index 0000000..9298bdb --- /dev/null +++ b/billsplit/lib/domain/assignment.dart @@ -0,0 +1,200 @@ +/// Assignment and the split calculation. +/// +/// Split out of models.dart to keep it under the 250-line cap. A `part` +/// because Dart privates are library-scoped. + +part of 'models.dart'; + +/// The assignment of one item: a symbolic mode plus mode-specific data. +class Assignment { + /// Creates an assignment; defaults to [AssignMode.everyone]. + Assignment({ + this.mode = AssignMode.everyone, + this.groupId, + Set? customIds, + }) : customIds = customIds ?? {}; + + /// Restores an assignment from its [toJson] map. + factory Assignment.fromJson(Map j) => Assignment( + mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone, + groupId: j['groupId'] as String?, + customIds: (j['customIds'] as List? ?? const []) + .cast() + .toSet(), + ); + + /// Current mode. + AssignMode mode; + + /// Group id when [mode] is [AssignMode.group]. + String? groupId; + + /// Hand-picked person ids when [mode] is [AssignMode.custom]. + final Set customIds; + + /// JSON representation for persistence. + Map toJson() => { + 'mode': mode.name, + if (groupId != null) 'groupId': groupId, + 'customIds': customIds.toList(), + }; +} + +/// Standard item categories. [Item.category] is free text so users can add +/// their own; these constants are the ones the importer produces. +abstract final class Categories { + /// Alcoholic drinks — default to drinkers only. + static const alcohol = 'Alcohol'; + + /// Mixers (tonic, limes, …) — tagged for visibility, split among everyone. + static const mixer = 'Mixer'; + + /// Returnable bottle deposits (kaucja). + static const deposit = 'Deposit'; + + /// Anything else. + static const general = 'General'; +} + +/// One receipt line. +class Item { + /// Creates an item; [totalGr] is the post-discount line total. + Item({ + required this.id, + required this.name, + required this.totalGr, + this.qty = 1, + this.unitPriceGr = 0, + this.discountGr = 0, + this.category = Categories.general, + Assignment? assignment, + }) : assignment = assignment ?? Assignment(); + + /// Restores an item from its [toJson] map. + factory Item.fromJson(Map j) => Item( + id: j['id'] as String, + name: j['name'] as String, + totalGr: j['totalGr'] as int, + qty: (j['qty'] as num? ?? 1).toDouble(), + unitPriceGr: j['unitPriceGr'] as int? ?? 0, + discountGr: j['discountGr'] as int? ?? 0, + category: j['category'] as String? ?? Categories.general, + assignment: Assignment.fromJson( + (j['assignment'] as Map?)?.cast() ?? const {}, + ), + ); + + /// Stable unique id. + final String id; + + /// Display name. + String name; + + /// Final line total after discount — the ground truth used for splitting. + int totalGr; + + /// Quantity (may be fractional for weighed goods). + double qty; + + /// Unit price in grosze. + int unitPriceGr; + + /// Discount already included in [totalGr]; kept for display (<= 0). + int discountGr; + + /// Free-text category; see [Categories] for the standard values. + String category; + + /// Who shares this item. + Assignment assignment; + + /// JSON representation for persistence. + Map toJson() => { + 'id': id, + 'name': name, + 'totalGr': totalGr, + 'qty': qty, + 'unitPriceGr': unitPriceGr, + 'discountGr': discountGr, + 'category': category, + 'assignment': assignment.toJson(), + }; +} + +/// One shopping receipt with its items and participants. +class Receipt { + /// Creates a receipt; [participantIds] defaults to empty. + Receipt({ + required this.id, + required this.title, + this.store, + DateTime? date, + List? items, + Set? participantIds, + this.paidTotalGr, + }) : date = date ?? DateTime.now(), + items = items ?? [], + participantIds = participantIds ?? {}; + + /// Restores a receipt from its [toJson] map. + factory Receipt.fromJson(Map j) => Receipt( + id: j['id'] as String, + title: j['title'] as String, + store: j['store'] as String?, + date: DateTime.tryParse(j['date'] as String? ?? '') ?? DateTime.now(), + items: (j['items'] as List? ?? const []) + .map((e) => Item.fromJson((e as Map).cast())) + .toList(), + participantIds: (j['participantIds'] as List? ?? const []) + .cast() + .toSet(), + paidTotalGr: j['paidTotalGr'] as int?, + ); + + /// Stable unique id. + final String id; + + /// Display title. + String title; + + /// Store name, if known. + String? store; + + /// Sale date. + DateTime date; + + /// Line items. + final List items; + + /// Subset of the roster taking part in this receipt. + final Set participantIds; + + /// What was actually paid (incl. deposits) — used for the reconciliation + /// check. Null for manual receipts where the user never entered it. + int? paidTotalGr; + + /// Sum of all item totals in grosze. + int get itemsTotalGr => items.fold(0, (s, i) => s + i.totalGr); + + /// JSON representation for persistence. + Map toJson() => { + 'id': id, + 'title': title, + 'store': store, + 'date': date.toIso8601String(), + 'items': items.map((i) => i.toJson()).toList(), + 'participantIds': participantIds.toList(), + 'paidTotalGr': paidTotalGr, + }; +} + +/// Simple unique id source: time + counter. Good enough for a local, +/// single-user app. +class Ids { + static int _c = 0; + + /// Returns a new unique id. + static String next() => + '${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}' + '-${(_c++).toRadixString(36)}'; +} diff --git a/billsplit/lib/domain/eparagon_fields.dart b/billsplit/lib/domain/eparagon_fields.dart new file mode 100644 index 0000000..bdeba1b --- /dev/null +++ b/billsplit/lib/domain/eparagon_fields.dart @@ -0,0 +1,124 @@ +/// Field extraction helpers for the e-paragon parser. +/// +/// Split out of eparagon_parser.dart to keep it under the 250-line cap. +/// A `part` because Dart privates are library-scoped. + +part of 'eparagon_parser.dart'; + +DateTime _saleDate( + Map paragon, + Map dokument, +) { + final raw = + paragon['zakSprzed'] ?? (dokument['naglowek'] as Map?)?['dataJPK']; + if (raw is String) { + final d = DateTime.tryParse(raw); + if (d != null) return d.toLocal(); + } + return DateTime.now(); +} + +String _iso(DateTime d) { + final m = d.month.toString().padLeft(2, '0'); + final day = d.day.toString().padLeft(2, '0'); + return '${d.year}-$m-$day'; +} + +Map _extractPayload(String source) { + dynamic root; + try { + root = jsonDecode(source.trim()); + } on FormatException { + // Maybe the user pasted just the JWS token. + final p = _tryJws(source.trim()); + if (p != null) return p; + throw EParagonParseException( + 'Input is neither valid JSON nor a JWS.', + ); + } + if (root is Map) { + if (root['dokument'] is Map) return root.cast(); + final data = root['data']; + if (data is String) { + final p = _tryJws(data); + if (p != null) return p; + throw EParagonParseException('"data" field is not a decodable JWS.'); + } + } + throw EParagonParseException( + 'Unrecognized structure: expected e-paragon wrapper or JPK payload.', + ); +} + +Map? _tryJws(String token) { + final parts = token.split('.'); + if (parts.length != 3) return null; + try { + final normalized = base64Url.normalize(parts[1]); + final decoded = utf8.decode(base64Url.decode(normalized)); + final obj = jsonDecode(decoded); + return obj is Map ? obj.cast() : null; + } on FormatException { + return null; + } +} + +Item? _itemFromTowar(Map t, List warnings) { + var name = (t['nazwa'] as String? ?? '').trim(); + // Names end with the VAT rate letter, e.g. "Tonic A". + if (name.length > 2 && + name[name.length - 2] == ' ' && + RegExp(r'[A-G]$').hasMatch(name)) { + name = name.substring(0, name.length - 2).trim(); + } + name = name.replaceAll(RegExp(r'\s+'), ' '); + if (name.isEmpty) return null; + + final total = _asIntOrNull(t['brutto']); + if (total == null) { + warnings.add('Skipped "$name": missing brutto amount.'); + return null; + } + final unit = _asIntOrNull(t['cena']) ?? 0; + final qty = _parseQty(t['ilosc']); + final rabat = t['rabat']; + final discount = rabat is Map ? (_asIntOrNull(rabat['wart']) ?? 0) : 0; + + final lower = name.toLowerCase(); + var category = Categories.general; + var assignment = Assignment(); + if (_alcoholKeywords.any(lower.contains)) { + category = Categories.alcohol; + assignment = Assignment(mode: AssignMode.drinkers); + } else if (_mixerKeywords.any(lower.contains)) { + category = Categories.mixer; // still split among everyone by default + } + + return Item( + id: Ids.next(), + name: name, + totalGr: total, + qty: qty, + unitPriceGr: unit, + discountGr: discount, + category: category, + assignment: assignment, + ); +} + +double _parseQty(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) { + return double.tryParse(v.replaceAll(',', '.').trim()) ?? 1; + } + return 1; +} + +int _asInt(dynamic v) => _asIntOrNull(v) ?? 0; + +int? _asIntOrNull(dynamic v) { + if (v is int) return v; + if (v is num) return v.round(); + if (v is String) return int.tryParse(v); + return null; +} diff --git a/billsplit/lib/domain/eparagon_parser.dart b/billsplit/lib/domain/eparagon_parser.dart new file mode 100644 index 0000000..bb960b6 --- /dev/null +++ b/billsplit/lib/domain/eparagon_parser.dart @@ -0,0 +1,177 @@ +/// 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 new file mode 100644 index 0000000..1f4dae7 --- /dev/null +++ b/billsplit/lib/domain/models.dart @@ -0,0 +1,86 @@ +/// 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 new file mode 100644 index 0000000..cdddf36 --- /dev/null +++ b/billsplit/lib/domain/split_engine.dart @@ -0,0 +1,155 @@ +/// 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 new file mode 100644 index 0000000..b2231a3 --- /dev/null +++ b/billsplit/lib/domain/xlsx_export.dart @@ -0,0 +1,215 @@ +/// 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 new file mode 100644 index 0000000..e8ad84e --- /dev/null +++ b/billsplit/lib/main.dart @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..9c3ba73 --- /dev/null +++ b/billsplit/lib/state/app_state.dart @@ -0,0 +1,166 @@ +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 new file mode 100644 index 0000000..6dd7c40 --- /dev/null +++ b/billsplit/lib/ui/assignment_sheet.dart @@ -0,0 +1,146 @@ +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 new file mode 100644 index 0000000..dbf00ea --- /dev/null +++ b/billsplit/lib/ui/home_screen.dart @@ -0,0 +1,210 @@ +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 new file mode 100644 index 0000000..40e2ec9 --- /dev/null +++ b/billsplit/lib/ui/item_edit.dart @@ -0,0 +1,137 @@ +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 new file mode 100644 index 0000000..c5b0465 --- /dev/null +++ b/billsplit/lib/ui/people_screen.dart @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..ac1b895 --- /dev/null +++ b/billsplit/lib/ui/people_tiles.dart @@ -0,0 +1,139 @@ +/// Person and group tile widgets. +/// +/// Split out of people_screen.dart to keep it under the 250-line cap. A `part` +/// because Dart privates are library-scoped. + +part of 'people_screen.dart'; + +class _PersonTile extends StatelessWidget { + const _PersonTile({required this.person}); + final Person person; + + @override + Widget build(BuildContext context) { + final state = context.read(); + return ListTile( + leading: CircleAvatar( + child: Text( + person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(), + ), + ), + title: Text(person.name), + subtitle: Wrap( + spacing: 6, + children: [ + for (final g in state.groups.where( + (g) => g.memberIds.contains(person.id), + )) + Chip( + label: Text(g.name, style: const TextStyle(fontSize: 11)), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: 'Drinks alcohol', + child: Switch( + value: person.drinks, + onChanged: (v) => state.mutate(() => person.drinks = v), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmRemove(context, state), + ), + ], + ), + onTap: () => _editMembership(context, state), + ); + } + + Future _confirmRemove(BuildContext context, AppState state) async { + final ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('Remove ${person.name}?'), + content: const Text( + 'They will be removed from all receipts and item assignments.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Remove'), + ), + ], + ), + ); + if (ok == true) state.removePerson(person); + } + + Future _editMembership(BuildContext context, AppState state) async { + if (state.groups.isEmpty) return; + await showModalBottomSheet( + context: context, + builder: (c) => StatefulBuilder( + builder: (c, setState) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '${person.name} — groups', + style: Theme.of(c).textTheme.titleMedium, + ), + ), + for (final g in state.groups) + CheckboxListTile( + title: Text(g.name), + value: g.memberIds.contains(person.id), + onChanged: (v) { + state.mutate(() { + if (v == true) { + g.memberIds.add(person.id); + } else { + g.memberIds.remove(person.id); + } + }); + setState(() {}); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _GroupTile extends StatelessWidget { + const _GroupTile({required this.group}); + final Group group; + + @override + Widget build(BuildContext context) { + final state = context.read(); + final names = state.people + .where((p) => group.memberIds.contains(p.id)) + .map((p) => p.name) + .join(', '); + return ListTile( + leading: const Icon(Icons.label_outline), + title: Text(group.name), + subtitle: + Text(names.isEmpty ? 'No members — tap a person to add' : names), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => state.removeGroup(group), + ), + ); + } +} diff --git a/billsplit/lib/ui/receipt_items.dart b/billsplit/lib/ui/receipt_items.dart new file mode 100644 index 0000000..24b4384 --- /dev/null +++ b/billsplit/lib/ui/receipt_items.dart @@ -0,0 +1,198 @@ +/// The receipt item list and its row widgets. +/// +/// Split out of receipt_screen.dart to keep it under the 250-line cap. A `part` +/// because Dart privates are library-scoped. + +part of 'receipt_screen.dart'; + +class _ItemsList extends StatelessWidget { + const _ItemsList({required this.receipt, required this.split}); + final Receipt receipt; + final SplitResult split; + + @override + Widget build(BuildContext context) { + final state = context.read(); + if (receipt.items.isEmpty) { + return const Center(child: Text('No items — add with +.')); + } + return ListView.builder( + padding: const EdgeInsets.only(bottom: 96), + itemCount: receipt.items.length, + itemBuilder: (context, i) { + final item = receipt.items[i]; + final people = + resolveParticipants(item, receipt, state.people, state.groups); + final unassigned = people.isEmpty; + return ListTile( + dense: true, + leading: _CategoryDot(category: item.category), + title: Text(item.name), + subtitle: Text( + _assignmentLabel(item, people.length, state), + style: TextStyle( + color: unassigned ? Theme.of(context).colorScheme.error : null, + fontWeight: unassigned ? FontWeight.bold : null, + ), + ), + trailing: Text(formatPln(item.totalGr)), + onTap: () => showAssignmentSheet(context, state, receipt, item), + onLongPress: () => showItemEditDialog(context, state, receipt, item), + ); + }, + ); + } + + String _assignmentLabel(Item item, int n, AppState state) { + switch (item.assignment.mode) { + case AssignMode.everyone: + return 'Everyone ($n)'; + case AssignMode.drinkers: + return 'Drinkers only ($n)'; + case AssignMode.nonDrinkers: + return 'Non-drinkers ($n)'; + case AssignMode.group: + final g = state.groups + .where((g) => g.id == item.assignment.groupId) + .firstOrNull; + return 'Group: ${g?.name ?? '?'} ($n)'; + case AssignMode.custom: + return n == 0 ? 'UNASSIGNED — tap to fix' : 'Selected people ($n)'; + } + } +} + +class _CategoryDot extends StatelessWidget { + const _CategoryDot({required this.category}); + final String category; + + @override + Widget build(BuildContext context) { + final color = switch (category) { + // Categorical encoding, deliberately NOT mapped onto ColorScheme slots. + // These four say "which category", not "which theme role", and a + // single-accent design system has no four distinct hues to lend. Folding + // them into primary/secondary/tertiary would make the categories stop + // being distinguishable, which is the only job they have. Tracked in + // unified-design-system/nielsen-audit.md as needing a categorical ramp. + Categories.alcohol => Colors.deepOrange, + Categories.mixer => Colors.amber, + Categories.deposit => Colors.blueGrey, + _ => Colors.teal, + }; + return CircleAvatar(radius: 6, backgroundColor: color); + } +} + +class _TotalsView extends StatelessWidget { + const _TotalsView({required this.receipt, required this.split}); + final Receipt receipt; + final SplitResult split; + + @override + Widget build(BuildContext context) { + final state = context.read(); + final people = receiptParticipants(receipt, state.people) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + final itemsTotal = receipt.itemsTotalGr; + final assigned = split.assignedTotalGr; + final paid = receipt.paidTotalGr; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (split.unassigned.isNotEmpty) + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + '${split.unassigned.length} item(s) have nobody assigned and ' + 'are excluded from totals:\n' + '${split.unassigned.map((i) => '· ${i.name}').join('\n')}', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + ), + for (final p in people) + _PersonRow(person: p, total: split.perPerson[p.id]), + const Divider(height: 32), + _kv(context, 'Items total', formatPln(itemsTotal), bold: true), + _kv( + context, + 'Assigned to people', + formatPln(assigned), + warn: assigned != itemsTotal, + ), + if (paid != null) + _kv( + context, + 'Paid (receipt)', + formatPln(paid), + warn: paid != itemsTotal, + ), + const SizedBox(height: 8), + Text( + 'Splits are grosz-exact: shares always add up to the item total, ' + 'so no fractional grosze are lost.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _kv( + BuildContext context, + String k, + String v, { + bool bold = false, + bool warn = false, + }) { + final style = TextStyle( + fontWeight: bold || warn ? FontWeight.bold : null, + color: warn ? Theme.of(context).colorScheme.error : null, + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(k, style: style), Text(v, style: style)], + ), + ); + } +} + +class _PersonRow extends StatelessWidget { + const _PersonRow({required this.person, required this.total}); + final Person person; + final PersonTotal? total; + + @override + Widget build(BuildContext context) { + final t = total; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + child: Text( + person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(), + ), + ), + title: Text(person.name), + subtitle: t == null || t.alcoholGr == 0 + ? null + : Text( + 'alcohol ${formatPln(t.alcoholGr)} · rest ${formatPln(t.restGr)}', + ), + trailing: Text( + formatPln(t?.totalGr ?? 0), + style: Theme.of(context).textTheme.titleMedium, + ), + ); + } +} + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/billsplit/lib/ui/receipt_screen.dart b/billsplit/lib/ui/receipt_screen.dart new file mode 100644 index 0000000..08f476f --- /dev/null +++ b/billsplit/lib/ui/receipt_screen.dart @@ -0,0 +1,211 @@ +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 new file mode 100644 index 0000000..c8ff25d --- /dev/null +++ b/billsplit/pubspec.lock @@ -0,0 +1,611 @@ +# 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 new file mode 100644 index 0000000..ed46da0 --- /dev/null +++ b/billsplit/pubspec.yaml @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..44a4ecc --- /dev/null +++ b/billsplit/test/app_state_test.dart @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000..b27d9af --- /dev/null +++ b/billsplit/test/engine_extra_test.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..755c308 --- /dev/null +++ b/billsplit/test/eparagon_edge_test.dart @@ -0,0 +1,232 @@ +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 new file mode 100644 index 0000000..4df10c6 --- /dev/null +++ b/billsplit/test/eparagon_parser_test.dart @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..de6f402 --- /dev/null +++ b/billsplit/test/fixtures/sample_receipt.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..d341bb1 --- /dev/null +++ b/billsplit/test/helpers.dart @@ -0,0 +1,118 @@ +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 new file mode 100644 index 0000000..0cfbb72 --- /dev/null +++ b/billsplit/test/models_test.dart @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..7c297b1 --- /dev/null +++ b/billsplit/test/people_flows_test.dart @@ -0,0 +1,154 @@ +// People screen flows. + +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/state/app_state.dart'; +import 'package:billsplit/ui/people_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +Receipt demoReceipt(AppState s, {bool withUnassigned = false}) { + final r = Receipt( + id: 'r1', + title: 'Demo', + store: 'Biedronka', + paidTotalGr: 1000, + items: [ + Item(id: 'i1', name: 'Bread', totalGr: 500), + Item( + id: 'i2', + name: 'Beer', + totalGr: 300, + category: Categories.alcohol, + assignment: Assignment(mode: AssignMode.drinkers), + ), + Item( + id: 'i3', + name: 'Tonic', + totalGr: 100, + category: Categories.mixer, + ), + Item( + id: 'i4', + name: 'Kaucja', + totalGr: 100, + category: Categories.deposit, + ), + if (withUnassigned) + Item( + id: 'i5', + name: 'Ghost', + totalGr: 50, + assignment: Assignment(mode: AssignMode.custom), + ), + ], + ); + s.addReceipt(r); + return r; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('people', () { + testWidgets('add person dialog: toggle, cancel, empty, valid', + (tester) async { + final s = tempState(); + await pumpApp(tester, s, const PeopleScreen()); + expect(find.textContaining('Nobody yet'), findsOneWidget); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Kasia'); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.people.single.name, 'Kasia'); + expect(s.people.single.drinks, isFalse); + await flushSaves(tester); + }); + + testWidgets('drinks switch, membership early-return, delete flows', + (tester) async { + final s = tempState(); + final p = s.addPerson('Ala'); + await pumpApp(tester, s, const PeopleScreen()); + await flushSaves(tester); + + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(p.drinks, isFalse); + + // No groups yet → tapping the person is a no-op branch. + await tester.tap(find.text('Ala')); + await tester.pumpAndSettle(); + expect(find.text('Group membership'), findsNothing); + + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.people, hasLength(1)); + + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + await flushSaves(tester); + }); + + testWidgets('groups: add, membership sheet, delete', (tester) async { + final s = tempState()..addPerson('Ala'); + await pumpApp(tester, s, const PeopleScreen()); + await flushSaves(tester); + + await tester.tap(find.text('Add group')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.groups, isEmpty); + + await tester.tap(find.text('Add group')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Meat'); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.groups.single.name, 'Meat'); + expect(find.textContaining('No members'), findsOneWidget); + + await tester.tap(find.text('Ala')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + expect(s.groups.single.memberIds, hasLength(1)); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + expect(s.groups.single.memberIds, isEmpty); + await tester.tapAt(const Offset(10, 10)); // close sheet + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete_outline).last); + await tester.pumpAndSettle(); + expect(s.groups, isEmpty); + await flushSaves(tester); + }); + }); +} diff --git a/billsplit/test/receipt_flows_test.dart b/billsplit/test/receipt_flows_test.dart new file mode 100644 index 0000000..09f5880 --- /dev/null +++ b/billsplit/test/receipt_flows_test.dart @@ -0,0 +1,140 @@ +// Receipt screen flows. + +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/state/app_state.dart'; +import 'package:billsplit/ui/receipt_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +Receipt demoReceipt(AppState s, {bool withUnassigned = false}) { + final r = Receipt( + id: 'r1', + title: 'Demo', + store: 'Biedronka', + paidTotalGr: 1000, + items: [ + Item(id: 'i1', name: 'Bread', totalGr: 500), + Item( + id: 'i2', + name: 'Beer', + totalGr: 300, + category: Categories.alcohol, + assignment: Assignment(mode: AssignMode.drinkers), + ), + Item( + id: 'i3', + name: 'Tonic', + totalGr: 100, + category: Categories.mixer, + ), + Item( + id: 'i4', + name: 'Kaucja', + totalGr: 100, + category: Categories.deposit, + ), + if (withUnassigned) + Item( + id: 'i5', + name: 'Ghost', + totalGr: 50, + assignment: Assignment(mode: AssignMode.custom), + ), + ], + ); + s.addReceipt(r); + return r; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('receipt screen', () { + testWidgets('unknown id shows deleted note', (tester) async { + final s = tempState(); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope')); + expect(find.text('Receipt deleted.'), findsOneWidget); + }); + + testWidgets('tabs on narrow, panes on wide', (tester) async { + final s = tempState()..addPerson('Ala'); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + expect(find.byType(TabBar), findsOneWidget); + await tester.tap(find.text('Totals')); + await tester.pumpAndSettle(); + expect(find.textContaining('grosz-exact'), findsOneWidget); + + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + expect(find.byType(TabBar), findsNothing); + expect(find.byType(VerticalDivider), findsOneWidget); + }); + + testWidgets('participants: empty roster snackbar, then editing', + (tester) async { + final s = tempState(); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + await tester.tap(find.byIcon(Icons.group)); + await tester.pumpAndSettle(); + expect(find.textContaining('Add people first'), findsOneWidget); + + final p = s.addPerson('Ala'); + s.addPerson('Bob'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.group)); + await tester.pumpAndSettle(); + expect(find.text('Who is on this receipt?'), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile).first); + await tester.pumpAndSettle(); + expect(s.receipts.single.participantIds.contains(p.id), isTrue); + await tester.tap(find.byType(CheckboxListTile).first); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + await flushSaves(tester); + }); + + testWidgets('export: no participants, success, cancel, failure', + (tester) async { + final s = tempState(); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Add participants'), findsOneWidget); + + s.addPerson('Ala'); + s.receipts.single.participantIds.addAll(s.people.map((p) => p.id)); + mockFilePicker(saveResult: '/tmp/out.xlsx'); + await tester.pump(const Duration(seconds: 5)); // expire prior snackbar + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Saved: out.xlsx'), findsOneWidget); + + mockFilePicker(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + + mockFilePicker(saveThrows: true); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Export failed'), findsOneWidget); + await flushSaves(tester); + }); + }); +} diff --git a/billsplit/test/receipt_items_test.dart b/billsplit/test/receipt_items_test.dart new file mode 100644 index 0000000..07059e9 --- /dev/null +++ b/billsplit/test/receipt_items_test.dart @@ -0,0 +1,195 @@ +// 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 new file mode 100644 index 0000000..9451f67 --- /dev/null +++ b/billsplit/test/split_engine_test.dart @@ -0,0 +1,189 @@ +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 new file mode 100644 index 0000000..4245290 --- /dev/null +++ b/billsplit/test/ui_flows_test.dart @@ -0,0 +1,210 @@ +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 new file mode 100644 index 0000000..e37c0ab --- /dev/null +++ b/billsplit/test/widget_test.dart @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..f9be9de --- /dev/null +++ b/billsplit/test/xlsx_export_test.dart @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..fcf912d --- /dev/null +++ b/billsplit/test/xlsx_extra_test.dart @@ -0,0 +1,56 @@ +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/python_pkg/billsplit_coverage/__init__.py b/python_pkg/billsplit_coverage/__init__.py new file mode 100644 index 0000000..3f7e006 --- /dev/null +++ b/python_pkg/billsplit_coverage/__init__.py @@ -0,0 +1,13 @@ +"""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 new file mode 100644 index 0000000..9e95907 --- /dev/null +++ b/python_pkg/billsplit_coverage/__main__.py @@ -0,0 +1,14 @@ +"""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 new file mode 100644 index 0000000..d076671 --- /dev/null +++ b/python_pkg/billsplit_coverage/checker.py @@ -0,0 +1,130 @@ +"""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 new file mode 100644 index 0000000..b20c235 --- /dev/null +++ b/python_pkg/billsplit_coverage/tests/__init__.py @@ -0,0 +1,3 @@ +"""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 new file mode 100644 index 0000000..cba107f --- /dev/null +++ b/python_pkg/billsplit_coverage/tests/test_checker.py @@ -0,0 +1,191 @@ +"""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()