M2b: Android skeleton, :core:model contract, :domain passing all vectors

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+53
View File
@@ -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)
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".KvittApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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()
@@ -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)
}
}
}
@@ -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()
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kvitt</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :app holds only the app name; shared strings live in :core:design, feature strings in each feature. -->
<resources>
<string name="app_name">Kvitt</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPEC §8.8: neither cloud backup nor device transfer copies the token or the database. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -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")
}
}