Archive billsplit and everything coupled to it
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
43
.github/workflows/billsplit-ci.yml
vendored
@ -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
@ -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
|
|
||||||
@ -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'
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
14
billsplit/android/.gitignore
vendored
@ -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
|
|
||||||
@ -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 = "../.."
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package dev.kuhy.billsplit
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@ -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>
|
|
||||||
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 959 B |
|
Before Width: | Height: | Size: 519 B |
|
Before Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@ -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>
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#211D1B</color>
|
|
||||||
</resources>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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")
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@ -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 |
|
Before Width: | Height: | Size: 14 KiB |
@ -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 |
|
Before Width: | Height: | Size: 12 KiB |
@ -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 |
@ -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)}';
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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';
|
|
||||||
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
1
billsplit/test/fixtures/sample_receipt.json
vendored
@ -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));
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
21
docs/superpowers/contracts/archive-billsplit-2026-08-23.json
Normal 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."
|
||||||
|
}
|
||||||
64
docs/superpowers/evidence/archive-billsplit-2026-08-23.json
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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())
|
|
||||||
@ -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)
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
"""Tests for the billsplit coverage gate."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
@ -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()
|
|
||||||