Archive billsplit and everything coupled to it
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

The coupled case the previous archive commit deferred. billsplit is not
just a directory: a coverage package exists only to gate it, app_icons
holds a registry entry pointing into it, a CI workflow builds it, and
.binary-allowlist / .gitignore carry a paired set of rules for its
launcher icons. Removing the directory alone would have failed the 100%
coverage gate and left three configs naming a missing path.

So all of it goes in one commit:

  billsplit/                        73 files
  python_pkg/billsplit_coverage/     5 files, gated billsplit only
  .github/workflows/billsplit-ci.yml
  the app_icons AppIcon(key="billsplit") entry
  the .binary-allowlist patterns and their .gitignore ! overrides
  the .prettierignore fixture line

billsplit/ and billsplit_coverage/ were extracted in a single filter-repo
pass (8 commits, matching the monorepo) and pushed to
github.com/kuhyx/testsAndMisc-archive, where the coverage package joins
the archive's existing python_pkg/ tree. Presence was confirmed through
the GitHub API before anything was deleted here.

.binary-allowlist is now empty of patterns -- billsplit's icons were its
only entries. The policy header stays and records that no exception is in
force, and check_no_binaries.sh still exits 0 on a comment-only file.

Verified: 2095 tests pass at 100% coverage, down from 2114 by exactly the
19 billsplit_coverage tests. Depth violations 451 -> 417.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 17:17:22 +02:00
parent ea281134b6
commit ec5d3aba34
82 changed files with 85 additions and 5600 deletions

View File

@ -1,43 +0,0 @@
name: billsplit CI
# Relocated here from billsplit/.github/workflows/ci.yaml when the app moved
# into this monorepo — GitHub only reads workflows from the repository root,
# so the nested copy would have been inert.
on:
push:
branches: [main]
paths:
- "billsplit/**"
- ".github/workflows/billsplit-ci.yml"
pull_request:
branches: [main]
paths:
- "billsplit/**"
- ".github/workflows/billsplit-ci.yml"
defaults:
run:
working-directory: billsplit
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- name: Format (fails on diff)
run: dart format --set-exit-if-changed .
- name: Analyze (infos are fatal)
run: flutter analyze --fatal-infos
- name: Tests with coverage
run: flutter test --coverage
# Runs from the repository root: every Python file in this repo lives
# under python_pkg/, so the gate is a module there rather than a script
# inside the app. It resolves billsplit/ from its own location.
- name: Enforce 100% line coverage
working-directory: .
run: python3 -m python_pkg.billsplit_coverage

45
billsplit/.gitignore vendored
View File

@ -1,45 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@ -1,42 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: android
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: ios
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: linux
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: macos
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: windows
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@ -1,81 +0,0 @@
# BillSplit
Flutter app (mobile + desktop) for splitting shop receipts between people —
including the drinkers vs non-drinkers case — with Biedronka e-paragon import
and .xlsx export.
## Features
- **People roster** with a per-person _drinks alcohol_ toggle, plus arbitrary
**groups** (tags) like "meat eaters".
- **Receipts**: import a Polish fiscal e-receipt JSON (`e-paragon`,
JPK_KASA_PARAGON_v2-0 — the file the Biedronka app exports; the embedded JWS
payload is decoded, signature not verified) or build one manually. Multiple
receipts persist locally (`state.json` in app documents).
- **Per-item assignment**: each item is split among _Everyone / Drinkers /
Non-drinkers / a group / hand-picked people_. Alcohol is auto-detected on
import (wódka/piwo/wino/… keywords) and defaults to drinkers only; deposits
(kaucja) become their own line split among everyone. Category and assignment
are always editable — the keyword detection is a default, not a decision.
- **Grosz-exact math**: every item's shares sum to exactly the item total
(largest-remainder distribution), so per-person totals reconcile with the
receipt to the grosz — no floating point drift.
- **Totals view**: per person, with alcohol / rest breakdown, unassigned-item
warnings, and a reconciliation check against the paid amount.
- **Export .xlsx**: one row per item with a 1/0 person matrix plus a summary —
same layout as the hand-made spreadsheet this app grew out of. Matrix and
summary are exported values (the app is the calculator); column totals are
live SUM formulas.
## Running
`android/` is the only platform runner folder the repo tracks. Generate the
others locally for whatever you want to run on:
```sh
cd billsplit
flutter create . --platforms=linux,windows,macos,ios
flutter pub get
flutter run # picks a connected device / desktop
```
Those four directories are gitignored, so a regenerated runner stays local
instead of being committed back. CI never builds them: `billsplit-ci.yml` runs
`flutter analyze` and `flutter test` only.
Tested with Flutter 3.44.8 / Dart 3.10.
## Tests
```sh
flutter test
```
Covers: parsing the real sample receipt in `test/fixtures/` (49 items +
deposit, totals 590.55 + 7.00 = 597.55 zł), split invariants incl. a 500-case
fuzz of the remainder distribution, assignment-mode resolution, and xlsx
generation (the test writes `build/test_export.xlsx` for external inspection).
## Design notes / edge cases
- Money is `int` grosze end to end; formatting happens only at the UI edge.
- An item resolving to zero people (e.g. "non-drinkers" on an all-drinker
receipt) is **excluded from totals and loudly flagged**, never silently
dropped into someone's bill.
- Deleting a person prunes them from groups, receipt participants and custom
assignments; deleting a group falls back affected items to _Everyone_.
- Assignment modes are stored symbolically (not as materialized ID sets), so
adding a person later automatically includes them in _Everyone_ items.
Touching a person checkbox materializes the current set and switches the
item to _custom_.
- `paidTotalGr` (what the card was charged) is kept separately from the item
sum; a mismatch shows red in Totals instead of being "fixed" silently.
## Quality gates (enforced in CI)
- `dart format --set-exit-if-changed .` — formatting is canonical
- `flutter analyze --fatal-infos` — `very_good_analysis` + `strict-casts`,
`strict-inference`, `strict-raw-types`; any info fails the build
- `flutter test --coverage` + `tool/coverage_check.py` — **100% line
coverage of `lib/` is mandatory**; the gate also fails if a lib file is
never executed by any test

View File

@ -1,15 +0,0 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
language:
strict-casts: true
strict-inference: true
strict-raw-types: true

View File

@ -1,14 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -1,45 +0,0 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "dev.kuhy.billsplit"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "dev.kuhy.billsplit"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@ -1,7 +0,0 @@
<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>

View File

@ -1,45 +0,0 @@
<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>

View File

@ -1,5 +0,0 @@
package dev.kuhy.billsplit
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,12 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,12 +0,0 @@
<?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>

View File

@ -1,14 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,18 +0,0 @@
<?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>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#211D1B</color>
</resources>

View File

@ -1,18 +0,0 @@
<?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>

View File

@ -1,7 +0,0 @@
<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>

View File

