commit 4dd582a1dff3cd7cdcd1cfb61c1172afc2d1fef1 Author: Krzysztof Rudnicki Date: Mon Aug 3 11:38:01 2026 +0200 Move billsplit Flutter app into the monorepo billsplit was a standalone Flutter app in ~/Downloads with one commit and no git remote — that directory was the only copy of its history. Three things the move needed beyond copying the tree: - The root .gitignore silently swallowed 32 of its files (*.png, *.ico, *.manifest). Ignored files do not error on `git add`, they just vanish, so this would have committed an app that cannot build. Added a trailing block of ! overrides plus .binary-allowlist (which did not exist yet) — the documented in-repo exception for binaries a build genuinely needs. The ../testsAndMisc_binaries/ symlink scheme does not apply: those directories are gone, and a symlinked PNG breaks a fresh clone. - billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads workflows at the repository root. Relocated with a working-directory and a billsplit/** path filter. - The app's coverage gate moved to python_pkg/billsplit_coverage/ so every Python file stays under python_pkg/, and gained a test suite at 100% branch coverage as that location requires. billsplit/ now holds only Dart. Also replaces the stock Flutter icon with a family icon from the shared generator. The glyph is a filled silhouette because the family's 72px stroke cannot carry a fine torn edge, and the split is an evenodd-cut perforation so the holes stay transparent on the foreground and monochrome layers. Co-Authored-By: Claude Opus 5 (1M context) 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..046d207 --- /dev/null +++ b/billsplit/README.md @@ -0,0 +1,77 @@ +# 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 + +The repo ships `lib/`, `test/`, and `pubspec.yaml` without platform runner +folders. Generate them once for the platforms you want: + +```sh +cd billsplit +flutter create . --platforms=linux,windows,macos,android,ios +flutter pub get +flutter run # picks a connected device / desktop +``` + +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..ae2b39e --- /dev/null +++ b/billsplit/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + 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..e96108c --- /dev/null +++ b/billsplit/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +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/ios/.gitignore b/billsplit/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/billsplit/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/billsplit/ios/Flutter/AppFrameworkInfo.plist b/billsplit/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/billsplit/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/billsplit/ios/Flutter/Debug.xcconfig b/billsplit/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/billsplit/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/billsplit/ios/Flutter/Release.xcconfig b/billsplit/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/billsplit/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/billsplit/ios/Runner.xcodeproj/project.pbxproj b/billsplit/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..363d280 --- /dev/null +++ b/billsplit/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/billsplit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/billsplit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/billsplit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/billsplit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/billsplit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billsplit/ios/Runner.xcworkspace/contents.xcworkspacedata b/billsplit/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/billsplit/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/billsplit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/billsplit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/billsplit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/billsplit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/billsplit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/billsplit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/billsplit/ios/Runner/AppDelegate.swift b/billsplit/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/billsplit/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e882ab9 --- /dev/null +++ b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-29x29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "Icon-App-40x40@3x.png", + "scale": "3x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "Icon-App-60x60@3x.png", + "scale": "3x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "Icon-App-20x20@1x.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "Icon-App-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "Icon-App-29x29@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "Icon-App-40x40@1x.png", + "scale": "1x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "Icon-App-40x40@2x.png", + "scale": "2x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "Icon-App-76x76@1x.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "Icon-App-76x76@2x.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "Icon-App-83.5x83.5@2x.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "Icon-App-1024x1024@1x.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..781d7cd --- /dev/null +++ b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "LaunchImage.png", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "LaunchImage@2x.png", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "LaunchImage@3x.png", + "scale": "3x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..b5b843a --- /dev/null +++ b/billsplit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. diff --git a/billsplit/ios/Runner/Base.lproj/LaunchScreen.storyboard b/billsplit/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/billsplit/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billsplit/ios/Runner/Base.lproj/Main.storyboard b/billsplit/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/billsplit/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billsplit/ios/Runner/Info.plist b/billsplit/ios/Runner/Info.plist new file mode 100644 index 0000000..bf9bdb6 --- /dev/null +++ b/billsplit/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Billsplit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + billsplit + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/billsplit/ios/Runner/Runner-Bridging-Header.h b/billsplit/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/billsplit/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/billsplit/ios/Runner/SceneDelegate.swift b/billsplit/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/billsplit/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/billsplit/ios/RunnerTests/RunnerTests.swift b/billsplit/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/billsplit/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/billsplit/lib/domain/eparagon_parser.dart b/billsplit/lib/domain/eparagon_parser.dart new file mode 100644 index 0000000..35aa51b --- /dev/null +++ b/billsplit/lib/domain/eparagon_parser.dart @@ -0,0 +1,293 @@ +/// 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'; + +/// 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; +} + +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/models.dart b/billsplit/lib/domain/models.dart new file mode 100644 index 0000000..771e512 --- /dev/null +++ b/billsplit/lib/domain/models.dart @@ -0,0 +1,278 @@ +/// 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; + +/// 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, +} + +/// 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/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..cd84c2c --- /dev/null +++ b/billsplit/lib/main.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:billsplit/state/app_state.dart'; +import 'package:billsplit/ui/home_screen.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, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + ), + 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..6ef5d8d --- /dev/null +++ b/billsplit/lib/ui/assignment_sheet.dart @@ -0,0 +1,143 @@ +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/domain/split_engine.dart'; +import 'package:billsplit/state/app_state.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 ? Colors.red : Colors.black54, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }, + ), + ); +} + +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..dcdec10 --- /dev/null +++ b/billsplit/lib/ui/home_screen.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +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:file_picker/file_picker.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: () => unawaited( + 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, + background: Container( + color: Colors.red.shade300, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.delete, color: Colors.white), + ), + 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: () => unawaited( + 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); + final picked = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + withData: true, + ); + final data = picked?.files.firstOrNull?.bytes; + if (data == null) return; + try { + final result = parseEParagonDetailed(utf8.decode(data)); + state.addReceipt(result.receipt); + for (final w in result.warnings) { + messenger.showSnackBar(SnackBar(content: Text(w))); + } + unawaited( + 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); + unawaited( + 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..227bee1 --- /dev/null +++ b/billsplit/lib/ui/item_edit.dart @@ -0,0 +1,130 @@ +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/state/app_state.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), + const Text( + 'Alcohol items default to drinkers only; everything else to ' + 'everyone. Fine-tune by tapping the item afterwards.', + style: TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + actions: [ + if (item != null) + TextButton( + onPressed: () { + state.mutate(() => receipt.items.remove(item)); + Navigator.pop(c); + }, + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + 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..70648b4 --- /dev/null +++ b/billsplit/lib/ui/people_screen.dart @@ -0,0 +1,273 @@ +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/state/app_state.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.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'), + 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'), + const Padding( + padding: 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: 12, color: Colors.black54), + ), + ), + 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'), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.text); + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text(text, style: Theme.of(context).textTheme.titleMedium), + ); +} + +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_screen.dart b/billsplit/lib/ui/receipt_screen.dart new file mode 100644 index 0000000..0f881eb --- /dev/null +++ b/billsplit/lib/ui/receipt_screen.dart @@ -0,0 +1,357 @@ +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_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// 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 ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'), + '_', + ); + final path = await FilePicker.saveFile( + dialogTitle: 'Save split as .xlsx', + fileName: '$safe split.xlsx', + type: FileType.custom, + allowedExtensions: ['xlsx'], + bytes: bytes, + ); + if (path != null) { + messenger.showSnackBar( + SnackBar(content: Text('Saved: ${path.split('/').last}')), + ); + } + } on Exception catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Export failed: $e'))); + } + } +} + +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 ? Colors.red : 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) { + 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: Colors.red.shade50, + 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: const TextStyle(color: Colors.red), + ), + ), + ), + 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 ? Colors.red : 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/linux/.gitignore b/billsplit/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/billsplit/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/billsplit/linux/CMakeLists.txt b/billsplit/linux/CMakeLists.txt new file mode 100644 index 0000000..0f0a3a3 --- /dev/null +++ b/billsplit/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "billsplit") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.kuhy.billsplit") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/billsplit/linux/flutter/CMakeLists.txt b/billsplit/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/billsplit/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/billsplit/linux/flutter/generated_plugin_registrant.cc b/billsplit/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/billsplit/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/billsplit/linux/flutter/generated_plugin_registrant.h b/billsplit/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/billsplit/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/billsplit/linux/flutter/generated_plugins.cmake b/billsplit/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..be1ee3e --- /dev/null +++ b/billsplit/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/billsplit/linux/runner/CMakeLists.txt b/billsplit/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/billsplit/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/billsplit/linux/runner/main.cc b/billsplit/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/billsplit/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/billsplit/linux/runner/my_application.cc b/billsplit/linux/runner/my_application.cc new file mode 100644 index 0000000..7d595bb --- /dev/null +++ b/billsplit/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "billsplit"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "billsplit"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/billsplit/linux/runner/my_application.h b/billsplit/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/billsplit/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/billsplit/macos/.gitignore b/billsplit/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/billsplit/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/billsplit/macos/Flutter/Flutter-Debug.xcconfig b/billsplit/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/billsplit/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/billsplit/macos/Flutter/Flutter-Release.xcconfig b/billsplit/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/billsplit/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..774a6b8 --- /dev/null +++ b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) +} diff --git a/billsplit/macos/Runner.xcodeproj/project.pbxproj b/billsplit/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..47cb314 --- /dev/null +++ b/billsplit/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* billsplit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "billsplit.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* billsplit.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* billsplit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/billsplit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/billsplit"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/billsplit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/billsplit"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/billsplit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/billsplit"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/billsplit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/billsplit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/billsplit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/billsplit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/billsplit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..64de869 --- /dev/null +++ b/billsplit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billsplit/macos/Runner.xcworkspace/contents.xcworkspacedata b/billsplit/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/billsplit/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/billsplit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/billsplit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/billsplit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/billsplit/macos/Runner/AppDelegate.swift b/billsplit/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/billsplit/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..60e60c9 --- /dev/null +++ b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/billsplit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/billsplit/macos/Runner/Base.lproj/MainMenu.xib b/billsplit/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/billsplit/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billsplit/macos/Runner/Configs/AppInfo.xcconfig b/billsplit/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..aa7d2d4 --- /dev/null +++ b/billsplit/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = billsplit + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 dev.kuhy. All rights reserved. diff --git a/billsplit/macos/Runner/Configs/Debug.xcconfig b/billsplit/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/billsplit/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/billsplit/macos/Runner/Configs/Release.xcconfig b/billsplit/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/billsplit/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/billsplit/macos/Runner/Configs/Warnings.xcconfig b/billsplit/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/billsplit/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/billsplit/macos/Runner/DebugProfile.entitlements b/billsplit/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/billsplit/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/billsplit/macos/Runner/Info.plist b/billsplit/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/billsplit/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/billsplit/macos/Runner/MainFlutterWindow.swift b/billsplit/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/billsplit/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/billsplit/macos/Runner/Release.entitlements b/billsplit/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/billsplit/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/billsplit/macos/RunnerTests/RunnerTests.swift b/billsplit/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/billsplit/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/billsplit/pubspec.lock b/billsplit/pubspec.lock new file mode 100644 index 0000000..141cfb7 --- /dev/null +++ b/billsplit/pubspec.lock @@ -0,0 +1,554 @@ +# 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" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + 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_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + url: "https://pub.dev" + source: hosted + version: "11.0.3" + 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_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + 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" + 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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + 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: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.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: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + 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: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + 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" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + 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.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/billsplit/pubspec.yaml b/billsplit/pubspec.yaml new file mode 100644 index 0000000..c940289 --- /dev/null +++ b/billsplit/pubspec.yaml @@ -0,0 +1,37 @@ +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: + excel: ^4.0.6 + file_picker: ^11.0.0 + flutter: + sdk: flutter + path_provider: ^2.1.4 + provider: ^6.1.2 + +dev_dependencies: + 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..f43e532 --- /dev/null +++ b/billsplit/test/eparagon_parser_test.dart @@ -0,0 +1,82 @@ +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. + final jws = RegExp('"data":"([^"]+)"').firstMatch(fixture)!.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..21a9911 --- /dev/null +++ b/billsplit/test/fixtures/sample_receipt.json @@ -0,0 +1,779 @@ +{ + "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..597fc2a --- /dev/null +++ b/billsplit/test/helpers.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:billsplit/state/app_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const _pickerChannel = MethodChannel('miguelruivo.flutter.plugins.filepicker'); +const _pathChannel = MethodChannel('plugins.flutter.io/path_provider'); + +/// Mocks the file_picker method channel. +/// +/// `pickResult`: bytes returned by pickFiles (null → user cancelled). +/// `saveResult`: path returned by saveFile (null → user cancelled). +/// `saveThrows`: saveFile raises a PlatformException. +void mockFilePicker({ + Uint8List? pickResult, + String? pickName, + String? saveResult, + bool saveThrows = false, +}) { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_pickerChannel, (call) async { + if (call.method == 'save') { + if (saveThrows) { + throw PlatformException(code: 'boom', message: 'disk full'); + } + return saveResult; + } + // pickFiles ('custom'/'any'/...) + if (pickResult == null) return null; + return >[ + { + 'path': '/tmp/${pickName ?? 'picked.json'}', + 'name': pickName ?? 'picked.json', + 'size': pickResult.length, + 'bytes': pickResult, + }, + ]; + }); +} + +/// Points path_provider's documents directory at [dir]. +void mockPathProvider(Directory dir) { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_pathChannel, (call) async => dir.path); +} + +/// Clears both channel mocks. +void clearChannelMocks() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(_pickerChannel, null) + ..setMockMethodCallHandler(_pathChannel, 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/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..1b24602 --- /dev/null +++ b/billsplit/test/ui_flows_test.dart @@ -0,0 +1,536 @@ +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); + }); + }); + + group('people', () { + testWidgets('add person dialog: toggle, cancel, empty, valid', + (tester) async { + final s = tempState(); + await pumpApp(tester, s, const PeopleScreen()); + expect(find.textContaining('Nobody yet'), findsOneWidget); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + + await tester.tap(find.text('Add person')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Kasia'); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.people.single.name, 'Kasia'); + expect(s.people.single.drinks, isFalse); + await flushSaves(tester); + }); + + testWidgets('drinks switch, membership early-return, delete flows', + (tester) async { + final s = tempState(); + final p = s.addPerson('Ala'); + await pumpApp(tester, s, const PeopleScreen()); + await flushSaves(tester); + + await tester.tap(find.byType(Switch).first); + await tester.pumpAndSettle(); + expect(p.drinks, isFalse); + + // No groups yet → tapping the person is a no-op branch. + await tester.tap(find.text('Ala')); + await tester.pumpAndSettle(); + expect(find.text('Group membership'), findsNothing); + + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.people, hasLength(1)); + + await tester.tap(find.byIcon(Icons.delete_outline).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(s.people, isEmpty); + await flushSaves(tester); + }); + + testWidgets('groups: add, membership sheet, delete', (tester) async { + final s = tempState()..addPerson('Ala'); + await pumpApp(tester, s, const PeopleScreen()); + await flushSaves(tester); + + await tester.tap(find.text('Add group')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(s.groups, isEmpty); + + await tester.tap(find.text('Add group')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Meat'); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(s.groups.single.name, 'Meat'); + expect(find.textContaining('No members'), findsOneWidget); + + await tester.tap(find.text('Ala')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + expect(s.groups.single.memberIds, hasLength(1)); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + expect(s.groups.single.memberIds, isEmpty); + await tester.tapAt(const Offset(10, 10)); // close sheet + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete_outline).last); + await tester.pumpAndSettle(); + expect(s.groups, isEmpty); + await flushSaves(tester); + }); + }); + + group('receipt screen', () { + testWidgets('unknown id shows deleted note', (tester) async { + final s = tempState(); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope')); + expect(find.text('Receipt deleted.'), findsOneWidget); + }); + + testWidgets('tabs on narrow, panes on wide', (tester) async { + final s = tempState()..addPerson('Ala'); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + expect(find.byType(TabBar), findsOneWidget); + await tester.tap(find.text('Totals')); + await tester.pumpAndSettle(); + expect(find.textContaining('grosz-exact'), findsOneWidget); + + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + expect(find.byType(TabBar), findsNothing); + expect(find.byType(VerticalDivider), findsOneWidget); + }); + + testWidgets('participants: empty roster snackbar, then editing', + (tester) async { + final s = tempState(); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + await tester.tap(find.byIcon(Icons.group)); + await tester.pumpAndSettle(); + expect(find.textContaining('Add people first'), findsOneWidget); + + final p = s.addPerson('Ala'); + s.addPerson('Bob'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.group)); + await tester.pumpAndSettle(); + expect(find.text('Who is on this receipt?'), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile).first); + await tester.pumpAndSettle(); + expect(s.receipts.single.participantIds.contains(p.id), isTrue); + await tester.tap(find.byType(CheckboxListTile).first); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + await flushSaves(tester); + }); + + testWidgets('export: no participants, success, cancel, failure', + (tester) async { + final s = tempState(); + demoReceipt(s); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Add participants'), findsOneWidget); + + s.addPerson('Ala'); + s.receipts.single.participantIds.addAll(s.people.map((p) => p.id)); + mockFilePicker(saveResult: '/tmp/out.xlsx'); + await tester.pump(const Duration(seconds: 5)); // expire prior snackbar + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Saved: out.xlsx'), findsOneWidget); + + mockFilePicker(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + + mockFilePicker(saveThrows: true); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.table_view)); + await tester.pumpAndSettle(); + expect(find.textContaining('Export failed'), findsOneWidget); + await flushSaves(tester); + }); + + testWidgets('items list: labels, unassigned, edit dialog paths', + (tester) async { + final s = tempState(); + final p = s.addPerson('Ala'); + s.addPerson('Bob', drinks: false); + final g = s.addGroup('Meat')..memberIds.add(p.id); + final r = demoReceipt(s, withUnassigned: true); + r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id); + r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers); + r.items[3].assignment = Assignment( + mode: AssignMode.group, + groupId: 'gone', + ); + await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1')); + await flushSaves(tester); + + expect(find.textContaining('Group: Meat'), findsOneWidget); + expect(find.textContaining('Group: ?'), findsOneWidget); + expect(find.textContaining('Drinkers'), findsOneWidget); + expect(find.textContaining('Non-drinkers'), findsOneWidget); + expect(find.textContaining('UNASSIGNED'), findsOneWidget); + + await tester.tap(find.text('Totals')); + await tester.pumpAndSettle(); + expect(find.textContaining('excluded from totals'), findsOneWidget); + await tester.tap(find.text('Items')); + await tester.pumpAndSettle(); + + // Edit via long-press: invalid total keeps dialog open, then fix+save. + await tester.longPress(find.text('Bread')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Total (zł)'), + 'abc', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('Save'), findsOneWidget); // still open + await tester.enterText( + find.widgetWithText(TextField, 'Total (zł)'), + '6,00', + ); + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(r.items[0].totalGr, 600); + expect(r.items[0].name, 'Rolls'); + + // Delete an item from the edit dialog. + await tester.longPress(find.text('Ghost')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(r.items.any((i) => i.name == 'Ghost'), isFalse); + + // Add a new alcohol item via the FAB: cancel first, then create. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine'); + await tester.enterText( + find.widgetWithText(TextField, 'Total (zł)'), + '30', + ); + await tester.tap(find.byType(DropdownButtonFormField)); + 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/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..9d5516f --- /dev/null +++ b/billsplit/test/xlsx_extra_test.dart @@ -0,0 +1,36 @@ +import 'package:billsplit/domain/models.dart'; +import 'package:billsplit/domain/xlsx_export.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); + }); +} diff --git a/billsplit/windows/.gitignore b/billsplit/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/billsplit/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/billsplit/windows/CMakeLists.txt b/billsplit/windows/CMakeLists.txt new file mode 100644 index 0000000..bd40246 --- /dev/null +++ b/billsplit/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(billsplit LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "billsplit") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/billsplit/windows/flutter/CMakeLists.txt b/billsplit/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/billsplit/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/billsplit/windows/flutter/generated_plugin_registrant.cc b/billsplit/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/billsplit/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/billsplit/windows/flutter/generated_plugin_registrant.h b/billsplit/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/billsplit/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/billsplit/windows/flutter/generated_plugins.cmake b/billsplit/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..3ad69c6 --- /dev/null +++ b/billsplit/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/billsplit/windows/runner/CMakeLists.txt b/billsplit/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/billsplit/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/billsplit/windows/runner/Runner.rc b/billsplit/windows/runner/Runner.rc new file mode 100644 index 0000000..9f9b0e5 --- /dev/null +++ b/billsplit/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.kuhy" "\0" + VALUE "FileDescription", "billsplit" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "billsplit" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.kuhy. All rights reserved." "\0" + VALUE "OriginalFilename", "billsplit.exe" "\0" + VALUE "ProductName", "billsplit" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/billsplit/windows/runner/flutter_window.cpp b/billsplit/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/billsplit/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/billsplit/windows/runner/flutter_window.h b/billsplit/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/billsplit/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/billsplit/windows/runner/main.cpp b/billsplit/windows/runner/main.cpp new file mode 100644 index 0000000..4dc011e --- /dev/null +++ b/billsplit/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"billsplit", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/billsplit/windows/runner/resource.h b/billsplit/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/billsplit/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/billsplit/windows/runner/resources/app_icon.ico b/billsplit/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/billsplit/windows/runner/resources/app_icon.ico differ diff --git a/billsplit/windows/runner/runner.exe.manifest b/billsplit/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/billsplit/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/billsplit/windows/runner/utils.cpp b/billsplit/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/billsplit/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/billsplit/windows/runner/utils.h b/billsplit/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/billsplit/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/billsplit/windows/runner/win32_window.cpp b/billsplit/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/billsplit/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/billsplit/windows/runner/win32_window.h b/billsplit/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/billsplit/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ 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()