Archive billsplit and its coverage gate from testsAndMisc
45
billsplit/.gitignore
vendored
Normal file
@ -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
|
||||||
42
billsplit/.metadata
Normal file
@ -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'
|
||||||
81
billsplit/README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# BillSplit
|
||||||
|
|
||||||
|
Flutter app (mobile + desktop) for splitting shop receipts between people —
|
||||||
|
including the drinkers vs non-drinkers case — with Biedronka e-paragon import
|
||||||
|
and .xlsx export.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **People roster** with a per-person _drinks alcohol_ toggle, plus arbitrary
|
||||||
|
**groups** (tags) like "meat eaters".
|
||||||
|
- **Receipts**: import a Polish fiscal e-receipt JSON (`e-paragon`,
|
||||||
|
JPK_KASA_PARAGON_v2-0 — the file the Biedronka app exports; the embedded JWS
|
||||||
|
payload is decoded, signature not verified) or build one manually. Multiple
|
||||||
|
receipts persist locally (`state.json` in app documents).
|
||||||
|
- **Per-item assignment**: each item is split among _Everyone / Drinkers /
|
||||||
|
Non-drinkers / a group / hand-picked people_. Alcohol is auto-detected on
|
||||||
|
import (wódka/piwo/wino/… keywords) and defaults to drinkers only; deposits
|
||||||
|
(kaucja) become their own line split among everyone. Category and assignment
|
||||||
|
are always editable — the keyword detection is a default, not a decision.
|
||||||
|
- **Grosz-exact math**: every item's shares sum to exactly the item total
|
||||||
|
(largest-remainder distribution), so per-person totals reconcile with the
|
||||||
|
receipt to the grosz — no floating point drift.
|
||||||
|
- **Totals view**: per person, with alcohol / rest breakdown, unassigned-item
|
||||||
|
warnings, and a reconciliation check against the paid amount.
|
||||||
|
- **Export .xlsx**: one row per item with a 1/0 person matrix plus a summary —
|
||||||
|
same layout as the hand-made spreadsheet this app grew out of. Matrix and
|
||||||
|
summary are exported values (the app is the calculator); column totals are
|
||||||
|
live SUM formulas.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
`android/` is the only platform runner folder the repo tracks. Generate the
|
||||||
|
others locally for whatever you want to run on:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd billsplit
|
||||||
|
flutter create . --platforms=linux,windows,macos,ios
|
||||||
|
flutter pub get
|
||||||
|
flutter run # picks a connected device / desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
Those four directories are gitignored, so a regenerated runner stays local
|
||||||
|
instead of being committed back. CI never builds them: `billsplit-ci.yml` runs
|
||||||
|
`flutter analyze` and `flutter test` only.
|
||||||
|
|
||||||
|
Tested with Flutter 3.44.8 / Dart 3.10.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers: parsing the real sample receipt in `test/fixtures/` (49 items +
|
||||||
|
deposit, totals 590.55 + 7.00 = 597.55 zł), split invariants incl. a 500-case
|
||||||
|
fuzz of the remainder distribution, assignment-mode resolution, and xlsx
|
||||||
|
generation (the test writes `build/test_export.xlsx` for external inspection).
|
||||||
|
|
||||||
|
## Design notes / edge cases
|
||||||
|
|
||||||
|
- Money is `int` grosze end to end; formatting happens only at the UI edge.
|
||||||
|
- An item resolving to zero people (e.g. "non-drinkers" on an all-drinker
|
||||||
|
receipt) is **excluded from totals and loudly flagged**, never silently
|
||||||
|
dropped into someone's bill.
|
||||||
|
- Deleting a person prunes them from groups, receipt participants and custom
|
||||||
|
assignments; deleting a group falls back affected items to _Everyone_.
|
||||||
|
- Assignment modes are stored symbolically (not as materialized ID sets), so
|
||||||
|
adding a person later automatically includes them in _Everyone_ items.
|
||||||
|
Touching a person checkbox materializes the current set and switches the
|
||||||
|
item to _custom_.
|
||||||
|
- `paidTotalGr` (what the card was charged) is kept separately from the item
|
||||||
|
sum; a mismatch shows red in Totals instead of being "fixed" silently.
|
||||||
|
|
||||||
|
## Quality gates (enforced in CI)
|
||||||
|
|
||||||
|
- `dart format --set-exit-if-changed .` — formatting is canonical
|
||||||
|
- `flutter analyze --fatal-infos` — `very_good_analysis` + `strict-casts`,
|
||||||
|
`strict-inference`, `strict-raw-types`; any info fails the build
|
||||||
|
- `flutter test --coverage` + `tool/coverage_check.py` — **100% line
|
||||||
|
coverage of `lib/` is mandatory**; the gate also fails if a lib file is
|
||||||
|
never executed by any test
|
||||||
15
billsplit/analysis_options.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
include: package:very_good_analysis/analysis_options.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
14
billsplit/android/.gitignore
vendored
Normal file
@ -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
|
||||||
45
billsplit/android/app/build.gradle.kts
Normal file
@ -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 = "../.."
|
||||||
|
}
|
||||||
7
billsplit/android/app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
billsplit/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="billsplit"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package dev.kuhy.billsplit
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
billsplit/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 959 B |
BIN
billsplit/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 519 B |
BIN
billsplit/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 984 B |
BIN
billsplit/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
18
billsplit/android/app/src/main/res/values-night/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
billsplit/android/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#211D1B</color>
|
||||||
|
</resources>
|
||||||
18
billsplit/android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
billsplit/android/app/src/profile/AndroidManifest.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
24
billsplit/android/build.gradle.kts
Normal file
@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
24
billsplit/android/gradle.properties
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Memory budget. The Flutter template ships `-Xmx8G -XX:MaxMetaspaceSize=4G`
|
||||||
|
# here, which asks for more than this 7.6 GB machine has and got a whole
|
||||||
|
# session OOM-killed. What actually brings the footprint down is cutting the
|
||||||
|
# NUMBER of JVMs, not just the heap: without these flags Gradle runs a daemon
|
||||||
|
# plus a separate Kotlin compile daemon plus workers, each with its own heap.
|
||||||
|
#
|
||||||
|
# Budget for anything run on this machine is <= 2 GB. Verify with PSS summed
|
||||||
|
# from /proc/<pid>/smaps_rollup across the build's own processes — system-wide
|
||||||
|
# "used" includes everything else, and RSS double-counts shared pages.
|
||||||
|
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
# Compile Kotlin inside the Gradle process instead of spawning a second daemon.
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
|
# No persistent daemon, no parallel project execution, one worker.
|
||||||
|
org.gradle.daemon=false
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.workers.max=1
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
# This newDsl flag was added by the Flutter template
|
||||||
|
android.newDsl=false
|
||||||
|
# This builtInKotlin flag was added by the Flutter template. Kept false to
|
||||||
|
# match every sibling app (todo, habit_stack, home_inventory, diet_guard_app),
|
||||||
|
# which all build fine on this same AGP 9.0.1 / Gradle 9.1.0 pair.
|
||||||
|
android.builtInKotlin=false
|
||||||
5
billsplit/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -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
|
||||||
26
billsplit/android/settings.gradle.kts
Normal file
@ -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")
|
||||||
BIN
billsplit/assets/icon/icon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
6
billsplit/assets/icon/icon.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<rect width="1024" height="1024" fill="#211D1B"/>
|
||||||
|
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
|
||||||
|
<path fill="#B8862E" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 727 B |
BIN
billsplit/assets/icon/icon_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
5
billsplit/assets/icon/icon_foreground.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
|
||||||
|
<path fill="#B8862E" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 675 B |
BIN
billsplit/assets/icon/icon_monochrome.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
5
billsplit/assets/icon/icon_monochrome.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
|
||||||
|
<path fill="#FFFFFF" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 675 B |
200
billsplit/lib/domain/assignment.dart
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/// Assignment and the split calculation.
|
||||||
|
///
|
||||||
|
/// Split out of models.dart to keep it under the 250-line cap. A `part`
|
||||||
|
/// because Dart privates are library-scoped.
|
||||||
|
|
||||||
|
part of 'models.dart';
|
||||||
|
|
||||||
|
/// The assignment of one item: a symbolic mode plus mode-specific data.
|
||||||
|
class Assignment {
|
||||||
|
/// Creates an assignment; defaults to [AssignMode.everyone].
|
||||||
|
Assignment({
|
||||||
|
this.mode = AssignMode.everyone,
|
||||||
|
this.groupId,
|
||||||
|
Set<String>? customIds,
|
||||||
|
}) : customIds = customIds ?? <String>{};
|
||||||
|
|
||||||
|
/// Restores an assignment from its [toJson] map.
|
||||||
|
factory Assignment.fromJson(Map<String, dynamic> j) => Assignment(
|
||||||
|
mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone,
|
||||||
|
groupId: j['groupId'] as String?,
|
||||||
|
customIds: (j['customIds'] as List? ?? const <dynamic>[])
|
||||||
|
.cast<String>()
|
||||||
|
.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<String> customIds;
|
||||||
|
|
||||||
|
/// JSON representation for persistence.
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>() ?? 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<String, dynamic> 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<Item>? items,
|
||||||
|
Set<String>? participantIds,
|
||||||
|
this.paidTotalGr,
|
||||||
|
}) : date = date ?? DateTime.now(),
|
||||||
|
items = items ?? <Item>[],
|
||||||
|
participantIds = participantIds ?? <String>{};
|
||||||
|
|
||||||
|
/// Restores a receipt from its [toJson] map.
|
||||||
|
factory Receipt.fromJson(Map<String, dynamic> 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 <dynamic>[])
|
||||||
|
.map((e) => Item.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
participantIds: (j['participantIds'] as List? ?? const <dynamic>[])
|
||||||
|
.cast<String>()
|
||||||
|
.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<Item> items;
|
||||||
|
|
||||||
|
/// Subset of the roster taking part in this receipt.
|
||||||
|
final Set<String> 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<String, dynamic> 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)}';
|
||||||
|
}
|
||||||
124
billsplit/lib/domain/eparagon_fields.dart
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/// Field extraction helpers for the e-paragon parser.
|
||||||
|
///
|
||||||
|
/// Split out of eparagon_parser.dart to keep it under the 250-line cap.
|
||||||
|
/// A `part` because Dart privates are library-scoped.
|
||||||
|
|
||||||
|
part of 'eparagon_parser.dart';
|
||||||
|
|
||||||
|
DateTime _saleDate(
|
||||||
|
Map<dynamic, dynamic> paragon,
|
||||||
|
Map<dynamic, dynamic> 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<String, dynamic> _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<String, dynamic>();
|
||||||
|
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<String, dynamic>? _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<String, dynamic>() : null;
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item? _itemFromTowar(Map<String, dynamic> t, List<String> 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;
|
||||||
|
}
|
||||||
177
billsplit/lib/domain/eparagon_parser.dart
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/// Parser for Polish fiscal e-receipts (e-paragon / JPK_KASA_PARAGON_v2-0),
|
||||||
|
/// e.g. the JSON files produced by the Biedronka app.
|
||||||
|
///
|
||||||
|
/// Accepted inputs:
|
||||||
|
/// * the full wrapper JSON containing a `data` field with a JWS
|
||||||
|
/// (header.payload.signature, base64url) whose payload is the JPK document;
|
||||||
|
/// * the decoded JPK payload itself (an object with `dokument.paragon`);
|
||||||
|
/// * a bare JWS token string.
|
||||||
|
///
|
||||||
|
/// The signature is NOT verified — this app only reads the contents.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
|
||||||
|
part 'eparagon_fields.dart';
|
||||||
|
|
||||||
|
/// A parsed receipt plus non-fatal [warnings] collected on the way.
|
||||||
|
class EParagonResult {
|
||||||
|
/// Bundles a parsed [receipt] with its [warnings].
|
||||||
|
EParagonResult({required this.receipt, required this.warnings});
|
||||||
|
|
||||||
|
/// The parsed receipt.
|
||||||
|
final Receipt receipt;
|
||||||
|
|
||||||
|
/// Human-readable warnings (e.g. sum mismatches, skipped lines).
|
||||||
|
final List<String> 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 = <String>[];
|
||||||
|
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 = <Item>[];
|
||||||
|
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<String, dynamic>(), 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<dynamic, dynamic> 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;
|
||||||
|
}
|
||||||
86
billsplit/lib/domain/models.dart
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/// Domain models. All money values are ints in grosze (1/100 PLN) to avoid
|
||||||
|
/// floating point drift; formatting happens only at the UI/export edge.
|
||||||
|
library;
|
||||||
|
|
||||||
|
part 'assignment.dart';
|
||||||
|
|
||||||
|
/// Formats [grosze] as a Polish złoty string, e.g. `1234` → `12,34 zł`.
|
||||||
|
String formatPln(int grosze) {
|
||||||
|
final neg = grosze < 0;
|
||||||
|
final a = grosze.abs();
|
||||||
|
final zl = a ~/ 100;
|
||||||
|
final gr = (a % 100).toString().padLeft(2, '0');
|
||||||
|
return '${neg ? '-' : ''}$zl,$gr zł';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person on the shared roster.
|
||||||
|
class Person {
|
||||||
|
/// Creates a person; [drinks] controls alcohol-item defaults.
|
||||||
|
Person({required this.id, required this.name, this.drinks = true});
|
||||||
|
|
||||||
|
/// Restores a person from its [toJson] map.
|
||||||
|
factory Person.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String>? memberIds})
|
||||||
|
: memberIds = memberIds ?? <String>{};
|
||||||
|
|
||||||
|
/// Restores a group from its [toJson] map.
|
||||||
|
factory Group.fromJson(Map<String, dynamic> j) => Group(
|
||||||
|
id: j['id'] as String,
|
||||||
|
name: j['name'] as String,
|
||||||
|
memberIds: (j['memberIds'] as List? ?? const <dynamic>[])
|
||||||
|
.cast<String>()
|
||||||
|
.toSet(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Stable unique id.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Display name.
|
||||||
|
String name;
|
||||||
|
|
||||||
|
/// Ids of [Person]s belonging to this group.
|
||||||
|
final Set<String> memberIds;
|
||||||
|
|
||||||
|
/// JSON representation for persistence.
|
||||||
|
Map<String, dynamic> 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,
|
||||||
|
}
|
||||||
155
billsplit/lib/domain/split_engine.dart
Normal file
@ -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<String, PersonTotal> perPerson;
|
||||||
|
|
||||||
|
/// itemId → (personId → share in grosze). Only resolved participants.
|
||||||
|
final Map<String, Map<String, int>> itemShares;
|
||||||
|
|
||||||
|
/// Items that resolved to zero participants (excluded from totals).
|
||||||
|
final List<Item> 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<Person> receiptParticipants(Receipt receipt, List<Person> 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<Person> resolveParticipants(
|
||||||
|
Item item,
|
||||||
|
Receipt receipt,
|
||||||
|
List<Person> roster,
|
||||||
|
List<Group> 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<String, int> splitAmount(
|
||||||
|
int totalGr,
|
||||||
|
List<String> 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 = <String, int>{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<Person> roster,
|
||||||
|
List<Group> groups,
|
||||||
|
) {
|
||||||
|
final perPerson = <String, PersonTotal>{
|
||||||
|
for (final p in receiptParticipants(receipt, roster))
|
||||||
|
p.id: PersonTotal(p.id),
|
||||||
|
};
|
||||||
|
final itemShares = <String, Map<String, int>>{};
|
||||||
|
final unassigned = <Item>[];
|
||||||
|
|
||||||
|
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<T> on Iterable<T> {
|
||||||
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
|
}
|
||||||
215
billsplit/lib/domain/xlsx_export.dart
Normal file
@ -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<int> exportXlsx({
|
||||||
|
required Receipt receipt,
|
||||||
|
required List<Person> roster,
|
||||||
|
required List<Group> 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 <String, int>{};
|
||||||
|
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;
|
||||||
|
}
|
||||||
39
billsplit/lib/main.dart
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/home_screen.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final state = AppState();
|
||||||
|
unawaited(state.load());
|
||||||
|
runApp(BillSplitApp(state: state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root widget wiring [AppState] into the tree.
|
||||||
|
class BillSplitApp extends StatelessWidget {
|
||||||
|
/// Creates the app around an existing [state].
|
||||||
|
const BillSplitApp({required this.state, super.key});
|
||||||
|
|
||||||
|
/// The single app-wide state instance.
|
||||||
|
final AppState state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProvider.value(
|
||||||
|
value: state,
|
||||||
|
child: MaterialApp(
|
||||||
|
title: 'BillSplit',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
// The shared light theme, replacing a teal-seeded scheme. Light, not
|
||||||
|
// dark: the receipt screens carry black-on-white helper text, so
|
||||||
|
// switching brightness here would make it unreadable.
|
||||||
|
theme: buildLightTheme(),
|
||||||
|
home: const HomeScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
billsplit/lib/state/app_state.dart
Normal file
@ -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<Person> people = [];
|
||||||
|
|
||||||
|
/// User-defined groups over [people].
|
||||||
|
final List<Group> groups = [];
|
||||||
|
|
||||||
|
/// Saved receipts, newest first.
|
||||||
|
final List<Receipt> receipts = [];
|
||||||
|
|
||||||
|
/// Whether [load] has completed.
|
||||||
|
bool loaded = false;
|
||||||
|
|
||||||
|
Timer? _saveTimer;
|
||||||
|
|
||||||
|
Future<File> _file() async {
|
||||||
|
final dir = _overrideDir ?? await getApplicationDocumentsDirectory();
|
||||||
|
final d = Directory('${dir.path}/billsplit');
|
||||||
|
if (!d.existsSync()) d.createSync(recursive: true);
|
||||||
|
return File('${d.path}/state.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads persisted state; failures leave the state empty but usable.
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final f = await _file();
|
||||||
|
if (f.existsSync()) {
|
||||||
|
final j = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
|
||||||
|
people
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
(j['people'] as List? ?? const <dynamic>[])
|
||||||
|
.map((e) => Person.fromJson((e as Map).cast())),
|
||||||
|
);
|
||||||
|
groups
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
(j['groups'] as List? ?? const <dynamic>[])
|
||||||
|
.map((e) => Group.fromJson((e as Map).cast())),
|
||||||
|
);
|
||||||
|
receipts
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
(j['receipts'] as List? ?? const <dynamic>[])
|
||||||
|
.map((e) => Receipt.fromJson((e as Map).cast())),
|
||||||
|
);
|
||||||
|
_prune();
|
||||||
|
}
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
debugPrint('State load failed (corrupt JSON): $e');
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
debugPrint('State load failed (IO): $e');
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop references to people that no longer exist.
|
||||||
|
void _prune() {
|
||||||
|
final ids = people.map((p) => p.id).toSet();
|
||||||
|
for (final g in groups) {
|
||||||
|
g.memberIds.removeWhere((id) => !ids.contains(id));
|
||||||
|
}
|
||||||
|
for (final r in receipts) {
|
||||||
|
r.participantIds.removeWhere((id) => !ids.contains(id));
|
||||||
|
for (final i in r.items) {
|
||||||
|
i.assignment.customIds.removeWhere((id) => !ids.contains(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSave() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
_saveTimer = Timer(const Duration(milliseconds: 400), save);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the current state to disk immediately.
|
||||||
|
Future<void> save() async {
|
||||||
|
try {
|
||||||
|
final f = await _file();
|
||||||
|
f.writeAsStringSync(
|
||||||
|
jsonEncode({
|
||||||
|
'people': people.map((p) => p.toJson()).toList(),
|
||||||
|
'groups': groups.map((g) => g.toJson()).toList(),
|
||||||
|
'receipts': receipts.map((r) => r.toJson()).toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
debugPrint('State save failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [fn], prunes dangling references, notifies, schedules a save.
|
||||||
|
void mutate(void Function() fn) {
|
||||||
|
fn();
|
||||||
|
_prune();
|
||||||
|
notifyListeners();
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience CRUD -------------------------------------------------------
|
||||||
|
|
||||||
|
/// Adds a person to the roster and returns it.
|
||||||
|
Person addPerson(String name, {bool drinks = true}) {
|
||||||
|
final p = Person(id: Ids.next(), name: name, drinks: drinks);
|
||||||
|
mutate(() => people.add(p));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes [p] everywhere (roster, groups, receipts, assignments).
|
||||||
|
void removePerson(Person p) => mutate(() => people.remove(p));
|
||||||
|
|
||||||
|
/// Adds an empty group and returns it.
|
||||||
|
Group addGroup(String name) {
|
||||||
|
final g = Group(id: Ids.next(), name: name);
|
||||||
|
mutate(() => groups.add(g));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes [g]; items assigned to it fall back to everyone.
|
||||||
|
void removeGroup(Group g) => mutate(() {
|
||||||
|
groups.remove(g);
|
||||||
|
for (final r in receipts) {
|
||||||
|
for (final i in r.items) {
|
||||||
|
if (i.assignment.groupId == g.id) {
|
||||||
|
i.assignment
|
||||||
|
..mode = AssignMode.everyone
|
||||||
|
..groupId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Inserts [r] at the top; empty participants default to the whole roster.
|
||||||
|
void addReceipt(Receipt r) => mutate(() {
|
||||||
|
if (r.participantIds.isEmpty) {
|
||||||
|
r.participantIds.addAll(people.map((p) => p.id));
|
||||||
|
}
|
||||||
|
receipts.insert(0, r);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Deletes [r].
|
||||||
|
void removeReceipt(Receipt r) => mutate(() => receipts.remove(r));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
146
billsplit/lib/ui/assignment_sheet.dart
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/domain/split_engine.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Bottom sheet to assign an item: quick modes (everyone / drinkers /
|
||||||
|
/// non-drinkers / group) or explicit person selection. Toggling a person
|
||||||
|
/// checkbox on a non-custom mode first materializes the current selection,
|
||||||
|
/// then switches to custom.
|
||||||
|
Future<void> showAssignmentSheet(
|
||||||
|
BuildContext context,
|
||||||
|
AppState state,
|
||||||
|
Receipt receipt,
|
||||||
|
Item item,
|
||||||
|
) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (c) => StatefulBuilder(
|
||||||
|
builder: (c, setSheet) {
|
||||||
|
final pool = receiptParticipants(receipt, state.people)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
final resolved =
|
||||||
|
resolveParticipants(item, receipt, state.people, state.groups)
|
||||||
|
.map((p) => p.id)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
void setMode(AssignMode mode, {String? groupId}) {
|
||||||
|
state.mutate(() {
|
||||||
|
item.assignment
|
||||||
|
..mode = mode
|
||||||
|
..groupId = groupId;
|
||||||
|
});
|
||||||
|
setSheet(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void togglePerson(Person p, {required bool selected}) {
|
||||||
|
state.mutate(() {
|
||||||
|
if (item.assignment.mode != AssignMode.custom) {
|
||||||
|
item.assignment
|
||||||
|
..customIds.clear()
|
||||||
|
..customIds.addAll(resolved)
|
||||||
|
..mode = AssignMode.custom
|
||||||
|
..groupId = null;
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
item.assignment.customIds.add(p.id);
|
||||||
|
} else {
|
||||||
|
item.assignment.customIds.remove(p.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSheet(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedSeg = switch (item.assignment.mode) {
|
||||||
|
AssignMode.everyone => 'everyone',
|
||||||
|
AssignMode.drinkers => 'drinkers',
|
||||||
|
AssignMode.nonDrinkers => 'nonDrinkers',
|
||||||
|
AssignMode.group => 'group:${item.assignment.groupId}',
|
||||||
|
AssignMode.custom => 'custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.name, style: Theme.of(c).textTheme.titleMedium),
|
||||||
|
Text(
|
||||||
|
'${formatPln(item.totalGr)} · ${item.category}',
|
||||||
|
style: Theme.of(c).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Everyone'),
|
||||||
|
selected: selectedSeg == 'everyone',
|
||||||
|
onSelected: (_) => setMode(AssignMode.everyone),
|
||||||
|
),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Drinkers'),
|
||||||
|
selected: selectedSeg == 'drinkers',
|
||||||
|
onSelected: (_) => setMode(AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('Non-drinkers'),
|
||||||
|
selected: selectedSeg == 'nonDrinkers',
|
||||||
|
onSelected: (_) => setMode(AssignMode.nonDrinkers),
|
||||||
|
),
|
||||||
|
for (final g in state.groups)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(g.name),
|
||||||
|
selected: selectedSeg == 'group:${g.id}',
|
||||||
|
onSelected: (_) =>
|
||||||
|
setMode(AssignMode.group, groupId: g.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Flexible(
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
for (final p in pool)
|
||||||
|
CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(p.name),
|
||||||
|
subtitle: p.drinks ? null : const Text('non-drinker'),
|
||||||
|
value: resolved.contains(p.id),
|
||||||
|
onChanged: (v) =>
|
||||||
|
togglePerson(p, selected: v ?? false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
resolved.isEmpty
|
||||||
|
? 'Nobody selected — this item will be excluded from '
|
||||||
|
'totals until someone is assigned.'
|
||||||
|
: _eachLabel(resolved.length, item.totalGr),
|
||||||
|
style: TextStyle(
|
||||||
|
color: resolved.isEmpty
|
||||||
|
? Theme.of(c).colorScheme.error
|
||||||
|
: Theme.of(c).colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: AppTextSize.caption,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _eachLabel(int n, int totalGr) =>
|
||||||
|
'$n people · ~${formatPln((totalGr / n).round())} each';
|
||||||
210
billsplit/lib/ui/home_screen.dart
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/eparagon_parser.dart';
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/people_screen.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
/// Receipt list plus entry points for import / manual creation.
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
/// Creates the home screen.
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AppState>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('BillSplit'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'People & groups',
|
||||||
|
icon: const Icon(Icons.group),
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(builder: (_) => const PeopleScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: !state.loaded
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: state.receipts.isEmpty
|
||||||
|
? const _EmptyHint()
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: state.receipts.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final r = state.receipts[i];
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(r.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
// The delete affordance revealed by swiping. The fill
|
||||||
|
// and the icon on top of it must move together, hence
|
||||||
|
// error/onError rather than two independent colours.
|
||||||
|
background: Container(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: AppSpacing.md),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete,
|
||||||
|
color: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) => _confirmDelete(context, r),
|
||||||
|
onDismissed: (_) => state.removeReceipt(r),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(r.title),
|
||||||
|
subtitle: Text(
|
||||||
|
'${r.items.length} items · '
|
||||||
|
'${r.participantIds.length} people',
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
formatPln(r.itemsTotalGr),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
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<bool> _confirmDelete(BuildContext context, Receipt r) async {
|
||||||
|
return await showDialog<bool>(
|
||||||
|
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<void> _import(BuildContext context) async {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
const jsonGroup = XTypeGroup(
|
||||||
|
label: 'e-paragon',
|
||||||
|
extensions: ['json'],
|
||||||
|
// Android matches on MIME rather than extension; without this the
|
||||||
|
// picker greys out every file.
|
||||||
|
mimeTypes: ['application/json'],
|
||||||
|
uniformTypeIdentifiers: ['public.json'],
|
||||||
|
);
|
||||||
|
final picked = await openFile(acceptedTypeGroups: [jsonGroup]);
|
||||||
|
if (picked == null) return;
|
||||||
|
final data = await picked.readAsBytes();
|
||||||
|
try {
|
||||||
|
final result = parseEParagonDetailed(utf8.decode(data));
|
||||||
|
state.addReceipt(result.receipt);
|
||||||
|
for (final w in result.warnings) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(w)));
|
||||||
|
}
|
||||||
|
nav.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
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<void> _createManual(BuildContext context) async {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final title = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (c) => AlertDialog(
|
||||||
|
title: const Text('New receipt'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
onSubmitted: (v) => Navigator.pop(c, v),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(c),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(c, controller.text),
|
||||||
|
child: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (title == null || title.trim().isEmpty) return;
|
||||||
|
final r = Receipt(id: Ids.next(), title: title.trim());
|
||||||
|
state.addReceipt(r);
|
||||||
|
nav.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
billsplit/lib/ui/item_edit.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Add ([item] == null) or edit an item. The user types amounts in złoty
|
||||||
|
/// ("12,34" or "12.34"); they are stored as int grosze.
|
||||||
|
Future<void> 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 = <String>{
|
||||||
|
Categories.general,
|
||||||
|
Categories.alcohol,
|
||||||
|
Categories.mixer,
|
||||||
|
Categories.deposit,
|
||||||
|
...receipt.items.map((i) => i.category),
|
||||||
|
}.toList();
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
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<String>(
|
||||||
|
initialValue: category,
|
||||||
|
decoration: const InputDecoration(labelText: 'Category'),
|
||||||
|
items: [
|
||||||
|
for (final cat in categories)
|
||||||
|
DropdownMenuItem(value: cat, child: Text(cat)),
|
||||||
|
],
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => category = v ?? Categories.general),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Alcohol items default to drinkers only; everything else to '
|
||||||
|
'everyone. Fine-tune by tapping the item afterwards.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppTextSize.caption,
|
||||||
|
color: Theme.of(c).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (item != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
state.mutate(() => receipt.items.remove(item));
|
||||||
|
Navigator.pop(c);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Delete',
|
||||||
|
style: TextStyle(color: Theme.of(c).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(c),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final gr = _parseZl(total.text);
|
||||||
|
if (name.text.trim().isEmpty || gr == null) return;
|
||||||
|
state.mutate(() {
|
||||||
|
if (item == null) {
|
||||||
|
final it = Item(
|
||||||
|
id: Ids.next(),
|
||||||
|
name: name.text.trim(),
|
||||||
|
totalGr: gr,
|
||||||
|
qty: double.tryParse(qty.text.replaceAll(',', '.')) ?? 1,
|
||||||
|
category: category,
|
||||||
|
assignment: category == Categories.alcohol
|
||||||
|
? Assignment(mode: AssignMode.drinkers)
|
||||||
|
: Assignment(),
|
||||||
|
);
|
||||||
|
receipt.items.add(it);
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
..name = name.text.trim()
|
||||||
|
..totalGr = gr
|
||||||
|
..qty = double.tryParse(qty.text.replaceAll(',', '.')) ?? 1
|
||||||
|
..category = category;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(c);
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _zl(int gr) => (gr / 100).toStringAsFixed(2);
|
||||||
|
|
||||||
|
int? _parseZl(String s) {
|
||||||
|
final v = double.tryParse(s.replaceAll(',', '.').replaceAll(' ', ''));
|
||||||
|
if (v == null) return null;
|
||||||
|
return (v * 100).round();
|
||||||
|
}
|
||||||
135
billsplit/lib/ui/people_screen.dart
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
part 'people_tiles.dart';
|
||||||
|
|
||||||
|
/// Roster management: people with a drinks toggle, plus custom groups.
|
||||||
|
class PeopleScreen extends StatelessWidget {
|
||||||
|
/// Creates the people screen.
|
||||||
|
const PeopleScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AppState>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('People & groups')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 96),
|
||||||
|
children: [
|
||||||
|
const SectionHeader('People', padding: SectionHeader.defaultPadding),
|
||||||
|
if (state.people.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('Nobody yet — add people below.'),
|
||||||
|
),
|
||||||
|
for (final p in state.people) _PersonTile(person: p),
|
||||||
|
const Divider(height: 32),
|
||||||
|
const SectionHeader('Groups', padding: SectionHeader.defaultPadding),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Drinkers / non-drinkers are built in (the toggle on each '
|
||||||
|
'person). Groups below are extra tags, e.g. "meat eaters".',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppTextSize.caption,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final g in state.groups) _GroupTile(group: g),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add group'),
|
||||||
|
onPressed: () => _addGroup(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
icon: const Icon(Icons.person_add),
|
||||||
|
label: const Text('Add person'),
|
||||||
|
onPressed: () => _addPerson(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addPerson(BuildContext context) async {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
final controller = TextEditingController();
|
||||||
|
var drinks = true;
|
||||||
|
await showDialog<void>(
|
||||||
|
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<void> _addGroup(BuildContext context) async {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
final controller = TextEditingController();
|
||||||
|
await showDialog<void>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
billsplit/lib/ui/people_tiles.dart
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/// Person and group tile widgets.
|
||||||
|
///
|
||||||
|
/// Split out of people_screen.dart to keep it under the 250-line cap. A `part`
|
||||||
|
/// because Dart privates are library-scoped.
|
||||||
|
|
||||||
|
part of 'people_screen.dart';
|
||||||
|
|
||||||
|
class _PersonTile extends StatelessWidget {
|
||||||
|
const _PersonTile({required this.person});
|
||||||
|
final Person person;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
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<void> _confirmRemove(BuildContext context, AppState state) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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<void> _editMembership(BuildContext context, AppState state) async {
|
||||||
|
if (state.groups.isEmpty) return;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
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<AppState>();
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
billsplit/lib/ui/receipt_items.dart
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/// The receipt item list and its row widgets.
|
||||||
|
///
|
||||||
|
/// Split out of receipt_screen.dart to keep it under the 250-line cap. A `part`
|
||||||
|
/// because Dart privates are library-scoped.
|
||||||
|
|
||||||
|
part of 'receipt_screen.dart';
|
||||||
|
|
||||||
|
class _ItemsList extends StatelessWidget {
|
||||||
|
const _ItemsList({required this.receipt, required this.split});
|
||||||
|
final Receipt receipt;
|
||||||
|
final SplitResult split;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
if (receipt.items.isEmpty) {
|
||||||
|
return const Center(child: Text('No items — add with +.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 96),
|
||||||
|
itemCount: receipt.items.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final item = receipt.items[i];
|
||||||
|
final people =
|
||||||
|
resolveParticipants(item, receipt, state.people, state.groups);
|
||||||
|
final unassigned = people.isEmpty;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: _CategoryDot(category: item.category),
|
||||||
|
title: Text(item.name),
|
||||||
|
subtitle: Text(
|
||||||
|
_assignmentLabel(item, people.length, state),
|
||||||
|
style: TextStyle(
|
||||||
|
color: unassigned ? Theme.of(context).colorScheme.error : null,
|
||||||
|
fontWeight: unassigned ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Text(formatPln(item.totalGr)),
|
||||||
|
onTap: () => showAssignmentSheet(context, state, receipt, item),
|
||||||
|
onLongPress: () => showItemEditDialog(context, state, receipt, item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _assignmentLabel(Item item, int n, AppState state) {
|
||||||
|
switch (item.assignment.mode) {
|
||||||
|
case AssignMode.everyone:
|
||||||
|
return 'Everyone ($n)';
|
||||||
|
case AssignMode.drinkers:
|
||||||
|
return 'Drinkers only ($n)';
|
||||||
|
case AssignMode.nonDrinkers:
|
||||||
|
return 'Non-drinkers ($n)';
|
||||||
|
case AssignMode.group:
|
||||||
|
final g = state.groups
|
||||||
|
.where((g) => g.id == item.assignment.groupId)
|
||||||
|
.firstOrNull;
|
||||||
|
return 'Group: ${g?.name ?? '?'} ($n)';
|
||||||
|
case AssignMode.custom:
|
||||||
|
return n == 0 ? 'UNASSIGNED — tap to fix' : 'Selected people ($n)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryDot extends StatelessWidget {
|
||||||
|
const _CategoryDot({required this.category});
|
||||||
|
final String category;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = switch (category) {
|
||||||
|
// Categorical encoding, deliberately NOT mapped onto ColorScheme slots.
|
||||||
|
// These four say "which category", not "which theme role", and a
|
||||||
|
// single-accent design system has no four distinct hues to lend. Folding
|
||||||
|
// them into primary/secondary/tertiary would make the categories stop
|
||||||
|
// being distinguishable, which is the only job they have. Tracked in
|
||||||
|
// unified-design-system/nielsen-audit.md as needing a categorical ramp.
|
||||||
|
Categories.alcohol => Colors.deepOrange,
|
||||||
|
Categories.mixer => Colors.amber,
|
||||||
|
Categories.deposit => Colors.blueGrey,
|
||||||
|
_ => Colors.teal,
|
||||||
|
};
|
||||||
|
return CircleAvatar(radius: 6, backgroundColor: color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TotalsView extends StatelessWidget {
|
||||||
|
const _TotalsView({required this.receipt, required this.split});
|
||||||
|
final Receipt receipt;
|
||||||
|
final SplitResult split;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.read<AppState>();
|
||||||
|
final people = receiptParticipants(receipt, state.people)
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
final itemsTotal = receipt.itemsTotalGr;
|
||||||
|
final assigned = split.assignedTotalGr;
|
||||||
|
final paid = receipt.paidTotalGr;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (split.unassigned.isNotEmpty)
|
||||||
|
Card(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'${split.unassigned.length} item(s) have nobody assigned and '
|
||||||
|
'are excluded from totals:\n'
|
||||||
|
'${split.unassigned.map((i) => '· ${i.name}').join('\n')}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final p in people)
|
||||||
|
_PersonRow(person: p, total: split.perPerson[p.id]),
|
||||||
|
const Divider(height: 32),
|
||||||
|
_kv(context, 'Items total', formatPln(itemsTotal), bold: true),
|
||||||
|
_kv(
|
||||||
|
context,
|
||||||
|
'Assigned to people',
|
||||||
|
formatPln(assigned),
|
||||||
|
warn: assigned != itemsTotal,
|
||||||
|
),
|
||||||
|
if (paid != null)
|
||||||
|
_kv(
|
||||||
|
context,
|
||||||
|
'Paid (receipt)',
|
||||||
|
formatPln(paid),
|
||||||
|
warn: paid != itemsTotal,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Splits are grosz-exact: shares always add up to the item total, '
|
||||||
|
'so no fractional grosze are lost.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _kv(
|
||||||
|
BuildContext context,
|
||||||
|
String k,
|
||||||
|
String v, {
|
||||||
|
bool bold = false,
|
||||||
|
bool warn = false,
|
||||||
|
}) {
|
||||||
|
final style = TextStyle(
|
||||||
|
fontWeight: bold || warn ? FontWeight.bold : null,
|
||||||
|
color: warn ? Theme.of(context).colorScheme.error : null,
|
||||||
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [Text(k, style: style), Text(v, style: style)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonRow extends StatelessWidget {
|
||||||
|
const _PersonRow({required this.person, required this.total});
|
||||||
|
final Person person;
|
||||||
|
final PersonTotal? total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = total;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
child: Text(
|
||||||
|
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(person.name),
|
||||||
|
subtitle: t == null || t.alcoholGr == 0
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
'alcohol ${formatPln(t.alcoholGr)} · rest ${formatPln(t.restGr)}',
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
formatPln(t?.totalGr ?? 0),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension<T> on Iterable<T> {
|
||||||
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
|
}
|
||||||
211
billsplit/lib/ui/receipt_screen.dart
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/domain/split_engine.dart';
|
||||||
|
import 'package:billsplit/domain/xlsx_export.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/assignment_sheet.dart';
|
||||||
|
import 'package:billsplit/ui/item_edit.dart';
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
part 'receipt_items.dart';
|
||||||
|
|
||||||
|
/// Writes exported [bytes] to [path]; the seam the export flow writes through.
|
||||||
|
///
|
||||||
|
/// Kept injectable because `file_selector` only returns a destination — the
|
||||||
|
/// app performs the write itself, where `file_picker` did it inside the
|
||||||
|
/// plugin. Real `dart:io` futures never complete inside `testWidgets`'
|
||||||
|
/// fake-async zone, so a widget test that exercised the real write could never
|
||||||
|
/// observe the resulting snackbar. Tests swap this for an in-memory stub.
|
||||||
|
typedef ExportWriter = Future<void> Function(
|
||||||
|
String path,
|
||||||
|
Uint8List bytes,
|
||||||
|
String mimeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The active export writer. Replaced in tests via [debugSetExportWriter].
|
||||||
|
ExportWriter writeExport = _writeExportToDisk;
|
||||||
|
|
||||||
|
Future<void> _writeExportToDisk(
|
||||||
|
String path,
|
||||||
|
Uint8List bytes,
|
||||||
|
String mimeType,
|
||||||
|
) =>
|
||||||
|
XFile.fromData(bytes, mimeType: mimeType).saveTo(path);
|
||||||
|
|
||||||
|
/// Overrides [writeExport]; pass null to restore the real disk writer.
|
||||||
|
@visibleForTesting
|
||||||
|
void debugSetExportWriter(ExportWriter? writer) {
|
||||||
|
writeExport = writer ?? _writeExportToDisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One receipt: item list with assignments, totals, participants, export.
|
||||||
|
class ReceiptScreen extends StatefulWidget {
|
||||||
|
/// Creates the screen for the receipt with [receiptId].
|
||||||
|
const ReceiptScreen({required this.receiptId, super.key});
|
||||||
|
|
||||||
|
/// Id of the receipt to show; resolved against [AppState.receipts].
|
||||||
|
final String receiptId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReceiptScreen> createState() => _ReceiptScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceiptScreenState extends State<ReceiptScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AppState>();
|
||||||
|
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<void> _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<void>(
|
||||||
|
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<void> _export(
|
||||||
|
BuildContext context,
|
||||||
|
AppState state,
|
||||||
|
Receipt receipt,
|
||||||
|
) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
if (receipt.participantIds.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Add participants before exporting.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final bytes = Uint8List.fromList(
|
||||||
|
exportXlsx(
|
||||||
|
receipt: receipt,
|
||||||
|
roster: state.people,
|
||||||
|
groups: state.groups,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final safe = receipt.title.replaceAll(
|
||||||
|
RegExp(r'[^\w\d ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'),
|
||||||
|
'_',
|
||||||
|
);
|
||||||
|
const xlsxGroup = XTypeGroup(
|
||||||
|
label: 'Excel workbook',
|
||||||
|
extensions: ['xlsx'],
|
||||||
|
mimeTypes: [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final location = await getSaveLocation(
|
||||||
|
suggestedName: '$safe split.xlsx',
|
||||||
|
acceptedTypeGroups: [xlsxGroup],
|
||||||
|
);
|
||||||
|
if (location != null) {
|
||||||
|
// file_selector hands back a destination and leaves the writing to the
|
||||||
|
// caller, unlike file_picker which wrote the bytes inside the plugin.
|
||||||
|
await writeExport(location.path, bytes, xlsxGroup.mimeTypes!.first);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('Saved: ${location.path.split('/').last}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Exception catch (e) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
611
billsplit/pubspec.lock
Normal file
@ -0,0 +1,611 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.6.1"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
code_assets:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_assets
|
||||||
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+4"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
design_system:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: design_system
|
||||||
|
ref: "design_system-v0.1.0"
|
||||||
|
resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b
|
||||||
|
url: "https://github.com/kuhyx/utils"
|
||||||
|
source: git
|
||||||
|
version: "0.1.0"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
excel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: excel
|
||||||
|
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.6"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
file_selector:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_selector
|
||||||
|
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
file_selector_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_android
|
||||||
|
sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2+9"
|
||||||
|
file_selector_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_ios
|
||||||
|
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.3+5"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_web
|
||||||
|
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
hooks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: hooks
|
||||||
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
|
jni_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_util
|
||||||
|
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.12.0"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.20"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.0"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
objective_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: objective_c
|
||||||
|
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.5.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.6"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.0"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: provider
|
||||||
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5+1"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
record_use:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: record_use
|
||||||
|
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
very_good_analysis:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: very_good_analysis
|
||||||
|
sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.12.2 <4.0.0"
|
||||||
|
flutter: ">=3.44.0"
|
||||||
45
billsplit/pubspec.yaml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: billsplit
|
||||||
|
description: Receipt bill-splitting with people, groups, per-item assignment and xlsx export.
|
||||||
|
publish_to: "none"
|
||||||
|
version: 0.1.0
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.5.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
# The shared palette and scales.
|
||||||
|
design_system:
|
||||||
|
git:
|
||||||
|
url: https://github.com/kuhyx/utils
|
||||||
|
ref: design_system-v0.1.0
|
||||||
|
path: design_system
|
||||||
|
|
||||||
|
excel: ^4.0.6
|
||||||
|
file_selector: ^1.1.0
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
provider: ^6.1.2
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
file_selector_platform_interface: ^2.7.0
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
very_good_analysis: ^10.3.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# Launcher icon generation. Source art is produced by the shared generator:
|
||||||
|
# PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app billsplit
|
||||||
|
# then run: dart run flutter_launcher_icons
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: false
|
||||||
|
image_path: "assets/icon/icon.png"
|
||||||
|
adaptive_icon_background: "#211D1B"
|
||||||
|
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
|
||||||
|
adaptive_icon_monochrome: "assets/icon/icon_monochrome.png"
|
||||||
|
min_sdk_android: 21
|
||||||
184
billsplit/test/app_state_test.dart
Normal file
@ -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<void>.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
29
billsplit/test/engine_extra_test.dart
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
232
billsplit/test/eparagon_edge_test.dart
Normal file
@ -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<String, dynamic> minimalPayload({
|
||||||
|
List<dynamic>? pozycja,
|
||||||
|
Map<String, dynamic>? extraParagon,
|
||||||
|
Map<String, dynamic>? dokumentExtra,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'dokument': {
|
||||||
|
...?dokumentExtra,
|
||||||
|
'paragon': {
|
||||||
|
if (pozycja != null) 'pozycja': pozycja,
|
||||||
|
...?extraParagon,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> towar(String name, int brutto) => {
|
||||||
|
'towar': {
|
||||||
|
'nazwa': name,
|
||||||
|
'ilosc': '1,000',
|
||||||
|
'cena': brutto,
|
||||||
|
'brutto': brutto,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
String asJws(Map<String, dynamic> 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<EParagonParseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three-part token with invalid base64 payload is rejected', () {
|
||||||
|
expect(
|
||||||
|
() => parseEParagon('aa.§§§.bb'),
|
||||||
|
throwsA(isA<EParagonParseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON root that is not a map is rejected', () {
|
||||||
|
expect(
|
||||||
|
() => parseEParagon('[1, 2]'),
|
||||||
|
throwsA(isA<EParagonParseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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': <String, dynamic>{}})),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
86
billsplit/test/eparagon_parser_test.dart
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/eparagon_parser.dart';
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final fixture = File('test/fixtures/sample_receipt.json').readAsStringSync();
|
||||||
|
|
||||||
|
test('parses the real Biedronka e-paragon wrapper', () {
|
||||||
|
final res = parseEParagonDetailed(fixture);
|
||||||
|
final r = res.receipt;
|
||||||
|
|
||||||
|
// 49 goods lines + 1 deposit line.
|
||||||
|
expect(r.items.length, 50);
|
||||||
|
final deposit =
|
||||||
|
r.items.where((i) => i.category == Categories.deposit).toList();
|
||||||
|
expect(deposit.length, 1);
|
||||||
|
expect(deposit.single.totalGr, 700);
|
||||||
|
expect(deposit.single.qty, 14);
|
||||||
|
|
||||||
|
// Goods sum matches receipt sumaBrutto; paid total includes deposit.
|
||||||
|
final goods = r.items
|
||||||
|
.where((i) => i.category != Categories.deposit)
|
||||||
|
.fold(0, (s, i) => s + i.totalGr);
|
||||||
|
expect(goods, 59055);
|
||||||
|
expect(r.paidTotalGr, 59755);
|
||||||
|
expect(res.warnings, isEmpty);
|
||||||
|
|
||||||
|
// Alcohol detection: exactly vodka + beer, drinkers-only by default.
|
||||||
|
final alcohol =
|
||||||
|
r.items.where((i) => i.category == Categories.alcohol).toList();
|
||||||
|
expect(alcohol.length, 2);
|
||||||
|
expect(
|
||||||
|
alcohol.map((i) => i.name).join(' | ').toLowerCase(),
|
||||||
|
allOf(contains('wódka'), contains('piwo')),
|
||||||
|
);
|
||||||
|
for (final a in alcohol) {
|
||||||
|
expect(a.assignment.mode, AssignMode.drinkers);
|
||||||
|
}
|
||||||
|
expect(alcohol.fold(0, (s, i) => s + i.totalGr), 10872);
|
||||||
|
|
||||||
|
// Mixers tagged but split among everyone.
|
||||||
|
final mixers =
|
||||||
|
r.items.where((i) => i.category == Categories.mixer).toList();
|
||||||
|
expect(mixers.length, greaterThanOrEqualTo(4));
|
||||||
|
for (final m in mixers) {
|
||||||
|
expect(m.assignment.mode, AssignMode.everyone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name cleanup: VAT letter stripped, spaces collapsed.
|
||||||
|
expect(r.items.first.name, 'Tonic');
|
||||||
|
// Weight-based quantity with comma decimal.
|
||||||
|
final kielbasa =
|
||||||
|
r.items.firstWhere((i) => i.name.startsWith('KiełbBrocka'));
|
||||||
|
expect(kielbasa.qty, closeTo(0.871, 1e-9));
|
||||||
|
// Discount captured (vodka -28.00).
|
||||||
|
final vodka = r.items.firstWhere((i) => i.name.startsWith('WódkaB'));
|
||||||
|
expect(vodka.discountGr, -2800);
|
||||||
|
expect(vodka.totalGr, 7998);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts bare JPK payload and bare JWS token', () {
|
||||||
|
final res = parseEParagonDetailed(fixture);
|
||||||
|
expect(res.receipt.items, isNotEmpty);
|
||||||
|
// Raw JWS from the wrapper should parse identically. Tolerate whitespace
|
||||||
|
// around the colon: the fixture is minified as the device emits it, but a
|
||||||
|
// pretty-printed payload is equally valid JSON and must not fail here.
|
||||||
|
final match = RegExp(r'"data"\s*:\s*"([^"]+)"').firstMatch(fixture);
|
||||||
|
expect(match, isNotNull, reason: 'fixture must carry a "data" JWS field');
|
||||||
|
final jws = match!.group(1)!;
|
||||||
|
final fromJws = parseEParagon(jws);
|
||||||
|
expect(fromJws.items.length, res.receipt.items.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects garbage input with a clear error', () {
|
||||||
|
expect(
|
||||||
|
() => parseEParagon('not json'),
|
||||||
|
throwsA(isA<EParagonParseException>()),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => parseEParagon('{"foo": 1}'),
|
||||||
|
throwsA(isA<EParagonParseException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
1
billsplit/test/fixtures/sample_receipt.json
vendored
Normal file
118
billsplit/test/helpers.dart
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
const _pathChannel = MethodChannel('plugins.flutter.io/path_provider');
|
||||||
|
|
||||||
|
/// Fake [FileSelectorPlatform] driving open/save dialogs in tests.
|
||||||
|
///
|
||||||
|
/// file_selector is swapped at the platform-interface level rather than at the
|
||||||
|
/// method channel: the real implementation talks Pigeon, so a MethodChannel
|
||||||
|
/// mock would not intercept it.
|
||||||
|
class _FakeFileSelector extends FileSelectorPlatform {
|
||||||
|
_FakeFileSelector({
|
||||||
|
this.pickResult,
|
||||||
|
this.pickName,
|
||||||
|
this.saveResult,
|
||||||
|
this.saveThrows = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Uint8List? pickResult;
|
||||||
|
final String? pickName;
|
||||||
|
final String? saveResult;
|
||||||
|
final bool saveThrows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<XFile?> openFile({
|
||||||
|
List<XTypeGroup>? acceptedTypeGroups,
|
||||||
|
String? initialDirectory,
|
||||||
|
String? confirmButtonText,
|
||||||
|
}) async {
|
||||||
|
if (pickResult == null) return null;
|
||||||
|
return XFile.fromData(pickResult!, name: pickName ?? 'picked.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FileSaveLocation?> getSaveLocation({
|
||||||
|
List<XTypeGroup>? acceptedTypeGroups,
|
||||||
|
SaveDialogOptions options = const SaveDialogOptions(),
|
||||||
|
}) async {
|
||||||
|
if (saveThrows) {
|
||||||
|
throw PlatformException(code: 'boom', message: 'disk full');
|
||||||
|
}
|
||||||
|
if (saveResult == null) return null;
|
||||||
|
return FileSaveLocation(saveResult!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a fake file_selector platform for the current test.
|
||||||
|
///
|
||||||
|
/// `pickResult`: bytes returned by openFile (null → user cancelled).
|
||||||
|
/// `saveResult`: path returned by getSaveLocation (null → user cancelled).
|
||||||
|
/// `saveThrows`: getSaveLocation raises a PlatformException.
|
||||||
|
void mockFilePicker({
|
||||||
|
Uint8List? pickResult,
|
||||||
|
String? pickName,
|
||||||
|
String? saveResult,
|
||||||
|
bool saveThrows = false,
|
||||||
|
}) {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
FileSelectorPlatform.instance = _FakeFileSelector(
|
||||||
|
pickResult: pickResult,
|
||||||
|
pickName: pickName,
|
||||||
|
saveResult: saveResult,
|
||||||
|
saveThrows: saveThrows,
|
||||||
|
);
|
||||||
|
// Record the write instead of touching the disk: a real dart:io future never
|
||||||
|
// completes inside testWidgets' fake-async zone, so the export flow would
|
||||||
|
// stall before showing its snackbar.
|
||||||
|
exportedFiles.clear();
|
||||||
|
debugSetExportWriter((path, bytes, mimeType) async {
|
||||||
|
exportedFiles[path] = bytes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths and payloads captured by the stub export writer.
|
||||||
|
final Map<String, Uint8List> exportedFiles = <String, Uint8List>{};
|
||||||
|
|
||||||
|
/// Points path_provider's documents directory at [dir].
|
||||||
|
void mockPathProvider(Directory dir) {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(_pathChannel, (call) async => dir.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the path_provider channel mock and the file_selector fake.
|
||||||
|
void clearChannelMocks() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(_pathChannel, null);
|
||||||
|
FileSelectorPlatform.instance = _FakeFileSelector();
|
||||||
|
debugSetExportWriter(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an [AppState] persisted under a fresh temp directory.
|
||||||
|
AppState tempState() {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('billsplit_ui');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
return AppState(overrideDir: dir)..loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumps [home] inside the app shell with [state] provided.
|
||||||
|
Future<void> 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<void> flushSaves(WidgetTester tester) =>
|
||||||
|
tester.pump(const Duration(milliseconds: 450));
|
||||||
78
billsplit/test/models_test.dart
Normal file
@ -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<String, dynamic>,
|
||||||
|
);
|
||||||
|
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<String, dynamic>,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
154
billsplit/test/people_flows_test.dart
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// People screen flows.
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/people_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
|
||||||
|
final r = Receipt(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Demo',
|
||||||
|
store: 'Biedronka',
|
||||||
|
paidTotalGr: 1000,
|
||||||
|
items: [
|
||||||
|
Item(id: 'i1', name: 'Bread', totalGr: 500),
|
||||||
|
Item(
|
||||||
|
id: 'i2',
|
||||||
|
name: 'Beer',
|
||||||
|
totalGr: 300,
|
||||||
|
category: Categories.alcohol,
|
||||||
|
assignment: Assignment(mode: AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i3',
|
||||||
|
name: 'Tonic',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.mixer,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i4',
|
||||||
|
name: 'Kaucja',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.deposit,
|
||||||
|
),
|
||||||
|
if (withUnassigned)
|
||||||
|
Item(
|
||||||
|
id: 'i5',
|
||||||
|
name: 'Ghost',
|
||||||
|
totalGr: 50,
|
||||||
|
assignment: Assignment(mode: AssignMode.custom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.addReceipt(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('people', () {
|
||||||
|
testWidgets('add person dialog: toggle, cancel, empty, valid',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const PeopleScreen());
|
||||||
|
expect(find.textContaining('Nobody yet'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add person'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.people, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add person'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.people, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add person'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Kasia');
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.people.single.name, 'Kasia');
|
||||||
|
expect(s.people.single.drinks, isFalse);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('drinks switch, membership early-return, delete flows',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
final p = s.addPerson('Ala');
|
||||||
|
await pumpApp(tester, s, const PeopleScreen());
|
||||||
|
await flushSaves(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(p.drinks, isFalse);
|
||||||
|
|
||||||
|
// No groups yet → tapping the person is a no-op branch.
|
||||||
|
await tester.tap(find.text('Ala'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Group membership'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.people, hasLength(1));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Remove'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.people, isEmpty);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('groups: add, membership sheet, delete', (tester) async {
|
||||||
|
final s = tempState()..addPerson('Ala');
|
||||||
|
await pumpApp(tester, s, const PeopleScreen());
|
||||||
|
await flushSaves(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add group'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.groups, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add group'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Meat');
|
||||||
|
await tester.tap(find.text('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.groups.single.name, 'Meat');
|
||||||
|
expect(find.textContaining('No members'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Ala'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CheckboxListTile), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(CheckboxListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.groups.single.memberIds, hasLength(1));
|
||||||
|
await tester.tap(find.byType(CheckboxListTile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.groups.single.memberIds, isEmpty);
|
||||||
|
await tester.tapAt(const Offset(10, 10)); // close sheet
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.groups, isEmpty);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
140
billsplit/test/receipt_flows_test.dart
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// Receipt screen flows.
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
|
||||||
|
final r = Receipt(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Demo',
|
||||||
|
store: 'Biedronka',
|
||||||
|
paidTotalGr: 1000,
|
||||||
|
items: [
|
||||||
|
Item(id: 'i1', name: 'Bread', totalGr: 500),
|
||||||
|
Item(
|
||||||
|
id: 'i2',
|
||||||
|
name: 'Beer',
|
||||||
|
totalGr: 300,
|
||||||
|
category: Categories.alcohol,
|
||||||
|
assignment: Assignment(mode: AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i3',
|
||||||
|
name: 'Tonic',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.mixer,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i4',
|
||||||
|
name: 'Kaucja',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.deposit,
|
||||||
|
),
|
||||||
|
if (withUnassigned)
|
||||||
|
Item(
|
||||||
|
id: 'i5',
|
||||||
|
name: 'Ghost',
|
||||||
|
totalGr: 50,
|
||||||
|
assignment: Assignment(mode: AssignMode.custom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.addReceipt(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('receipt screen', () {
|
||||||
|
testWidgets('unknown id shows deleted note', (tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope'));
|
||||||
|
expect(find.text('Receipt deleted.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tabs on narrow, panes on wide', (tester) async {
|
||||||
|
final s = tempState()..addPerson('Ala');
|
||||||
|
demoReceipt(s);
|
||||||
|
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
|
||||||
|
await flushSaves(tester);
|
||||||
|
expect(find.byType(TabBar), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Totals'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('grosz-exact'), findsOneWidget);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(1600, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TabBar), findsNothing);
|
||||||
|
expect(find.byType(VerticalDivider), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('participants: empty roster snackbar, then editing',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
demoReceipt(s);
|
||||||
|
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
|
||||||
|
await flushSaves(tester);
|
||||||
|
await tester.tap(find.byIcon(Icons.group));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Add people first'), findsOneWidget);
|
||||||
|
|
||||||
|
final p = s.addPerson('Ala');
|
||||||
|
s.addPerson('Bob');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.group));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Who is on this receipt?'), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(CheckboxListTile).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts.single.participantIds.contains(p.id), isTrue);
|
||||||
|
await tester.tap(find.byType(CheckboxListTile).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('export: no participants, success, cancel, failure',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
demoReceipt(s);
|
||||||
|
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
|
||||||
|
await flushSaves(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.table_view));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Add participants'), findsOneWidget);
|
||||||
|
|
||||||
|
s.addPerson('Ala');
|
||||||
|
s.receipts.single.participantIds.addAll(s.people.map((p) => p.id));
|
||||||
|
mockFilePicker(saveResult: '/tmp/out.xlsx');
|
||||||
|
await tester.pump(const Duration(seconds: 5)); // expire prior snackbar
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.table_view));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Saved: out.xlsx'), findsOneWidget);
|
||||||
|
|
||||||
|
mockFilePicker();
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.table_view));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
mockFilePicker(saveThrows: true);
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.table_view));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Export failed'), findsOneWidget);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
195
billsplit/test/receipt_items_test.dart
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
// Receipt item list and assignment sheet flows.
|
||||||
|
|
||||||
|
// Receipt screen flows.
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
|
||||||
|
final r = Receipt(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Demo',
|
||||||
|
store: 'Biedronka',
|
||||||
|
paidTotalGr: 1000,
|
||||||
|
items: [
|
||||||
|
Item(id: 'i1', name: 'Bread', totalGr: 500),
|
||||||
|
Item(
|
||||||
|
id: 'i2',
|
||||||
|
name: 'Beer',
|
||||||
|
totalGr: 300,
|
||||||
|
category: Categories.alcohol,
|
||||||
|
assignment: Assignment(mode: AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i3',
|
||||||
|
name: 'Tonic',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.mixer,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i4',
|
||||||
|
name: 'Kaucja',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.deposit,
|
||||||
|
),
|
||||||
|
if (withUnassigned)
|
||||||
|
Item(
|
||||||
|
id: 'i5',
|
||||||
|
name: 'Ghost',
|
||||||
|
totalGr: 50,
|
||||||
|
assignment: Assignment(mode: AssignMode.custom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.addReceipt(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('receipt items', () {
|
||||||
|
testWidgets('items list: labels, unassigned, edit dialog paths',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
final p = s.addPerson('Ala');
|
||||||
|
s.addPerson('Bob', drinks: false);
|
||||||
|
final g = s.addGroup('Meat')..memberIds.add(p.id);
|
||||||
|
final r = demoReceipt(s, withUnassigned: true);
|
||||||
|
r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id);
|
||||||
|
r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers);
|
||||||
|
r.items[3].assignment = Assignment(
|
||||||
|
mode: AssignMode.group,
|
||||||
|
groupId: 'gone',
|
||||||
|
);
|
||||||
|
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
|
||||||
|
await flushSaves(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('Group: Meat'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Group: ?'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Drinkers'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Non-drinkers'), findsOneWidget);
|
||||||
|
expect(find.textContaining('UNASSIGNED'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Totals'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('excluded from totals'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Items'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Edit via long-press: invalid total keeps dialog open, then fix+save.
|
||||||
|
await tester.longPress(find.text('Bread'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Total (zł)'),
|
||||||
|
'abc',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Save'), findsOneWidget); // still open
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Total (zł)'),
|
||||||
|
'6,00',
|
||||||
|
);
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls');
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(r.items[0].totalGr, 600);
|
||||||
|
expect(r.items[0].name, 'Rolls');
|
||||||
|
|
||||||
|
// Delete an item from the edit dialog.
|
||||||
|
await tester.longPress(find.text('Ghost'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(r.items.any((i) => i.name == 'Ghost'), isFalse);
|
||||||
|
|
||||||
|
// Add a new alcohol item via the FAB: cancel first, then create.
|
||||||
|
await tester.tap(find.byType(FloatingActionButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byType(FloatingActionButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine');
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Total (zł)'),
|
||||||
|
'30',
|
||||||
|
);
|
||||||
|
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
189
billsplit/test/split_engine_test.dart
Normal file
@ -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<Person> makePeople(int n, {Set<int> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
210
billsplit/test/ui_flows_test.dart
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/main.dart' as app;
|
||||||
|
import 'package:billsplit/state/app_state.dart';
|
||||||
|
import 'package:billsplit/ui/home_screen.dart';
|
||||||
|
import 'package:billsplit/ui/people_screen.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
|
||||||
|
final r = Receipt(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Demo',
|
||||||
|
store: 'Biedronka',
|
||||||
|
paidTotalGr: 1000,
|
||||||
|
items: [
|
||||||
|
Item(id: 'i1', name: 'Bread', totalGr: 500),
|
||||||
|
Item(
|
||||||
|
id: 'i2',
|
||||||
|
name: 'Beer',
|
||||||
|
totalGr: 300,
|
||||||
|
category: Categories.alcohol,
|
||||||
|
assignment: Assignment(mode: AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i3',
|
||||||
|
name: 'Tonic',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.mixer,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i4',
|
||||||
|
name: 'Kaucja',
|
||||||
|
totalGr: 100,
|
||||||
|
category: Categories.deposit,
|
||||||
|
),
|
||||||
|
if (withUnassigned)
|
||||||
|
Item(
|
||||||
|
id: 'i5',
|
||||||
|
name: 'Ghost',
|
||||||
|
totalGr: 50,
|
||||||
|
assignment: Assignment(mode: AssignMode.custom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.addReceipt(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
tearDown(clearChannelMocks);
|
||||||
|
|
||||||
|
group('main()', () {
|
||||||
|
testWidgets('boots with the real load path', (tester) async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('billsplit_main');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
mockPathProvider(dir);
|
||||||
|
app.main();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('BillSplit'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('home', () {
|
||||||
|
testWidgets('import cancel does nothing', (tester) async {
|
||||||
|
mockFilePicker();
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.text('Import e-paragon'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import parse error shows message', (tester) async {
|
||||||
|
mockFilePicker(pickResult: Uint8List.fromList('zzz'.codeUnits));
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.text('Import e-paragon'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('neither valid JSON'), findsOneWidget);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import invalid UTF-8 hits the generic handler',
|
||||||
|
(tester) async {
|
||||||
|
mockFilePicker(pickResult: Uint8List.fromList([0xFF, 0xFE, 0x00]));
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.text('Import e-paragon'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Import failed'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import success navigates; warning shows a snackbar',
|
||||||
|
(tester) async {
|
||||||
|
final fixture =
|
||||||
|
File('test/fixtures/sample_receipt.json').readAsBytesSync();
|
||||||
|
mockFilePicker(pickResult: fixture);
|
||||||
|
final s = tempState()..addPerson('Ala');
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.text('Import e-paragon'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, hasLength(1));
|
||||||
|
expect(find.byType(ReceiptScreen), findsOneWidget);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('import surfaces parser warnings', (tester) async {
|
||||||
|
final payload = {
|
||||||
|
'dokument': {
|
||||||
|
'paragon': {
|
||||||
|
'pozycja': [
|
||||||
|
{
|
||||||
|
'towar': {'nazwa': 'Chleb', 'ilosc': 1, 'brutto': 500},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'podsum': {'sumaBrutto': 999},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
final body = base64Url.encode(utf8.encode(jsonEncode(payload)));
|
||||||
|
final wrapper = jsonEncode({'data': 'h.$body.s'});
|
||||||
|
mockFilePicker(pickResult: Uint8List.fromList(utf8.encode(wrapper)));
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.text('Import e-paragon'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('differs'), findsOneWidget);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('manual create: cancel, empty and valid', (tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
|
||||||
|
await tester.tap(find.text('New receipt'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New receipt'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New receipt'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Pizza night');
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts.single.title, 'Pizza night');
|
||||||
|
expect(find.byType(ReceiptScreen), findsOneWidget);
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('New receipt'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Kebab');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts.first.title, 'Kebab');
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('list tile navigates; dismiss asks and deletes',
|
||||||
|
(tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
demoReceipt(s);
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await flushSaves(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('4 items'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Demo'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(ReceiptScreen), findsOneWidget);
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, hasLength(1));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(s.receipts, isEmpty);
|
||||||
|
await flushSaves(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('people button opens roster', (tester) async {
|
||||||
|
final s = tempState();
|
||||||
|
await pumpApp(tester, s, const HomeScreen());
|
||||||
|
await tester.tap(find.byIcon(Icons.group));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PeopleScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
billsplit/test/widget_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
31
billsplit/test/xlsx_export_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
56
billsplit/test/xlsx_extra_test.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:billsplit/domain/models.dart';
|
||||||
|
import 'package:billsplit/domain/xlsx_export.dart';
|
||||||
|
import 'package:billsplit/ui/receipt_screen.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('export handles no store/paid, unassigned items and non-drinkers', () {
|
||||||
|
final people = [
|
||||||
|
Person(id: 'a', name: 'Ala'),
|
||||||
|
Person(id: 'b', name: 'Bob', drinks: false),
|
||||||
|
];
|
||||||
|
final receipt = Receipt(
|
||||||
|
id: 'r',
|
||||||
|
title: 'Manual',
|
||||||
|
participantIds: {'a', 'b'},
|
||||||
|
items: [
|
||||||
|
Item(id: 'i1', name: 'Bread', totalGr: 500),
|
||||||
|
Item(
|
||||||
|
id: 'i2',
|
||||||
|
name: 'Beer',
|
||||||
|
totalGr: 300,
|
||||||
|
category: Categories.alcohol,
|
||||||
|
assignment: Assignment(mode: AssignMode.drinkers),
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: 'i3',
|
||||||
|
name: 'Ghost',
|
||||||
|
totalGr: 100,
|
||||||
|
assignment: Assignment(mode: AssignMode.custom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final bytes = exportXlsx(receipt: receipt, roster: people, groups: []);
|
||||||
|
expect(bytes.length, greaterThan(1000));
|
||||||
|
expect(bytes[0], 0x50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exercises the REAL disk writer, deliberately as a plain `test` rather than
|
||||||
|
// a `testWidgets`: a real dart:io future never completes inside the
|
||||||
|
// fake-async zone, which is the whole reason the export flow writes through
|
||||||
|
// an injectable seam.
|
||||||
|
test('the real export writer puts the bytes on disk', () async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('billsplit_export');
|
||||||
|
addTearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
final target = '${dir.path}/split.xlsx';
|
||||||
|
final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]);
|
||||||
|
|
||||||
|
debugSetExportWriter(null); // restore the real writer
|
||||||
|
await writeExport(target, payload, 'application/vnd.ms-excel');
|
||||||
|
|
||||||
|
expect(File(target).readAsBytesSync(), payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
13
python_pkg/billsplit_coverage/__init__.py
Normal file
@ -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
|
||||||
14
python_pkg/billsplit_coverage/__main__.py
Normal file
@ -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())
|
||||||
130
python_pkg/billsplit_coverage/checker.py
Normal file
@ -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)
|
||||||
3
python_pkg/billsplit_coverage/tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Tests for the billsplit coverage gate."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
191
python_pkg/billsplit_coverage/tests/test_checker.py
Normal file
@ -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()
|
||||||