@ -1,24 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -1,24 +0,0 @@
# Memory budget. The Flutter template ships `-Xmx8G -XX:MaxMetaspaceSize=4G`
# here, which asks for more than this 7.6 GB machine has and got a whole
# session OOM-killed. What actually brings the footprint down is cutting the
# NUMBER of JVMs, not just the heap: without these flags Gradle runs a daemon
# plus a separate Kotlin compile daemon plus workers, each with its own heap.
#
# Budget for anything run on this machine is <= 2 GB. Verify with PSS summed
# from /proc/<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

View File

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@ -1,26 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 675 B

View File

@ -1,200 +0,0 @@
/// Assignment and the split calculation.
///
/// Split out of models.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'models.dart';
/// The assignment of one item: a symbolic mode plus mode-specific data.
class Assignment {
/// Creates an assignment; defaults to [AssignMode.everyone].
Assignment({
this.mode = AssignMode.everyone,
this.groupId,
Set<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)}';
}

View File

@ -1,124 +0,0 @@
/// Field extraction helpers for the e-paragon parser.
///
/// Split out of eparagon_parser.dart to keep it under the 250-line cap.
/// A `part` because Dart privates are library-scoped.
part of 'eparagon_parser.dart';
DateTime _saleDate(
Map<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;
}

View File

@ -1,177 +0,0 @@
/// Parser for Polish fiscal e-receipts (e-paragon / JPK_KASA_PARAGON_v2-0),
/// e.g. the JSON files produced by the Biedronka app.
///
/// Accepted inputs:
/// * the full wrapper JSON containing a `data` field with a JWS
/// (header.payload.signature, base64url) whose payload is the JPK document;
/// * the decoded JPK payload itself (an object with `dokument.paragon`);
/// * a bare JWS token string.
///
/// The signature is NOT verified — this app only reads the contents.
library;
import 'dart:convert';
import 'package:billsplit/domain/models.dart';
part 'eparagon_fields.dart';
/// A parsed receipt plus non-fatal [warnings] collected on the way.
class EParagonResult {
/// Bundles a parsed [receipt] with its [warnings].
EParagonResult({required this.receipt, required this.warnings});
/// The parsed receipt.
final Receipt receipt;
/// Human-readable warnings (e.g. sum mismatches, skipped lines).
final List<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;
}

View File

@ -1,86 +0,0 @@
/// Domain models. All money values are ints in grosze (1/100 PLN) to avoid
/// floating point drift; formatting happens only at the UI/export edge.
library;
part 'assignment.dart';
/// Formats [grosze] as a Polish złoty string, e.g. `1234` → `12,34 zł`.
String formatPln(int grosze) {
final neg = grosze < 0;
final a = grosze.abs();
final zl = a ~/ 100;
final gr = (a % 100).toString().padLeft(2, '0');
return '${neg ? '-' : ''}$zl,$gr zł';
}
/// A person on the shared roster.
class Person {
/// Creates a person; [drinks] controls alcohol-item defaults.
Person({required this.id, required this.name, this.drinks = true});
/// Restores a person from its [toJson] map.
factory Person.fromJson(Map<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,
}

View File

@ -1,155 +0,0 @@
/// Split computation. Grosz-exact: every item's shares sum to exactly the
/// item total (largest-remainder distribution, deterministic order), so the
/// per-person totals always reconcile with the receipt to the grosz.
library;
import 'package:billsplit/domain/models.dart';
/// Accumulated amounts one person owes on a receipt.
class PersonTotal {
/// Creates an empty total for [personId].
PersonTotal(this.personId);
/// The person this total belongs to.
final String personId;
/// Everything owed, in grosze.
int totalGr = 0;
/// The alcohol portion of [totalGr].
int alcoholGr = 0;
/// The non-alcohol portion of [totalGr].
int get restGr => totalGr - alcoholGr;
}
/// Result of [computeSplit].
class SplitResult {
/// Bundles the computed maps.
SplitResult({
required this.perPerson,
required this.itemShares,
required this.unassigned,
});
/// personId → totals. Contains every receipt participant, even if 0.
final Map<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;
}

View File

@ -1,215 +0,0 @@
/// Exports a receipt + computed split to an .xlsx workbook whose layout
/// mirrors the hand-made "podzial_rachunku" sheet: one row per item, a 1/0
/// person matrix, per-item cost-per-person, and a per-person summary.
///
/// Values are written as computed numbers (grosz-exact, matching the in-app
/// engine), plus live SUM formulas for the column totals so the sheet stays
/// consistent when opened in Excel/LibreOffice.
library;
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:excel/excel.dart';
/// Renders [receipt] with its current split into xlsx bytes.
List<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;
}

View File

@ -1,39 +0,0 @@
import 'dart:async';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/home_screen.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
final state = AppState();
unawaited(state.load());
runApp(BillSplitApp(state: state));
}
/// Root widget wiring [AppState] into the tree.
class BillSplitApp extends StatelessWidget {
/// Creates the app around an existing [state].
const BillSplitApp({required this.state, super.key});
/// The single app-wide state instance.
final AppState state;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: state,
child: MaterialApp(
title: 'BillSplit',
debugShowCheckedModeBanner: false,
// The shared light theme, replacing a teal-seeded scheme. Light, not
// dark: the receipt screens carry black-on-white helper text, so
// switching brightness here would make it unreadable.
theme: buildLightTheme(),
home: const HomeScreen(),
),
);
}
}

View File

@ -1,166 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:billsplit/domain/models.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Single source of truth: roster, groups, receipts. Persisted as one JSON
/// file in the app documents directory; saves are debounced.
class AppState extends ChangeNotifier {
/// Creates the state; [overrideDir] replaces the platform documents
/// directory (used by tests).
AppState({Directory? overrideDir}) : _overrideDir = overrideDir;
final Directory? _overrideDir;
/// The shared roster of people.
final List<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();
}
}

View File

@ -1,146 +0,0 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Bottom sheet to assign an item: quick modes (everyone / drinkers /
/// non-drinkers / group) or explicit person selection. Toggling a person
/// checkbox on a non-custom mode first materializes the current selection,
/// then switches to custom.
Future<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';

View File

@ -1,210 +0,0 @@
import 'dart:convert';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:design_system/design_system.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Receipt list plus entry points for import / manual creation.
class HomeScreen extends StatelessWidget {
/// Creates the home screen.
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<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,
),
),
);
}
}

View File

@ -1,137 +0,0 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Add ([item] == null) or edit an item. The user types amounts in złoty
/// ("12,34" or "12.34"); they are stored as int grosze.
Future<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();
}

View File

@ -1,135 +0,0 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
part 'people_tiles.dart';
/// Roster management: people with a drinks toggle, plus custom groups.
class PeopleScreen extends StatelessWidget {
/// Creates the people screen.
const PeopleScreen({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<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'),
),
],
),
);
}
}

View File

@ -1,139 +0,0 @@
/// Person and group tile widgets.
///
/// Split out of people_screen.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'people_screen.dart';
class _PersonTile extends StatelessWidget {
const _PersonTile({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final state = context.read<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),
),
);
}
}

View File

