Move billsplit Flutter app into the monorepo

billsplit was a standalone Flutter app in ~/Downloads with one commit and no
git remote — that directory was the only copy of its history.

Three things the move needed beyond copying the tree:

- The root .gitignore silently swallowed 32 of its files (*.png, *.ico,
  *.manifest). Ignored files do not error on `git add`, they just vanish, so
  this would have committed an app that cannot build. Added a trailing block
  of ! overrides plus .binary-allowlist (which did not exist yet) — the
  documented in-repo exception for binaries a build genuinely needs. The
  ../testsAndMisc_binaries/ symlink scheme does not apply: those directories
  are gone, and a symlinked PNG breaks a fresh clone.

- billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads
  workflows at the repository root. Relocated with a working-directory and a
  billsplit/** path filter.

- The app's coverage gate moved to python_pkg/billsplit_coverage/ so every
  Python file stays under python_pkg/, and gained a test suite at 100% branch
  coverage as that location requires. billsplit/ now holds only Dart.

Also replaces the stock Flutter icon with a family icon from the shared
generator. The glyph is a filled silhouette because the family's 72px stroke
cannot carry a fine torn edge, and the split is an evenodd-cut perforation so
the holes stay transparent on the foreground and monochrome layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 11:38:01 +02:00
commit 4dd582a1df
167 changed files with 10095 additions and 0 deletions

45
billsplit/.gitignore vendored Normal file
View File

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

42
billsplit/.metadata Normal file
View File

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

77
billsplit/README.md Normal file
View File

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

View File

@ -0,0 +1,7 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true

14
billsplit/android/.gitignore vendored Normal file
View File

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

View File

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

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="billsplit"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

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

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

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

View File

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#211D1B"/>
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
<path fill="#B8862E" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g fill="none" stroke="#B8862E" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
<path fill="#B8862E" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g fill="none" stroke="#FFFFFF" stroke-width="72" stroke-linecap="round" stroke-linejoin="round" transform="translate(0,-5)">
<path fill="#FFFFFF" fill-rule="evenodd" stroke="none" d="M 312 282 L 712 282 L 712 706 L 678 752 L 645 706 L 612 752 L 578 706 L 545 752 L 512 706 L 478 752 L 445 706 L 412 752 L 378 706 L 345 752 L 312 706 Z M 482 350 A 30 30 0 1 0 542 350 A 30 30 0 1 0 482 350 Z M 482 446 A 30 30 0 1 0 542 446 A 30 30 0 1 0 482 446 Z M 482 542 A 30 30 0 1 0 542 542 A 30 30 0 1 0 482 542 Z M 482 638 A 30 30 0 1 0 542 638 A 30 30 0 1 0 482 638 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 675 B

34
billsplit/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.kuhy.billsplit;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@ -0,0 +1,122 @@
{
"images": [
{
"size": "20x20",
"idiom": "iphone",
"filename": "Icon-App-20x20@2x.png",
"scale": "2x"
},
{
"size": "20x20",
"idiom": "iphone",
"filename": "Icon-App-20x20@3x.png",
"scale": "3x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "Icon-App-29x29@1x.png",
"scale": "1x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "Icon-App-29x29@2x.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "Icon-App-29x29@3x.png",
"scale": "3x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "Icon-App-40x40@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "Icon-App-40x40@3x.png",
"scale": "3x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "Icon-App-60x60@2x.png",
"scale": "2x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "Icon-App-60x60@3x.png",
"scale": "3x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "Icon-App-20x20@1x.png",
"scale": "1x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "Icon-App-20x20@2x.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "Icon-App-29x29@1x.png",
"scale": "1x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "Icon-App-29x29@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "Icon-App-40x40@1x.png",
"scale": "1x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "Icon-App-40x40@2x.png",
"scale": "2x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "Icon-App-76x76@1x.png",
"scale": "1x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "Icon-App-76x76@2x.png",
"scale": "2x"
},
{
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "Icon-App-83.5x83.5@2x.png",
"scale": "2x"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "Icon-App-1024x1024@1x.png",
"scale": "1x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images": [
{
"idiom": "universal",
"filename": "LaunchImage.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "LaunchImage@2x.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "LaunchImage@3x.png",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Billsplit</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>billsplit</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@ -0,0 +1,293 @@
/// Parser for Polish fiscal e-receipts (e-paragon / JPK_KASA_PARAGON_v2-0),
/// e.g. the JSON files produced by the Biedronka app.
///
/// Accepted inputs:
/// * the full wrapper JSON containing a `data` field with a JWS
/// (header.payload.signature, base64url) whose payload is the JPK document;
/// * the decoded JPK payload itself (an object with `dokument.paragon`);
/// * a bare JWS token string.
///
/// The signature is NOT verified — this app only reads the contents.
library;
import 'dart:convert';
import 'package:billsplit/domain/models.dart';
/// A parsed receipt plus non-fatal [warnings] collected on the way.
class EParagonResult {
/// Bundles a parsed [receipt] with its [warnings].
EParagonResult({required this.receipt, required this.warnings});
/// The parsed receipt.
final Receipt receipt;
/// Human-readable warnings (e.g. sum mismatches, skipped lines).
final List<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;
}
DateTime _saleDate(
Map<dynamic, dynamic> paragon,
Map<dynamic, dynamic> dokument,
) {
final raw =
paragon['zakSprzed'] ?? (dokument['naglowek'] as Map?)?['dataJPK'];
if (raw is String) {
final d = DateTime.tryParse(raw);
if (d != null) return d.toLocal();
}
return DateTime.now();
}
String _iso(DateTime d) {
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '${d.year}-$m-$day';
}
Map<String, dynamic> _extractPayload(String source) {
dynamic root;
try {
root = jsonDecode(source.trim());
} on FormatException {
// Maybe the user pasted just the JWS token.
final p = _tryJws(source.trim());
if (p != null) return p;
throw EParagonParseException(
'Input is neither valid JSON nor a JWS.',
);
}
if (root is Map) {
if (root['dokument'] is Map) return root.cast<String, dynamic>();
final data = root['data'];
if (data is String) {
final p = _tryJws(data);
if (p != null) return p;
throw EParagonParseException('"data" field is not a decodable JWS.');
}
}
throw EParagonParseException(
'Unrecognized structure: expected e-paragon wrapper or JPK payload.',
);
}
Map<String, dynamic>? _tryJws(String token) {
final parts = token.split('.');
if (parts.length != 3) return null;
try {
final normalized = base64Url.normalize(parts[1]);
final decoded = utf8.decode(base64Url.decode(normalized));
final obj = jsonDecode(decoded);
return obj is Map ? obj.cast<String, dynamic>() : null;
} on FormatException {
return null;
}
}
Item? _itemFromTowar(Map<String, dynamic> t, List<String> warnings) {
var name = (t['nazwa'] as String? ?? '').trim();
// Names end with the VAT rate letter, e.g. "Tonic A".
if (name.length > 2 &&
name[name.length - 2] == ' ' &&
RegExp(r'[A-G]$').hasMatch(name)) {
name = name.substring(0, name.length - 2).trim();
}
name = name.replaceAll(RegExp(r'\s+'), ' ');
if (name.isEmpty) return null;
final total = _asIntOrNull(t['brutto']);
if (total == null) {
warnings.add('Skipped "$name": missing brutto amount.');
return null;
}
final unit = _asIntOrNull(t['cena']) ?? 0;
final qty = _parseQty(t['ilosc']);
final rabat = t['rabat'];
final discount = rabat is Map ? (_asIntOrNull(rabat['wart']) ?? 0) : 0;
final lower = name.toLowerCase();
var category = Categories.general;
var assignment = Assignment();
if (_alcoholKeywords.any(lower.contains)) {
category = Categories.alcohol;
assignment = Assignment(mode: AssignMode.drinkers);
} else if (_mixerKeywords.any(lower.contains)) {
category = Categories.mixer; // still split among everyone by default
}
return Item(
id: Ids.next(),
name: name,
totalGr: total,
qty: qty,
unitPriceGr: unit,
discountGr: discount,
category: category,
assignment: assignment,
);
}
double _parseQty(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) {
return double.tryParse(v.replaceAll(',', '.').trim()) ?? 1;
}
return 1;
}
int _asInt(dynamic v) => _asIntOrNull(v) ?? 0;
int? _asIntOrNull(dynamic v) {
if (v is int) return v;
if (v is num) return v.round();
if (v is String) return int.tryParse(v);
return null;
}

View File

@ -0,0 +1,278 @@
/// Domain models. All money values are ints in grosze (1/100 PLN) to avoid
/// floating point drift; formatting happens only at the UI/export edge.
library;
/// Formats [grosze] as a Polish złoty string, e.g. `1234` → `12,34 zł`.
String formatPln(int grosze) {
final neg = grosze < 0;
final a = grosze.abs();
final zl = a ~/ 100;
final gr = (a % 100).toString().padLeft(2, '0');
return '${neg ? '-' : ''}$zl,$gr zł';
}
/// A person on the shared roster.
class Person {
/// Creates a person; [drinks] controls alcohol-item defaults.
Person({required this.id, required this.name, this.drinks = true});
/// Restores a person from its [toJson] map.
factory Person.fromJson(Map<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,
}
/// The assignment of one item: a symbolic mode plus mode-specific data.
class Assignment {
/// Creates an assignment; defaults to [AssignMode.everyone].
Assignment({
this.mode = AssignMode.everyone,
this.groupId,
Set<String>? customIds,
}) : customIds = customIds ?? <String>{};
/// Restores an assignment from its [toJson] map.
factory Assignment.fromJson(Map<String, dynamic> j) => Assignment(
mode: AssignMode.values.asNameMap()[j['mode']] ?? AssignMode.everyone,
groupId: j['groupId'] as String?,
customIds: (j['customIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
);
/// Current mode.
AssignMode mode;
/// Group id when [mode] is [AssignMode.group].
String? groupId;
/// Hand-picked person ids when [mode] is [AssignMode.custom].
final Set<String> customIds;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'mode': mode.name,
if (groupId != null) 'groupId': groupId,
'customIds': customIds.toList(),
};
}
/// Standard item categories. [Item.category] is free text so users can add
/// their own; these constants are the ones the importer produces.
abstract final class Categories {
/// Alcoholic drinks — default to drinkers only.
static const alcohol = 'Alcohol';
/// Mixers (tonic, limes, …) — tagged for visibility, split among everyone.
static const mixer = 'Mixer';
/// Returnable bottle deposits (kaucja).
static const deposit = 'Deposit';
/// Anything else.
static const general = 'General';
}
/// One receipt line.
class Item {
/// Creates an item; [totalGr] is the post-discount line total.
Item({
required this.id,
required this.name,
required this.totalGr,
this.qty = 1,
this.unitPriceGr = 0,
this.discountGr = 0,
this.category = Categories.general,
Assignment? assignment,
}) : assignment = assignment ?? Assignment();
/// Restores an item from its [toJson] map.
factory Item.fromJson(Map<String, dynamic> j) => Item(
id: j['id'] as String,
name: j['name'] as String,
totalGr: j['totalGr'] as int,
qty: (j['qty'] as num? ?? 1).toDouble(),
unitPriceGr: j['unitPriceGr'] as int? ?? 0,
discountGr: j['discountGr'] as int? ?? 0,
category: j['category'] as String? ?? Categories.general,
assignment: Assignment.fromJson(
(j['assignment'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
/// Stable unique id.
final String id;
/// Display name.
String name;
/// Final line total after discount — the ground truth used for splitting.
int totalGr;
/// Quantity (may be fractional for weighed goods).
double qty;
/// Unit price in grosze.
int unitPriceGr;
/// Discount already included in [totalGr]; kept for display (<= 0).
int discountGr;
/// Free-text category; see [Categories] for the standard values.
String category;
/// Who shares this item.
Assignment assignment;
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'totalGr': totalGr,
'qty': qty,
'unitPriceGr': unitPriceGr,
'discountGr': discountGr,
'category': category,
'assignment': assignment.toJson(),
};
}
/// One shopping receipt with its items and participants.
class Receipt {
/// Creates a receipt; [participantIds] defaults to empty.
Receipt({
required this.id,
required this.title,
this.store,
DateTime? date,
List<Item>? items,
Set<String>? participantIds,
this.paidTotalGr,
}) : date = date ?? DateTime.now(),
items = items ?? <Item>[],
participantIds = participantIds ?? <String>{};
/// Restores a receipt from its [toJson] map.
factory Receipt.fromJson(Map<String, dynamic> j) => Receipt(
id: j['id'] as String,
title: j['title'] as String,
store: j['store'] as String?,
date: DateTime.tryParse(j['date'] as String? ?? '') ?? DateTime.now(),
items: (j['items'] as List? ?? const <dynamic>[])
.map((e) => Item.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
participantIds: (j['participantIds'] as List? ?? const <dynamic>[])
.cast<String>()
.toSet(),
paidTotalGr: j['paidTotalGr'] as int?,
);
/// Stable unique id.
final String id;
/// Display title.
String title;
/// Store name, if known.
String? store;
/// Sale date.
DateTime date;
/// Line items.
final List<Item> items;
/// Subset of the roster taking part in this receipt.
final Set<String> participantIds;
/// What was actually paid (incl. deposits) — used for the reconciliation
/// check. Null for manual receipts where the user never entered it.
int? paidTotalGr;
/// Sum of all item totals in grosze.
int get itemsTotalGr => items.fold(0, (s, i) => s + i.totalGr);
/// JSON representation for persistence.
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'store': store,
'date': date.toIso8601String(),
'items': items.map((i) => i.toJson()).toList(),
'participantIds': participantIds.toList(),
'paidTotalGr': paidTotalGr,
};
}
/// Simple unique id source: time + counter. Good enough for a local,
/// single-user app.
class Ids {
static int _c = 0;
/// Returns a new unique id.
static String next() =>
'${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'
'-${(_c++).toRadixString(36)}';
}

View File

@ -0,0 +1,155 @@
/// Split computation. Grosz-exact: every item's shares sum to exactly the
/// item total (largest-remainder distribution, deterministic order), so the
/// per-person totals always reconcile with the receipt to the grosz.
library;
import 'package:billsplit/domain/models.dart';
/// Accumulated amounts one person owes on a receipt.
class PersonTotal {
/// Creates an empty total for [personId].
PersonTotal(this.personId);
/// The person this total belongs to.
final String personId;
/// Everything owed, in grosze.
int totalGr = 0;
/// The alcohol portion of [totalGr].
int alcoholGr = 0;
/// The non-alcohol portion of [totalGr].
int get restGr => totalGr - alcoholGr;
}
/// Result of [computeSplit].
class SplitResult {
/// Bundles the computed maps.
SplitResult({
required this.perPerson,
required this.itemShares,
required this.unassigned,
});
/// personId → totals. Contains every receipt participant, even if 0.
final Map<String, PersonTotal> perPerson;
/// itemId → (personId → share in grosze). Only resolved participants.
final Map<String, Map<String, int>> itemShares;
/// Items that resolved to zero participants (excluded from totals).
final List<Item> unassigned;
/// Sum of all per-person totals, in grosze.
int get assignedTotalGr => perPerson.values.fold(0, (s, p) => s + p.totalGr);
}
/// People from [Receipt.participantIds] that still exist in [roster].
List<Person> receiptParticipants(Receipt receipt, List<Person> roster) =>
roster.where((p) => receipt.participantIds.contains(p.id)).toList();
/// Resolves an item's assignment to concrete people, always intersected with
/// the receipt's participant pool.
List<Person> resolveParticipants(
Item item,
Receipt receipt,
List<Person> roster,
List<Group> groups,
) {
final pool = receiptParticipants(receipt, roster);
final a = item.assignment;
switch (a.mode) {
case AssignMode.everyone:
return pool;
case AssignMode.drinkers:
return pool.where((p) => p.drinks).toList();
case AssignMode.nonDrinkers:
return pool.where((p) => !p.drinks).toList();
case AssignMode.group:
final g = groups.where((g) => g.id == a.groupId).firstOrNull;
if (g == null) return const [];
return pool.where((p) => g.memberIds.contains(p.id)).toList();
case AssignMode.custom:
return pool.where((p) => a.customIds.contains(p.id)).toList();
}
}
/// Splits [totalGr] between [personIds]. Base share is floor(total/n); the
/// remainder is handed out one grosz at a time starting at a position derived
/// from [rotation] (e.g. a stable hash of the item id). Rotating the start
/// per item avoids systematically overcharging the same person across a whole
/// receipt, while staying fully deterministic. Shares always sum to exactly
/// [totalGr] (also for negative totals, e.g. returns).
Map<String, int> splitAmount(
int totalGr,
List<String> personIds, {
int rotation = 0,
}) {
if (personIds.isEmpty) return const {};
final ids = [...personIds]..sort();
final n = ids.length;
final base = (totalGr / n).floor();
final remainder = totalGr - base * n; // 0 <= remainder < n, also if negative
final start = ((rotation % n) + n) % n;
final out = <String, int>{for (final id in ids) id: base};
for (var k = 0; k < remainder; k++) {
out[ids[(start + k) % n]] = base + 1;
}
return out;
}
/// Stable string hash (unlike `String.hashCode`, which is not guaranteed
/// identical across Dart VM runs) so splits don't shift between app launches.
int stableHash(String s) {
var h = 0;
for (final c in s.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return h;
}
/// Computes the full split of [receipt] between [roster] members using
/// [groups] for group-mode assignments.
SplitResult computeSplit(
Receipt receipt,
List<Person> roster,
List<Group> groups,
) {
final perPerson = <String, PersonTotal>{
for (final p in receiptParticipants(receipt, roster))
p.id: PersonTotal(p.id),
};
final itemShares = <String, Map<String, int>>{};
final unassigned = <Item>[];
for (final item in receipt.items) {
final people = resolveParticipants(item, receipt, roster, groups);
if (people.isEmpty) {
unassigned.add(item);
continue;
}
final shares = splitAmount(
item.totalGr,
people.map((p) => p.id).toList(),
rotation: stableHash(item.id),
);
itemShares[item.id] = shares;
final isAlcohol = item.category == Categories.alcohol;
shares.forEach((pid, amount) {
perPerson[pid]!
..totalGr += amount
..alcoholGr += isAlcohol ? amount : 0;
});
}
return SplitResult(
perPerson: perPerson,
itemShares: itemShares,
unassigned: unassigned,
);
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View File

@ -0,0 +1,215 @@
/// Exports a receipt + computed split to an .xlsx workbook whose layout
/// mirrors the hand-made "podzial_rachunku" sheet: one row per item, a 1/0
/// person matrix, per-item cost-per-person, and a per-person summary.
///
/// Values are written as computed numbers (grosz-exact, matching the in-app
/// engine), plus live SUM formulas for the column totals so the sheet stays
/// consistent when opened in Excel/LibreOffice.
library;
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:excel/excel.dart';
/// Renders [receipt] with its current split into xlsx bytes.
List<int> exportXlsx({
required Receipt receipt,
required List<Person> roster,
required List<Group> groups,
}) {
final excel = Excel.createExcel()..rename('Sheet1', 'Split');
final sheet = excel['Split'];
final people = receiptParticipants(receipt, roster)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final split = computeSplit(receipt, roster, groups);
final bold = CellStyle(bold: true);
final boldGrey = CellStyle(
bold: true,
backgroundColorHex: ExcelColor.fromHexString('#D9D9D9'),
);
final alcoholStyle =
CellStyle(backgroundColorHex: ExcelColor.fromHexString('#FCE4D6'));
final warnStyle = CellStyle(
bold: true,
backgroundColorHex: ExcelColor.fromHexString('#FFC7CE'),
);
void put(int col, int row, CellValue value, [CellStyle? style]) {
final index = CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row);
final cell = sheet.cell(index)..value = value;
if (style != null) cell.cellStyle = style;
}
double zl(int gr) => gr / 100.0;
var r = 0;
put(0, r, TextCellValue(receipt.title), bold);
r++;
put(
0,
r,
TextCellValue(
[
if (receipt.store != null) receipt.store!,
receipt.date.toIso8601String().substring(0, 10),
if (receipt.paidTotalGr != null)
'paid ${formatPln(receipt.paidTotalGr!)}',
].join(' · '),
),
);
r += 2;
// Header row.
const fixed = ['Lp', 'Item', 'Category', 'Qty', 'Unit', 'Discount', 'Total'];
for (var c = 0; c < fixed.length; c++) {
put(c, r, TextCellValue(fixed[c]), boldGrey);
}
const p0 = 7;
for (var i = 0; i < people.length; i++) {
put(p0 + i, r, TextCellValue(people[i].name), boldGrey);
}
put(p0 + people.length, r, TextCellValue('People on item'), boldGrey);
put(p0 + people.length + 1, r, TextCellValue('Cost / person'), boldGrey);
r++;
final firstDataRow = r;
var lp = 1;
for (final item in receipt.items) {
final shares = split.itemShares[item.id] ?? const <String, int>{};
final isAlcohol = item.category == Categories.alcohol;
final rowStyle = shares.isEmpty
? warnStyle
: isAlcohol
? alcoholStyle
: null;
put(0, r, IntCellValue(lp++), rowStyle);
put(1, r, TextCellValue(item.name), rowStyle);
put(2, r, TextCellValue(item.category), rowStyle);
put(3, r, DoubleCellValue(item.qty), rowStyle);
put(4, r, DoubleCellValue(zl(item.unitPriceGr)), rowStyle);
put(5, r, DoubleCellValue(zl(item.discountGr)), rowStyle);
put(6, r, DoubleCellValue(zl(item.totalGr)), rowStyle);
for (var i = 0; i < people.length; i++) {
put(
p0 + i,
r,
IntCellValue(shares.containsKey(people[i].id) ? 1 : 0),
rowStyle,
);
}
put(p0 + people.length, r, IntCellValue(shares.length), rowStyle);
if (shares.isNotEmpty) {
// Representative value; the exact grosz split may differ by ±0.01
// between people — the summary uses the exact per-person numbers.
put(
p0 + people.length + 1,
r,
DoubleCellValue(zl((item.totalGr / shares.length).round())),
rowStyle,
);
} else {
put(p0 + people.length + 1, r, TextCellValue('UNASSIGNED'), warnStyle);
}
r++;
}
final lastDataRow = r - 1;
// Totals row with a live formula over the Total column.
put(1, r, TextCellValue('TOTAL'), bold);
final totalCol = colName(6);
put(
6,
r,
FormulaCellValue(
'SUM($totalCol${firstDataRow + 1}:$totalCol${lastDataRow + 1})',
),
bold,
);
r += 2;
// Per-person summary (exact grosz numbers from the engine).
put(1, r, TextCellValue('SUMMARY'), bold);
r++;
put(1, r, TextCellValue('Person'), boldGrey);
put(2, r, TextCellValue('Owes'), boldGrey);
put(3, r, TextCellValue('of which alcohol'), boldGrey);
put(4, r, TextCellValue('rest'), boldGrey);
r++;
final firstSummaryRow = r;
for (final p in people) {
final t = split.perPerson[p.id];
put(1, r, TextCellValue(p.name + (p.drinks ? '' : ' (non-drinker)')));
put(2, r, DoubleCellValue(zl(t?.totalGr ?? 0)));
put(3, r, DoubleCellValue(zl(t?.alcoholGr ?? 0)));
put(4, r, DoubleCellValue(zl(t?.restGr ?? 0)));
r++;
}
put(1, r, TextCellValue('SUM'), bold);
for (var c = 2; c <= 4; c++) {
final col = colName(c);
put(c, r, FormulaCellValue('SUM($col$firstSummaryRow:$col$r)'), bold);
}
r += 2;
// Reconciliation note.
final assigned = split.assignedTotalGr;
final itemsTotal = receipt.itemsTotalGr;
put(
1,
r,
TextCellValue(
_checkLine(receipt, assigned, itemsTotal, split),
),
split.unassigned.isEmpty && assigned == itemsTotal ? bold : warnStyle,
);
r += 2;
put(
0,
r,
TextCellValue(
'1 = person shares the item. Matrix and summary are exported values '
'from the app (grosz-exact); edit splits in the app, not here.',
),
);
// Column widths.
sheet
..setColumnWidth(0, 5)
..setColumnWidth(1, 34)
..setColumnWidth(2, 11);
for (var c = 3; c < 7; c++) {
sheet.setColumnWidth(c, 10);
}
for (var i = 0; i < people.length + 2; i++) {
sheet.setColumnWidth(p0 + i, 12);
}
return excel.encode()!;
}
String _checkLine(
Receipt receipt,
int assigned,
int itemsTotal,
SplitResult split,
) {
final paid = receipt.paidTotalGr;
final paidPart = paid == null ? '' : ' / paid ${formatPln(paid)}';
final un = split.unassigned.length;
final unPart = un == 0 ? '' : ' — $un UNASSIGNED item(s) excluded';
return 'Check: assigned ${formatPln(assigned)} '
'/ items ${formatPln(itemsTotal)}$paidPart$unPart';
}
/// Converts a 0-based column index into its A1-style name (0 → A, 26 → AA).
String colName(int index) {
var i = index;
var s = '';
while (i >= 0) {
s = String.fromCharCode(65 + (i % 26)) + s;
i = i ~/ 26 - 1;
}
return s;
}

37
billsplit/lib/main.dart Normal file
View File

@ -0,0 +1,37 @@
import 'dart:async';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/home_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
final state = AppState();
unawaited(state.load());
runApp(BillSplitApp(state: state));
}
/// Root widget wiring [AppState] into the tree.
class BillSplitApp extends StatelessWidget {
/// Creates the app around an existing [state].
const BillSplitApp({required this.state, super.key});
/// The single app-wide state instance.
final AppState state;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: state,
child: MaterialApp(
title: 'BillSplit',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const HomeScreen(),
),
);
}
}

View File

@ -0,0 +1,166 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:billsplit/domain/models.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Single source of truth: roster, groups, receipts. Persisted as one JSON
/// file in the app documents directory; saves are debounced.
class AppState extends ChangeNotifier {
/// Creates the state; [overrideDir] replaces the platform documents
/// directory (used by tests).
AppState({Directory? overrideDir}) : _overrideDir = overrideDir;
final Directory? _overrideDir;
/// The shared roster of people.
final List<Person> people = [];
/// User-defined groups over [people].
final List<Group> groups = [];
/// Saved receipts, newest first.
final List<Receipt> receipts = [];
/// Whether [load] has completed.
bool loaded = false;
Timer? _saveTimer;
Future<File> _file() async {
final dir = _overrideDir ?? await getApplicationDocumentsDirectory();
final d = Directory('${dir.path}/billsplit');
if (!d.existsSync()) d.createSync(recursive: true);
return File('${d.path}/state.json');
}
/// Loads persisted state; failures leave the state empty but usable.
Future<void> load() async {
try {
final f = await _file();
if (f.existsSync()) {
final j = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
people
..clear()
..addAll(
(j['people'] as List? ?? const <dynamic>[])
.map((e) => Person.fromJson((e as Map).cast())),
);
groups
..clear()
..addAll(
(j['groups'] as List? ?? const <dynamic>[])
.map((e) => Group.fromJson((e as Map).cast())),
);
receipts
..clear()
..addAll(
(j['receipts'] as List? ?? const <dynamic>[])
.map((e) => Receipt.fromJson((e as Map).cast())),
);
_prune();
}
} on FormatException catch (e) {
debugPrint('State load failed (corrupt JSON): $e');
} on FileSystemException catch (e) {
debugPrint('State load failed (IO): $e');
}
loaded = true;
notifyListeners();
}
/// Drop references to people that no longer exist.
void _prune() {
final ids = people.map((p) => p.id).toSet();
for (final g in groups) {
g.memberIds.removeWhere((id) => !ids.contains(id));
}
for (final r in receipts) {
r.participantIds.removeWhere((id) => !ids.contains(id));
for (final i in r.items) {
i.assignment.customIds.removeWhere((id) => !ids.contains(id));
}
}
}
void _scheduleSave() {
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 400), save);
}
/// Writes the current state to disk immediately.
Future<void> save() async {
try {
final f = await _file();
f.writeAsStringSync(
jsonEncode({
'people': people.map((p) => p.toJson()).toList(),
'groups': groups.map((g) => g.toJson()).toList(),
'receipts': receipts.map((r) => r.toJson()).toList(),
}),
);
} on FileSystemException catch (e) {
debugPrint('State save failed: $e');
}
}
/// Runs [fn], prunes dangling references, notifies, schedules a save.
void mutate(void Function() fn) {
fn();
_prune();
notifyListeners();
_scheduleSave();
}
// Convenience CRUD -------------------------------------------------------
/// Adds a person to the roster and returns it.
Person addPerson(String name, {bool drinks = true}) {
final p = Person(id: Ids.next(), name: name, drinks: drinks);
mutate(() => people.add(p));
return p;
}
/// Removes [p] everywhere (roster, groups, receipts, assignments).
void removePerson(Person p) => mutate(() => people.remove(p));
/// Adds an empty group and returns it.
Group addGroup(String name) {
final g = Group(id: Ids.next(), name: name);
mutate(() => groups.add(g));
return g;
}
/// Removes [g]; items assigned to it fall back to everyone.
void removeGroup(Group g) => mutate(() {
groups.remove(g);
for (final r in receipts) {
for (final i in r.items) {
if (i.assignment.groupId == g.id) {
i.assignment
..mode = AssignMode.everyone
..groupId = null;
}
}
}
});
/// Inserts [r] at the top; empty participants default to the whole roster.
void addReceipt(Receipt r) => mutate(() {
if (r.participantIds.isEmpty) {
r.participantIds.addAll(people.map((p) => p.id));
}
receipts.insert(0, r);
});
/// Deletes [r].
void removeReceipt(Receipt r) => mutate(() => receipts.remove(r));
@override
void dispose() {
_saveTimer?.cancel();
super.dispose();
}
}

View File

@ -0,0 +1,143 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:flutter/material.dart';
/// Bottom sheet to assign an item: quick modes (everyone / drinkers /
/// non-drinkers / group) or explicit person selection. Toggling a person
/// checkbox on a non-custom mode first materializes the current selection,
/// then switches to custom.
Future<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 ? Colors.red : Colors.black54,
fontSize: 12,
),
),
],
),
),
);
},
),
);
}
String _eachLabel(int n, int totalGr) =>
'$n people · ~${formatPln((totalGr / n).round())} each';

View File

@ -0,0 +1,208 @@
import 'dart:async';
import 'dart:convert';
import 'package:billsplit/domain/eparagon_parser.dart';
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/people_screen.dart';
import 'package:billsplit/ui/receipt_screen.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Receipt list plus entry points for import / manual creation.
class HomeScreen extends StatelessWidget {
/// Creates the home screen.
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
return Scaffold(
appBar: AppBar(
title: const Text('BillSplit'),
actions: [
IconButton(
tooltip: 'People & groups',
icon: const Icon(Icons.group),
onPressed: () => unawaited(
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,
background: Container(
color: Colors.red.shade300,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) => _confirmDelete(context, r),
onDismissed: (_) => state.removeReceipt(r),
child: ListTile(
title: Text(r.title),
subtitle: Text(
'${r.items.length} items · '
'${r.participantIds.length} people',
),
trailing: Text(
formatPln(r.itemsTotalGr),
style: Theme.of(context).textTheme.titleMedium,
),
onTap: () => unawaited(
Navigator.push(
context,
MaterialPageRoute<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);
final picked = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
withData: true,
);
final data = picked?.files.firstOrNull?.bytes;
if (data == null) return;
try {
final result = parseEParagonDetailed(utf8.decode(data));
state.addReceipt(result.receipt);
for (final w in result.warnings) {
messenger.showSnackBar(SnackBar(content: Text(w)));
}
unawaited(
nav.push(
MaterialPageRoute<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);
unawaited(
nav.push(
MaterialPageRoute<void>(
builder: (_) => ReceiptScreen(receiptId: r.id),
),
),
);
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint();
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'No receipts yet.\n\nAdd people first (top right), then import a '
'Biedronka e-paragon JSON or create a receipt manually.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
}

View File

@ -0,0 +1,130 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:flutter/material.dart';
/// Add ([item] == null) or edit an item. The user types amounts in złoty
/// ("12,34" or "12.34"); they are stored as int grosze.
Future<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),
const Text(
'Alcohol items default to drinkers only; everything else to '
'everyone. Fine-tune by tapping the item afterwards.',
style: TextStyle(fontSize: 11, color: Colors.black54),
),
],
),
),
actions: [
if (item != null)
TextButton(
onPressed: () {
state.mutate(() => receipt.items.remove(item));
Navigator.pop(c);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final gr = _parseZl(total.text);
if (name.text.trim().isEmpty || gr == null) return;
state.mutate(() {
if (item == null) {
final it = Item(
id: Ids.next(),
name: name.text.trim(),
totalGr: gr,
qty: double.tryParse(qty.text.replaceAll(',', '.')) ?? 1,
category: category,
assignment: category == Categories.alcohol
? Assignment(mode: AssignMode.drinkers)
: Assignment(),
);
receipt.items.add(it);
} else {
item
..name = name.text.trim()
..totalGr = gr
..qty = double.tryParse(qty.text.replaceAll(',', '.')) ?? 1
..category = category;
}
});
Navigator.pop(c);
},
child: const Text('Save'),
),
],
),
),
);
}
String _zl(int gr) => (gr / 100).toStringAsFixed(2);
int? _parseZl(String s) {
final v = double.tryParse(s.replaceAll(',', '.').replaceAll(' ', ''));
if (v == null) return null;
return (v * 100).round();
}

View File

@ -0,0 +1,273 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Roster management: people with a drinks toggle, plus custom groups.
class PeopleScreen extends StatelessWidget {
/// Creates the people screen.
const PeopleScreen({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('People & groups')),
body: ListView(
padding: const EdgeInsets.only(bottom: 96),
children: [
const _SectionHeader('People'),
if (state.people.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text('Nobody yet — add people below.'),
),
for (final p in state.people) _PersonTile(person: p),
const Divider(height: 32),
const _SectionHeader('Groups'),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Drinkers / non-drinkers are built in (the toggle on each '
'person). Groups below are extra tags, e.g. "meat eaters".',
style: TextStyle(fontSize: 12, color: Colors.black54),
),
),
for (final g in state.groups) _GroupTile(group: g),
Padding(
padding: const EdgeInsets.all(16),
child: OutlinedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add group'),
onPressed: () => _addGroup(context),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.person_add),
label: const Text('Add person'),
onPressed: () => _addPerson(context),
),
);
}
Future<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'),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
);
}
class _PersonTile extends StatelessWidget {
const _PersonTile({required this.person});
final Person person;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
return ListTile(
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: Wrap(
spacing: 6,
children: [
for (final g in state.groups.where(
(g) => g.memberIds.contains(person.id),
))
Chip(
label: Text(g.name, style: const TextStyle(fontSize: 11)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: 'Drinks alcohol',
child: Switch(
value: person.drinks,
onChanged: (v) => state.mutate(() => person.drinks = v),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmRemove(context, state),
),
],
),
onTap: () => _editMembership(context, state),
);
}
Future<void> _confirmRemove(BuildContext context, AppState state) async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Remove ${person.name}?'),
content: const Text(
'They will be removed from all receipts and item assignments.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Remove'),
),
],
),
);
if (ok == true) state.removePerson(person);
}
Future<void> _editMembership(BuildContext context, AppState state) async {
if (state.groups.isEmpty) return;
await showModalBottomSheet<void>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, setState) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${person.name} — groups',
style: Theme.of(c).textTheme.titleMedium,
),
),
for (final g in state.groups)
CheckboxListTile(
title: Text(g.name),
value: g.memberIds.contains(person.id),
onChanged: (v) {
state.mutate(() {
if (v == true) {
g.memberIds.add(person.id);
} else {
g.memberIds.remove(person.id);
}
});
setState(() {});
},
),
],
),
),
),
);
}
}
class _GroupTile extends StatelessWidget {
const _GroupTile({required this.group});
final Group group;
@override
Widget build(BuildContext context) {
final state = context.read<AppState>();
final names = state.people
.where((p) => group.memberIds.contains(p.id))
.map((p) => p.name)
.join(', ');
return ListTile(
leading: const Icon(Icons.label_outline),
title: Text(group.name),
subtitle:
Text(names.isEmpty ? 'No members — tap a person to add' : names),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => state.removeGroup(group),
),
);
}
}

View File

@ -0,0 +1,357 @@
import 'package:billsplit/domain/models.dart';
import 'package:billsplit/domain/split_engine.dart';
import 'package:billsplit/domain/xlsx_export.dart';
import 'package:billsplit/state/app_state.dart';
import 'package:billsplit/ui/assignment_sheet.dart';
import 'package:billsplit/ui/item_edit.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// One receipt: item list with assignments, totals, participants, export.
class ReceiptScreen extends StatefulWidget {
/// Creates the screen for the receipt with [receiptId].
const ReceiptScreen({required this.receiptId, super.key});
/// Id of the receipt to show; resolved against [AppState.receipts].
final String receiptId;
@override
State<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 ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'),
'_',
);
final path = await FilePicker.saveFile(
dialogTitle: 'Save split as .xlsx',
fileName: '$safe split.xlsx',
type: FileType.custom,
allowedExtensions: ['xlsx'],
bytes: bytes,
);
if (path != null) {
messenger.showSnackBar(
SnackBar(content: Text('Saved: ${path.split('/').last}')),
);
}
} on Exception catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
class _ItemsList extends StatelessWidget {
const _ItemsList({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<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 ? Colors.red : null,
fontWeight: unassigned ? FontWeight.bold : null,
),
),
trailing: Text(formatPln(item.totalGr)),
onTap: () => showAssignmentSheet(context, state, receipt, item),
onLongPress: () => showItemEditDialog(context, state, receipt, item),
);
},
);
}
String _assignmentLabel(Item item, int n, AppState state) {
switch (item.assignment.mode) {
case AssignMode.everyone:
return 'Everyone ($n)';
case AssignMode.drinkers:
return 'Drinkers only ($n)';
case AssignMode.nonDrinkers:
return 'Non-drinkers ($n)';
case AssignMode.group:
final g = state.groups
.where((g) => g.id == item.assignment.groupId)
.firstOrNull;
return 'Group: ${g?.name ?? '?'} ($n)';
case AssignMode.custom:
return n == 0 ? 'UNASSIGNED — tap to fix' : 'Selected people ($n)';
}
}
}
class _CategoryDot extends StatelessWidget {
const _CategoryDot({required this.category});
final String category;
@override
Widget build(BuildContext context) {
final color = switch (category) {
Categories.alcohol => Colors.deepOrange,
Categories.mixer => Colors.amber,
Categories.deposit => Colors.blueGrey,
_ => Colors.teal,
};
return CircleAvatar(radius: 6, backgroundColor: color);
}
}
class _TotalsView extends StatelessWidget {
const _TotalsView({required this.receipt, required this.split});
final Receipt receipt;
final SplitResult split;
@override
Widget build(BuildContext context) {
final state = context.read<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: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'${split.unassigned.length} item(s) have nobody assigned and '
'are excluded from totals:\n'
'${split.unassigned.map((i) => '· ${i.name}').join('\n')}',
style: const TextStyle(color: Colors.red),
),
),
),
for (final p in people)
_PersonRow(person: p, total: split.perPerson[p.id]),
const Divider(height: 32),
_kv(context, 'Items total', formatPln(itemsTotal), bold: true),
_kv(
context,
'Assigned to people',
formatPln(assigned),
warn: assigned != itemsTotal,
),
if (paid != null)
_kv(
context,
'Paid (receipt)',
formatPln(paid),
warn: paid != itemsTotal,
),
const SizedBox(height: 8),
Text(
'Splits are grosz-exact: shares always add up to the item total, '
'so no fractional grosze are lost.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _kv(
BuildContext context,
String k,
String v, {
bool bold = false,
bool warn = false,
}) {
final style = TextStyle(
fontWeight: bold || warn ? FontWeight.bold : null,
color: warn ? Colors.red : null,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(k, style: style), Text(v, style: style)],
),
);
}
}
class _PersonRow extends StatelessWidget {
const _PersonRow({required this.person, required this.total});
final Person person;
final PersonTotal? total;
@override
Widget build(BuildContext context) {
final t = total;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
person.name.isEmpty ? '?' : person.name.substring(0, 1).toUpperCase(),
),
),
title: Text(person.name),
subtitle: t == null || t.alcoholGr == 0
? null
: Text(
'alcohol ${formatPln(t.alcoholGr)} · rest ${formatPln(t.restGr)}',
),
trailing: Text(
formatPln(t?.totalGr ?? 0),
style: Theme.of(context).textTheme.titleMedium,
),
);
}
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

1
billsplit/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

View File

@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "billsplit")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "dev.kuhy.billsplit")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@ -0,0 +1,11 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

View File

@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

Some files were not shown because too many files have changed in this diff Show More