From c277ee37c2f9a0eec4a7bc876febb62f4e947f10 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 21:23:43 +0200 Subject: [PATCH] M2b: Android skeleton, :core:model contract, :domain passing all vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog, architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3 entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation as a lint error, Roborazzi goldens of the empty screen in light and dark. Co-Authored-By: Claude Opus 5.5 --- android/README.md | 60 ++ android/adapter/fcm/build.gradle.kts | 8 + .../hagstedt/kvitt/adapter/fcm/FcmAdapter.kt | 10 + android/app/build.gradle.kts | 53 + android/app/src/main/AndroidManifest.xml | 21 + .../se/hagstedt/kvitt/KvittApplication.kt | 8 + .../se/hagstedt/kvitt/KvittEmptyScreen.kt | 21 + .../kotlin/se/hagstedt/kvitt/MainActivity.kt | 21 + .../app/src/main/res/values-sv/strings.xml | 4 + android/app/src/main/res/values/strings.xml | 5 + .../main/res/xml/data_extraction_rules.xml | 18 + .../kvitt/KvittEmptyScreenScreenshotTest.kt | 38 + android/build-logic/build.gradle.kts | 42 + android/build-logic/settings.gradle.kts | 14 + .../buildlogic/AndroidApplicationPlugin.kt | 34 + .../kvitt/buildlogic/AndroidCommon.kt | 47 + .../kvitt/buildlogic/AndroidComposePlugin.kt | 30 + .../kvitt/buildlogic/AndroidFeaturePlugin.kt | 28 + .../kvitt/buildlogic/AndroidLibraryPlugin.kt | 35 + .../kvitt/buildlogic/ArchitectureCheckTask.kt | 97 ++ .../kvitt/buildlogic/ArchitecturePlugin.kt | 38 + .../kvitt/buildlogic/ArchitectureRules.kt | 40 + .../se/hagstedt/kvitt/buildlogic/Catalog.kt | 19 + .../hagstedt/kvitt/buildlogic/HiltPlugin.kt | 16 + .../kvitt/buildlogic/JvmLibraryPlugin.kt | 32 + android/build.gradle.kts | 12 + android/core/design/build.gradle.kts | 9 + .../se/hagstedt/kvitt/design/KvittTheme.kt | 16 + android/core/fakes/build.gradle.kts | 12 + .../kvitt/fakes/FakeAnalyticsRepository.kt | 107 ++ .../kvitt/fakes/FakeExpenseRepository.kt | 176 ++++ .../kvitt/fakes/FakeHouseholdRepositories.kt | 158 +++ .../hagstedt/kvitt/fakes/FakeRepositories.kt | 21 + .../se/hagstedt/kvitt/fakes/FakeScenario.kt | 92 ++ .../se/hagstedt/kvitt/fakes/FakeScenarios.kt | 77 ++ .../kvitt/fakes/FakeSettingsRepositories.kt | 292 ++++++ .../kvitt/fakes/FakeSettlementRepository.kt | 134 +++ .../se/hagstedt/kvitt/fakes/FakeStore.kt | 123 +++ .../hagstedt/kvitt/fakes/EmptyScenarioTest.kt | 84 ++ android/core/model/build.gradle.kts | 9 + .../se/hagstedt/kvitt/model/Accounts.kt | 16 + .../se/hagstedt/kvitt/model/Analytics.kt | 21 + .../kotlin/se/hagstedt/kvitt/model/Enums.kt | 39 + .../se/hagstedt/kvitt/model/Expenses.kt | 25 + .../kotlin/se/hagstedt/kvitt/model/Home.kt | 18 + .../kotlin/se/hagstedt/kvitt/model/Ids.kt | 19 + .../kotlin/se/hagstedt/kvitt/model/Ledger.kt | 17 + .../kotlin/se/hagstedt/kvitt/model/Records.kt | 25 + .../kotlin/se/hagstedt/kvitt/model/Session.kt | 18 + .../se/hagstedt/kvitt/model/Settings.kt | 46 + .../se/hagstedt/kvitt/model/Settlements.kt | 21 + .../kotlin/se/hagstedt/kvitt/model/Sync.kt | 42 + .../kvitt/model/analytics/Analytics.kt | 22 + .../hagstedt/kvitt/model/money/Allocation.kt | 16 + .../se/hagstedt/kvitt/model/money/Balance.kt | 11 + .../se/hagstedt/kvitt/model/money/Split.kt | 17 + .../se/hagstedt/kvitt/model/ContractTest.kt | 71 ++ android/data/build.gradle.kts | 18 + .../1.json | 986 ++++++++++++++++++ .../se/hagstedt/kvitt/data/db/Entities.kt | 191 ++++ .../hagstedt/kvitt/data/db/KvittDatabase.kt | 33 + android/domain/build.gradle.kts | 20 + .../se/hagstedt/kvitt/domain/Allocation.kt | 64 ++ .../se/hagstedt/kvitt/domain/Analytics.kt | 115 ++ .../se/hagstedt/kvitt/domain/Balances.kt | 41 + .../kotlin/se/hagstedt/kvitt/domain/Limits.kt | 13 + .../kotlin/se/hagstedt/kvitt/domain/Split.kt | 45 + .../hagstedt/kvitt/domain/ResolveRangeTest.kt | 24 + .../hagstedt/kvitt/domain/VectorFilesTest.kt | 22 + .../se/hagstedt/kvitt/domain/VectorTest.kt | 159 +++ .../se/hagstedt/kvitt/domain/Vectors.kt | 58 ++ android/feature/account/build.gradle.kts | 4 + .../kvitt/feature/account/AccountEntry.kt | 14 + android/feature/analytics/build.gradle.kts | 4 + .../kvitt/feature/analytics/AnalyticsEntry.kt | 14 + android/feature/expense/build.gradle.kts | 4 + .../kvitt/feature/expense/ExpenseEntry.kt | 14 + android/feature/home/build.gradle.kts | 4 + .../hagstedt/kvitt/feature/home/HomeEntry.kt | 14 + android/feature/onboarding/build.gradle.kts | 4 + .../feature/onboarding/OnboardingEntry.kt | 14 + android/feature/settings/build.gradle.kts | 4 + .../kvitt/feature/settings/SettingsEntry.kt | 14 + android/feature/settle/build.gradle.kts | 4 + .../kvitt/feature/settle/SettleEntry.kt | 14 + android/gradle.properties | 3 + android/gradle/libs.versions.toml | 86 ++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48749 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + android/gradlew | 248 +++++ android/gradlew.bat | 112 ++ android/settings.gradle.kts | 36 + tools/git-hooks/pre-commit | 2 +- 93 files changed, 4915 insertions(+), 1 deletion(-) create mode 100644 android/README.md create mode 100644 android/adapter/fcm/build.gradle.kts create mode 100644 android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt create mode 100644 android/app/src/main/kotlin/se/hagstedt/kvitt/KvittEmptyScreen.kt create mode 100644 android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt create mode 100644 android/app/src/main/res/values-sv/strings.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt create mode 100644 android/build-logic/build.gradle.kts create mode 100644 android/build-logic/settings.gradle.kts create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidApplicationPlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidCommon.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidComposePlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidLibraryPlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitecturePlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/Catalog.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/HiltPlugin.kt create mode 100644 android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/JvmLibraryPlugin.kt create mode 100644 android/build.gradle.kts create mode 100644 android/core/design/build.gradle.kts create mode 100644 android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt create mode 100644 android/core/fakes/build.gradle.kts create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt create mode 100644 android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt create mode 100644 android/core/model/build.gradle.kts create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Accounts.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Analytics.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ids.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ledger.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Records.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/analytics/Analytics.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Allocation.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Balance.kt create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Split.kt create mode 100644 android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt create mode 100644 android/data/build.gradle.kts create mode 100644 android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt create mode 100644 android/domain/build.gradle.kts create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Allocation.kt create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Analytics.kt create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Balances.kt create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Limits.kt create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Split.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/ResolveRangeTest.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt create mode 100644 android/feature/account/build.gradle.kts create mode 100644 android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt create mode 100644 android/feature/analytics/build.gradle.kts create mode 100644 android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt create mode 100644 android/feature/expense/build.gradle.kts create mode 100644 android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt create mode 100644 android/feature/home/build.gradle.kts create mode 100644 android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt create mode 100644 android/feature/onboarding/build.gradle.kts create mode 100644 android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt create mode 100644 android/feature/settings/build.gradle.kts create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt create mode 100644 android/feature/settle/build.gradle.kts create mode 100644 android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..7b1ad25 --- /dev/null +++ b/android/README.md @@ -0,0 +1,60 @@ +# Kvitt Android project + +One Gradle project, modules as in SPEC §6.2 and §14.7. Build only through `tools/gradle-serial.sh` with +`PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH` (CLAUDE.md › Build and verify). The gate is `tools/gradle-serial.sh check`: +compile, unit tests (including every vector of `contracts/test-vectors/`), lint (`MissingTranslation` and +`ExtraTranslation` are errors), the architecture check and the screenshot tests. + +## Modules + +| Module | Plugin (build-logic) | Notes | +|---|---|---| +| `:core:model` | `kvitt.jvm.library` | UI contract (§14.2.4), domain types `model.money` (§14.5.1), analytics types `model.analytics` (§14.5.4) | +| `:domain` | `kvitt.jvm.library` | §14.5 functions; `VectorTest` runs every vector file, copied by `:domain:copyTestVectors` | +| `:core:design` | `kvitt.android.library` + `kvitt.android.compose` | `KvittTheme` placeholder; M6 owns the module | +| `:core:fakes` | `kvitt.android.library` | `FakeRepositories(scenario)`, `FakeScenarios.empty`; scenarios of feature X go in `fakes//` | +| `:data` | `kvitt.android.library` + KSP + Room 3 | Room entities and `KvittDatabase` v1, schema in `data/schemas/`; M5 owns the module | +| `:adapter:fcm` | `kvitt.android.library` | shell; M8 owns it | +| `:feature:*` | `kvitt.android.feature` | shells with one entry composable; M7a to M7f own them | +| `:app` | `kvitt.android.application` + compose + hilt + Roborazzi | Application, MainActivity, goldens in `app/src/test/screenshots/` | + +## Architecture check + +`architectureCheck` runs in every module's `check` (`build-logic/.../ArchitectureRules.kt` holds the rules as data). +It fails on a project dependency outside the §6.2 table, on `:core:fakes` in any configuration that is not a test or +debug configuration, and, in `:core:model` and `:domain`, on an Android plugin, on a library outside +`org.jetbrains.kotlin*`, and on any import or fully qualified reference outside `kotlin.*`, `kotlinx.*` and +`se.hagstedt.kvitt.*` in `src/main`. A new module must be added to `ArchitectureRules.allowed`. + +## Feature convention + +* Each feature module exposes **one composable entry point per screen**. By default it obtains its ViewModel with + `hiltViewModel()` (from `androidx.hilt:hilt-lifecycle-viewmodel-compose`) and takes navigation as plain lambdas, + for example: + + ```kotlin + @Composable + fun AccountDetailScreen( + onOpenExpense: (ExpenseId) -> Unit, + onAddExpense: (AccountId) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AccountDetailViewModel = hiltViewModel(), + ) + ``` + + A stateless overload that takes the `*State` and event lambdas is what previews and screenshot tests render. +* ViewModels are `@HiltViewModel` classes that receive repositories **through the interfaces of `:core:model`** only. + `:app` binds those interfaces to `:data` (release) or `:core:fakes` (debug and tests) with Hilt (M8). +* Features never reference another feature, the `NavHost` or `:data`/`:domain`. `:app` owns the `NavHost` and wires + the lambdas. +* Strings live in the feature's own `src/main/res/values/strings.xml` and `values-sv/strings.xml`, keys prefixed with + the feature name; shared strings in `:core:design`; `:app` holds only the app name. +* Previews and screenshot tests use `FakeRepositories(scenario)` from `:core:fakes` (`debugImplementation` and + `testImplementation`, added by the feature plugin). Fakes never reach a release build. + +## Screenshot tests + +Roborazzi on Robolectric. `roborazzi.test.verify=true` in `gradle.properties` makes every unit-test run compare with the +committed goldens. Re-record an intended change with `tools/gradle-serial.sh recordRoborazziDebug` and open every +changed PNG before committing. diff --git a/android/adapter/fcm/build.gradle.kts b/android/adapter/fcm/build.gradle.kts new file mode 100644 index 0000000..95c3651 --- /dev/null +++ b/android/adapter/fcm/build.gradle.kts @@ -0,0 +1,8 @@ +// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (owned by M8). M2b ships the shell. +plugins { + id("kvitt.android.library") +} + +dependencies { + implementation(project(":core:model")) +} diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt new file mode 100644 index 0000000..5572eca --- /dev/null +++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt @@ -0,0 +1,10 @@ +package se.hagstedt.kvitt.adapter.fcm + +/** + * Module shell. M8 adds the FirebaseMessagingService, token registration and the mapping of data messages to the + * project's own push events (SPEC §6.2, §14.6), once the Firebase licence and google-services.json are in place. + */ +internal object FcmAdapter { + /** Push payload version this adapter understands (SPEC §14.6). */ + const val PAYLOAD_VERSION: Int = 1 +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..8e2e799 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,53 @@ +// :app — MainActivity, Application, Hilt wiring; navigation, bindings, deep links and notifications arrive with M8. +plugins { + id("kvitt.android.application") + id("kvitt.android.compose") + id("kvitt.android.hilt") + alias(libs.plugins.roborazzi) +} + +android { + namespace = "se.hagstedt.kvitt" + defaultConfig { + applicationId = "se.hagstedt.kvitt" + // SPEC §10.6: the release script passes kvitt.versionCode and kvitt.versionName; debug builds use 1 and "dev". + versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt() + versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get() + } + buildTypes { + debug { + applicationIdSuffix = ".debug" + } + } +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:design")) + implementation(project(":domain")) + implementation(project(":data")) + implementation(project(":adapter:fcm")) + implementation(project(":feature:onboarding")) + implementation(project(":feature:home")) + implementation(project(":feature:account")) + implementation(project(":feature:expense")) + implementation(project(":feature:settle")) + implementation(project(":feature:analytics")) + implementation(project(":feature:settings")) + debugImplementation(project(":core:fakes")) + testImplementation(project(":core:fakes")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..19a64a1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt new file mode 100644 index 0000000..df82e56 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt @@ -0,0 +1,8 @@ +package se.hagstedt.kvitt + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** Hilt's root. M8 adds the bindings from the `:core:model` interfaces to `:data`. */ +@HiltAndroidApp +class KvittApplication : Application() diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittEmptyScreen.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittEmptyScreen.kt new file mode 100644 index 0000000..ac02d9f --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittEmptyScreen.kt @@ -0,0 +1,21 @@ +package se.hagstedt.kvitt + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource + +/** The M2b shell: the themed surface with the app name. M8 replaces it with the navigation graph. */ +@Composable +fun KvittEmptyScreen(modifier: Modifier = Modifier) { + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) + } + } +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt new file mode 100644 index 0000000..6a7d7c4 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt @@ -0,0 +1,21 @@ +package se.hagstedt.kvitt + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import se.hagstedt.kvitt.design.KvittTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + KvittTheme { + KvittEmptyScreen() + } + } + } +} diff --git a/android/app/src/main/res/values-sv/strings.xml b/android/app/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..bb2d897 --- /dev/null +++ b/android/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,4 @@ + + + Kvitt + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..83f5a49 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + Kvitt + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9f99804 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt new file mode 100644 index 0000000..56ba91a --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt @@ -0,0 +1,38 @@ +package se.hagstedt.kvitt + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.design.KvittTheme + +/** + * Golden screenshots of the empty screen in light and dark (Roborazzi on Robolectric). `check` verifies them; + * re-record an intended change with `tools/gradle-serial.sh recordRoborazziDebug` and open every changed PNG. + * Viewport: the Galaxy S26 (about 411 x 891 dp, SPEC §13.1). + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi") +class KvittEmptyScreenScreenshotTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun emptyScreenLight() = capture(darkTheme = false, name = "empty_screen_light") + + @Test + fun emptyScreenDark() = capture(darkTheme = true, name = "empty_screen_dark") + + private fun capture(darkTheme: Boolean, name: String) { + compose.setContent { + KvittTheme(darkTheme = darkTheme) { KvittEmptyScreen() } + } + compose.onRoot().captureRoboImage("src/test/screenshots/$name.png") + } +} diff --git a/android/build-logic/build.gradle.kts b/android/build-logic/build.gradle.kts new file mode 100644 index 0000000..a83b3d6 --- /dev/null +++ b/android/build-logic/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.compose.compiler.gradle.plugin) +} + +gradlePlugin { + plugins { + register("kvittJvmLibrary") { + id = "kvitt.jvm.library" + implementationClass = "se.hagstedt.kvitt.buildlogic.JvmLibraryPlugin" + } + register("kvittAndroidLibrary") { + id = "kvitt.android.library" + implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidLibraryPlugin" + } + register("kvittAndroidCompose") { + id = "kvitt.android.compose" + implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidComposePlugin" + } + register("kvittAndroidFeature") { + id = "kvitt.android.feature" + implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidFeaturePlugin" + } + register("kvittAndroidApplication") { + id = "kvitt.android.application" + implementationClass = "se.hagstedt.kvitt.buildlogic.AndroidApplicationPlugin" + } + register("kvittAndroidHilt") { + id = "kvitt.android.hilt" + implementationClass = "se.hagstedt.kvitt.buildlogic.HiltPlugin" + } + register("kvittArchitecture") { + id = "kvitt.architecture" + implementationClass = "se.hagstedt.kvitt.buildlogic.ArchitecturePlugin" + } + } +} diff --git a/android/build-logic/settings.gradle.kts b/android/build-logic/settings.gradle.kts new file mode 100644 index 0000000..011684d --- /dev/null +++ b/android/build-logic/settings.gradle.kts @@ -0,0 +1,14 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidApplicationPlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidApplicationPlugin.kt new file mode 100644 index 0000000..3c011f5 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidApplicationPlugin.kt @@ -0,0 +1,34 @@ +package se.hagstedt.kvitt.buildlogic + +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** The `:app` module: SDK levels from the catalog, Java 17 bytecode, shared lint rules. */ +class AndroidApplicationPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + pluginManager.apply("kvitt.architecture") + val catalog = libs + extensions.configure(ApplicationExtension::class.java) { + compileSdk = catalog.version("compileSdk").toInt() + defaultConfig { + minSdk = catalog.version("minSdk").toInt() + targetSdk = catalog.version("targetSdk").toInt() + } + compileOptions { + sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17 + targetCompatibility = org.gradle.api.JavaVersion.VERSION_17 + } + testOptions { + unitTests.isIncludeAndroidResources = true + } + lint { kvittLint(this) } + } + configureKotlinAndroid() + configureRobolectricJvm() + dependencies.add("testImplementation", catalog.library("junit")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidCommon.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidCommon.kt new file mode 100644 index 0000000..970a944 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidCommon.kt @@ -0,0 +1,47 @@ +package se.hagstedt.kvitt.buildlogic + +import com.android.build.api.dsl.Lint +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/** + * Lint rules shared by every Android module. A string key missing from `values-sv` (or present only there) fails the + * build (SPEC AC-19.1). No baseline file: findings are fixed, never silenced (CLAUDE.md › Anti-patterns). + */ +internal fun kvittLint(lint: Lint) { + lint.abortOnError = true + lint.checkReleaseBuilds = true + lint.error.add("MissingTranslation") + lint.error.add("ExtraTranslation") +} + +/** Kotlin compiled by the Android Gradle Plugin's built-in Kotlin support: Java 17 bytecode, warnings are errors. */ +internal fun Project.configureKotlinAndroid() { + tasks.withType(KotlinCompile::class.java).configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } + } +} + +/** + * A module without `src/test` (a module shell) has only the generated R classes on its unit-test classpath, which + * Gradle reports as "test sources present, no tests discovered". The guard stays on for every module with tests. + */ +internal fun Project.failOnNoTestsOnlyWhenTestsExist() { + val hasTests = file("src/test").exists() + tasks.withType(Test::class.java).configureEach { failOnNoDiscoveredTests.set(hasTests) } +} + +/** + * Robolectric on the pinned JDK 25 reads FileDescriptor internals through jdk.internal.access; the module system + * blocks that unless the package is exported to the test classpath. + */ +internal fun Project.configureRobolectricJvm() { + tasks.withType(Test::class.java).configureEach { + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", "--add-opens=java.base/java.io=ALL-UNNAMED") + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidComposePlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidComposePlugin.kt new file mode 100644 index 0000000..f87f79d --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidComposePlugin.kt @@ -0,0 +1,30 @@ +package se.hagstedt.kvitt.buildlogic + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** Jetpack Compose with Material 3 for an Android application or library module. */ +class AndroidComposePlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + pluginManager.withPlugin("com.android.library") { + extensions.configure(LibraryExtension::class.java) { buildFeatures.compose = true } + } + pluginManager.withPlugin("com.android.application") { + extensions.configure(ApplicationExtension::class.java) { buildFeatures.compose = true } + } + val catalog = libs + val bom = dependencies.platform(catalog.library("androidx-compose-bom")) + dependencies.add("implementation", bom) + dependencies.add("testImplementation", bom) + dependencies.add("implementation", catalog.library("androidx-compose-ui")) + dependencies.add("implementation", catalog.library("androidx-compose-ui-graphics")) + dependencies.add("implementation", catalog.library("androidx-compose-ui-tooling-preview")) + dependencies.add("implementation", catalog.library("androidx-compose-material3")) + dependencies.add("debugImplementation", catalog.library("androidx-compose-ui-tooling")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt new file mode 100644 index 0000000..34a3caa --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidFeaturePlugin.kt @@ -0,0 +1,28 @@ +package se.hagstedt.kvitt.buildlogic + +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Feature module (SPEC §6.2, convention in android/README.md): Compose screens against the UI contract only. + * Features see `:core:model` and `:core:design`; `:core:fakes` only for debug previews and tests, so fakes never reach + * a release build. ViewModels are Hilt ViewModels obtained with `hiltViewModel()`. + */ +class AndroidFeaturePlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("kvitt.android.library") + pluginManager.apply("kvitt.android.compose") + pluginManager.apply("kvitt.android.hilt") + val catalog = libs + dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:model"))) + dependencies.add("implementation", dependencies.project(mapOf("path" to ":core:design"))) + dependencies.add("debugImplementation", dependencies.project(mapOf("path" to ":core:fakes"))) + dependencies.add("testImplementation", dependencies.project(mapOf("path" to ":core:fakes"))) + dependencies.add("implementation", catalog.library("androidx-hilt-lifecycle-viewmodel-compose")) + dependencies.add("implementation", catalog.library("androidx-lifecycle-viewmodel-compose")) + dependencies.add("implementation", catalog.library("androidx-lifecycle-runtime-compose")) + dependencies.add("implementation", catalog.library("kotlinx-coroutines-core")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidLibraryPlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidLibraryPlugin.kt new file mode 100644 index 0000000..bc94fef --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/AndroidLibraryPlugin.kt @@ -0,0 +1,35 @@ +package se.hagstedt.kvitt.buildlogic + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** Android library module: SDK levels from the catalog, Java 17 bytecode, shared lint rules. */ +class AndroidLibraryPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("kvitt.architecture") + val catalog = libs + extensions.configure(LibraryExtension::class.java) { + namespace = kvittNamespace() + compileSdk = catalog.version("compileSdk").toInt() + defaultConfig { + minSdk = catalog.version("minSdk").toInt() + } + compileOptions { + sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17 + targetCompatibility = org.gradle.api.JavaVersion.VERSION_17 + } + testOptions { + unitTests.isIncludeAndroidResources = true + } + lint { kvittLint(this) } + } + configureKotlinAndroid() + configureRobolectricJvm() + failOnNoTestsOnlyWhenTestsExist() + dependencies.add("testImplementation", catalog.library("junit")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt new file mode 100644 index 0000000..2c4e551 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureCheckTask.kt @@ -0,0 +1,97 @@ +package se.hagstedt.kvitt.buildlogic + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Fails the build on a dependency edge outside SPEC §6.2, on `:core:fakes` in a release configuration, and on + * imports or fully qualified references outside kotlin.*, kotlinx.* and the project's packages in a pure module. + */ +@CacheableTask +abstract class ArchitectureCheckTask : DefaultTask() { + @get:Input + abstract val modulePath: Property + + /** Declared project dependencies as `configuration|:path`. */ + @get:Input + abstract val projectDependencies: ListProperty + + /** Declared external dependencies of the main configurations as `configuration|group:name`. */ + @get:Input + abstract val externalDependencies: ListProperty + + @get:Input + abstract val androidPluginApplied: Property + + /** Main source files of the module (scanned only for pure modules). */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val mainSources: ConfigurableFileCollection + + @get:OutputFile + abstract val report: RegularFileProperty + + @TaskAction + fun check() { + val path = modulePath.get() + val violations = mutableListOf() + if (!ArchitectureRules.allowed.containsKey(path)) { + violations += "$path is not in ArchitectureRules.allowed; add it with its allowed edges (SPEC §6.2)" + } + val allowed = ArchitectureRules.allowed[path] + for (entry in projectDependencies.get()) { + val (configuration, target) = entry.split('|', limit = 2) + if (target == path) continue + if (allowed != null && target !in allowed) { + violations += "$path -> $target ($configuration) is not an allowed edge (SPEC §6.2)" + } + if (target == ArchitectureRules.FAKES && !ArchitectureRules.isTestOrDebugConfiguration(configuration)) { + violations += "$path -> $target in '$configuration': fakes only in test or debug configurations" + } + } + if (path in ArchitectureRules.pureModules) { + if (androidPluginApplied.get()) violations += "$path is pure Kotlin/JVM and must not apply an Android plugin" + for (entry in externalDependencies.get()) { + val (configuration, coordinate) = entry.split('|', limit = 2) + if (ArchitectureRules.pureExternalGroups.none { coordinate.startsWith("$it.") || coordinate.startsWith("$it:") }) { + violations += "$path uses $coordinate in '$configuration'; pure modules use only kotlin and kotlinx libraries" + } + } + mainSources.files.sortedBy { it.path }.forEach { file -> + file.readLines().forEachIndexed { index, raw -> + val line = raw.trim() + val where = "${file.name}:${index + 1}" + val import = IMPORT.find(line)?.groupValues?.get(1) + if (import != null) { + if (ArchitectureRules.pureImportPrefixes.none { import.startsWith(it) }) { + violations += "$path $where imports $import (only kotlin.*, kotlinx.*, se.hagstedt.kvitt.*)" + } + } else if (!line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*")) { + QUALIFIED.find(line)?.let { violations += "$path $where references ${it.value} (platform API)" } + } + } + } + } + val text = if (violations.isEmpty()) "OK $path\n" else violations.joinToString("\n", postfix = "\n") + report.get().asFile.apply { parentFile.mkdirs() }.writeText(text) + if (violations.isNotEmpty()) { + throw GradleException("Architecture check failed:\n" + violations.joinToString("\n") { " $it" }) + } + } + + private companion object { + val IMPORT = Regex("""^import\s+([\w.]+)""") + val QUALIFIED = Regex("""(? { + override fun apply(target: Project) { + with(target) { + pluginManager.apply(BasePlugin::class.java) + val configurations = configurations + val projectPath = path + val task = tasks.register("architectureCheck", ArchitectureCheckTask::class.java) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Checks module dependency edges and pure-module imports (SPEC §6.2)." + modulePath.set(projectPath) + projectDependencies.set(provider { + configurations.flatMap { c -> + c.dependencies.withType(ProjectDependency::class.java).map { "${c.name}|${it.path}" } + }.distinct().sorted() + }) + externalDependencies.set(provider { + configurations.filter { it.name in ArchitectureRules.mainConfigurations }.flatMap { c -> + c.dependencies.withType(ExternalModuleDependency::class.java).map { "${c.name}|${it.group}:${it.name}" } + }.distinct().sorted() + }) + androidPluginApplied.set(provider { pluginManager.hasPlugin("com.android.library") || pluginManager.hasPlugin("com.android.application") }) + mainSources.from(fileTree("src/main") { include("**/*.kt", "**/*.java") }) + report.set(layout.buildDirectory.file("reports/architecture/check.txt")) + } + tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME) { dependsOn(task) } + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt new file mode 100644 index 0000000..f65c3d3 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/ArchitectureRules.kt @@ -0,0 +1,40 @@ +package se.hagstedt.kvitt.buildlogic + +/** + * The module graph of SPEC §6.2 and CLAUDE.md › Architecture rules, as data. One entry per module; a module missing + * here fails the check, so a new module must be added on purpose. + */ +object ArchitectureRules { + const val FAKES = ":core:fakes" + + private val features = listOf("onboarding", "home", "account", "expense", "settle", "analytics", "settings") + .map { ":feature:$it" } + + /** Pure Kotlin/JVM modules: no Android, no java.time, no java.io; only kotlin.*, kotlinx.* and project imports. */ + val pureModules: Set = setOf(":core:model", ":domain") + + /** Allowed project dependencies per module. `null` means any module (only `:app`). */ + val allowed: Map?> = buildMap { + put(":core:model", emptySet()) + put(":domain", setOf(":core:model")) + put(":core:design", setOf(":core:model")) + put(FAKES, setOf(":core:model", ":domain")) + put(":data", setOf(":core:model", ":domain")) + put(":adapter:fcm", setOf(":core:model")) + features.forEach { put(it, setOf(":core:model", ":core:design", FAKES)) } + put(":app", null) + } + + /** Package prefixes a pure module may import. */ + val pureImportPrefixes: List = listOf("kotlin.", "kotlinx.", "se.hagstedt.kvitt.") + + /** Group prefixes of external libraries a pure module may use in its main code. */ + val pureExternalGroups: List = listOf("org.jetbrains.kotlin", "org.jetbrains.kotlinx") + + /** Configurations that feed main (release) code; everything else is a test, debug or tool configuration. */ + val mainConfigurations: Set = setOf("api", "implementation", "compileOnly", "compileOnlyApi", "runtimeOnly") + + /** `:core:fakes` may appear only in test and debug configurations (fakes never reach a release build). */ + fun isTestOrDebugConfiguration(name: String): Boolean = + name.contains("test", ignoreCase = true) || name.contains("debug", ignoreCase = true) +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/Catalog.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/Catalog.kt new file mode 100644 index 0000000..2677d5d --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/Catalog.kt @@ -0,0 +1,19 @@ +package se.hagstedt.kvitt.buildlogic + +import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.Provider + +internal val Project.libs: VersionCatalog + get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + +internal fun VersionCatalog.version(name: String): String = + findVersion(name).orElseThrow { IllegalStateException("libs.versions.toml has no version '$name'") }.requiredVersion + +internal fun VersionCatalog.library(alias: String): Provider = + findLibrary(alias).orElseThrow { IllegalStateException("libs.versions.toml has no library '$alias'") } + +/** Namespace of an Android module, derived from its path: `:feature:home` -> `se.hagstedt.kvitt.feature.home`. */ +internal fun Project.kvittNamespace(): String = "se.hagstedt.kvitt." + path.removePrefix(":").replace(':', '.') diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/HiltPlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/HiltPlugin.kt new file mode 100644 index 0000000..81126ec --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/HiltPlugin.kt @@ -0,0 +1,16 @@ +package se.hagstedt.kvitt.buildlogic + +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** Hilt with KSP for an Android module. */ +class HiltPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + pluginManager.apply("com.google.dagger.hilt.android") + dependencies.add("implementation", libs.library("hilt-android")) + dependencies.add("ksp", libs.library("hilt-compiler")) + } + } +} diff --git a/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/JvmLibraryPlugin.kt b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/JvmLibraryPlugin.kt new file mode 100644 index 0000000..cf95204 --- /dev/null +++ b/android/build-logic/src/main/kotlin/se/hagstedt/kvitt/buildlogic/JvmLibraryPlugin.kt @@ -0,0 +1,32 @@ +package se.hagstedt.kvitt.buildlogic + +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension + +/** Pure Kotlin/JVM module (`:core:model`, `:domain`): no Android, bytecode Java 17 (SPEC §6.2). */ +class JvmLibraryPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.jvm") + pluginManager.apply("kvitt.architecture") + extensions.configure(JavaPluginExtension::class.java) { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + tasks.withType(JavaCompile::class.java).configureEach { options.release.set(17) } + extensions.configure(KotlinJvmProjectExtension::class.java) { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xjdk-release=17") + allWarningsAsErrors.set(true) + } + } + dependencies.add("testImplementation", libs.library("junit")) + } + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..9306532 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,12 @@ +// Plugins are declared once here (apply false) so every module resolves them from the same classloader. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.room3) apply false + alias(libs.plugins.roborazzi) apply false +} diff --git a/android/core/design/build.gradle.kts b/android/core/design/build.gradle.kts new file mode 100644 index 0000000..bfd942b --- /dev/null +++ b/android/core/design/build.gradle.kts @@ -0,0 +1,9 @@ +// :core:design — theme, tokens, shared components and formatters. M2b ships only the placeholder theme; M6 owns it. +plugins { + id("kvitt.android.library") + id("kvitt.android.compose") +} + +dependencies { + api(project(":core:model")) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt new file mode 100644 index 0000000..5f3822b --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt @@ -0,0 +1,16 @@ +package se.hagstedt.kvitt.design + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +/** + * Placeholder theme from M2b: Material 3 baseline colours, light and dark following the system (SPEC §13.1). + * M6 replaces the colour scheme, typography and shapes with the tokens of docs/ux/design-spec.md. + */ +@Composable +fun KvittTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + MaterialTheme(colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content) +} diff --git a/android/core/fakes/build.gradle.kts b/android/core/fakes/build.gradle.kts new file mode 100644 index 0000000..e86fef0 --- /dev/null +++ b/android/core/fakes/build.gradle.kts @@ -0,0 +1,12 @@ +// :core:fakes — scenario-driven fakes of every repository interface of SPEC §14.2.4. Only for tests, screenshot tests +// and debug previews (the architecture check rejects it in release configurations). Computed values come from :domain. +// M7x add their scenarios under src/main/kotlin/se/hagstedt/kvitt/fakes// and nowhere else (SPEC §11.3). +plugins { + id("kvitt.android.library") +} + +dependencies { + api(project(":core:model")) + implementation(project(":domain")) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt new file mode 100644 index 0000000..63333d8 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt @@ -0,0 +1,107 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDate +import se.hagstedt.kvitt.domain.resolveRange +import se.hagstedt.kvitt.domain.spendSeries +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AnalyticsFilter +import se.hagstedt.kvitt.model.AnalyticsRepository +import se.hagstedt.kvitt.model.AnalyticsState +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.DrillDownState +import se.hagstedt.kvitt.model.GroupBy +import se.hagstedt.kvitt.model.GroupKey +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.Measure +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.SpendBucket +import se.hagstedt.kvitt.model.analytics.AnalyticsExpense +import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity +import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy +import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure +import se.hagstedt.kvitt.model.analytics.DateRange + +/** FR-14 from the scenario's non-deleted expenses through `resolveRange` and `spendSeries` (§14.5.4). */ +class FakeAnalyticsRepository(private val store: FakeStore) : AnalyticsRepository { + override fun analytics(filter: AnalyticsFilter): Flow = store.derive { s -> + val range = s.range(filter) + val expenses = s.analyticsExpenses() + val measure = s.measure(filter.measure) + val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK + val chosen = if (filter.groupBy == GroupBy.CATEGORY) AnalyticsGroupBy.CATEGORY else AnalyticsGroupBy.ACCOUNT + val series = spendSeries(expenses, range, granularity, measure, chosen) + val byCategory = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.CATEGORY).groupTotals + val byAccount = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.ACCOUNT).groupTotals + fun ranked(totals: Map, groupBy: GroupBy) = + totals.entries.sortedByDescending { it.value }.map { s.groupKey(it.key, groupBy) to it.value } + AnalyticsState( + filter = filter, + start = range.start.toString(), + end = range.endInclusive.toString(), + groups = ranked(series.groupTotals, filter.groupBy).map { it.first }, + buckets = series.buckets.map { SpendBucket(it.key, it.start.toString(), it.endInclusive.toString(), it.totals, it.total) }, + byCategory = ranked(byCategory, GroupBy.CATEGORY), + byAccount = ranked(byAccount, GroupBy.ACCOUNT), + totalOre = series.grandTotal, + ) + } + + override fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow = + store.derive { s -> + val range = s.range(filter) + val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK + val bucket = bucketKey?.let { key -> + spendSeries(emptyList(), range, granularity, AnalyticsMeasure.Household, AnalyticsGroupBy.CATEGORY).buckets.firstOrNull { it.key == key } + } + val start = bucket?.start ?: range.start + val end = bucket?.endInclusive ?: range.endInclusive + val matching = s.expenses.filter { e -> + val date = LocalDate.parse(e.date) + !e.deleted && date >= start && date <= end && + (categoryId == null || e.categoryId == categoryId) && (accountId == null || e.accountId == accountId) + } + val measured = matching.sumOf { e -> + if (filter.measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == s.me.id }?.shareOre ?: 0L + } + // TODO(spec): DrillDownState.title is a String but S-12's title ("Mat & hushåll, oktober 2026") needs the + // UI's localised names and month format; the fakes join the raw parts (group label or key, bucket key). + val title = listOfNotNull( + categoryId?.let { id -> s.category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } }, + accountId?.let { id -> s.account(id)?.name }, + bucketKey, + ).joinToString(", ") + DrillDownState( + title = title, + totalOre = measured, + rows = matching.sortedWith { a, b -> ledgerOrder.compare(a.date to a.createdAt, b.date to b.createdAt) } + .mapNotNull { s.expenseRow(it) }, + ) + } +} + +private fun FakeScenario.analyticsExpenses(): List = expenses.filter { !it.deleted }.map { e -> + AnalyticsExpense(LocalDate.parse(e.date), e.accountId.value, e.categoryId.value, e.amountOre, e.shares.associate { it.userId.value to it.shareOre }) +} + +private fun FakeScenario.measure(measure: Measure): AnalyticsMeasure = + if (measure == Measure.HOUSEHOLD) AnalyticsMeasure.Household else AnalyticsMeasure.Share(me.id.value) + +/** + * AC-14.2 via `resolveRange`. The UI only sends CUSTOM with both dates; the real repository maps + * IncompleteCustomRangeException to `validation_failed`. The fakes fill a missing custom date with today instead, since + * AnalyticsState has no error field (TODO(spec): how an analytics flow reports `validation_failed`, §14.2.4). + */ +private fun FakeScenario.range(filter: AnalyticsFilter): DateRange { + val today = LocalDate.parse(today) + val earliest = expenses.filter { !it.deleted }.minOfOrNull { it.date }?.let { LocalDate.parse(it) } + val custom = filter.preset == RangePreset.CUSTOM + val start = filter.customStart?.let { LocalDate.parse(it) } ?: today.takeIf { custom } + val end = filter.customEnd?.let { LocalDate.parse(it) } ?: today.takeIf { custom } + return resolveRange(filter.preset, today, earliest, start, end) +} + +private fun FakeScenario.groupKey(id: String, groupBy: GroupBy): GroupKey = when (groupBy) { + GroupBy.CATEGORY -> categories.firstOrNull { it.id.value == id }.let { GroupKey(id, it?.customName, it?.color) } + GroupBy.ACCOUNT -> GroupKey(id, accounts.firstOrNull { it.id.value == id }?.name, null) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt new file mode 100644 index 0000000..7c10b2e --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt @@ -0,0 +1,176 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import se.hagstedt.kvitt.domain.BP_TOTAL +import se.hagstedt.kvitt.domain.computeSplit +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.ExpenseDetailState +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.model.money.SplitError +import se.hagstedt.kvitt.model.money.SplitResult +import se.hagstedt.kvitt.model.money.SplitSpec + +class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository { + override fun editorOptions(): Flow = store.derive { s -> + val partner = s.partner ?: return@derive null + val active = s.accountsInOrder().filter { !it.archived } + val categories = s.categories.filter { !it.archived }.sortedBy { it.sortOrder } + val other = categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER } + ExpenseEditorOptions( + me = s.me, + partner = partner, + accounts = active, + categories = categories, + // AC-8.6: the account last used on this phone if still active, else the oldest active account. + defaultAccountId = s.defaultAccountId?.takeIf { id -> active.any { it.id == id } } ?: active.firstOrNull()?.id, + // AC-8.6: the category last used in that account if still active, else `other`. + defaultCategoryFor = active.mapNotNull { account -> + val last = s.lastCategoryFor[account.id]?.takeIf { id -> categories.any { it.id == id } } + (last ?: other?.id)?.let { account.id to it } + }.toMap(), + today = s.today, + ) + }.filterNotNull() + + override suspend fun load(id: ExpenseId): ExpenseDraft? { + val e = store.current.expenses.firstOrNull { it.id == id && !it.deleted } ?: return null + return ExpenseDraft( + id = e.id, + accountId = e.accountId, + categoryId = e.categoryId, + amountOre = e.amountOre, + description = e.description, + notes = e.notes, + date = e.date, + payer = e.payer, + mode = e.mode, + ratioBp = if (e.mode == SplitMode.RATIO) e.shares.associate { it.userId to (it.shareBp ?: 0) } else null, + exactOre = if (e.mode == SplitMode.EXACT) e.shares.associate { it.userId to it.shareOre } else null, + fullOn = if (e.mode == SplitMode.FULL) e.shares.firstOrNull { it.shareBp == BP_TOTAL }?.userId else null, + ) + } + + override fun preview(draft: ExpenseDraft): List? = (store.current.split(draft) as? SplitOutcome.Ok)?.shares + + override suspend fun save(draft: ExpenseDraft): SaveResult { + val s = store.current + val amountOre = draft.amountOre + val accountId = draft.accountId?.takeIf { s.account(it) != null } + val categoryId = draft.categoryId?.takeIf { s.category(it) != null } + val errors = mutableListOf() + if (amountOre == null) errors += FieldError("amountOre", "required") + if (accountId == null) errors += FieldError("accountId", "required") + if (categoryId == null) errors += FieldError("categoryId", "required") + // TODO(spec): §14.2.4 names only domain tokens and "required" for FieldError.error; the §9.6 length and date + // limits have no domain token, so the fakes use the REST code `validation_failed` for them. + when (draft.description.codePointLength()) { + 0 -> errors += FieldError("description", "required") + !in 1..100 -> errors += FieldError("description", "validation_failed") + } + if (draft.notes.codePointLength() > 1000) errors += FieldError("notes", "validation_failed") + if (!s.validDate(draft.date)) errors += FieldError("date", "validation_failed") + val outcome = s.split(draft) + if (outcome is SplitOutcome.Err) { + val field = if (outcome.error == SplitError.AMOUNT_OUT_OF_RANGE) "amountOre" else "split" + errors += FieldError(field, outcome.error.name.lowercase()) + } + if (errors.isNotEmpty() || outcome !is SplitOutcome.Ok || amountOre == null || accountId == null || categoryId == null) { + return SaveResult.Invalid(errors) + } + + val existing = draft.id?.let { id -> s.expenses.firstOrNull { it.id == id } } + val id = draft.id ?: ExpenseId(store.newId()) + val saved = FakeExpense( + id = id, + accountId = accountId, + categoryId = categoryId, + amountOre = amountOre, + description = draft.description.trim(), + notes = draft.notes, + date = draft.date, + payer = draft.payer, + mode = draft.mode, + shares = outcome.shares, + createdAt = existing?.createdAt ?: s.now, + recurringTemplateId = existing?.recurringTemplateId, + pending = true, + timeline = existing?.timeline.orEmpty(), + ) + store.edit { scenario -> + scenario.copy( + expenses = scenario.expenses.filter { it.id != id } + saved, + defaultAccountId = accountId, + lastCategoryFor = scenario.lastCategoryFor + (accountId to categoryId), + ) + } + return SaveResult.Saved(id) + } + + override suspend fun delete(id: ExpenseId) = store.edit { s -> + s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it }) + } + + override fun detail(id: ExpenseId): Flow = store.derive { s -> + val e = s.expenses.firstOrNull { it.id == id } ?: return@derive null + val account = s.account(e.accountId) ?: return@derive null + val category = s.category(e.categoryId) ?: return@derive null + val payer = s.user(e.payer) ?: return@derive null + ExpenseDetailState( + id = e.id, + account = account, + category = category, + amountOre = e.amountOre, + description = e.description, + notes = e.notes, + date = e.date, + payer = payer, + mode = e.mode, + shares = e.shares, + recurringTemplateName = e.recurringTemplateId?.let { t -> s.templates.firstOrNull { it.id == t }?.draft?.description }, + deleted = e.deleted, + pending = e.pending, + timeline = s.timelineWithPending(e.timeline, e.pending, e.deleted), + ) + } +} + +internal sealed interface SplitOutcome { + data class Ok(val shares: List) : SplitOutcome + data class Err(val error: SplitError) : SplitOutcome + data object Incomplete : SplitOutcome +} + +/** §9.1 through `computeSplit`; mode `default` copies the account's default ratio (AC-8.5). */ +internal fun FakeScenario.split(draft: ExpenseDraft): SplitOutcome { + val amount = draft.amountOre ?: return SplitOutcome.Incomplete + val userIds = users.map { it.id } + val bp: Map? = when (draft.mode) { + SplitMode.DEFAULT -> draft.accountId?.let { account(it) }?.defaultShares ?: return SplitOutcome.Incomplete + SplitMode.RATIO -> draft.ratioBp ?: return SplitOutcome.Incomplete + SplitMode.FULL -> { + val bearer = draft.fullOn ?: return SplitOutcome.Incomplete + userIds.associateWith { if (it == bearer) BP_TOTAL else 0 } + } + SplitMode.EXACT -> null + } + val spec = when (draft.mode) { + SplitMode.EXACT -> SplitSpec.Exact((draft.exactOre ?: return SplitOutcome.Incomplete).mapKeys { it.key.value }) + SplitMode.FULL -> SplitSpec.Full((draft.fullOn ?: return SplitOutcome.Incomplete).value) + else -> SplitSpec.Ratio(bp.orEmpty().mapKeys { it.key.value }) + } + return when (val result = computeSplit(amount, draft.payer.value, userIds.map { it.value }, spec)) { + is SplitResult.Err -> SplitOutcome.Err(result.error) + is SplitResult.Ok -> SplitOutcome.Ok( + userIds.map { u -> Share(u, result.shares[u.value] ?: 0L, bp?.get(u)) }, + ) + } +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt new file mode 100644 index 0000000..ee5e36e --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt @@ -0,0 +1,158 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountDetailState +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.BalanceView +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.HomeState +import se.hagstedt.kvitt.model.LedgerMonth +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.UpdateInfo +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.domain.BP_TOTAL + +class FakeSessionRepository(private val store: FakeStore) : SessionRepository { + override val session: Flow = store.derive { it.session } + + override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult = + store.online { s -> + store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) } + OnlineResult.Ok(s.me) + } + + override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) } +} + +internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary( + lastSuccessAt = lastSuccessAt, + pendingCount = expenses.count { it.pending } + settlements.count { it.pending }, + rejectedCount = rejections.size, + clockSkew = clockSkew, + running = syncRunning, +) + +internal fun UpdateState.info(): UpdateInfo? = when (this) { + UpdateState.None -> null + is UpdateState.Available -> info + is UpdateState.Downloading -> info + is UpdateState.Failed -> info + is UpdateState.NeedsInstallPermission -> info +} + +class FakeHomeRepository(private val store: FakeStore) : HomeRepository { + override fun home(): Flow = store.derive { s -> + val balances = s.balances() + val month = s.today.take(7) + val summaries = s.accountsInOrder().map { account -> + AccountSummary( + account = account, + netOre = balances.net(account.id, s.me.id), + expensesThisMonth = s.expenses.count { !it.deleted && it.accountId == account.id && it.date.startsWith(month) }, + ) + } + HomeState( + me = s.me, + partner = s.partner, + global = s.partner?.let { BalanceView(balances.globalNet(s.me.id), it) }, + active = summaries.filter { !it.account.archived }, + archived = summaries.filter { it.account.archived }, + sync = s.syncSummary(), + update = s.update.info(), + ) + } +} + +class FakeAccountRepository(private val store: FakeStore) : AccountRepository { + override fun detail(id: AccountId): Flow = store.derive { s -> + val account = s.account(id) ?: return@derive null + val partner = s.partner ?: return@derive null + AccountDetailState( + account = account, + balance = BalanceView(s.balances().net(id, s.me.id), partner), + months = s.ledgerMonths(id), + deletable = s.accountDeletable(id), + users = s.users, + ) + } + + override fun all(): Flow> = store.derive { it.accountsInOrder() } + + override suspend fun create(name: String, defaultShares: Map): OnlineResult = store.online { s -> + s.validateAccount(null, name, defaultShares) ?: run { + val id = AccountId(store.newId()) + store.edit { it.copy(accounts = it.accounts + Account(id, name.trim(), defaultShares, it.now, archived = false)) } + OnlineResult.Ok(id) + } + } + + override suspend fun update(id: AccountId, name: String, defaultShares: Map): OnlineResult = store.online { s -> + if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null) + s.validateAccount(id, name, defaultShares) ?: run { + store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(name = name.trim(), defaultShares = defaultShares) else a }) } + OnlineResult.Ok(Unit) + } + } + + override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult = store.online { s -> + if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null) + store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(archived = archived) else a }) } + OnlineResult.Ok(Unit) + } + + override suspend fun delete(id: AccountId): OnlineResult = store.online { s -> + when { + s.account(id) == null -> OnlineResult.Failed("not_found", null) + !s.accountDeletable(id) -> OnlineResult.Failed("account_not_empty", null) + else -> { + store.edit { it.copy(accounts = it.accounts.filter { a -> a.id != id }) } + OnlineResult.Ok(Unit) + } + } + } +} + +/** AC-6.1, AC-6.2, AC-5.2, §9.6: the server's answers to an account write, or null when it is accepted. */ +private fun FakeScenario.validateAccount(id: AccountId?, name: String, shares: Map): OnlineResult.Failed? { + val key = name.trim().lowercase() + return when { + partner == null -> OnlineResult.Failed("household_incomplete", null) + name.codePointLength() !in 1..60 -> OnlineResult.Failed("validation_failed", "name") + shares.keys != users.map { it.id }.toSet() || shares.values.any { it !in 0..BP_TOTAL } || shares.values.sum() != BP_TOTAL -> + OnlineResult.Failed("validation_failed", "default_shares") + accounts.any { it.id != id && it.name.trim().lowercase() == key } -> OnlineResult.Failed("name_taken", "name") + else -> null + } +} + +/** AC-6.5 */ +internal fun FakeScenario.accountDeletable(id: AccountId): Boolean = + expenses.none { !it.deleted && it.accountId == id } && + settlements.none { !it.deleted && id in it.allocations } && + templates.none { it.draft.accountId == id } + +/** + * S-5 / AC-10.3: expenses and settlements of one account interleaved by date, grouped by month, newest first; the + * month total is the household spend of the account's expenses in that month. + */ +private fun FakeScenario.ledgerMonths(id: AccountId): List { + val expenseRows = expenses.filter { !it.deleted && it.accountId == id } + .mapNotNull { e -> expenseRow(e)?.let { Triple, LedgerRow, Long>(e.date to e.createdAt, it, e.amountOre) } } + // TODO(spec): §14.2.4 does not say whether a settlement row in an account list carries the settlement's total + // or its allocation to that account; the fakes show the total, with the account count, as AC-11.7 describes. + val settlementRows = settlements.filter { !it.deleted && id in it.allocations } + .mapNotNull { s -> settlementRow(s)?.let { Triple, LedgerRow, Long>(s.date to s.createdAt, it, 0L) } } + return (expenseRows + settlementRows) + .sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) } + .groupBy { it.first.first.take(7) } + .map { (period, rows) -> LedgerMonth(period, rows.sumOf { it.third }, rows.map { it.second }) } +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt new file mode 100644 index 0000000..78b19ae --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt @@ -0,0 +1,21 @@ +package se.hagstedt.kvitt.fakes + +/** One fake of every repository interface of SPEC §14.2.4, all backed by the same [store] and [scenario]. */ +class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) { + val store = FakeStore(scenario) + val session = FakeSessionRepository(store) + val home = FakeHomeRepository(store) + val accounts = FakeAccountRepository(store) + val expenses = FakeExpenseRepository(store) + val settlements = FakeSettlementRepository(store) + val analytics = FakeAnalyticsRepository(store) + val profile = FakeProfileRepository(store) + val categories = FakeCategoryRepository(store) + val recurring = FakeRecurringRepository(store) + val devices = FakeDeviceRepository(store) + val invites = FakeInviteRepository(store) + val sync = FakeSyncRepository(store) + val update = FakeUpdateRepository(store) + val about = FakeAboutRepository(store) + val connectivity = FakeConnectivityRepository(store) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt new file mode 100644 index 0000000..33770e2 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt @@ -0,0 +1,92 @@ +package se.hagstedt.kvitt.fakes + +import se.hagstedt.kvitt.model.AboutState +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.DeviceId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.Invite +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Rejection +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +/** An expense as the fakes hold it: the stored row of SPEC §14.2.1 in model types. */ +data class FakeExpense( + val id: ExpenseId, + val accountId: AccountId, + val categoryId: CategoryId, + val amountOre: Long, + val description: String, + val notes: String, + val date: String, + val payer: UserId, + val mode: SplitMode, + val shares: List, + val createdAt: String, + val recurringTemplateId: TemplateId? = null, + val deleted: Boolean = false, + val pending: Boolean = false, + val timeline: List = emptyList(), +) + +/** A settlement as the fakes hold it; allocations in their stored order. */ +data class FakeSettlement( + val id: SettlementId, + val from: UserId, + val to: UserId, + val amountOre: Long, + val date: String, + val note: String, + val allocations: Map, + val createdAt: String, + val deleted: Boolean = false, + val pending: Boolean = false, + val timeline: List = emptyList(), +) + +/** + * Everything the fakes know. A scenario is plain data; the fake repositories derive every `*State` from it with the + * rules in `:domain`, so fakes never disagree with the real rules (SPEC §6.2). + * + * @property me the viewer; [partner] is null until the partner has joined (AC-5.1). + * @property today the phone's local date (`YYYY-MM-DD`); [now] the phone's clock in the fixed RFC 3339 form. + * @property defaultAccountId the account last used on this phone (AC-8.6); [lastCategoryFor] the category last used + * per account on this phone. + */ +data class FakeScenario( + val me: User, + val partner: User?, + val deviceId: DeviceId, + val session: SessionState, + val accounts: List, + val categories: List, + val expenses: List, + val settlements: List, + val templates: List, + val devices: List, + val invites: List, + val rejections: List, + val lastSuccessAt: String?, + val clockSkew: Boolean, + val syncRunning: Boolean, + val update: UpdateState, + val about: AboutState, + val online: Boolean, + val today: String, + val now: String, + val defaultAccountId: AccountId?, + val lastCategoryFor: Map, +) { + val users: List get() = listOfNotNull(me, partner) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt new file mode 100644 index 0000000..bd65c76 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt @@ -0,0 +1,77 @@ +package se.hagstedt.kvitt.fakes + +import se.hagstedt.kvitt.model.AboutState +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.DeviceId +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +/** Shared scenarios. Feature scenarios live in `fakes//` (SPEC §11.3). */ +object FakeScenarios { + val owner = User(UserId("01920000-0000-7000-8000-0000000000a1"), Role.ADMIN, "Jacob", null) + val partner = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna", null) + private val ownerDevice = DeviceId("01920000-0000-7000-8000-0000000000d1") + + /** The ten seeded default categories (SPEC AC-7.2, ids of §14.1). */ + val defaultCategories: List = listOf( + Triple(DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN), + Triple(DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE), + Triple(DefaultCategoryKey.HOME, "home", ColorToken.BROWN), + Triple(DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW), + Triple(DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE), + Triple(DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL), + Triple(DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE), + Triple(DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED), + Triple(DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK), + Triple(DefaultCategoryKey.OTHER, "category", ColorToken.GREY), + ).mapIndexed { index, (key, icon, color) -> + Category( + id = CategoryId("01920000-0000-7000-8000-" + (index + 1).toString(16).padStart(12, '0')), + defaultKey = key, + customName = null, + icon = icon, + color = color, + sortOrder = (index + 1) * 10, + archived = false, + ) + } + + /** + * The empty scenario: the household is complete (owner and partner, no pictures), online, synced, no accounts, the + * ten default categories only, no expenses, settlements, templates, invites or rejections. Every required field + * of every `*State` is satisfied. + */ + val empty = FakeScenario( + me = owner, + partner = partner, + deviceId = ownerDevice, + session = SessionState.Paired(owner, partner, ownerDevice), + accounts = emptyList(), + categories = defaultCategories, + expenses = emptyList(), + settlements = emptyList(), + templates = emptyList(), + devices = listOf( + Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, current = true, revoked = false), + ), + invites = emptyList(), + rejections = emptyList(), + lastSuccessAt = "2026-10-17T06:00:00.000Z", + clockSkew = false, + syncRunning = false, + update = UpdateState.None, + about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null), + online = true, + today = "2026-10-17", + now = "2026-10-17T06:00:00.000Z", + defaultAccountId = null, + lastCategoryFor = emptyMap(), + ) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt new file mode 100644 index 0000000..47a2adf --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt @@ -0,0 +1,292 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus +import se.hagstedt.kvitt.model.AboutRepository +import se.hagstedt.kvitt.model.AboutState +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryIcons +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.DeviceId +import se.hagstedt.kvitt.model.DeviceRepository +import se.hagstedt.kvitt.model.Invite +import se.hagstedt.kvitt.model.InviteId +import se.hagstedt.kvitt.model.InviteKind +import se.hagstedt.kvitt.model.InviteRepository +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.SyncStatusState +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.UpdateRepository +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +class FakeProfileRepository(private val store: FakeStore) : ProfileRepository { + override fun household(): Flow> = store.derive { it.users } + + override suspend fun setDisplayName(name: String): OnlineResult = store.online { + if (name.codePointLength() !in 1..40) return@online OnlineResult.Failed("validation_failed", "display_name") + store.editMe { it.copy(displayName = name.trim()) } + OnlineResult.Ok(Unit) + } + + override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult = store.online { + if (mimeType !in setOf("image/jpeg", "image/png", "image/webp")) return@online OnlineResult.Failed("unsupported_media_type", null) + if (bytes.size > 5 * 1024 * 1024) return@online OnlineResult.Failed("payload_too_large", null) + store.editMe { it.copy(pictureVersion = (it.pictureVersion ?: 0) + 1) } + OnlineResult.Ok(Unit) + } + + override suspend fun removePicture(): OnlineResult = store.online { + store.editMe { it.copy(pictureVersion = null) } + OnlineResult.Ok(Unit) + } +} + +private fun FakeStore.editMe(change: (User) -> User) = edit { s -> + val me = change(s.me) + s.copy(me = me, devices = s.devices.map { d -> if (d.user.id == me.id) d.copy(user = me) else d }) +} + +class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository { + override fun all(): Flow> = store.derive { s -> s.categories.sortedBy { it.sortOrder } } + + override suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult = store.online { s -> + validateCategory(name, icon) ?: run { + val id = CategoryId(store.newId()) + val sortOrder = (s.categories.maxOfOrNull { it.sortOrder } ?: 0) + 10 // AC-7.3 + store.edit { it.copy(categories = it.categories + Category(id, null, name.trim(), icon, color, sortOrder, archived = false)) } + OnlineResult.Ok(id) + } + } + + override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult = store.online { s -> + val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null) + if (name == null && category.defaultKey == null) return@online OnlineResult.Failed("category_is_default", "name") + validateCategory(name, icon) ?: run { + store.editCategory(id) { it.copy(customName = name?.trim(), icon = icon, color = color) } + OnlineResult.Ok(Unit) + } + } + + override suspend fun reorder(idsInOrder: List): OnlineResult = store.online { + store.edit { s -> + s.copy(categories = s.categories.map { c -> idsInOrder.indexOf(c.id).takeIf { it >= 0 }?.let { c.copy(sortOrder = (it + 1) * 10) } ?: c }) + } + OnlineResult.Ok(Unit) + } + + override suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult = store.online { s -> + if (s.category(id) == null) return@online OnlineResult.Failed("not_found", null) + store.editCategory(id) { it.copy(archived = archived) } + OnlineResult.Ok(Unit) + } + + override suspend fun delete(id: CategoryId): OnlineResult = store.online { s -> + val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null) + when { + category.defaultKey != null -> OnlineResult.Failed("category_is_default", null) // AC-7.6 + s.expenses.any { !it.deleted && it.categoryId == id } || s.templates.any { it.draft.categoryId == id } -> + OnlineResult.Failed("category_in_use", null) + else -> { + store.edit { it.copy(categories = it.categories.filter { c -> c.id != id }) } + OnlineResult.Ok(Unit) + } + } + } + + /** §9.6 and §9.7. A null name (reset of a default name) is valid. */ + private fun validateCategory(name: String?, icon: String): OnlineResult.Failed? = when { + name != null && name.codePointLength() !in 1..40 -> OnlineResult.Failed("validation_failed", "name") + icon !in CategoryIcons.ALL -> OnlineResult.Failed("validation_failed", "icon") + else -> null + } + + private fun FakeStore.editCategory(id: CategoryId, change: (Category) -> Category) = + edit { s -> s.copy(categories = s.categories.map { if (it.id == id) change(it) else it }) } +} + +class FakeRecurringRepository(private val store: FakeStore) : RecurringRepository { + override fun all(): Flow> = store.derive { it.templates } + + override suspend fun create(t: RecurringTemplate): OnlineResult = store.online { s -> + s.validateTemplate(t) ?: run { + val id = TemplateId(store.newId()) + val created = t.copy(id = id, nextOccurrence = s.nextOccurrence(t)) + store.edit { it.copy(templates = it.templates + created) } + OnlineResult.Ok(id) + } + } + + override suspend fun update(t: RecurringTemplate): OnlineResult = store.online { s -> + if (s.templates.none { it.id == t.id }) return@online OnlineResult.Failed("not_found", null) + s.validateTemplate(t) ?: run { + store.edit { it.copy(templates = it.templates.map { x -> if (x.id == t.id) t.copy(nextOccurrence = s.nextOccurrence(t)) else x }) } + OnlineResult.Ok(Unit) + } + } + + override suspend fun delete(id: TemplateId): OnlineResult = store.online { s -> + if (s.templates.none { it.id == id }) return@online OnlineResult.Failed("not_found", null) + store.edit { it.copy(templates = it.templates.filter { x -> x.id != id }) } + OnlineResult.Ok(Unit) + } +} + +/** §9.6 limits of a template (the split itself is checked like an expense's). */ +private fun FakeScenario.validateTemplate(t: RecurringTemplate): OnlineResult.Failed? { + val period = Regex("""\d{4}-(0[1-9]|1[0-2])""") + val outcome = split(t.draft) + return when { + partner == null -> OnlineResult.Failed("household_incomplete", null) + t.dayOfMonth !in 1..31 -> OnlineResult.Failed("validation_failed", "day_of_month") + !period.matches(t.startPeriod) -> OnlineResult.Failed("validation_failed", "start_period") + t.endPeriod?.let { !period.matches(it) || it < t.startPeriod } == true -> OnlineResult.Failed("validation_failed", "end_period") + t.draft.description.codePointLength() !in 1..100 -> OnlineResult.Failed("validation_failed", "description") + t.draft.accountId?.let { account(it) } == null -> OnlineResult.Failed("validation_failed", "account_id") + t.draft.categoryId?.let { category(it) } == null -> OnlineResult.Failed("validation_failed", "category_id") + outcome !is SplitOutcome.Ok -> OnlineResult.Failed("validation_failed", "splits") + else -> null + } +} + +/** + * The server's `next_occurrence_date` (§9.5) as the fakes see it: the clamped date of the first period from + * `start_period` whose date is today or later, within `end_period`; null when paused. + */ +private fun FakeScenario.nextOccurrence(t: RecurringTemplate): String? { + if (t.paused) return null + val today = LocalDate.parse(today) + var month = YearMonth.parse(t.startPeriod) + val end = t.endPeriod?.let { YearMonth.parse(it) } + while (end == null || month <= end) { + val first = LocalDate(month.year, month.month, 1) + val last = first.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY) + val date = LocalDate(month.year, month.month.number, minOf(t.dayOfMonth, last.day)) + if (date >= today) return date.toString() + month = month.plus(1, DateTimeUnit.MONTH) + } + return null +} + +class FakeDeviceRepository(private val store: FakeStore) : DeviceRepository { + /** AC-3.1: the admin sees all devices, a member their own. */ + override suspend fun list(): OnlineResult> = store.online { s -> + OnlineResult.Ok(if (s.me.role == Role.ADMIN) s.devices else s.devices.filter { it.user.id == s.me.id }) + } + + override suspend fun revoke(id: DeviceId): OnlineResult = store.online { s -> + val device = s.devices.firstOrNull { it.id == id } ?: return@online OnlineResult.Failed("not_found", null) + when { + device.current -> OnlineResult.Failed("cannot_revoke_current_device", null) // AC-3.2 + s.me.role != Role.ADMIN && device.user.id != s.me.id -> OnlineResult.Failed("forbidden", null) + else -> { + store.edit { it.copy(devices = it.devices.map { d -> if (d.id == id) d.copy(revoked = true) else d }) } + OnlineResult.Ok(Unit) + } + } + } +} + +class FakeInviteRepository(private val store: FakeStore) : InviteRepository { + override suspend fun list(): OnlineResult> = store.online { s -> + OnlineResult.Ok(s.invites.map { it.copy(code = null, link = null) }) + } + + override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult = store.online { s -> + when { + s.me.role != Role.ADMIN -> OnlineResult.Failed("forbidden", null) + kind == InviteKind.ADMIN_SETUP -> OnlineResult.Failed("invalid_request", "kind") + kind == InviteKind.PARTNER && s.partner != null -> OnlineResult.Failed("household_full", null) // AC-2.1 + kind == InviteKind.DEVICE && (targetUser == null || s.user(targetUser) == null) -> OnlineResult.Failed("not_found", "target_user_id") + else -> { + val id = store.newId() + val code = inviteCode(id) + // 72-hour expiry (AC-2.3): three calendar days later at the same UTC time. + val expires = LocalDate.parse(s.now.take(10)).plus(3, DateTimeUnit.DAY).toString() + s.now.drop(10) + val invite = Invite(InviteId(id), kind, targetUser.takeIf { kind == InviteKind.DEVICE }, code, "${s.about.serverUrl}/i/$code", s.now, expires) + store.edit { st -> + // AC-2.1: a new partner invite cancels any pending one. + val kept = if (kind == InviteKind.PARTNER) st.invites.filter { it.kind != InviteKind.PARTNER } else st.invites + st.copy(invites = kept + invite) + } + OnlineResult.Ok(invite) + } + } + } + + override suspend fun cancel(id: InviteId): OnlineResult = store.online { s -> + if (s.invites.none { it.id == id }) return@online OnlineResult.Failed("not_found", null) + store.edit { it.copy(invites = it.invites.filter { x -> x.id != id }) } + OnlineResult.Ok(Unit) + } + + /** 12 characters of the Crockford base32 alphabet (§8.3), derived from the id so screenshots are stable. */ + private fun inviteCode(id: String): String { + val alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + var value = id.filter { it.isLetterOrDigit() }.takeLast(12).toLong(16) + return buildString { repeat(12) { append(alphabet[(value % 32).toInt()]); value /= 32 } }.reversed() + } +} + +class FakeSyncRepository(private val store: FakeStore) : SyncRepository { + override fun status(): Flow = store.derive { s -> + val rows: List, LedgerRow?>> = + s.expenses.filter { it.pending }.map { (it.date to it.createdAt) to s.expenseRow(it) } + + s.settlements.filter { it.pending }.map { (it.date to it.createdAt) to s.settlementRow(it) } + SyncStatusState( + summary = s.syncSummary(), + pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second }, + rejections = s.rejections, + ) + } + + /** A sync in the fakes: online, every pending change is accepted. */ + override suspend fun syncNow() = store.edit { s -> + if (!s.online || s.clockSkew) { + s + } else { + s.copy( + expenses = s.expenses.map { it.copy(pending = false) }, + settlements = s.settlements.map { it.copy(pending = false) }, + lastSuccessAt = s.now, + ) + } + } + + override suspend fun dismiss(mutationId: String) = store.edit { s -> s.copy(rejections = s.rejections.filter { it.mutationId != mutationId }) } +} + +/** The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, AC-16.5). */ +class FakeUpdateRepository(store: FakeStore) : UpdateRepository { + override val state: Flow = store.derive { it.update } + + override suspend fun check() = Unit + + override suspend fun install() = Unit +} + +class FakeAboutRepository(store: FakeStore) : AboutRepository { + private val flow = store.derive { it.about } + + override fun about(): Flow = flow +} + +class FakeConnectivityRepository(store: FakeStore) : ConnectivityRepository { + override val online: Flow = store.derive { it.online } +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt new file mode 100644 index 0000000..e7b10bf --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt @@ -0,0 +1,134 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import se.hagstedt.kvitt.domain.overSettledAccounts +import se.hagstedt.kvitt.domain.proposeAllocation +import se.hagstedt.kvitt.domain.validateSettlement +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AllocationLine +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.SettleRequest +import se.hagstedt.kvitt.model.SettleState +import se.hagstedt.kvitt.model.SettlementDetailState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.SettlementRepository +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.money.AccountPosition +import se.hagstedt.kvitt.model.money.Allocation +import se.hagstedt.kvitt.model.money.AllocationResult +import se.hagstedt.kvitt.model.money.SettlementError +import kotlin.math.absoluteValue + +class FakeSettlementRepository(private val store: FakeStore) : SettlementRepository { + override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState { + val s = store.current + val partner = requireNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" } + val existing = editing?.let { id -> s.settlements.firstOrNull { it.id == id } } + if (existing != null) { + return s.settleState( + SettleRequest(existing.id, existing.from, existing.amountOre, fixedAccount, existing.allocations, existing.date, existing.note), + ) + } + // AC-11.3 / AC-11.4: the user who owes pays; the amount is the absolute balance, empty when it is zero. + val balances = s.balances() + val myNet = if (fixedAccount != null) balances.net(fixedAccount, s.me.id) else balances.globalNet(s.me.id) + val payer = if (myNet > 0) partner.id else s.me.id + val amount = myNet.absoluteValue.takeIf { it > 0 } + return s.settleState(SettleRequest(null, payer, amount, fixedAccount, null, s.today, "")) + } + + override suspend fun recompute(request: SettleRequest): SettleState = store.current.settleState(request) + + override suspend fun save(request: SettleRequest): SaveResult { + val s = store.current + val state = s.settleState(request) + val errors = mutableListOf() + val amount = request.amountOre + if (amount == null) { + errors += FieldError("amountOre", "required") + } else { + validateSettlement(amount, state.payer.id.value, state.recipient.id.value, s.users.map { it.id.value }, state.allocations())?.let { + errors += FieldError(if (it == SettlementError.AMOUNT_OUT_OF_RANGE) "amountOre" else "allocations", it.name.lowercase()) + } + } + // TODO(spec): no domain token for the §9.6 note and date limits; the fakes use `validation_failed` (as expenses). + if (request.note.codePointLength() > 200) errors += FieldError("note", "validation_failed") + if (!s.validDate(request.date)) errors += FieldError("date", "validation_failed") + if (errors.isNotEmpty() || amount == null) return SaveResult.Invalid(errors) + + val existing = request.editing?.let { id -> s.settlements.firstOrNull { it.id == id } } + val id = request.editing ?: SettlementId(store.newId()) + val saved = FakeSettlement( + id = id, + from = state.payer.id, + to = state.recipient.id, + amountOre = amount, + date = request.date, + note = request.note.trim(), + allocations = state.lines.associate { it.account.id to it.amountOre }, + createdAt = existing?.createdAt ?: s.now, + pending = true, + timeline = existing?.timeline.orEmpty(), + ) + store.edit { it.copy(settlements = it.settlements.filter { x -> x.id != id } + saved) } + return SaveResult.Saved(id) + } + + override suspend fun delete(id: SettlementId) = store.edit { s -> + s.copy(settlements = s.settlements.map { if (it.id == id) it.copy(deleted = true, pending = true) else it }) + } + + override fun detail(id: SettlementId): Flow = store.derive { s -> + val st = s.settlements.firstOrNull { it.id == id } ?: return@derive null + val from = s.user(st.from) ?: return@derive null + val to = s.user(st.to) ?: return@derive null + SettlementDetailState( + id = st.id, + from = from, + to = to, + amountOre = st.amountOre, + date = st.date, + note = st.note, + allocations = st.allocations.mapNotNull { (account, amount) -> s.account(account)?.let { it to amount } }, + deleted = st.deleted, + pending = st.pending, + timeline = s.timelineWithPending(st.timeline, st.pending, st.deleted), + ) + } +} + +private fun SettleState.allocations(): List = lines.map { Allocation(it.account.id.value, it.amountOre) } + +/** + * §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows; + * a per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6). + */ +private fun FakeScenario.settleState(request: SettleRequest): SettleState { + val payer: User = requireNotNull(user(request.payer)) { "unknown payer" } + val recipient: User = requireNotNull(users.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" } + val balances = balances(excludingSettlement = request.editing?.value) + val positions = accountsInOrder().map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id, request.payer)) } + val amount = request.amountOre + val fixed = request.fixedAccount + val given = request.allocations + val rows: List> = when { + fixed != null -> listOf(fixed to (given?.get(fixed) ?: amount ?: 0L)) + given != null -> given.toList() + amount == null -> emptyList() + else -> when (val proposal = proposeAllocation(amount, positions)) { + is AllocationResult.Ok -> proposal.allocations.map { AccountId(it.accountId) to it.amountOre } + is AllocationResult.Err -> emptyList() + } + }.mapNotNull { (id, value) -> id.takeIf { account(it) != null }?.let { it to value } } + val over = overSettledAccounts(rows.map { Allocation(it.first.value, it.second) }, positions).toSet() + val lines = rows.mapNotNull { (id, value) -> + account(id)?.let { AllocationLine(it, balances.net(id, request.payer), value, id.value in over) } + } + val allocated = lines.sumOf { it.amountOre } + val valid = amount != null && validateSettlement( + amount, payer.id.value, recipient.id.value, users.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) }, + ) == null + return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note) +} + diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt new file mode 100644 index 0000000..a178e95 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt @@ -0,0 +1,123 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import se.hagstedt.kvitt.domain.computeBalances +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseEffect +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.model.money.BalanceExpense +import se.hagstedt.kvitt.model.money.BalanceSettlement +import se.hagstedt.kvitt.model.money.Balances + +/** + * The single mutable state behind all fake repositories of one [FakeRepositories]. Writes go through [edit], so every + * flow re-emits, like Room after a local write (AC-10.4). + */ +class FakeStore(initial: FakeScenario) { + private val mutable = MutableStateFlow(initial) + val state: StateFlow = mutable + val current: FakeScenario get() = mutable.value + private var nextId = 0x100 + + fun edit(transform: (FakeScenario) -> FakeScenario) = mutable.update(transform) + + fun derive(transform: (FakeScenario) -> T): Flow = mutable.map(transform) + + /** A new UUIDv7-shaped id, deterministic so screenshots are stable. */ + fun newId(): String { + nextId += 1 + return "01920000-0000-7000-8000-" + nextId.toString(16).padStart(12, '0') + } + + /** §9.9: online-only actions report [OnlineResult.Offline] when the scenario is offline. */ + inline fun online(action: (FakeScenario) -> OnlineResult): OnlineResult = + if (!current.online) OnlineResult.Offline else action(current) +} + +// Derived values shared by the fakes. Each one is the rule of the spec section named, computed with :domain. + +internal fun FakeScenario.user(id: UserId): User? = users.firstOrNull { it.id == id } + +internal fun FakeScenario.account(id: AccountId): Account? = accounts.firstOrNull { it.id == id } + +internal fun FakeScenario.category(id: CategoryId): Category? = categories.firstOrNull { it.id == id } + +/** AC-6.6: accounts by `created_at`, then id. */ +internal fun FakeScenario.accountsInOrder(): List = accounts.sortedWith(compareBy({ it.createdAt }, { it.id.value })) + +/** §9.3 over non-deleted rows, optionally excluding the settlement being edited (AC-11.6). */ +internal fun FakeScenario.balances(excludingSettlement: String? = null): Balances = computeBalances( + userIds = users.map { it.id.value }, + accountIds = accounts.map { it.id.value }, + expenses = expenses.filter { !it.deleted }.map { e -> + BalanceExpense(e.accountId.value, e.amountOre, e.payer.value, e.shares.associate { it.userId.value to it.shareOre }) + }, + settlements = settlements.filter { !it.deleted && it.id.value != excludingSettlement }.map { s -> + BalanceSettlement(s.from.value, s.to.value, s.allocations.mapKeys { it.key.value }) + }, +) + +internal fun Balances.net(account: AccountId, user: UserId): Long = perAccount[account.value]?.get(user.value) ?: 0L + +internal fun Balances.globalNet(user: UserId): Long = global[user.value] ?: 0L + +/** §9.2: the effect line of an expense from the viewer's side. */ +internal fun FakeExpense.effectFor(viewer: UserId): ExpenseEffect { + val nonPayerShare = shares.firstOrNull { it.userId != payer }?.shareOre ?: 0L + return when { + nonPayerShare == 0L -> if (viewer == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved + viewer == payer -> ExpenseEffect.Lent(nonPayerShare) + else -> ExpenseEffect.Borrowed(shares.firstOrNull { it.userId == viewer }?.shareOre ?: 0L) + } +} + +internal fun FakeScenario.expenseRow(e: FakeExpense): LedgerRow.ExpenseRow? { + val category = category(e.categoryId) ?: return null + return LedgerRow.ExpenseRow(e.id, e.date, e.description, category, e.amountOre, e.effectFor(me.id), e.pending) +} + +internal fun FakeScenario.settlementRow(s: FakeSettlement): LedgerRow.SettlementRow? { + val from = user(s.from) ?: return null + val to = user(s.to) ?: return null + return LedgerRow.SettlementRow(s.id, s.date, from, to, s.amountOre, s.allocations.size, s.pending) +} + +/** AC-8.10: date descending, then `created_at` descending. */ +internal val ledgerOrder: Comparator> = + compareByDescending> { it.first }.thenByDescending { it.second } + +/** AC-12.5: a pending local change ends the timeline with a line marked [TimelineLine.pendingLocal]. */ +internal fun FakeScenario.timelineWithPending(timeline: List, pending: Boolean, deleted: Boolean): List { + if (!pending) return timeline + val action = when { + deleted -> AuditAction.DELETE + timeline.isEmpty() -> AuditAction.CREATE + else -> AuditAction.UPDATE + } + return timeline + TimelineLine(me, action, AuditSource.APP, now, emptyList(), pendingLocal = true) +} + +/** Length in Unicode code points after trimming (§9.6). */ +internal fun String.codePointLength(): Int = trim().let { it.codePointCount(0, it.length) } + +/** §9.6: a valid date from 2000-01-01 to today + 365 days. */ +internal fun FakeScenario.validDate(date: String): Boolean { + val parsed = runCatching { LocalDate.parse(date) }.getOrNull() ?: return false + return parsed >= LocalDate(2000, 1, 1) && parsed <= LocalDate.parse(today).plus(365, DateTimeUnit.DAY) +} diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt new file mode 100644 index 0000000..b2c097e --- /dev/null +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt @@ -0,0 +1,84 @@ +package se.hagstedt.kvitt.fakes + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AnalyticsFilter +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.GroupBy +import se.hagstedt.kvitt.model.Measure +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.UpdateState + +/** Every fake repository answers from the empty scenario with a complete, empty state. */ +class EmptyScenarioTest { + private val fakes = FakeRepositories() + + @Test + fun sessionIsPairedWithTheWholeHousehold() = runTest { + val session = fakes.session.session.first() + assertTrue(session is SessionState.Paired) + assertEquals(listOf(FakeScenarios.owner, FakeScenarios.partner), fakes.profile.household().first()) + } + + @Test + fun homeIsSettledWithNoAccounts() = runTest { + val home = fakes.home.home().first() + assertEquals(0L, home.global?.netOre) + assertTrue(home.active.isEmpty() && home.archived.isEmpty()) + assertEquals(0, home.sync.pendingCount) + assertNull(home.update) + } + + @Test + fun accountsExpensesAndSettlementsAreEmpty() = runTest { + assertTrue(fakes.accounts.all().first().isEmpty()) + assertNull(fakes.accounts.detail(AccountId("none")).first()) + assertNull(fakes.expenses.detail(ExpenseId("none")).first()) + assertNull(fakes.settlements.detail(SettlementId("none")).first()) + val options = fakes.expenses.editorOptions().first() + assertNull(options.defaultAccountId) + assertEquals(10, options.categories.size) + val settle = fakes.settlements.start(null, null) + assertNull(settle.amountOre) + assertTrue(settle.lines.isEmpty()) + } + + @Test + fun analyticsHasEmptyBucketsForTheDefaultRange() = runTest { + val filter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY) + val state = fakes.analytics.analytics(filter).first() + assertEquals("2026-05-01", state.start) + assertEquals(6, state.buckets.size) + assertEquals(0L, state.totalOre) + } + + @Test + fun settingsRepositoriesAnswer() = runTest { + assertEquals(10, fakes.categories.all().first().size) + assertTrue(fakes.recurring.all().first().isEmpty()) + assertEquals(1, (fakes.devices.list() as OnlineResult.Ok).value.size) + assertTrue((fakes.invites.list() as OnlineResult.Ok).value.isEmpty()) + assertTrue(fakes.sync.status().first().rejections.isEmpty()) + assertEquals(UpdateState.None, fakes.update.state.first()) + assertEquals("dev", fakes.about.about().first().versionName) + assertTrue(fakes.connectivity.online.first()) + } + + @Test + fun createdAccountFlowsIntoHomeWithZeroBalance() = runTest { + val shares = mapOf(FakeScenarios.owner.id to 5000, FakeScenarios.partner.id to 5000) + val created = fakes.accounts.create("Hemma", shares) + assertTrue(created is OnlineResult.Ok) + assertEquals(OnlineResult.Failed("name_taken", "name"), fakes.accounts.create(" hemma ", shares)) + assertEquals(listOf("Hemma"), fakes.home.home().first().active.map { it.account.name }) + } +} diff --git a/android/core/model/build.gradle.kts b/android/core/model/build.gradle.kts new file mode 100644 index 0000000..c13d31e --- /dev/null +++ b/android/core/model/build.gradle.kts @@ -0,0 +1,9 @@ +// :core:model — the UI contract (SPEC §14.2.4) and the domain types (SPEC §14.5.1, §14.5.4). Pure Kotlin/JVM. +plugins { + id("kvitt.jvm.library") +} + +dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.datetime) +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Accounts.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Accounts.kt new file mode 100644 index 0000000..56c8c67 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Accounts.kt @@ -0,0 +1,16 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Accounts (S-5, S-6), SPEC §14.2.4. + +data class AccountDetailState(val account: Account, val balance: BalanceView, val months: List, val deletable: Boolean, val users: List) + +interface AccountRepository { + fun detail(id: AccountId): Flow + fun all(): Flow> + suspend fun create(name: String, defaultShares: Map): OnlineResult + suspend fun update(id: AccountId, name: String, defaultShares: Map): OnlineResult + suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult + suspend fun delete(id: AccountId): OnlineResult +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Analytics.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Analytics.kt new file mode 100644 index 0000000..883fec7 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Analytics.kt @@ -0,0 +1,21 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Analytics (S-11, S-12), SPEC §14.2.4. + +data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy) + +/** label null: UI uses the default category name. */ +data class GroupKey(val id: String, val label: String?, val color: ColorToken?) + +data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map, val totalOre: Long) + +data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List, val buckets: List, val byCategory: List>, val byAccount: List>, val totalOre: Long) + +data class DrillDownState(val title: String, val totalOre: Long, val rows: List) + +interface AnalyticsRepository { + fun analytics(filter: AnalyticsFilter): Flow + fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt new file mode 100644 index 0000000..f5a55e3 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt @@ -0,0 +1,39 @@ +package se.hagstedt.kvitt.model + +// Enums (SPEC §14.2.4). Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default"). + +enum class Role { ADMIN, MEMBER } + +enum class SplitMode { DEFAULT, RATIO, EXACT, FULL } + +enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE } + +enum class EntityType { EXPENSE, SETTLEMENT } + +enum class AuditAction { CREATE, UPDATE, DELETE } + +enum class AuditSource { APP, RECURRING, IMPORT } + +enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER } + +enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME } + +enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM } + +enum class Granularity { MONTH, WEEK } + +enum class Measure { HOUSEHOLD, MY_SHARE } + +enum class GroupBy { CATEGORY, ACCOUNT } + +enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW } + +/** Category icon tokens: Material Symbols names, the exhaustive v1 set of SPEC §9.7, in that order. */ +object CategoryIcons { + val ALL: List = listOf( + "shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train", + "local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center", + "medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard", + "celebration", "build", "chair", "subscriptions", "category", + ) +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt new file mode 100644 index 0000000..64e4f01 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt @@ -0,0 +1,25 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Expenses (S-7, S-8), SPEC §14.2.4. + +data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map?, val exactOre: Map?, val fullOn: UserId?) + +data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List, val categories: List, val defaultAccountId: AccountId?, val defaultCategoryFor: Map, val today: String) + +data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List, val pendingLocal: Boolean) + +/** Ids already resolved to names; amounts and dates raw (öre as String, ISO date), formatted by the UI. */ +data class FieldChange(val field: String, val oldDisplay: String?, val newDisplay: String?) + +data class ExpenseDetailState(val id: ExpenseId, val account: Account, val category: Category, val amountOre: Long, val description: String, val notes: String, val date: String, val payer: User, val mode: SplitMode, val shares: List, val recurringTemplateName: String?, val deleted: Boolean, val pending: Boolean, val timeline: List) + +interface ExpenseRepository { + fun editorOptions(): Flow + suspend fun load(id: ExpenseId): ExpenseDraft? + fun preview(draft: ExpenseDraft): List? // null while the draft cannot be computed + suspend fun save(draft: ExpenseDraft): SaveResult + suspend fun delete(id: ExpenseId) + fun detail(id: ExpenseId): Flow +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt new file mode 100644 index 0000000..dc19b9f --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt @@ -0,0 +1,18 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Balances shared by several screens and Home (S-4), SPEC §14.2.4. + +/** netOre is the viewer's net (§9.3): > 0 partner owes viewer. */ +data class BalanceView(val netOre: Long, val partner: User) + +data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int) + +data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean) + +data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List, val archived: List, val sync: SyncSummary, val update: UpdateInfo?) + +interface HomeRepository { + fun home(): Flow +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ids.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ids.kt new file mode 100644 index 0000000..9514867 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ids.kt @@ -0,0 +1,19 @@ +package se.hagstedt.kvitt.model + +// Ids (SPEC §14.2.4). Values are UUIDv7 strings on the wire. + +@JvmInline value class UserId(val value: String) + +@JvmInline value class AccountId(val value: String) + +@JvmInline value class CategoryId(val value: String) + +@JvmInline value class ExpenseId(val value: String) + +@JvmInline value class SettlementId(val value: String) + +@JvmInline value class TemplateId(val value: String) + +@JvmInline value class DeviceId(val value: String) + +@JvmInline value class InviteId(val value: String) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ledger.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ledger.kt new file mode 100644 index 0000000..51dc3df --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Ledger.kt @@ -0,0 +1,17 @@ +package se.hagstedt.kvitt.model + +// Ledger rows (S-5, S-12), SPEC §14.2.4. + +sealed interface ExpenseEffect { + data class Lent(val ore: Long) : ExpenseEffect + data class Borrowed(val ore: Long) : ExpenseEffect + data object NotInvolved : ExpenseEffect + data object PaidForYourself : ExpenseEffect +} + +sealed interface LedgerRow { + data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow + data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow +} + +data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Records.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Records.kt new file mode 100644 index 0000000..bc44645 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Records.kt @@ -0,0 +1,25 @@ +package se.hagstedt.kvitt.model + +// Plain records and results (SPEC §14.2.4). + +data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?) + +data class Account(val id: AccountId, val name: String, val defaultShares: Map, val createdAt: String, val archived: Boolean) + +data class Category(val id: CategoryId, val defaultKey: DefaultCategoryKey?, val customName: String?, val icon: String, val color: ColorToken, val sortOrder: Int, val archived: Boolean) + +data class Share(val userId: UserId, val shareOre: Long, val shareBp: Int?) + +sealed interface OnlineResult { + data class Ok(val value: T) : OnlineResult + data object Offline : OnlineResult + data class Failed(val code: String, val field: String?) : OnlineResult // code from §14.3.2 +} + +sealed interface SaveResult { + data class Saved(val id: T) : SaveResult + data class Invalid(val errors: List) : SaveResult +} + +/** `error` is a domain error token of §14.5.3 or "required". */ +data class FieldError(val field: String, val error: String) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt new file mode 100644 index 0000000..9b5809a --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt @@ -0,0 +1,18 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Session and onboarding (S-1, S-2, S-3, S-18), SPEC §14.2.4. + +sealed interface SessionState { + data object Unpaired : SessionState + data class Paired(val me: User, val partner: User?, val deviceId: DeviceId) : SessionState + data object Revoked : SessionState + data object Unrecognised : SessionState // 401 unauthorized: pair again, data kept (§14.4.8) +} + +interface SessionRepository { + val session: Flow + suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult + suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt new file mode 100644 index 0000000..00dc6d3 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt @@ -0,0 +1,46 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Settings (S-13 to S-15, S-19 to S-22), SPEC §14.2.4. + +interface ProfileRepository { + fun household(): Flow> + suspend fun setDisplayName(name: String): OnlineResult + suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult + suspend fun removePicture(): OnlineResult +} + +interface CategoryRepository { + fun all(): Flow> + suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult + suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult // name null = reset default name + suspend fun reorder(idsInOrder: List): OnlineResult + suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult + suspend fun delete(id: CategoryId): OnlineResult +} + +data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?) + +interface RecurringRepository { + fun all(): Flow> + suspend fun create(t: RecurringTemplate): OnlineResult + suspend fun update(t: RecurringTemplate): OnlineResult + suspend fun delete(id: TemplateId): OnlineResult +} + +data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val current: Boolean, val revoked: Boolean) + +interface DeviceRepository { + suspend fun list(): OnlineResult> + suspend fun revoke(id: DeviceId): OnlineResult +} + +/** code and link only right after creation. */ +data class Invite(val id: InviteId, val kind: InviteKind, val targetUser: UserId?, val code: String?, val link: String?, val createdAt: String, val expiresAt: String) + +interface InviteRepository { + suspend fun list(): OnlineResult> + suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult + suspend fun cancel(id: InviteId): OnlineResult +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt new file mode 100644 index 0000000..e48e9e6 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt @@ -0,0 +1,21 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Settlements (S-9, S-10), SPEC §14.2.4. + +data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean) + +data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String) + +data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map?, val date: String, val note: String) + +data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List>, val deleted: Boolean, val pending: Boolean, val timeline: List) + +interface SettlementRepository { + suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState // defaults per AC-11.3 / AC-11.4 + suspend fun recompute(request: SettleRequest): SettleState // allocations == null: propose (§9.4); else validate and warn + suspend fun save(request: SettleRequest): SaveResult + suspend fun delete(id: SettlementId) + fun detail(id: SettlementId): Flow +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt new file mode 100644 index 0000000..c9766ad --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt @@ -0,0 +1,42 @@ +package se.hagstedt.kvitt.model + +import kotlinx.coroutines.flow.Flow + +// Sync status, update, about and connectivity (S-16, S-17, S-22, §9.9), SPEC §14.2.4. + +data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String) + +data class SyncStatusState(val summary: SyncSummary, val pending: List, val rejections: List) + +interface SyncRepository { + fun status(): Flow + suspend fun syncNow() + suspend fun dismiss(mutationId: String) +} + +data class UpdateInfo(val versionCode: Int, val versionName: String, val sizeBytes: Long) + +sealed interface UpdateState { + data object None : UpdateState + data class Available(val info: UpdateInfo) : UpdateState + data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState + data class Failed(val info: UpdateInfo) : UpdateState + data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState +} + +interface UpdateRepository { + val state: Flow + suspend fun check() + suspend fun install() +} + +data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?) + +interface AboutRepository { + fun about(): Flow +} + +/** §9.9 */ +interface ConnectivityRepository { + val online: Flow +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/analytics/Analytics.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/analytics/Analytics.kt new file mode 100644 index 0000000..f9e2506 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/analytics/Analytics.kt @@ -0,0 +1,22 @@ +package se.hagstedt.kvitt.model.analytics + +import kotlinx.datetime.LocalDate + +// Analytics input and output types (SPEC §14.5.4), package per the orchestrator's M2b decision. + +data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map) + +data class DateRange(val start: LocalDate, val endInclusive: LocalDate) + +enum class AnalyticsGranularity { MONTH, WEEK } + +sealed interface AnalyticsMeasure { + data object Household : AnalyticsMeasure + data class Share(val userId: String) : AnalyticsMeasure +} + +enum class AnalyticsGroupBy { CATEGORY, ACCOUNT } + +data class Bucket(val key: String, val start: LocalDate, val endInclusive: LocalDate, val totals: Map, val total: Long) + +data class SpendSeries(val buckets: List, val groupTotals: Map, val grandTotal: Long) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Allocation.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Allocation.kt new file mode 100644 index 0000000..3b68186 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Allocation.kt @@ -0,0 +1,16 @@ +package se.hagstedt.kvitt.model.money + +// SPEC §14.5.1 + +data class AccountPosition(val accountId: String, val createdAt: String, val archived: Boolean, val payerNetOre: Long) + +enum class AllocationError { AMOUNT_OUT_OF_RANGE, NO_ACCOUNTS } + +data class Allocation(val accountId: String, val amountOre: Long) + +sealed interface AllocationResult { + data class Ok(val allocations: List, val excessOre: Long, val overSettledAccountIds: List) : AllocationResult + data class Err(val error: AllocationError) : AllocationResult +} + +enum class SettlementError { AMOUNT_OUT_OF_RANGE, SAME_USER, USER_UNKNOWN, NO_ALLOCATIONS, TOO_MANY_ALLOCATIONS, ALLOCATION_NOT_POSITIVE, DUPLICATE_ACCOUNT, ALLOCATION_SUM_MISMATCH } diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Balance.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Balance.kt new file mode 100644 index 0000000..a058cd9 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Balance.kt @@ -0,0 +1,11 @@ +package se.hagstedt.kvitt.model.money + +// SPEC §14.5.1 + +data class BalanceExpense(val accountId: String, val amountOre: Long, val payerUserId: String, val shares: Map) + +/** allocations: accountId -> öre. */ +data class BalanceSettlement(val fromUserId: String, val toUserId: String, val allocations: Map) + +/** perAccount: accountId -> userId -> net; global: userId -> net. */ +data class Balances(val perAccount: Map>, val global: Map) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Split.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Split.kt new file mode 100644 index 0000000..f96ffb0 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/money/Split.kt @@ -0,0 +1,17 @@ +package se.hagstedt.kvitt.model.money + +// Domain input and output types (SPEC §14.5.1). Ids are plain strings; money is öre (Long); bp is Int. + +sealed interface SplitSpec { + data class Ratio(val bp: Map) : SplitSpec // modes default and ratio + data class Exact(val ore: Map) : SplitSpec // mode exact + data class Full(val bearerUserId: String) : SplitSpec // mode full +} + +/** Checked in this order; the first failure is returned (§14.5.2). Wire token = lowercase name (§14.5.3). */ +enum class SplitError { AMOUNT_OUT_OF_RANGE, USERS_INVALID, PAYER_UNKNOWN, BP_OUT_OF_RANGE, BP_SUM_NOT_10000, EXACT_NEGATIVE, EXACT_SUM_MISMATCH, BEARER_UNKNOWN, SHARE_USERS_MISMATCH } + +sealed interface SplitResult { + data class Ok(val shares: Map) : SplitResult + data class Err(val error: SplitError) : SplitResult +} diff --git a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt new file mode 100644 index 0000000..99e3398 --- /dev/null +++ b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt @@ -0,0 +1,71 @@ +package se.hagstedt.kvitt.model + +import org.junit.Assert.assertEquals +import org.junit.Test +import se.hagstedt.kvitt.model.money.AllocationError +import se.hagstedt.kvitt.model.money.SettlementError +import se.hagstedt.kvitt.model.money.SplitError + +/** Wire values (lowercase enum names, SPEC §14.2.4, §14.5.3) and the §9.7 token lists. */ +class ContractTest { + private fun wire(values: Array>) = values.map { it.name.lowercase() } + + @Test + fun enumWireValuesMatchTheSpec() { + assertEquals(listOf("admin", "member"), wire(Role.entries.toTypedArray())) + assertEquals(listOf("default", "ratio", "exact", "full"), wire(SplitMode.entries.toTypedArray())) + assertEquals(listOf("admin_setup", "partner", "device"), wire(InviteKind.entries.toTypedArray())) + assertEquals(listOf("expense", "settlement"), wire(EntityType.entries.toTypedArray())) + assertEquals(listOf("create", "update", "delete"), wire(AuditAction.entries.toTypedArray())) + assertEquals(listOf("app", "recurring", "import"), wire(AuditSource.entries.toTypedArray())) + assertEquals( + listOf("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other"), + wire(DefaultCategoryKey.entries.toTypedArray()), + ) + assertEquals( + listOf("green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"), + wire(ColorToken.entries.toTypedArray()), + ) + assertEquals( + listOf("this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"), + wire(RangePreset.entries.toTypedArray()), + ) + assertEquals(listOf("month", "week"), wire(Granularity.entries.toTypedArray())) + assertEquals(listOf("household", "my_share"), wire(Measure.entries.toTypedArray())) + assertEquals(listOf("category", "account"), wire(GroupBy.entries.toTypedArray())) + assertEquals( + listOf("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew"), + wire(SyncRejectionReason.entries.toTypedArray()), + ) + } + + @Test + fun domainErrorTokensMatchSpec14_5_3() { + assertEquals( + listOf( + "amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000", + "exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch", + ), + wire(SplitError.entries.toTypedArray()), + ) + assertEquals(listOf("amount_out_of_range", "no_accounts"), wire(AllocationError.entries.toTypedArray())) + assertEquals( + listOf( + "amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations", + "allocation_not_positive", "duplicate_account", "allocation_sum_mismatch", + ), + wire(SettlementError.entries.toTypedArray()), + ) + } + + @Test + fun categoryIconsAreTheThirtyTokensOfSpec9_7InOrder() { + assertEquals(30, CategoryIcons.ALL.size) + assertEquals(30, CategoryIcons.ALL.toSet().size) + assertEquals("shopping_cart", CategoryIcons.ALL.first()) + assertEquals("category", CategoryIcons.ALL.last()) + // The ten default categories (AC-7.2) use icons from the set. + listOf("shopping_cart", "restaurant", "home", "bolt", "directions_car", "flight", "movie", "medical_services", "shopping_bag", "category") + .forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) } + } +} diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts new file mode 100644 index 0000000..3768277 --- /dev/null +++ b/android/data/build.gradle.kts @@ -0,0 +1,18 @@ +// :data — Room database, Ktor client, sync engine and the real repositories (owned by M5). M2b ships the Room +// entities of SPEC §14.2 and the database class with its exported schema (version 1), no DAOs. +plugins { + id("kvitt.android.library") + alias(libs.plugins.ksp) + alias(libs.plugins.room3) +} + +room3 { + schemaDirectory("$projectDir/schemas") +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":domain")) + implementation(libs.androidx.room3.runtime) + ksp(libs.androidx.room3.compiler) +} diff --git a/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json b/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json new file mode 100644 index 0000000..d09d89f --- /dev/null +++ b/android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json @@ -0,0 +1,986 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "294140ea943f5cc1f2c01e4725cac69d", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `display_name` TEXT NOT NULL, `picture_version` INTEGER, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pictureVersion", + "columnName": "picture_version", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `archived_at` TEXT, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdByUserId", + "columnName": "created_by_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "TEXT" + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_created_at", + "unique": false, + "columnNames": [ + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_created_at` ON `${TABLE_NAME}` (`created_at`)" + } + ] + }, + { + "tableName": "account_default_shares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER NOT NULL, PRIMARY KEY(`account_id`, `user_id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareBp", + "columnName": "share_bp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_id", + "user_id" + ] + } + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `default_key` TEXT, `name` TEXT, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `archived_at` TEXT, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultKey", + "columnName": "default_key", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "TEXT" + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "recurring_templates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `day_of_month` INTEGER NOT NULL, `start_period` TEXT NOT NULL, `end_period` TEXT, `paused` INTEGER NOT NULL, `last_generated_period` TEXT, `created_by_user_id` TEXT NOT NULL, `updated_by_user_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER NOT NULL, `next_occurrence_date` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountOre", + "columnName": "amount_ore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payerUserId", + "columnName": "payer_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "splitMode", + "columnName": "split_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dayOfMonth", + "columnName": "day_of_month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startPeriod", + "columnName": "start_period", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endPeriod", + "columnName": "end_period", + "affinity": "TEXT" + }, + { + "fieldPath": "paused", + "columnName": "paused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastGeneratedPeriod", + "columnName": "last_generated_period", + "affinity": "TEXT" + }, + { + "fieldPath": "createdByUserId", + "columnName": "created_by_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedByUserId", + "columnName": "updated_by_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextOccurrenceDate", + "columnName": "next_occurrence_date", + "affinity": "TEXT" + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recurring_templates_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recurring_templates_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ] + }, + { + "tableName": "recurring_template_splits", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`template_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_bp` INTEGER, `share_ore` INTEGER, PRIMARY KEY(`template_id`, `user_id`))", + "fields": [ + { + "fieldPath": "templateId", + "columnName": "template_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareBp", + "columnName": "share_bp", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareOre", + "columnName": "share_ore", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "template_id", + "user_id" + ] + } + }, + { + "tableName": "expenses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `description` TEXT NOT NULL, `notes` TEXT NOT NULL, `expense_date` TEXT NOT NULL, `payer_user_id` TEXT NOT NULL, `split_mode` TEXT NOT NULL, `recurring_template_id` TEXT, `recurring_period` TEXT, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountOre", + "columnName": "amount_ore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expenseDate", + "columnName": "expense_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payerUserId", + "columnName": "payer_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "splitMode", + "columnName": "split_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recurringTemplateId", + "columnName": "recurring_template_id", + "affinity": "TEXT" + }, + { + "fieldPath": "recurringPeriod", + "columnName": "recurring_period", + "affinity": "TEXT" + }, + { + "fieldPath": "createdByUserId", + "columnName": "created_by_user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedByUserId", + "columnName": "updated_by_user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER" + }, + { + "fieldPath": "pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingMutationId", + "columnName": "pending_mutation_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_expenses_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_account_id` ON `${TABLE_NAME}` (`account_id`)" + }, + { + "name": "index_expenses_expense_date", + "unique": false, + "columnNames": [ + "expense_date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_expense_date` ON `${TABLE_NAME}` (`expense_date`)" + }, + { + "name": "index_expenses_pending", + "unique": false, + "columnNames": [ + "pending" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_expenses_pending` ON `${TABLE_NAME}` (`pending`)" + } + ] + }, + { + "tableName": "expense_splits", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`expense_id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `share_ore` INTEGER NOT NULL, `share_bp` INTEGER, PRIMARY KEY(`expense_id`, `user_id`))", + "fields": [ + { + "fieldPath": "expenseId", + "columnName": "expense_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shareOre", + "columnName": "share_ore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareBp", + "columnName": "share_bp", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "expense_id", + "user_id" + ] + } + }, + { + "tableName": "settlements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `from_user_id` TEXT NOT NULL, `to_user_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, `settlement_date` TEXT NOT NULL, `note` TEXT NOT NULL, `created_by_user_id` TEXT, `updated_by_user_id` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `deleted_at` TEXT, `server_version` INTEGER, `pending` INTEGER NOT NULL, `pending_mutation_id` TEXT, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromUserId", + "columnName": "from_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toUserId", + "columnName": "to_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountOre", + "columnName": "amount_ore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settlementDate", + "columnName": "settlement_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdByUserId", + "columnName": "created_by_user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedByUserId", + "columnName": "updated_by_user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER" + }, + { + "fieldPath": "pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingMutationId", + "columnName": "pending_mutation_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_settlements_settlement_date", + "unique": false, + "columnNames": [ + "settlement_date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_settlement_date` ON `${TABLE_NAME}` (`settlement_date`)" + }, + { + "name": "index_settlements_pending", + "unique": false, + "columnNames": [ + "pending" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_settlements_pending` ON `${TABLE_NAME}` (`pending`)" + } + ] + }, + { + "tableName": "settlement_allocations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`settlement_id` TEXT NOT NULL, `account_id` TEXT NOT NULL, `amount_ore` INTEGER NOT NULL, PRIMARY KEY(`settlement_id`, `account_id`))", + "fields": [ + { + "fieldPath": "settlementId", + "columnName": "settlement_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountOre", + "columnName": "amount_ore", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "settlement_id", + "account_id" + ] + }, + "indices": [ + { + "name": "index_settlement_allocations_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_settlement_allocations_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ] + }, + { + "tableName": "audit_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `action` TEXT NOT NULL, `source` TEXT NOT NULL, `actor_user_id` TEXT NOT NULL, `client_at` TEXT NOT NULL, `server_at` TEXT NOT NULL, `changes` TEXT NOT NULL, `server_version` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entity_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorUserId", + "columnName": "actor_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientAt", + "columnName": "client_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverAt", + "columnName": "server_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changes", + "columnName": "changes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_audit_log_entity_type_entity_id", + "unique": false, + "columnNames": [ + "entity_type", + "entity_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_audit_log_entity_type_entity_id` ON `${TABLE_NAME}` (`entity_type`, `entity_id`)" + } + ] + }, + { + "tableName": "sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `epoch` TEXT, `last_success_at` TEXT, `last_attempt_at` TEXT, `last_error_code` TEXT, `clock_skew` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSuccessAt", + "columnName": "last_success_at", + "affinity": "TEXT" + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "last_attempt_at", + "affinity": "TEXT" + }, + { + "fieldPath": "lastErrorCode", + "columnName": "last_error_code", + "affinity": "TEXT" + }, + { + "fieldPath": "clockSkew", + "columnName": "clock_skew", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sync_rejections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mutation_id` TEXT NOT NULL, `entity_type` TEXT NOT NULL, `entity_id` TEXT NOT NULL, `reason` TEXT NOT NULL, `message` TEXT NOT NULL, `rejected_payload` TEXT NOT NULL, `server_current` TEXT, `rejected_at` TEXT NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`mutation_id`))", + "fields": [ + { + "fieldPath": "mutationId", + "columnName": "mutation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityType", + "columnName": "entity_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rejectedPayload", + "columnName": "rejected_payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverCurrent", + "columnName": "server_current", + "affinity": "TEXT" + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mutation_id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '294140ea943f5cc1f2c01e4725cac69d')" + ] + } +} \ No newline at end of file diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt new file mode 100644 index 0000000..5c8a648 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Entities.kt @@ -0,0 +1,191 @@ +package se.hagstedt.kvitt.data.db + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.Index +import androidx.room3.PrimaryKey + +// Room entities of SPEC §14.2. Table and column names are the server's (§14.1); no foreign keys (sync pages may +// deliver a child before its parent). Timestamps are fixed-form RFC 3339 Strings, dates `YYYY-MM-DD`, money Long, bp Int. +// `local_updated_at` is epoch milliseconds on the phone clock, set on every write and never sent. + +// 14.2.1 Mirrored tables + +@Entity(tableName = "users") +data class UserEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "role") val role: String, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "picture_version") val pictureVersion: Int?, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "server_version") val serverVersion: Long, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "accounts", indices = [Index("created_at")]) +data class AccountEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "created_by_user_id") val createdByUserId: String, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "archived_at") val archivedAt: String?, + @ColumnInfo(name = "deleted_at") val deletedAt: String?, + @ColumnInfo(name = "server_version") val serverVersion: Long, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "account_default_shares", primaryKeys = ["account_id", "user_id"]) +data class AccountDefaultShareEntity( + @ColumnInfo(name = "account_id") val accountId: String, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "share_bp") val shareBp: Int, +) + +@Entity(tableName = "categories") +data class CategoryEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "default_key") val defaultKey: String?, + @ColumnInfo(name = "name") val name: String?, + @ColumnInfo(name = "icon") val icon: String, + @ColumnInfo(name = "color") val color: String, + @ColumnInfo(name = "sort_order") val sortOrder: Int, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "archived_at") val archivedAt: String?, + @ColumnInfo(name = "deleted_at") val deletedAt: String?, + @ColumnInfo(name = "server_version") val serverVersion: Long, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "recurring_templates", indices = [Index("account_id")]) +data class RecurringTemplateEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "account_id") val accountId: String, + @ColumnInfo(name = "category_id") val categoryId: String, + @ColumnInfo(name = "amount_ore") val amountOre: Long, + @ColumnInfo(name = "description") val description: String, + @ColumnInfo(name = "notes") val notes: String, + @ColumnInfo(name = "payer_user_id") val payerUserId: String, + @ColumnInfo(name = "split_mode") val splitMode: String, + @ColumnInfo(name = "day_of_month") val dayOfMonth: Int, + @ColumnInfo(name = "start_period") val startPeriod: String, + @ColumnInfo(name = "end_period") val endPeriod: String?, + @ColumnInfo(name = "paused") val paused: Boolean, + @ColumnInfo(name = "last_generated_period") val lastGeneratedPeriod: String?, + @ColumnInfo(name = "created_by_user_id") val createdByUserId: String, + @ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "deleted_at") val deletedAt: String?, + @ColumnInfo(name = "server_version") val serverVersion: Long, + @ColumnInfo(name = "next_occurrence_date") val nextOccurrenceDate: String?, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "recurring_template_splits", primaryKeys = ["template_id", "user_id"]) +data class RecurringTemplateSplitEntity( + @ColumnInfo(name = "template_id") val templateId: String, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "share_bp") val shareBp: Int?, + @ColumnInfo(name = "share_ore") val shareOre: Long?, +) + +@Entity(tableName = "expenses", indices = [Index("account_id"), Index("expense_date"), Index("pending")]) +data class ExpenseEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "account_id") val accountId: String, + @ColumnInfo(name = "category_id") val categoryId: String, + @ColumnInfo(name = "amount_ore") val amountOre: Long, + @ColumnInfo(name = "description") val description: String, + @ColumnInfo(name = "notes") val notes: String, + @ColumnInfo(name = "expense_date") val expenseDate: String, + @ColumnInfo(name = "payer_user_id") val payerUserId: String, + @ColumnInfo(name = "split_mode") val splitMode: String, + @ColumnInfo(name = "recurring_template_id") val recurringTemplateId: String?, + @ColumnInfo(name = "recurring_period") val recurringPeriod: String?, + @ColumnInfo(name = "created_by_user_id") val createdByUserId: String?, + @ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "deleted_at") val deletedAt: String?, + @ColumnInfo(name = "server_version") val serverVersion: Long?, + @ColumnInfo(name = "pending") val pending: Boolean, + @ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "expense_splits", primaryKeys = ["expense_id", "user_id"]) +data class ExpenseSplitEntity( + @ColumnInfo(name = "expense_id") val expenseId: String, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "share_ore") val shareOre: Long, + @ColumnInfo(name = "share_bp") val shareBp: Int?, +) + +@Entity(tableName = "settlements", indices = [Index("settlement_date"), Index("pending")]) +data class SettlementEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "from_user_id") val fromUserId: String, + @ColumnInfo(name = "to_user_id") val toUserId: String, + @ColumnInfo(name = "amount_ore") val amountOre: Long, + @ColumnInfo(name = "settlement_date") val settlementDate: String, + @ColumnInfo(name = "note") val note: String, + @ColumnInfo(name = "created_by_user_id") val createdByUserId: String?, + @ColumnInfo(name = "updated_by_user_id") val updatedByUserId: String?, + @ColumnInfo(name = "created_at") val createdAt: String, + @ColumnInfo(name = "updated_at") val updatedAt: String, + @ColumnInfo(name = "deleted_at") val deletedAt: String?, + @ColumnInfo(name = "server_version") val serverVersion: Long?, + @ColumnInfo(name = "pending") val pending: Boolean, + @ColumnInfo(name = "pending_mutation_id") val pendingMutationId: String?, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, +) + +@Entity(tableName = "settlement_allocations", primaryKeys = ["settlement_id", "account_id"], indices = [Index("account_id")]) +data class SettlementAllocationEntity( + @ColumnInfo(name = "settlement_id") val settlementId: String, + @ColumnInfo(name = "account_id") val accountId: String, + @ColumnInfo(name = "amount_ore") val amountOre: Long, +) + +@Entity(tableName = "audit_log", indices = [Index("entity_type", "entity_id")]) +data class AuditEntryEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "entity_type") val entityType: String, + @ColumnInfo(name = "entity_id") val entityId: String, + @ColumnInfo(name = "action") val action: String, + @ColumnInfo(name = "source") val source: String, + @ColumnInfo(name = "actor_user_id") val actorUserId: String, + @ColumnInfo(name = "client_at") val clientAt: String, + @ColumnInfo(name = "server_at") val serverAt: String, + @ColumnInfo(name = "changes") val changes: String, + @ColumnInfo(name = "server_version") val serverVersion: Long, +) + +// 14.2.2 Local-only tables + +@Entity(tableName = "sync_state") +data class SyncStateEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "cursor") val cursor: Long, + @ColumnInfo(name = "epoch") val epoch: String?, + @ColumnInfo(name = "last_success_at") val lastSuccessAt: String?, + @ColumnInfo(name = "last_attempt_at") val lastAttemptAt: String?, + @ColumnInfo(name = "last_error_code") val lastErrorCode: String?, + @ColumnInfo(name = "clock_skew") val clockSkew: Boolean, +) + +@Entity(tableName = "sync_rejections") +data class SyncRejectionEntity( + @PrimaryKey @ColumnInfo(name = "mutation_id") val mutationId: String, + @ColumnInfo(name = "entity_type") val entityType: String, + @ColumnInfo(name = "entity_id") val entityId: String, + @ColumnInfo(name = "reason") val reason: String, + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "rejected_payload") val rejectedPayload: String, + @ColumnInfo(name = "server_current") val serverCurrent: String?, + @ColumnInfo(name = "rejected_at") val rejectedAt: String, + @ColumnInfo(name = "dismissed") val dismissed: Boolean, +) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt new file mode 100644 index 0000000..90d8702 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt @@ -0,0 +1,33 @@ +package se.hagstedt.kvitt.data.db + +import androidx.room3.Database +import androidx.room3.RoomDatabase + +/** + * The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`. + * DAOs, the builder and the SQLite driver are added by M5. Every later version adds a migration and its test. + */ +@Database( + entities = [ + UserEntity::class, + AccountEntity::class, + AccountDefaultShareEntity::class, + CategoryEntity::class, + RecurringTemplateEntity::class, + RecurringTemplateSplitEntity::class, + ExpenseEntity::class, + ExpenseSplitEntity::class, + SettlementEntity::class, + SettlementAllocationEntity::class, + AuditEntryEntity::class, + SyncStateEntity::class, + SyncRejectionEntity::class, + ], + version = 1, + exportSchema = true, +) +abstract class KvittDatabase : RoomDatabase() { + companion object { + const val FILE_NAME = "kvitt.db" + } +} diff --git a/android/domain/build.gradle.kts b/android/domain/build.gradle.kts new file mode 100644 index 0000000..29d51f5 --- /dev/null +++ b/android/domain/build.gradle.kts @@ -0,0 +1,20 @@ +// :domain — the pure rules of SPEC §14.5 (split, balances, allocation, settlement check, analytics). +plugins { + id("kvitt.jvm.library") +} + +dependencies { + api(project(":core:model")) + testImplementation(libs.kotlinx.serialization.json) +} + +// The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources. +// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present. +val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors") +val copyTestVectors = tasks.register("copyTestVectors") { + description = "Copies contracts/test-vectors/*.json into the :domain test resources." + from(layout.projectDirectory.dir("../../contracts/test-vectors")) { include("*.json") } + into(testVectorsRoot.map { it.dir("test-vectors") }) +} +sourceSets.named("test") { resources.srcDir(testVectorsRoot) } +tasks.named("processTestResources") { dependsOn(copyTestVectors) } diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Allocation.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Allocation.kt new file mode 100644 index 0000000..831f466 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Allocation.kt @@ -0,0 +1,64 @@ +package se.hagstedt.kvitt.domain + +import se.hagstedt.kvitt.model.money.AccountPosition +import se.hagstedt.kvitt.model.money.Allocation +import se.hagstedt.kvitt.model.money.AllocationError +import se.hagstedt.kvitt.model.money.AllocationResult +import se.hagstedt.kvitt.model.money.SettlementError + +private val accountOrder = compareBy({ it.createdAt }, { it.accountId }) + +/** Settlement allocation proposal, the normative algorithm of SPEC §9.4 (§14.5.2). */ +fun proposeAllocation(amountOre: Long, accounts: List): AllocationResult { + if (!amountInRange(amountOre)) return AllocationResult.Err(AllocationError.AMOUNT_OUT_OF_RANGE) + if (accounts.isEmpty()) return AllocationResult.Err(AllocationError.NO_ACCOUNTS) + + val owing = accounts.filter { it.payerNetOre < 0 }.sortedWith(accountOrder) + if (owing.isEmpty()) { + val target = accounts.filter { !it.archived }.sortedWith(accountOrder).firstOrNull() + ?: accounts.sortedWith(accountOrder).first() + return AllocationResult.Ok(listOf(Allocation(target.accountId, amountOre)), amountOre, listOf(target.accountId)) + } + val allocations = mutableListOf() + var remaining = amountOre + for (position in owing) { + if (remaining <= 0) break + val amount = minOf(remaining, -position.payerNetOre) + allocations += Allocation(position.accountId, amount) + remaining -= amount + } + if (remaining <= 0) return AllocationResult.Ok(allocations, 0L, emptyList()) + val last = allocations.removeAt(allocations.lastIndex) + allocations += last.copy(amountOre = last.amountOre + remaining) + return AllocationResult.Ok(allocations, remaining, listOf(last.accountId)) +} + +/** Settlement validity of SPEC §9.4; errors in [SettlementError] order, `null` when valid. References are not checked. */ +fun validateSettlement( + amountOre: Long, + fromUserId: String, + toUserId: String, + userIds: List, + allocations: List, +): SettlementError? = when { + !amountInRange(amountOre) -> SettlementError.AMOUNT_OUT_OF_RANGE + fromUserId == toUserId -> SettlementError.SAME_USER + fromUserId !in userIds || toUserId !in userIds -> SettlementError.USER_UNKNOWN + allocations.isEmpty() -> SettlementError.NO_ALLOCATIONS + allocations.size > MAX_ALLOCATIONS -> SettlementError.TOO_MANY_ALLOCATIONS + allocations.any { it.amountOre < 1 } -> SettlementError.ALLOCATION_NOT_POSITIVE + allocations.map { it.accountId }.toSet().size != allocations.size -> SettlementError.DUPLICATE_ACCOUNT + // Every allocation is >= 1 here, so one above the amount already proves a mismatch (and avoids overflow). + allocations.any { it.amountOre > amountOre } || allocations.sumOf { it.amountOre } != amountOre -> + SettlementError.ALLOCATION_SUM_MISMATCH + else -> null +} + +/** + * Over-settle warnings (SPEC §9.4): ids, in allocation order, whose allocation exceeds max(0, -payerNetOre). + * An allocation for an account not in [accounts] counts as net 0. + */ +fun overSettledAccounts(allocations: List, accounts: List): List { + val net = accounts.associate { it.accountId to it.payerNetOre } + return allocations.filter { it.amountOre > maxOf(0L, -(net[it.accountId] ?: 0L)) }.map { it.accountId } +} diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Analytics.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Analytics.kt new file mode 100644 index 0000000..caad8a1 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Analytics.kt @@ -0,0 +1,115 @@ +package se.hagstedt.kvitt.domain + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.analytics.AnalyticsExpense +import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity +import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy +import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure +import se.hagstedt.kvitt.model.analytics.Bucket +import se.hagstedt.kvitt.model.analytics.DateRange +import se.hagstedt.kvitt.model.analytics.SpendSeries + +/** + * Spend per bucket and group (SPEC §14.5.4). Buckets cover the range completely, in order, empty ones included, with + * their start and end clipped to the range. Zero groups are omitted. Expenses outside the range are ignored. + */ +fun spendSeries( + expenses: List, + range: DateRange, + granularity: AnalyticsGranularity, + measure: AnalyticsMeasure, + groupBy: AnalyticsGroupBy, +): SpendSeries { + val start = range.start + val end = range.endInclusive + val keyOf: (LocalDate) -> String = when (granularity) { + AnalyticsGranularity.MONTH -> ::monthKey + AnalyticsGranularity.WEEK -> ::isoWeekKey + } + val slots = mutableListOf>() + var cursor = when (granularity) { + AnalyticsGranularity.MONTH -> LocalDate(start.year, start.month, 1) + AnalyticsGranularity.WEEK -> start.minus(start.dayOfWeek.isoDayNumber - 1, DateTimeUnit.DAY) + } + while (cursor <= end) { + val next = when (granularity) { + AnalyticsGranularity.MONTH -> cursor.plus(1, DateTimeUnit.MONTH) + AnalyticsGranularity.WEEK -> cursor.plus(7, DateTimeUnit.DAY) + } + val last = next.minus(1, DateTimeUnit.DAY) + slots += Triple(keyOf(cursor), maxOf(cursor, start), minOf(last, end)) + cursor = next + } + + val totals = slots.associateTo(LinkedHashMap()) { it.first to LinkedHashMap() } + for (expense in expenses) { + if (expense.date < start || expense.date > end) continue + val value = when (measure) { + AnalyticsMeasure.Household -> expense.amountOre + is AnalyticsMeasure.Share -> expense.shares[measure.userId] ?: 0L + } + val group = when (groupBy) { + AnalyticsGroupBy.CATEGORY -> expense.categoryId + AnalyticsGroupBy.ACCOUNT -> expense.accountId + } + totals[keyOf(expense.date)]?.let { it[group] = (it[group] ?: 0L) + value } + } + + val groupTotals = LinkedHashMap() + val buckets = slots.map { (key, bucketStart, bucketEnd) -> + val nonZero = totals[key].orEmpty().filterValues { it != 0L } + nonZero.forEach { (group, value) -> groupTotals[group] = (groupTotals[group] ?: 0L) + value } + Bucket(key, bucketStart, bucketEnd, nonZero, nonZero.values.sum()) + } + val nonZeroGroups = groupTotals.filterValues { it != 0L } + return SpendSeries(buckets, nonZeroGroups, nonZeroGroups.values.sum()) +} + +/** `CUSTOM` without a start or an end date: the caller maps this to `validation_failed` (SPEC §14.5.4). */ +class IncompleteCustomRangeException(missing: String) : + IllegalArgumentException("resolveRange: CUSTOM needs both dates; $missing is null") + +/** + * The date range of an analytics preset (SPEC §14.5.4, AC-14.2). + * @throws IncompleteCustomRangeException for [RangePreset.CUSTOM] with a null [customStart] or [customEnd]. + */ +fun resolveRange( + preset: RangePreset, + today: LocalDate, + earliestExpense: LocalDate?, + customStart: LocalDate?, + customEnd: LocalDate?, +): DateRange { + val firstOfMonth = LocalDate(today.year, today.month, 1) + return when (preset) { + RangePreset.THIS_MONTH -> DateRange(firstOfMonth, today) + RangePreset.LAST_3_MONTHS -> DateRange(firstOfMonth.minus(2, DateTimeUnit.MONTH), today) + RangePreset.LAST_6_MONTHS -> DateRange(firstOfMonth.minus(5, DateTimeUnit.MONTH), today) + RangePreset.LAST_12_MONTHS -> DateRange(firstOfMonth.minus(11, DateTimeUnit.MONTH), today) + RangePreset.THIS_YEAR -> DateRange(LocalDate(today.year, 1, 1), today) + // An earliest expense after today gives today..today (SPEC §14.5.4). + RangePreset.ALL_TIME -> DateRange(earliestExpense?.takeIf { it <= today } ?: today, today) + RangePreset.CUSTOM -> { + val s = customStart ?: throw IncompleteCustomRangeException("customStart") + val e = customEnd ?: throw IncompleteCustomRangeException("customEnd") + if (s > e) DateRange(e, s) else DateRange(s, e) + } + } +} + +private fun monthKey(date: LocalDate): String = "${pad(date.year, 4)}-${pad(date.month.number, 2)}" + +/** ISO week key `YYYY-Www`: the week-numbering year is the year of the week's Thursday. */ +private fun isoWeekKey(date: LocalDate): String { + val thursday = date.plus(4 - date.dayOfWeek.isoDayNumber, DateTimeUnit.DAY) + val week = (thursday.dayOfYear - 1) / 7 + 1 + return "${pad(thursday.year, 4)}-W${pad(week, 2)}" +} + +private fun pad(value: Int, width: Int): String = value.toString().padStart(width, '0') diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Balances.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Balances.kt new file mode 100644 index 0000000..6e33dc9 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Balances.kt @@ -0,0 +1,41 @@ +package se.hagstedt.kvitt.domain + +import se.hagstedt.kvitt.model.money.BalanceExpense +import se.hagstedt.kvitt.model.money.BalanceSettlement +import se.hagstedt.kvitt.model.money.Balances + +/** + * Net per account and user (SPEC §9.3, §14.5.2). Inputs hold only non-deleted rows. [Balances.perAccount] has an entry + * for every id of [accountIds] and for any other account referenced; each inner map has every id of [userIds]. + */ +fun computeBalances( + userIds: List, + accountIds: List, + expenses: List, + settlements: List, +): Balances { + val perAccount = LinkedHashMap>() + fun account(id: String): MutableMap = + perAccount.getOrPut(id) { userIds.associateWithTo(LinkedHashMap()) { 0L } } + + accountIds.forEach { account(it) } + for (expense in expenses) { + val net = account(expense.accountId) + net.add(expense.payerUserId, expense.amountOre) + expense.shares.forEach { (user, share) -> net.add(user, -share) } + } + for (settlement in settlements) { + settlement.allocations.forEach { (accountId, amount) -> + val net = account(accountId) + net.add(settlement.fromUserId, amount) + net.add(settlement.toUserId, -amount) + } + } + val global = userIds.associateWithTo(LinkedHashMap()) { 0L } + perAccount.values.forEach { net -> net.forEach { (user, value) -> global.add(user, value) } } + return Balances(perAccount, global) +} + +private fun MutableMap.add(key: String, value: Long) { + this[key] = (this[key] ?: 0L) + value +} diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Limits.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Limits.kt new file mode 100644 index 0000000..9ef0df8 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Limits.kt @@ -0,0 +1,13 @@ +package se.hagstedt.kvitt.domain + +/** Amount limits of SPEC §9.6 (expense, settlement, template). */ +const val AMOUNT_MIN_ORE: Long = 1L +const val AMOUNT_MAX_ORE: Long = 1_000_000_000L + +/** 10000 bp = 100 % (SPEC §9.1). */ +const val BP_TOTAL: Int = 10_000 + +/** Allocations per settlement (SPEC §9.6). */ +const val MAX_ALLOCATIONS: Int = 50 + +internal fun amountInRange(amountOre: Long): Boolean = amountOre in AMOUNT_MIN_ORE..AMOUNT_MAX_ORE diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Split.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Split.kt new file mode 100644 index 0000000..cbdbc92 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/Split.kt @@ -0,0 +1,45 @@ +package se.hagstedt.kvitt.domain + +import se.hagstedt.kvitt.model.money.SplitError +import se.hagstedt.kvitt.model.money.SplitResult +import se.hagstedt.kvitt.model.money.SplitSpec + +/** + * Shares of an expense (SPEC §9.1, §14.5.2). Errors are checked in [SplitError] order and the first failure wins. + * Ratio rule: the non-payer's share is rounded down, the payer absorbs the remainder. + */ +fun computeSplit(amountOre: Long, payerUserId: String, userIds: List, spec: SplitSpec): SplitResult { + if (!amountInRange(amountOre)) return SplitResult.Err(SplitError.AMOUNT_OUT_OF_RANGE) + val users = userIds.toSet() + if (userIds.size != 2 || users.size != 2) return SplitResult.Err(SplitError.USERS_INVALID) + if (payerUserId !in users) return SplitResult.Err(SplitError.PAYER_UNKNOWN) + return when (spec) { + is SplitSpec.Ratio -> when { + spec.bp.values.any { it !in 0..BP_TOTAL } -> SplitResult.Err(SplitError.BP_OUT_OF_RANGE) + spec.bp.values.sumOf { it.toLong() } != BP_TOTAL.toLong() -> SplitResult.Err(SplitError.BP_SUM_NOT_10000) + spec.bp.keys != users -> SplitResult.Err(SplitError.SHARE_USERS_MISMATCH) + else -> SplitResult.Ok(ratioRule(amountOre, payerUserId, userIds, spec.bp)) + } + is SplitSpec.Exact -> when { + spec.ore.values.any { it < 0 } -> SplitResult.Err(SplitError.EXACT_NEGATIVE) + // Every value is >= 0 here, so one value above the amount already proves a mismatch (and avoids overflow). + spec.ore.values.any { it > amountOre } || spec.ore.values.sum() != amountOre -> + SplitResult.Err(SplitError.EXACT_SUM_MISMATCH) + spec.ore.keys != users -> SplitResult.Err(SplitError.SHARE_USERS_MISMATCH) + else -> SplitResult.Ok(userIds.associateWith { spec.ore[it] ?: 0L }) + } + is SplitSpec.Full -> if (spec.bearerUserId !in users) { + SplitResult.Err(SplitError.BEARER_UNKNOWN) + } else { + val bp = userIds.associateWith { if (it == spec.bearerUserId) BP_TOTAL else 0 } + SplitResult.Ok(ratioRule(amountOre, payerUserId, userIds, bp)) + } + } +} + +private fun ratioRule(amountOre: Long, payerUserId: String, userIds: List, bp: Map): Map { + val nonPayer = userIds.first { it != payerUserId } + // 64-bit: the largest product, 1 000 000 000 x 10000, fits. Operands are non-negative, so division floors. + val nonPayerShare = amountOre * (bp[nonPayer] ?: 0) / BP_TOTAL + return mapOf(payerUserId to amountOre - nonPayerShare, nonPayer to nonPayerShare) +} diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/ResolveRangeTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/ResolveRangeTest.kt new file mode 100644 index 0000000..5e93e23 --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/ResolveRangeTest.kt @@ -0,0 +1,24 @@ +package se.hagstedt.kvitt.domain + +import kotlinx.datetime.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.analytics.DateRange + +/** Cases of SPEC §14.5.4 that no vector covers. */ +class ResolveRangeTest { + private val today = LocalDate(2026, 10, 17) + + @Test + fun customWithoutStartOrEndIsAnError() { + assertThrows(IncompleteCustomRangeException::class.java) { resolveRange(RangePreset.CUSTOM, today, null, null, today) } + assertThrows(IncompleteCustomRangeException::class.java) { resolveRange(RangePreset.CUSTOM, today, null, today, null) } + } + + @Test + fun allTimeWithEarliestAfterTodayIsTodayOnly() { + assertEquals(DateRange(today, today), resolveRange(RangePreset.ALL_TIME, today, LocalDate(2026, 12, 1), null, null)) + } +} diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt new file mode 100644 index 0000000..766c1be --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt @@ -0,0 +1,22 @@ +package se.hagstedt.kvitt.domain + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */ +class VectorFilesTest { + @Test + fun everyVectorFileIsPresentAndTested() { + val url = VectorFilesTest::class.java.getResource("/${Vectors.DIRECTORY}") + ?: error("no test-vectors resource directory: contracts/test-vectors was not copied") + val present = File(url.toURI()).listFiles().orEmpty().map { it.name }.filter { it.endsWith(".json") }.toSet() + assertEquals(VECTOR_FUNCTIONS.map { "$it.json" }.toSet(), present) + } + + @Test + fun everyFileHasCases() { + VECTOR_FUNCTIONS.forEach { assertTrue("$it.json has no cases", Vectors.cases(it).isNotEmpty()) } + } +} diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt new file mode 100644 index 0000000..0f1b768 --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt @@ -0,0 +1,159 @@ +package se.hagstedt.kvitt.domain + +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.analytics.AnalyticsExpense +import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity +import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy +import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure +import se.hagstedt.kvitt.model.analytics.DateRange +import se.hagstedt.kvitt.model.money.AccountPosition +import se.hagstedt.kvitt.model.money.Allocation +import se.hagstedt.kvitt.model.money.AllocationResult +import se.hagstedt.kvitt.model.money.BalanceExpense +import se.hagstedt.kvitt.model.money.BalanceSettlement +import se.hagstedt.kvitt.model.money.SplitResult +import se.hagstedt.kvitt.model.money.SplitSpec + +/** Every case of every file in contracts/test-vectors/ (SPEC §14.5.5), one JUnit test per case. */ +@RunWith(Parameterized::class) +class VectorTest(private val case: VectorCase, private val label: String) { + + @Test + fun matchesExpected() { + assertEquals(label, case.expected, run(case.function, case.input)) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{1}") + fun cases(): List> = VECTOR_FUNCTIONS.flatMap { Vectors.cases(it) }.map { arrayOf(it, "${it.function}: ${it.name}") } + + fun run(function: String, input: JsonObject): JsonElement = when (function) { + "compute_split" -> split(input) + "compute_balances" -> balances(input) + "propose_allocation" -> proposal(input) + "validate_settlement" -> settlement(input) + "over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input)))) + "spend_series" -> series(input) + "resolve_range" -> range(input) + else -> error("no runner for $function") + } + + private fun split(input: JsonObject): JsonElement { + val spec = input.getValue("spec").jsonObject + val splitSpec = when (val mode = spec.str("mode")) { + "ratio" -> SplitSpec.Ratio(spec.intMap("bp")) + "exact" -> SplitSpec.Exact(spec.longMap("ore")) + "full" -> SplitSpec.Full(spec.str("bearer_user_id")) + else -> error("unknown mode $mode") + } + return when (val r = computeSplit(input.long("amount_ore"), input.str("payer_user_id"), input.strings("user_ids"), splitSpec)) { + is SplitResult.Ok -> ok(longs(r.shares)) + is SplitResult.Err -> err(r.error) + } + } + + private fun balances(input: JsonObject): JsonElement { + val expenses = input.objects("expenses").map { + BalanceExpense(it.str("account_id"), it.long("amount_ore"), it.str("payer_user_id"), it.longMap("shares")) + } + val settlements = input.objects("settlements").map { + BalanceSettlement(it.str("from_user_id"), it.str("to_user_id"), it.longMap("allocations")) + } + val b = computeBalances(input.strings("user_ids"), input.strings("account_ids"), expenses, settlements) + return ok(JsonObject(mapOf("per_account" to JsonObject(b.perAccount.mapValues { longs(it.value) }), "global" to longs(b.global)))) + } + + private fun positions(input: JsonObject): List = input.objects("accounts").map { + AccountPosition(it.str("account_id"), it.str("created_at"), it.bool("archived"), it.long("payer_net_ore")) + } + + private fun allocations(input: JsonObject): List = + input.objects("allocations").map { Allocation(it.str("account_id"), it.long("amount_ore")) } + + private fun proposal(input: JsonObject): JsonElement = when (val r = proposeAllocation(input.long("amount_ore"), positions(input))) { + is AllocationResult.Ok -> ok( + JsonObject( + mapOf( + "allocations" to JsonArray(r.allocations.map { + JsonObject(mapOf("account_id" to JsonPrimitive(it.accountId), "amount_ore" to JsonPrimitive(it.amountOre))) + }), + "excess_ore" to JsonPrimitive(r.excessOre), + "over_settled_account_ids" to strings(r.overSettledAccountIds), + ), + ), + ) + is AllocationResult.Err -> err(r.error) + } + + private fun settlement(input: JsonObject): JsonElement { + val error = validateSettlement( + input.long("amount_ore"), input.str("from_user_id"), input.str("to_user_id"), input.strings("user_ids"), allocations(input), + ) + return if (error == null) ok(JsonNull) else err(error) + } + + private fun series(input: JsonObject): JsonElement { + val expenses = input.objects("expenses").map { + AnalyticsExpense(LocalDate.parse(it.str("date")), it.str("account_id"), it.str("category_id"), it.long("amount_ore"), it.longMap("shares")) + } + val r = input.getValue("range").jsonObject + val range = DateRange(LocalDate.parse(r.str("start")), LocalDate.parse(r.str("end_inclusive"))) + val granularity = when (val g = input.str("granularity")) { + "month" -> AnalyticsGranularity.MONTH + "week" -> AnalyticsGranularity.WEEK + else -> error("granularity $g") + } + val m = input.getValue("measure") + val measure = if (m is JsonPrimitive && m.content == "household") { + AnalyticsMeasure.Household + } else { + AnalyticsMeasure.Share(m.jsonObject.getValue("share").jsonPrimitive.content) + } + val groupBy = when (val g = input.str("group_by")) { + "category" -> AnalyticsGroupBy.CATEGORY + "account" -> AnalyticsGroupBy.ACCOUNT + else -> error("group_by $g") + } + val s = spendSeries(expenses, range, granularity, measure, groupBy) + return ok( + JsonObject( + mapOf( + "buckets" to JsonArray(s.buckets.map { + JsonObject( + mapOf( + "key" to JsonPrimitive(it.key), + "start" to JsonPrimitive(it.start.toString()), + "end_inclusive" to JsonPrimitive(it.endInclusive.toString()), + "totals" to longs(it.totals), + "total" to JsonPrimitive(it.total), + ), + ) + }), + "group_totals" to longs(s.groupTotals), + "grand_total" to JsonPrimitive(s.grandTotal), + ), + ), + ) + } + + private fun range(input: JsonObject): JsonElement { + val preset = RangePreset.valueOf(input.str("preset").uppercase()) + fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) } + val r = resolveRange(preset, LocalDate.parse(input.str("today")), date("earliest_expense"), date("custom_start"), date("custom_end")) + return ok(JsonObject(mapOf("start" to JsonPrimitive(r.start.toString()), "end_inclusive" to JsonPrimitive(r.endInclusive.toString())))) + } + } +} diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt new file mode 100644 index 0000000..846377f --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt @@ -0,0 +1,58 @@ +package se.hagstedt.kvitt.domain + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long + +/** The seven vector files of SPEC §14.5.5, keyed by function name. */ +val VECTOR_FUNCTIONS: List = listOf( + "compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts", + "spend_series", "resolve_range", +) + +data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement) + +object Vectors { + const val DIRECTORY = "test-vectors" + + fun text(function: String): String { + val path = "/$DIRECTORY/$function.json" + val stream = Vectors::class.java.getResourceAsStream(path) + ?: error("missing vector file $path (copied from contracts/test-vectors by :domain:copyTestVectors)") + return stream.use { it.readBytes().decodeToString() } + } + + fun cases(function: String): List { + val root = Json.parseToJsonElement(text(function)).jsonObject + check(root.getValue("format").jsonPrimitive.int == 1) { "$function.json: format must be 1" } + check(root.getValue("function").jsonPrimitive.content == function) { "$function.json: function key mismatch" } + return root.getValue("cases").jsonArray.map { case -> + val o = case.jsonObject + VectorCase(function, o.getValue("name").jsonPrimitive.content, o.getValue("input").jsonObject, o.getValue("expected")) + } + } +} + +// JSON helpers for the runner. +internal fun JsonObject.str(key: String): String = getValue(key).jsonPrimitive.content +internal fun JsonObject.strOrNull(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content +internal fun JsonObject.long(key: String): Long = getValue(key).jsonPrimitive.long +internal fun JsonObject.bool(key: String): Boolean = getValue(key).jsonPrimitive.boolean +internal fun JsonObject.strings(key: String): List = getValue(key).jsonArray.map { it.jsonPrimitive.content } +internal fun JsonObject.longMap(key: String): Map = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.long } +internal fun JsonObject.intMap(key: String): Map = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.int } +internal fun JsonObject.objects(key: String): List = getValue(key).jsonArray.map { it.jsonObject } + +internal fun ok(value: JsonElement): JsonElement = JsonObject(mapOf("ok" to value)) +internal fun err(token: Enum<*>): JsonElement = JsonObject(mapOf("error" to JsonPrimitive(token.name.lowercase()))) +internal fun longs(map: Map): JsonObject = JsonObject(map.mapValues { JsonPrimitive(it.value) }) +internal fun strings(list: List): JsonArray = JsonArray(list.map { JsonPrimitive(it) }) diff --git a/android/feature/account/build.gradle.kts b/android/feature/account/build.gradle.kts new file mode 100644 index 0000000..132870a --- /dev/null +++ b/android/feature/account/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:account — Account detail and account editor (S-5, S-6; M7b). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt new file mode 100644 index 0000000..48c4920 --- /dev/null +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.account + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun AccountEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/analytics/build.gradle.kts b/android/feature/analytics/build.gradle.kts new file mode 100644 index 0000000..d8a9f9b --- /dev/null +++ b/android/feature/analytics/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:analytics — Analytics and drill-down (S-11, S-12; M7e). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt new file mode 100644 index 0000000..ef01e0a --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.analytics + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun AnalyticsEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/expense/build.gradle.kts b/android/feature/expense/build.gradle.kts new file mode 100644 index 0000000..7cee3c2 --- /dev/null +++ b/android/feature/expense/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:expense — Expense editor, expense detail and timeline (S-7, S-8; M7c). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt new file mode 100644 index 0000000..cbbc881 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun ExpenseEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/home/build.gradle.kts b/android/feature/home/build.gradle.kts new file mode 100644 index 0000000..0d464c1 --- /dev/null +++ b/android/feature/home/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:home — Home with global balance, accounts, sync line and update banner (S-4, S-16, S-17; M7b). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt new file mode 100644 index 0000000..d698dbd --- /dev/null +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun HomeEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/onboarding/build.gradle.kts b/android/feature/onboarding/build.gradle.kts new file mode 100644 index 0000000..49aac00 --- /dev/null +++ b/android/feature/onboarding/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:onboarding — Pairing, partner profile step, waiting for partner, device removed (S-1, S-2, S-3, S-18; M7a). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt new file mode 100644 index 0000000..d780f5a --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.onboarding + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun OnboardingEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/settings/build.gradle.kts b/android/feature/settings/build.gradle.kts new file mode 100644 index 0000000..8757b07 --- /dev/null +++ b/android/feature/settings/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:settings — Settings hub, profile, categories, recurring, devices, invites, about (S-13 to S-15, S-19 to S-22; M7f). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt new file mode 100644 index 0000000..875008e --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun SettingsEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/feature/settle/build.gradle.kts b/android/feature/settle/build.gradle.kts new file mode 100644 index 0000000..44f373a --- /dev/null +++ b/android/feature/settle/build.gradle.kts @@ -0,0 +1,4 @@ +// :feature:settle — Settle up and settlement detail (S-9, S-10; M7d). Convention: android/README.md. +plugins { + id("kvitt.android.feature") +} diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt new file mode 100644 index 0000000..af5052a --- /dev/null +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleEntry.kt @@ -0,0 +1,14 @@ +package se.hagstedt.kvitt.feature.settle + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen + * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). + */ +@Composable +fun SettleEntry(modifier: Modifier = Modifier) { + Box(modifier) +} diff --git a/android/gradle.properties b/android/gradle.properties index 54ac376..e12c866 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,3 +10,6 @@ kotlin.code.style=official org.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home # Nothing is downloaded: if openjdk@25 is missing the build says so. org.gradle.java.installations.auto-download=false +# Screenshot tests (Roborazzi): every unit-test run verifies against the committed goldens, so `check` fails when a +# render differs. Re-record an intended change with: tools/gradle-serial.sh recordRoborazziDebug +roborazzi.test.verify=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..4116eff --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,86 @@ +# Kvitt version catalog. Versions are the latest stable releases on Google Maven / Maven Central as of 2026-10-17 +# (milestone M2b). Drift: python3 tools/dependency-report/latest.py +[versions] +minSdk = "36" +compileSdk = "37" +targetSdk = "37" +agp = "9.4.1" +kotlin = "2.4.21" +ksp = "2.3.12" +composeBom = "2026.09.00" +material3 = "1.4.0" +activityCompose = "1.13.0" +coreKtx = "1.19.1" +lifecycle = "2.11.0" +datastore = "1.2.1" +sqlite = "2.7.1" +navigationCompose = "2.10.2" +hilt = "2.60.1" +androidxHilt = "1.4.0" +room3 = "3.0.3" +ktor = "3.6.0" +work = "2.12.0" +kotlinxSerialization = "1.11.0" +kotlinxCoroutines = "1.11.0" +kotlinxDatetime = "0.8.0" +coil = "3.6.3" +vico = "3.3.1" +junit = "4.13.2" +robolectric = "4.17" +roborazzi = "1.76.0" + +[libraries] +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHilt" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" } +junit = { module = "junit:junit", version.ref = "junit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } +roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } +# Gradle plugins as libraries, for the convention plugins in build-logic +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +room3 = { id = "androidx.room3", version.ref = "room3" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9fa7f288283f81eec4952784c271abd7e0938dee GIT binary patch literal 48749 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..03754c4 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a1390d0 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,36 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "kvitt" + +// Module graph: SPEC §6.2. The allowed dependency edges are enforced by the architecture check (build-logic). +include( + ":core:model", + ":core:design", + ":core:fakes", + ":domain", + ":data", + ":adapter:fcm", + ":feature:onboarding", + ":feature:home", + ":feature:account", + ":feature:expense", + ":feature:settle", + ":feature:analytics", + ":feature:settings", + ":app", +) diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit index e06f87d..87629d3 100755 --- a/tools/git-hooks/pre-commit +++ b/tools/git-hooks/pre-commit @@ -20,7 +20,7 @@ if [ -x "$top/android/gradlew" ] && [ -d "$top/android/core/model" ]; then *\"25*) ;; *) [ -d /opt/homebrew/opt/openjdk@25 ] && export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home ;; esac - (cd "$top/android" && ./gradlew --quiet :core:model:test) || { + (cd "$top/android" && ./gradlew --quiet :core:model:test :domain:test) || { echo "pre-commit: Android fast tests failed (see above). Run tools/gradle-serial.sh check for the full picture." >&2 exit 1 }