@ -1,198 +0,0 @@
/// The receipt item list and its row widgets.
///
/// Split out of receipt_screen.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped.
part of 'receipt_screen.dart';
class _ItemsList extends StatelessWidget {
const _ItemsList({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<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;
}

View File

@ -1,211 +0,0 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/domain/xlsx_export.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/assignment_sheet.dart';
import 'package:billsplit/ui/item_edit.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
part 'receipt_items.dart';
/// Writes exported [bytes] to [path]; the seam the export flow writes through.
///
/// Kept injectable because `file_selector` only returns a destination — the
/// app performs the write itself, where `file_picker` did it inside the
/// plugin. Real `dart:io` futures never complete inside `testWidgets`'
/// fake-async zone, so a widget test that exercised the real write could never
/// observe the resulting snackbar. Tests swap this for an in-memory stub.
typedef ExportWriter = Future<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')));
}
}
}

View File

@ -1,611 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.dev"
source: hosted
version: "0.3.5+4"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
design_system:
dependency: "direct main"
description:
path: design_system
ref: "design_system-v0.1.0"
resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
excel:
dependency: "direct main"
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840"
url: "https://pub.dev"
source: hosted
version: "0.5.2+9"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
url: "https://pub.dev"
source: hosted
version: "0.5.3+5"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: "direct dev"
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.4.2"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"

View File

@ -1,45 +0,0 @@
name: billsplit
description: Receipt bill-splitting with people, groups, per-item assignment and xlsx export.
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.5.0
dependencies:
# The shared palette and scales.
design_system:
git:
url: https://github.com/kuhyx/utils
ref: design_system-v0.1.0
path: design_system
excel: ^4.0.6
file_selector: ^1.1.0
flutter:
sdk: flutter
path_provider: ^2.1.4
provider: ^6.1.2
dev_dependencies:
file_selector_platform_interface: ^2.7.0
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
very_good_analysis: ^10.3.0
flutter:
uses-material-design: true
# Launcher icon generation. Source art is produced by the shared generator:
# PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app billsplit
# then run: dart run flutter_launcher_icons
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/icon/icon.png"
adaptive_icon_background: "#211D1B"
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
adaptive_icon_monochrome: "assets/icon/icon_monochrome.png"
min_sdk_android: 21

View File

@ -1,184 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Directory temp() {
final d = Directory.systemTemp.createTempSync('billsplit_state');
addTearDown(() {
if (d.existsSync()) d.deleteSync(recursive: true);
});
return d;
}
test('load on a fresh directory yields empty, usable state', () async {
final s = AppState(overrideDir: temp());
await s.load();
expect(s.loaded, isTrue);
expect(s.people, isEmpty);
s.dispose();
});
test('save/load roundtrip preserves everything', () async {
final dir = temp();
final s = AppState(overrideDir: dir);
await s.load();
final p = s.addPerson('Ala', drinks: false);
final g = s.addGroup('Meat')..memberIds.add(p.id);
s.addReceipt(
Receipt(
id: 'r1',
title: 'T',
items: [
Item(
id: 'i1',
name: 'x',
totalGr: 100,
assignment: Assignment(mode: AssignMode.group, groupId: g.id),
),
],
),
);
await s.save();
s.dispose();
final s2 = AppState(overrideDir: dir);
await s2.load();
expect(s2.people.single.name, 'Ala');
expect(s2.people.single.drinks, isFalse);
expect(s2.groups.single.memberIds, {p.id});
expect(s2.receipts.single.participantIds, {p.id});
expect(s2.receipts.single.items.single.assignment.groupId, g.id);
s2.dispose();
});
test('load prunes dangling person references', () async {
final dir = temp();
File('${dir.path}/billsplit/state.json')
..createSync(recursive: true)
..writeAsStringSync(
jsonEncode({
'people': [
{'id': 'p1', 'name': 'Ala'},
],
'groups': [
{
'id': 'g1',
'name': 'G',
'memberIds': ['p1', 'ghost'],
},
],
'receipts': [
{
'id': 'r1',
'title': 'T',
'date': '2026-01-01T00:00:00.000',
'participantIds': ['p1', 'ghost'],
'items': [
{
'id': 'i1',
'name': 'x',
'totalGr': 1,
'assignment': {
'mode': 'custom',
'customIds': ['p1', 'ghost'],
},
},
],
},
],
}),
);
final s = AppState(overrideDir: dir);
await s.load();
expect(s.groups.single.memberIds, {'p1'});
expect(s.receipts.single.participantIds, {'p1'});
expect(s.receipts.single.items.single.assignment.customIds, {'p1'});
s.dispose();
});
test('corrupt JSON is survived', () async {
final dir = temp();
File('${dir.path}/billsplit/state.json')
..createSync(recursive: true)
..writeAsStringSync('{oops');
final s = AppState(overrideDir: dir);
await s.load();
expect(s.loaded, isTrue);
expect(s.people, isEmpty);
s.dispose();
});
test('IO failure on load is survived (billsplit path is a file)', () async {
final dir = temp();
File('${dir.path}/billsplit').createSync(recursive: true);
final s = AppState(overrideDir: dir);
await s.load();
expect(s.loaded, isTrue);
s.dispose();
});
test('IO failure on save is survived (state.json is a directory)', () async {
final dir = temp();
Directory('${dir.path}/billsplit/state.json').createSync(recursive: true);
final s = AppState(overrideDir: dir);
await s.save(); // must not throw
s.dispose();
});
test('mutations debounce into a save', () async {
final dir = temp();
final s = AppState(overrideDir: dir);
await s.load();
s.addPerson('Ala');
expect(File('${dir.path}/billsplit/state.json').existsSync(), isFalse);
await Future<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();
});
}

View File

@ -1,29 +0,0 @@
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/domain/xlsx_export.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('splitAmount with no people returns empty', () {
expect(splitAmount(100, const []), isEmpty);
});
test('splitAmount accepts negative rotation', () {
final s = splitAmount(10, ['a', 'b', 'c'], rotation: -1);
expect(s.values.fold(0, (x, y) => x + y), 10);
});
test('stableHash is stable and differs between strings', () {
expect(stableHash(''), 0);
expect(stableHash('abc'), stableHash('abc'));
expect(stableHash('abc'), isNot(stableHash('abd')));
});
test('colName covers single and multi letter columns', () {
expect(colName(0), 'A');
expect(colName(25), 'Z');
expect(colName(26), 'AA');
expect(colName(27), 'AB');
expect(colName(701), 'ZZ');
expect(colName(702), 'AAA');
});
}

View File

@ -1,232 +0,0 @@
import 'dart:convert';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:flutter_test/flutter_test.dart';
Map<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);
});
}

View File

@ -1,86 +0,0 @@
import 'dart:io';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final fixture = File('test/fixtures/sample_receipt.json').readAsStringSync();
test('parses the real Biedronka e-paragon wrapper', () {
final res = parseEParagonDetailed(fixture);
final r = res.receipt;
// 49 goods lines + 1 deposit line.
expect(r.items.length, 50);
final deposit =
r.items.where((i) => i.category == Categories.deposit).toList();
expect(deposit.length, 1);
expect(deposit.single.totalGr, 700);
expect(deposit.single.qty, 14);
// Goods sum matches receipt sumaBrutto; paid total includes deposit.
final goods = r.items
.where((i) => i.category != Categories.deposit)
.fold(0, (s, i) => s + i.totalGr);
expect(goods, 59055);
expect(r.paidTotalGr, 59755);
expect(res.warnings, isEmpty);
// Alcohol detection: exactly vodka + beer, drinkers-only by default.
final alcohol =
r.items.where((i) => i.category == Categories.alcohol).toList();
expect(alcohol.length, 2);
expect(
alcohol.map((i) => i.name).join(' | ').toLowerCase(),
allOf(contains('wódka'), contains('piwo')),
);
for (final a in alcohol) {
expect(a.assignment.mode, AssignMode.drinkers);
}
expect(alcohol.fold(0, (s, i) => s + i.totalGr), 10872);
// Mixers tagged but split among everyone.
final mixers =
r.items.where((i) => i.category == Categories.mixer).toList();
expect(mixers.length, greaterThanOrEqualTo(4));
for (final m in mixers) {
expect(m.assignment.mode, AssignMode.everyone);
}
// Name cleanup: VAT letter stripped, spaces collapsed.
expect(r.items.first.name, 'Tonic');
// Weight-based quantity with comma decimal.
final kielbasa =
r.items.firstWhere((i) => i.name.startsWith('KiełbBrocka'));
expect(kielbasa.qty, closeTo(0.871, 1e-9));
// Discount captured (vodka -28.00).
final vodka = r.items.firstWhere((i) => i.name.startsWith('WódkaB'));
expect(vodka.discountGr, -2800);
expect(vodka.totalGr, 7998);
});
test('accepts bare JPK payload and bare JWS token', () {
final res = parseEParagonDetailed(fixture);
expect(res.receipt.items, isNotEmpty);
// Raw JWS from the wrapper should parse identically. Tolerate whitespace
// around the colon: the fixture is minified as the device emits it, but a
// pretty-printed payload is equally valid JSON and must not fail here.
final match = RegExp(r'"data"\s*:\s*"([^"]+)"').firstMatch(fixture);
expect(match, isNotNull, reason: 'fixture must carry a "data" JWS field');
final jws = match!.group(1)!;
final fromJws = parseEParagon(jws);
expect(fromJws.items.length, res.receipt.items.length);
});
test('rejects garbage input with a clear error', () {
expect(
() => parseEParagon('not json'),
throwsA(isA<EParagonParseException>()),
);
expect(
() => parseEParagon('{"foo": 1}'),
throwsA(isA<EParagonParseException>()),
);
});
}

File diff suppressed because one or more lines are too long

View File

@ -1,118 +0,0 @@
import 'dart:io';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
const _pathChannel = MethodChannel('plugins.flutter.io/path_provider');
/// Fake [FileSelectorPlatform] driving open/save dialogs in tests.
///
/// file_selector is swapped at the platform-interface level rather than at the
/// method channel: the real implementation talks Pigeon, so a MethodChannel
/// mock would not intercept it.
class _FakeFileSelector extends FileSelectorPlatform {
_FakeFileSelector({
this.pickResult,
this.pickName,
this.saveResult,
this.saveThrows = false,
});
final Uint8List? pickResult;
final String? pickName;
final String? saveResult;
final bool saveThrows;
@override
Future<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));

View File

@ -1,78 +0,0 @@
import 'dart:convert';
import 'package:billsplit/domain/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('formatPln handles zero, cents padding and negatives', () {
expect(formatPln(0), '0,00 zł');
expect(formatPln(5), '0,05 zł');
expect(formatPln(123456), '1234,56 zł');
expect(formatPln(-7), '-0,07 zł');
});
test('Ids are unique and ordered enough', () {
final a = Ids.next();
final b = Ids.next();
expect(a, isNot(b));
});
test('Person/Group/Assignment/Item/Receipt JSON roundtrips', () {
final p = Person(id: 'p1', name: 'Ala', drinks: false);
expect(Person.fromJson(p.toJson()).drinks, isFalse);
final g = Group(id: 'g1', name: 'Meat', memberIds: {'p1'});
expect(Group.fromJson(g.toJson()).memberIds, {'p1'});
final a = Assignment(mode: AssignMode.group, groupId: 'g1');
final a2 = Assignment.fromJson(a.toJson());
expect(a2.mode, AssignMode.group);
expect(a2.groupId, 'g1');
final i = Item(
id: 'i1',
name: 'Piwo',
totalGr: 500,
qty: 2,
unitPriceGr: 250,
discountGr: -100,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.custom, customIds: {'p1'}),
);
final i2 = Item.fromJson(
jsonDecode(jsonEncode(i.toJson())) as Map<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);
});
}

View File

@ -1,154 +0,0 @@
// People screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('people', () {
testWidgets('add person dialog: toggle, cancel, empty, valid',
(tester) async {
final s = tempState();
await pumpApp(tester, s, const PeopleScreen());
expect(find.textContaining('Nobody yet'), findsOneWidget);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await tester.tap(find.text('Add person'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Kasia');
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.people.single.name, 'Kasia');
expect(s.people.single.drinks, isFalse);
await flushSaves(tester);
});
testWidgets('drinks switch, membership early-return, delete flows',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.byType(Switch).first);
await tester.pumpAndSettle();
expect(p.drinks, isFalse);
// No groups yet → tapping the person is a no-op branch.
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.text('Group membership'), findsNothing);
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.people, hasLength(1));
await tester.tap(find.byIcon(Icons.delete_outline).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
expect(s.people, isEmpty);
await flushSaves(tester);
});
testWidgets('groups: add, membership sheet, delete', (tester) async {
final s = tempState()..addPerson('Ala');
await pumpApp(tester, s, const PeopleScreen());
await flushSaves(tester);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await tester.tap(find.text('Add group'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Meat');
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(s.groups.single.name, 'Meat');
expect(find.textContaining('No members'), findsOneWidget);
await tester.tap(find.text('Ala'));
await tester.pumpAndSettle();
expect(find.byType(CheckboxListTile), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, hasLength(1));
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
expect(s.groups.single.memberIds, isEmpty);
await tester.tapAt(const Offset(10, 10)); // close sheet
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete_outline).last);
await tester.pumpAndSettle();
expect(s.groups, isEmpty);
await flushSaves(tester);
});
});
}

View File

@ -1,140 +0,0 @@
// Receipt screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('receipt screen', () {
testWidgets('unknown id shows deleted note', (tester) async {
final s = tempState();
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope'));
expect(find.text('Receipt deleted.'), findsOneWidget);
});
testWidgets('tabs on narrow, panes on wide', (tester) async {
final s = tempState()..addPerson('Ala');
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.byType(TabBar), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('grosz-exact'), findsOneWidget);
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpAndSettle();
expect(find.byType(TabBar), findsNothing);
expect(find.byType(VerticalDivider), findsOneWidget);
});
testWidgets('participants: empty roster snackbar, then editing',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.textContaining('Add people first'), findsOneWidget);
final p = s.addPerson('Ala');
s.addPerson('Bob');
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.text('Who is on this receipt?'), findsOneWidget);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
expect(s.receipts.single.participantIds.contains(p.id), isTrue);
await tester.tap(find.byType(CheckboxListTile).first);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
await flushSaves(tester);
});
testWidgets('export: no participants, success, cancel, failure',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Add participants'), findsOneWidget);
s.addPerson('Ala');
s.receipts.single.participantIds.addAll(s.people.map((p) => p.id));
mockFilePicker(saveResult: '/tmp/out.xlsx');
await tester.pump(const Duration(seconds: 5)); // expire prior snackbar
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Saved: out.xlsx'), findsOneWidget);
mockFilePicker();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
mockFilePicker(saveThrows: true);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.table_view));
await tester.pumpAndSettle();
expect(find.textContaining('Export failed'), findsOneWidget);
await flushSaves(tester);
});
});
}

View File

@ -1,195 +0,0 @@
// Receipt item list and assignment sheet flows.
// Receipt screen flows.
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('receipt items', () {
testWidgets('items list: labels, unassigned, edit dialog paths',
(tester) async {
final s = tempState();
final p = s.addPerson('Ala');
s.addPerson('Bob', drinks: false);
final g = s.addGroup('Meat')..memberIds.add(p.id);
final r = demoReceipt(s, withUnassigned: true);
r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id);
r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers);
r.items[3].assignment = Assignment(
mode: AssignMode.group,
groupId: 'gone',
);
await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
await flushSaves(tester);
expect(find.textContaining('Group: Meat'), findsOneWidget);
expect(find.textContaining('Group: ?'), findsOneWidget);
expect(find.textContaining('Drinkers'), findsOneWidget);
expect(find.textContaining('Non-drinkers'), findsOneWidget);
expect(find.textContaining('UNASSIGNED'), findsOneWidget);
await tester.tap(find.text('Totals'));
await tester.pumpAndSettle();
expect(find.textContaining('excluded from totals'), findsOneWidget);
await tester.tap(find.text('Items'));
await tester.pumpAndSettle();
// Edit via long-press: invalid total keeps dialog open, then fix+save.
await tester.longPress(find.text('Bread'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'abc',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Save'), findsOneWidget); // still open
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'6,00',
);
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(r.items[0].totalGr, 600);
expect(r.items[0].name, 'Rolls');
// Delete an item from the edit dialog.
await tester.longPress(find.text('Ghost'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(r.items.any((i) => i.name == 'Ghost'), isFalse);
// Add a new alcohol item via the FAB: cancel first, then create.
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine');
await tester.enterText(
find.widgetWithText(TextField, 'Total (zł)'),
'30',
);
await tester.tap(find.byType(DropdownButtonFormField<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);
});
});
}

View File

@ -1,189 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:flutter_test/flutter_test.dart';
List<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);
});
}

View File

@ -1,210 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/main.dart' as app;
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/home_screen.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
final r = Receipt(
id: 'r1',
title: 'Demo',
store: 'Biedronka',
paidTotalGr: 1000,
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Tonic',
totalGr: 100,
category: Categories.mixer,
),
Item(
id: 'i4',
name: 'Kaucja',
totalGr: 100,
category: Categories.deposit,
),
if (withUnassigned)
Item(
id: 'i5',
name: 'Ghost',
totalGr: 50,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
s.addReceipt(r);
return r;
}
void main() {
tearDown(clearChannelMocks);
group('main()', () {
testWidgets('boots with the real load path', (tester) async {
final dir = Directory.systemTemp.createTempSync('billsplit_main');
addTearDown(() => dir.deleteSync(recursive: true));
mockPathProvider(dir);
app.main();
await tester.pump();
await tester.pump();
expect(find.text('BillSplit'), findsOneWidget);
});
});
group('home', () {
testWidgets('import cancel does nothing', (tester) async {
mockFilePicker();
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('Import e-paragon'));
await tester.pumpAndSettle();
expect(s.receipts, isEmpty);
});
testWidgets('import parse error shows message', (tester) async {
mockFilePicker(pickResult: Uint8List.fromList('zzz'.codeUnits));
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('Import e-paragon'));
await tester.pumpAndSettle();
expect(find.textContaining('neither valid JSON'), findsOneWidget);
await flushSaves(tester);
});
testWidgets('import invalid UTF-8 hits the generic handler',
(tester) async {
mockFilePicker(pickResult: Uint8List.fromList([0xFF, 0xFE, 0x00]));
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('Import e-paragon'));
await tester.pumpAndSettle();
expect(find.textContaining('Import failed'), findsOneWidget);
});
testWidgets('import success navigates; warning shows a snackbar',
(tester) async {
final fixture =
File('test/fixtures/sample_receipt.json').readAsBytesSync();
mockFilePicker(pickResult: fixture);
final s = tempState()..addPerson('Ala');
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('Import e-paragon'));
await tester.pumpAndSettle();
expect(s.receipts, hasLength(1));
expect(find.byType(ReceiptScreen), findsOneWidget);
await flushSaves(tester);
});
testWidgets('import surfaces parser warnings', (tester) async {
final payload = {
'dokument': {
'paragon': {
'pozycja': [
{
'towar': {'nazwa': 'Chleb', 'ilosc': 1, 'brutto': 500},
},
],
'podsum': {'sumaBrutto': 999},
},
},
};
final body = base64Url.encode(utf8.encode(jsonEncode(payload)));
final wrapper = jsonEncode({'data': 'h.$body.s'});
mockFilePicker(pickResult: Uint8List.fromList(utf8.encode(wrapper)));
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('Import e-paragon'));
await tester.pumpAndSettle();
expect(find.textContaining('differs'), findsOneWidget);
await flushSaves(tester);
});
testWidgets('manual create: cancel, empty and valid', (tester) async {
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.text('New receipt'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.receipts, isEmpty);
await tester.tap(find.text('New receipt'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(s.receipts, isEmpty);
await tester.tap(find.text('New receipt'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Pizza night');
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(s.receipts.single.title, 'Pizza night');
expect(find.byType(ReceiptScreen), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('New receipt'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Kebab');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(s.receipts.first.title, 'Kebab');
await flushSaves(tester);
});
testWidgets('list tile navigates; dismiss asks and deletes',
(tester) async {
final s = tempState();
demoReceipt(s);
await pumpApp(tester, s, const HomeScreen());
await flushSaves(tester);
expect(find.textContaining('4 items'), findsOneWidget);
await tester.tap(find.text('Demo'));
await tester.pumpAndSettle();
expect(find.byType(ReceiptScreen), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(s.receipts, hasLength(1));
await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(s.receipts, isEmpty);
await flushSaves(tester);
});
testWidgets('people button opens roster', (tester) async {
final s = tempState();
await pumpApp(tester, s, const HomeScreen());
await tester.tap(find.byIcon(Icons.group));
await tester.pumpAndSettle();
expect(find.byType(PeopleScreen), findsOneWidget);
});
});
}

View File

@ -1,16 +0,0 @@
import 'package:billsplit/main.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('app builds and shows empty state', (tester) async {
// Real file IO never completes inside the fake-async test zone, so skip
// load() and mark the state ready directly; this is a pure widget smoke
// test (persistence is exercised by the domain tests / at runtime).
final state = AppState()..loaded = true;
await tester.pumpWidget(BillSplitApp(state: state));
await tester.pump();
expect(find.text('BillSplit'), findsOneWidget);
expect(find.textContaining('No receipts yet'), findsOneWidget);
});
}

View File

@ -1,31 +0,0 @@
import 'dart:io';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/xlsx_export.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('exports a non-trivial xlsx for the real receipt', () {
final receipt = parseEParagon(
File('test/fixtures/sample_receipt.json').readAsStringSync(),
);
final people = [
for (var i = 0; i < 8; i++)
Person(id: 'p$i', name: 'Person ${i + 1}', drinks: i < 6),
];
receipt.participantIds.addAll(people.map((p) => p.id));
final bytes = exportXlsx(receipt: receipt, roster: people, groups: []);
expect(bytes.length, greaterThan(4000));
// PK zip magic.
expect(bytes[0], 0x50);
expect(bytes[1], 0x4B);
// Write out for external validation (recalc via LibreOffice + openpyxl).
final out = File('build/test_export.xlsx')
..createSync(recursive: true)
..writeAsBytesSync(bytes);
expect(out.existsSync(), isTrue);
});
}

View File

@ -1,56 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/xlsx_export.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('export handles no store/paid, unassigned items and non-drinkers', () {
final people = [
Person(id: 'a', name: 'Ala'),
Person(id: 'b', name: 'Bob', drinks: false),
];
final receipt = Receipt(
id: 'r',
title: 'Manual',
participantIds: {'a', 'b'},
items: [
Item(id: 'i1', name: 'Bread', totalGr: 500),
Item(
id: 'i2',
name: 'Beer',
totalGr: 300,
category: Categories.alcohol,
assignment: Assignment(mode: AssignMode.drinkers),
),
Item(
id: 'i3',
name: 'Ghost',
totalGr: 100,
assignment: Assignment(mode: AssignMode.custom),
),
],
);
final bytes = exportXlsx(receipt: receipt, roster: people, groups: []);
expect(bytes.length, greaterThan(1000));
expect(bytes[0], 0x50);
});
// Exercises the REAL disk writer, deliberately as a plain `test` rather than
// a `testWidgets`: a real dart:io future never completes inside the
// fake-async zone, which is the whole reason the export flow writes through
// an injectable seam.
test('the real export writer puts the bytes on disk', () async {
final dir = Directory.systemTemp.createTempSync('billsplit_export');
addTearDown(() => dir.deleteSync(recursive: true));
final target = '${dir.path}/split.xlsx';
final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]);
debugSetExportWriter(null); // restore the real writer
await writeExport(target, payload, 'application/vnd.ms-excel');
expect(File(target).readAsBytesSync(), payload);
});
}

View File

@ -0,0 +1,21 @@
{
"title": "Archive billsplit together with every artifact coupled to it",
"objective": "Remove the billsplit Flutter app from testsAndMisc without leaving the repo in a state where a gate fails or a config rule points at a missing path. billsplit is the coupled case the earlier archive commit deliberately deferred: a coverage package, an app_icons registry entry, a CI workflow and two paired binary rules all name it.",
"acceptance_criteria": [
"billsplit/ and python_pkg/billsplit_coverage/ are both present in kuhyx/testsAndMisc-archive, confirmed via the GitHub API before the local delete.",
"Archived commit count equals the monorepo count for those paths (8), and tracked file counts match exactly (73 and 5).",
"python_pkg/app_icons/apps.py no longer contains a billsplit AppIcon entry, and the app_icons registry still imports cleanly.",
"The repo-wide pytest gate still reports 100% coverage, with the test count reduced by exactly the 19 billsplit_coverage tests and nothing else.",
"check_no_binaries.sh exits 0 with a .binary-allowlist that now carries no patterns.",
".gitignore contains no billsplit rule; the ! overrides are removed in the same commit as the allowlist patterns they paired with, so the two halves never disagree.",
"No file under .github/workflows/ references billsplit or billsplit_coverage.",
"AGENTS.md no longer presents billsplit as a live app, while keeping the both-halves guidance for any future Flutter app.",
"pre-commit passes without --no-verify."
],
"out_of_scope": [
"Removing python_pkg/app_icons itself, which serves eleven other apps.",
"Changing how check_no_binaries.sh or the coverage gate work; this commit only removes data they read.",
"Historical planning documents that mention billsplit as past work."
],
"verifier": "gh api confirms both paths in the archive; git ls-files counts match 73/5; diff -r clean; python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py reports 2095 passed at 100%; ./meta/scripts/check_no_binaries.sh exits 0; ./meta/scripts/check_directory_depth.sh --all drops 451 -> 417; git grep billsplit finds only intentional prose."
}

View File

@ -0,0 +1,64 @@
{
"intent": "Archive the billsplit Flutter app to testsAndMisc-archive together with everything coupled to it, so the monorepo's 100% coverage gate and binary-allowlist stay consistent.",
"scope": [
"billsplit/ (73 files) - removed",
"python_pkg/billsplit_coverage/ (5 files) - removed, its only purpose was gating billsplit",
".github/workflows/billsplit-ci.yml - removed",
"python_pkg/app_icons/apps.py - billsplit registry entry removed",
".binary-allowlist, .gitignore, .prettierignore, AGENTS.md - billsplit rules removed",
"Non-goal: app_icons itself stays; only its billsplit entry goes"
],
"changes": [
"Extracted billsplit/ and python_pkg/billsplit_coverage/ in one filter-repo pass (8 commits, matching the monorepo's count for those paths) and merged into kuhyx/testsAndMisc-archive, where billsplit_coverage joins the existing python_pkg/ tree.",
"Deleted the app_icons AppIcon entry keyed 'billsplit', whose repo= pointed at _HOME/testsAndMisc/billsplit. app_icons stays in the repo and its tests iterate APPS generically, so a dangling entry would have failed the suite.",
"Emptied .binary-allowlist: billsplit's launcher icons were its only entries. The policy header is kept and now records that no exception is in force.",
"Removed the 27-line billsplit block from .gitignore (the ! overrides that paired with the allowlist) and the .prettierignore fixture line.",
"Rewrote the AGENTS.md 'Adding a Flutter app' section: the both-halves guidance is still correct for a future app, but no longer claims billsplit exists, and notes a Flutter app now belongs in its own repo."
],
"verification": [
{
"command": "git ls-files <path> | wc -l, archive vs source",
"result": "pass",
"evidence": "billsplit 73 = 73, python_pkg/billsplit_coverage 5 = 5; 8 commits, equal to git log --oneline -- billsplit python_pkg/billsplit_coverage."
},
{
"command": "diff -r --brief between archived copy and working tree",
"result": "pass",
"evidence": "No content differences for either path."
},
{
"command": "gh api repos/kuhyx/testsAndMisc-archive/contents --jq '.[].name'",
"result": "pass",
"evidence": "billsplit listed; billsplit_coverage confirmed under the archive's python_pkg/ alongside the previously archived packages."
},
{
"command": "python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py",
"result": "pass",
"evidence": "2095 passed, TOTAL coverage 100.00% - the gate holds after removing the registry entry. Count fell from 2114 by exactly the 19 billsplit_coverage tests."
},
{
"command": "PYTHONPATH=. python3 -c 'from python_pkg.app_icons import apps; ...'",
"result": "pass",
"evidence": "Registry loads with 12 apps and 'billsplit' in apps.APPS is False. Seven other apps still carry linux=False, so no cli.py branch lost its only exercise."
},
{
"command": "./meta/scripts/check_no_binaries.sh",
"result": "pass",
"evidence": "Exit 0 with an entry-free .binary-allowlist - the checker tolerates a comment-only file."
},
{
"command": "./meta/scripts/check_directory_depth.sh --all",
"result": "pass (improvement)",
"evidence": "451 -> 417 violations, matching billsplit's 34 deep files."
}
],
"risks": [
"app_icons' generator can no longer produce billsplit's icons; that is intended, since the app and its assets now live in the archive.",
".binary-allowlist is now empty of patterns. If a future Flutter app is added, both it and the .gitignore ! overrides must be repopulated - AGENTS.md still documents that requirement.",
"The archive push happened before the local delete; presence was confirmed through the GitHub API rather than assumed."
],
"rollback": [
"git revert the removal commit to restore all four trees and the config blocks.",
"Re-run meta/scripts/pytest_changed_packages.py to confirm the coverage gate is still satisfied after the revert."
]
}

View File

@ -115,20 +115,6 @@ APPS: Final[dict[str, AppIcon]] = {
icon_name="home-inventory", icon_name="home-inventory",
linux=True, linux=True,
), ),
AppIcon(
key="billsplit",
# The only app in this registry that lives inside the testsAndMisc
# monorepo rather than in its own repo, so its path carries a
# directory component the others do not.
repo=_HOME / "testsAndMisc" / "billsplit",
accent="#B8862E",
glyph="receipt-split",
icon_name="billsplit",
# `flutter create` scaffolded a linux/ target, but the app is
# phone-first and ships no desktop entry, so the hicolor icon set
# is not generated for it.
linux=False,
),
AppIcon( AppIcon(
key="epopeja_karta", key="epopeja_karta",
repo=_HOME / "epopeja_karta", repo=_HOME / "epopeja_karta",

View File

@ -1,13 +0,0 @@
"""Coverage gate for the ``billsplit/`` Flutter app.
``flutter test --coverage`` reports success while leaving whole files untouched,
so this package re-reads the lcov report it produces and fails unless every
``lib/`` file is present *and* fully covered. It lives here rather than beside
the app because every Python file in this repository belongs under
``python_pkg/``; the app itself holds only Dart.
Usage:
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage
"""
from __future__ import annotations

View File

@ -1,14 +0,0 @@
"""Module entry point for the billsplit coverage gate.
Usage:
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage
"""
from __future__ import annotations
import sys
from python_pkg.billsplit_coverage.checker import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,130 +0,0 @@
"""Fail unless lcov reports 100% line coverage for every ``billsplit/lib`` file.
Two separate checks, because they catch different mistakes: a file present in
the report but with unhit lines, and a file missing from the report entirely.
The second is the one that catches a newly added Dart file nothing imports —
partial coverage is visible in the report, absence is not.
The default project root is resolved from this file's location rather than the
working directory, so the CI job can invoke the module from anywhere in the
repository.
"""
from __future__ import annotations
import argparse
import pathlib
import sys
from typing import Final
# python_pkg/billsplit_coverage/checker.py -> repo root -> billsplit/
_DEFAULT_PROJECT: Final[pathlib.Path] = (
pathlib.Path(__file__).resolve().parents[2] / "billsplit"
)
EXIT_INCOMPLETE: Final[int] = 1
EXIT_NO_REPORT: Final[int] = 2
def parse_lcov(lcov: pathlib.Path) -> dict[str, list[int]]:
"""Map each file in an lcov report to its uncovered line numbers.
Parameters:
lcov (pathlib.Path): Path to an ``lcov.info`` report.
Returns:
dict[str, list[int]]: Source path to the lines with a zero hit count. A
fully covered file maps to an empty list, which is what lets the caller
tell "covered completely" apart from "absent from the report".
"""
uncovered: dict[str, list[int]] = {}
current: str | None = None
for line in lcov.read_text(encoding="utf-8").splitlines():
if line.startswith("SF:"):
# lcov quotes the path verbatim; normalise Windows separators.
current = line[3:].replace("\\", "/")
uncovered.setdefault(current, [])
elif line.startswith("DA:") and current is not None:
number, hits = line[3:].split(",")
if int(hits) == 0:
uncovered[current].append(int(number))
return uncovered
def find_failures(
uncovered: dict[str, list[int]],
project: pathlib.Path,
) -> list[str]:
"""Describe every coverage gap, as lines ready to print.
Parameters:
uncovered (dict[str, list[int]]): Output of :func:`parse_lcov`.
project (pathlib.Path): Flutter project root holding ``lib/``.
Returns:
list[str]: One message per gap; empty when coverage is complete.
"""
failures = [
f"{path}: uncovered lines {misses}"
for path, misses in sorted(uncovered.items())
if misses
]
lib_files = {
str(path.relative_to(project)).replace("\\", "/")
for path in (project / "lib").rglob("*.dart")
}
failures.extend(
f"{path}: not executed by any test (0% coverage)"
for path in sorted(lib_files - set(uncovered))
)
return failures
def check(project: pathlib.Path) -> int:
"""Run the gate for one Flutter project and report to stdout/stderr.
Parameters:
project (pathlib.Path): Flutter project root, i.e. the directory holding
``lib/`` and ``coverage/lcov.info``.
Returns:
int: 0 when coverage is complete, :data:`EXIT_INCOMPLETE` when it is not,
:data:`EXIT_NO_REPORT` when the lcov report is missing.
"""
lcov = project / "coverage" / "lcov.info"
if not lcov.exists():
sys.stderr.write(f"{lcov} missing — run: flutter test --coverage\n")
return EXIT_NO_REPORT
uncovered = parse_lcov(lcov)
failures = find_failures(uncovered, project)
if failures:
sys.stderr.write("COVERAGE < 100%:\n")
for failure in failures:
sys.stderr.write(f" {failure}\n")
return EXIT_INCOMPLETE
sys.stdout.write(f"100% line coverage across {len(uncovered)} lib files.\n")
return 0
def main(argv: list[str] | None = None) -> int:
"""Parse arguments and run the gate.
Parameters:
argv (list[str] | None): Argument list, or None to read ``sys.argv``.
Returns:
int: Process exit status.
"""
parser = argparse.ArgumentParser(
description="Fail unless billsplit has 100% line coverage.",
)
parser.add_argument(
"--project",
type=pathlib.Path,
default=_DEFAULT_PROJECT,
help="Flutter project root (defaults to the billsplit/ app in this repo)",
)
args = parser.parse_args(argv)
return check(args.project)

View File

@ -1,3 +0,0 @@
"""Tests for the billsplit coverage gate."""
from __future__ import annotations

View File

@ -1,191 +0,0 @@
"""Tests for the billsplit coverage gate."""
from __future__ import annotations
import runpy
import sys
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.billsplit_coverage import checker
if TYPE_CHECKING:
import pathlib
def make_project(
root: pathlib.Path,
lcov: str | None = None,
dart_files: tuple[str, ...] = ("lib/main.dart",),
) -> pathlib.Path:
"""Build a throwaway Flutter project tree.
Parameters:
root (pathlib.Path): Directory to build the project in.
lcov (str | None): Contents of ``coverage/lcov.info``; None writes no
report at all, which is the missing-report case.
dart_files (tuple[str, ...]): Project-relative Dart files to create.
Returns:
pathlib.Path: The project root.
"""
for relative in dart_files:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("void main() {}\n", encoding="utf-8")
if lcov is not None:
report = root / "coverage" / "lcov.info"
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(lcov, encoding="utf-8")
return root
class TestParseLcov:
def test_records_only_unhit_lines(self, tmp_path: pathlib.Path) -> None:
report = tmp_path / "lcov.info"
report.write_text(
"SF:lib/main.dart\nDA:1,3\nDA:2,0\nDA:9,0\nend_of_record\n",
encoding="utf-8",
)
assert checker.parse_lcov(report) == {"lib/main.dart": [2, 9]}
def test_fully_covered_file_maps_to_empty_list(
self, tmp_path: pathlib.Path
) -> None:
"""Present-and-covered must be distinguishable from absent."""
report = tmp_path / "lcov.info"
report.write_text("SF:lib/a.dart\nDA:1,1\nend_of_record\n", encoding="utf-8")
assert checker.parse_lcov(report) == {"lib/a.dart": []}
def test_normalises_windows_separators(self, tmp_path: pathlib.Path) -> None:
report = tmp_path / "lcov.info"
report.write_text("SF:lib\\ui\\home.dart\nDA:1,1\n", encoding="utf-8")
assert "lib/ui/home.dart" in checker.parse_lcov(report)
def test_da_before_any_sf_is_ignored(self, tmp_path: pathlib.Path) -> None:
"""A DA: line with no preceding SF: has no file to attribute to."""
report = tmp_path / "lcov.info"
report.write_text("DA:1,0\nSF:lib/a.dart\nDA:2,0\n", encoding="utf-8")
assert checker.parse_lcov(report) == {"lib/a.dart": [2]}
def test_unrelated_lines_are_skipped(self, tmp_path: pathlib.Path) -> None:
report = tmp_path / "lcov.info"
report.write_text(
"TN:\nSF:lib/a.dart\nLF:2\nLH:1\nDA:1,0\nend_of_record\n",
encoding="utf-8",
)
assert checker.parse_lcov(report) == {"lib/a.dart": [1]}
def test_empty_report_yields_nothing(self, tmp_path: pathlib.Path) -> None:
report = tmp_path / "lcov.info"
report.write_text("", encoding="utf-8")
assert checker.parse_lcov(report) == {}
class TestFindFailures:
def test_no_gaps_returns_empty(self, tmp_path: pathlib.Path) -> None:
project = make_project(tmp_path, lcov="")
assert checker.find_failures({"lib/main.dart": []}, project) == []
def test_reports_uncovered_lines(self, tmp_path: pathlib.Path) -> None:
project = make_project(tmp_path, lcov="")
failures = checker.find_failures({"lib/main.dart": [2, 7]}, project)
assert failures == ["lib/main.dart: uncovered lines [2, 7]"]
def test_reports_file_absent_from_report(self, tmp_path: pathlib.Path) -> None:
project = make_project(
tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart")
)
failures = checker.find_failures({"lib/main.dart": []}, project)
assert failures == ["lib/orphan.dart: not executed by any test (0% coverage)"]
def test_reports_both_kinds_together(self, tmp_path: pathlib.Path) -> None:
project = make_project(
tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart")
)
failures = checker.find_failures({"lib/main.dart": [4]}, project)
assert failures == [
"lib/main.dart: uncovered lines [4]",
"lib/orphan.dart: not executed by any test (0% coverage)",
]
def test_finds_dart_files_in_subdirectories(self, tmp_path: pathlib.Path) -> None:
project = make_project(
tmp_path, lcov="", dart_files=("lib/domain/split_engine.dart",)
)
failures = checker.find_failures({}, project)
assert failures == [
"lib/domain/split_engine.dart: not executed by any test (0% coverage)"
]
class TestCheck:
def test_missing_report_exits_two(
self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
) -> None:
project = make_project(tmp_path)
assert checker.check(project) == checker.EXIT_NO_REPORT
assert "flutter test --coverage" in capsys.readouterr().err
def test_incomplete_coverage_exits_one_and_names_files(
self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
) -> None:
project = make_project(
tmp_path,
lcov="SF:lib/main.dart\nDA:1,1\nDA:2,0\n",
dart_files=("lib/main.dart", "lib/orphan.dart"),
)
assert checker.check(project) == checker.EXIT_INCOMPLETE
err = capsys.readouterr().err
assert "COVERAGE < 100%:" in err
assert "lib/main.dart: uncovered lines [2]" in err
assert "lib/orphan.dart: not executed by any test" in err
def test_full_coverage_exits_zero(
self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
) -> None:
project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n")
assert checker.check(project) == 0
assert "100% line coverage across 1 lib files." in capsys.readouterr().out
class TestMain:
def test_project_flag_selects_the_tree(self, tmp_path: pathlib.Path) -> None:
project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n")
assert checker.main(["--project", str(project)]) == 0
def test_defaults_to_the_repo_billsplit_app(self) -> None:
"""With no --project, the gate resolves billsplit/ from this file."""
with patch.object(checker, "check", return_value=0) as mock_check:
assert checker.main([]) == 0
assert mock_check.call_args[0][0].name == "billsplit"
def test_propagates_failure_status(self, tmp_path: pathlib.Path) -> None:
project = make_project(tmp_path)
assert checker.main(["--project", str(project)]) == checker.EXIT_NO_REPORT
class TestModuleEntry:
def test_main_called_when_run_as_module(self) -> None:
"""python3 -m python_pkg.billsplit_coverage runs main() and exits."""
mock_main = MagicMock(return_value=0)
# runpy warns if __main__ is already imported, and the suite may run in
# a random order, so another test could have imported it first.
sys.modules.pop("python_pkg.billsplit_coverage.__main__", None)
with (
patch("python_pkg.billsplit_coverage.checker.main", mock_main),
pytest.raises(SystemExit) as excinfo,
):
runpy.run_module("python_pkg.billsplit_coverage", run_name="__main__")
assert excinfo.value.code == 0
mock_main.assert_called_once()
def test_main_not_called_on_plain_import(self) -> None:
"""Importing __main__ must not run the gate as a side effect."""
mock_main = MagicMock()
with patch("python_pkg.billsplit_coverage.checker.main", mock_main):
sys.modules.pop("python_pkg.billsplit_coverage.__main__", None)
runpy.run_module("python_pkg.billsplit_coverage.__main__")
mock_main.assert_not_called()