diff --git a/.gitignore b/.gitignore
index 309818b9..e8a96262 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,6 @@
/fastlane/report.xml
/fastlane/Appfile
/fastlane/README.md
-.kotlin
\ No newline at end of file
+.kotlin
+# Screenshot test reference images (ephemeral, regenerated on demand)
+app/src/screenshotTest*/reference/
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 959e43a4..eb3bc532 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,10 +3,10 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("com.google.devtools.ksp")
- id("kotlin-kapt")
id("kotlin-parcelize")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.android.compose.screenshot") version "0.0.1-alpha13"
}
apply(plugin = "dagger.hilt.android.plugin")
@@ -95,6 +95,8 @@ android {
}
}
+ experimentalProperties["android.experimental.enableScreenshotTest"] = true
+
buildFeatures {
viewBinding = true
buildConfig = true
@@ -193,4 +195,8 @@ dependencies {
"gplayImplementation"("com.android.billingclient:billing:8.0.0")
"gplayImplementation"("com.android.billingclient:billing-ktx:8.0.0")
+
+ "screenshotTestImplementation"(platform("androidx.compose:compose-bom:${Versions.Compose.bom}"))
+ "screenshotTestImplementation"("com.android.tools.screenshot:screenshot-validation-api:0.0.1-alpha13")
+ "screenshotTestImplementation"("androidx.compose.ui:ui-tooling")
}
\ No newline at end of file
diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt
new file mode 100644
index 00000000..20679233
--- /dev/null
+++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt
@@ -0,0 +1,355 @@
+package eu.darken.capod.screenshots
+
+import android.content.res.Configuration
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import eu.darken.capod.R
+import eu.darken.capod.common.compose.PreviewWrapper
+import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
+import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
+import eu.darken.capod.main.ui.widget.WidgetTheme
+import eu.darken.capod.common.compose.preview.MOCK_NOW
+import eu.darken.capod.common.compose.preview.MockPodDataProvider
+import eu.darken.capod.common.theming.CapodTheme
+import eu.darken.capod.main.ui.overview.OverviewScreen
+import eu.darken.capod.main.ui.overview.OverviewViewModel
+import eu.darken.capod.main.ui.settings.SettingsScreen
+import eu.darken.capod.main.ui.settings.SettingsViewModel
+import eu.darken.capod.pods.core.PodDevice
+import eu.darken.capod.profiles.ui.DeviceManagerScreen
+import eu.darken.capod.profiles.ui.DeviceManagerViewModel
+import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
+import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationViewModel
+import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
+import eu.darken.capod.reaction.ui.ReactionSettingsScreen
+import eu.darken.capod.reaction.ui.ReactionSettingsViewModel
+
+internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
+
+@Composable
+internal fun DashboardContent() = PreviewWrapper {
+ OverviewScreen(
+ state = OverviewViewModel.State(
+ now = MOCK_NOW,
+ permissions = emptySet(),
+ devices = listOf(
+ MockPodDataProvider.airPodsProMixed(),
+ MockPodDataProvider.airPodsMax(),
+ MockPodDataProvider.unknownDevice(),
+ ),
+ isDebugMode = false,
+ isBluetoothEnabled = true,
+ profiles = listOf(
+ MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
+ MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
+ ),
+ upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
+ showUnmatchedDevices = false,
+ ),
+ onRequestPermission = {},
+ onManageDevices = {},
+ onSettings = {},
+ onUpgrade = {},
+ onToggleUnmatched = {},
+ )
+}
+
+@Composable
+internal fun DeviceProfilesContent() = PreviewWrapper {
+ DeviceManagerScreen(
+ state = DeviceManagerViewModel.State(
+ profiles = listOf(
+ MockPodDataProvider.profile("Work AirPods", PodDevice.Model.AIRPODS_PRO2),
+ MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
+ MockPodDataProvider.profile("Gym Beats", PodDevice.Model.POWERBEATS_PRO),
+ ),
+ ),
+ onBack = {},
+ onAddDevice = {},
+ onEditProfile = {},
+ )
+}
+
+@Composable
+internal fun AddProfileContent() = PreviewWrapper {
+ DeviceProfileCreationScreen(
+ state = DeviceProfileCreationViewModel.State(
+ isEditMode = false,
+ name = "",
+ nameError = null,
+ selectedModel = null,
+ availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
+ identityKey = null,
+ encryptionKey = null,
+ selectedDevice = null,
+ bondedDevices = emptyList(),
+ minimumSignalQuality = 0.15f,
+ canSave = false,
+ ),
+ onBack = {},
+ onSave = {},
+ onDelete = {},
+ onNameChange = {},
+ onModelChange = {},
+ onDeviceChange = {},
+ onIdentityKeyChange = {},
+ onEncryptionKeyChange = {},
+ onSignalQualityChange = {},
+ onKeyGuide = {},
+ )
+}
+
+@Composable
+internal fun SettingsIndexContent() = PreviewWrapper {
+ SettingsScreen(
+ state = SettingsViewModel.State(sponsorUrl = "https://example.com"),
+ onNavigateUp = {},
+ onGeneralSettings = {},
+ onDeviceManager = {},
+ onReactions = {},
+ onSupport = {},
+ onChangelog = {},
+ onHelpTranslate = {},
+ onAcknowledgements = {},
+ onPrivacyPolicy = {},
+ onSponsor = {},
+ )
+}
+
+@Composable
+internal fun ReactionSettingsContent() = PreviewWrapper {
+ ReactionSettingsScreen(
+ state = ReactionSettingsViewModel.State(
+ isPro = true,
+ onePodMode = false,
+ autoPlay = true,
+ autoPause = true,
+ autoConnect = false,
+ autoConnectCondition = AutoConnectCondition.WHEN_SEEN,
+ showPopUpOnCaseOpen = true,
+ showPopUpOnConnection = false,
+ ),
+ onNavigateUp = {},
+ onOnePodModeChanged = {},
+ onAutoPlayChanged = {},
+ onAutoPauseChanged = {},
+ onAutoConnectChanged = {},
+ onAutoConnectConditionSelected = {},
+ onShowPopUpOnCaseOpenChanged = {},
+ onShowPopUpOnConnectionChanged = {},
+ )
+}
+
+@Composable
+internal fun WidgetConfigurationContent() = PreviewWrapper {
+ val profiles = listOf(
+ MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
+ MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
+ )
+ WidgetConfigurationScreen(
+ state = WidgetConfigurationViewModel.State(
+ profiles = profiles,
+ selectedProfile = profiles.first().id,
+ isPro = true,
+ theme = WidgetTheme.DEFAULT,
+ activePreset = WidgetTheme.Preset.MATERIAL_YOU,
+ isCustomMode = false,
+ ),
+ onSelectProfile = {},
+ onSelectPreset = {},
+ onEnterCustomMode = { _, _ -> },
+ onSetBackgroundColor = {},
+ onSetForegroundColor = {},
+ onSetBackgroundAlpha = {},
+ onSetShowDeviceLabel = {},
+ onReset = {},
+ onConfirm = {},
+ onCancel = {},
+ )
+}
+
+@Composable
+internal fun HomescreenWidgetContent() {
+ CapodTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF1A237E),
+ Color(0xFF0D47A1),
+ Color(0xFF006064),
+ )
+ )
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ WidgetDualCompact()
+ }
+ }
+}
+
+@Composable
+private fun WidgetDualCompact() {
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ WidgetPodRow(
+ icon = R.drawable.device_airpods_pro2_left,
+ percent = 85,
+ charging = false,
+ inEar = true,
+ )
+ WidgetPodRow(
+ icon = R.drawable.device_airpods_pro2_right,
+ percent = 92,
+ charging = true,
+ inEar = false,
+ )
+ WidgetCaseRow(
+ icon = R.drawable.device_airpods_pro2_case,
+ percent = 100,
+ charging = false,
+ )
+ Text(
+ text = "My AirPods Pro",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+@Composable
+private fun WidgetPodRow(
+ @DrawableRes icon: Int,
+ percent: Int,
+ charging: Boolean,
+ inEar: Boolean,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(vertical = 2.dp),
+ ) {
+ Image(
+ painter = painterResource(icon),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "$percent%",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ if (charging) {
+ Image(
+ painter = painterResource(R.drawable.ic_baseline_power_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ if (inEar) {
+ Image(
+ painter = painterResource(R.drawable.ic_baseline_hearing_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun WidgetCaseRow(
+ @DrawableRes icon: Int,
+ percent: Int,
+ charging: Boolean,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(vertical = 2.dp),
+ ) {
+ Image(
+ painter = painterResource(icon),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "$percent%",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
+ if (charging) {
+ Image(
+ painter = painterResource(R.drawable.ic_baseline_power_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Preview(name = "1 - Dashboard Light", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewDashboardLight() = DashboardContent()
+
+@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
+@Composable
+private fun PreviewDashboardDark() = DashboardContent()
+
+@Preview(name = "3 - Device Profiles", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewDeviceProfiles() = DeviceProfilesContent()
+
+@Preview(name = "4 - Add Profile", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewAddProfile() = AddProfileContent()
+
+@Preview(name = "5 - Settings", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewSettingsIndex() = SettingsIndexContent()
+
+@Preview(name = "6 - Reaction Settings", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewReactionSettings() = ReactionSettingsContent()
+
+@Preview(name = "7 - Homescreen Widget", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewHomescreenWidget() = HomescreenWidgetContent()
+
+@Preview(name = "7 - Widget Configuration", locale = "en", device = DS, showSystemUi = true)
+@Composable
+private fun PreviewWidgetConfiguration() = WidgetConfigurationContent()
diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt
index f7f121b4..da370ace 100644
--- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt
+++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt
@@ -18,10 +18,13 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Instant
+/** Fixed timestamp for deterministic preview/screenshot rendering. */
+val MOCK_NOW: Instant = Instant.parse("2025-06-15T10:30:00Z")
+
object MockPodDataProvider {
fun dummyScanResult(rssi: Int = -50): BleScanResult = BleScanResult(
- receivedAt = Instant.now(),
+ receivedAt = MOCK_NOW,
address = "AA:BB:CC:DD:EE:FF",
rssi = rssi,
generatedAtNanos = 0L,
@@ -177,8 +180,8 @@ private class MockDualPodDevice(
) : DualPodDevice, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
- override val seenLastAt: Instant = Instant.now()
- override val seenFirstAt: Instant = Instant.now()
+ override val seenLastAt: Instant = MOCK_NOW
+ override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
@@ -219,8 +222,8 @@ private class MockDualPodDeviceNoCase(
) : DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
- override val seenLastAt: Instant = Instant.now()
- override val seenFirstAt: Instant = Instant.now()
+ override val seenLastAt: Instant = MOCK_NOW
+ override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
@@ -251,8 +254,8 @@ private class MockSinglePodDevice(
) : SinglePodDevice, HasChargeDetection, HasEarDetection {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
- override val seenLastAt: Instant = Instant.now()
- override val seenFirstAt: Instant = Instant.now()
+ override val seenLastAt: Instant = MOCK_NOW
+ override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt
index 8e2a7651..47c6c6e4 100644
--- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt
+++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt
@@ -121,6 +121,14 @@ fun WidgetConfigurationScreen(
.verticalScroll(rememberScrollState())
.padding(top = 24.dp, bottom = 16.dp),
) {
+ Text(
+ text = stringResource(R.string.widget_config_screen_title),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = screenHPad, end = screenHPad, bottom = 16.dp),
+ )
+
// Card A — Select Device
Card(
modifier = Modifier
@@ -170,16 +178,15 @@ fun WidgetConfigurationScreen(
) {
Column(modifier = Modifier.padding(cardPad)) {
// Appearance header + reset
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.widget_config_appearance_label),
style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.weight(1f),
)
- TextButton(onClick = onReset) {
+ TextButton(
+ onClick = onReset,
+ modifier = Modifier.align(Alignment.End),
+ ) {
Text(text = stringResource(R.string.widget_config_reset_label))
}
}
@@ -327,7 +334,7 @@ fun WidgetConfigurationScreen(
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 12.dp),
+ .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@@ -496,11 +503,13 @@ private fun WidgetPreview(
// Inner preview content
LayoutInflater.from(ctx).inflate(R.layout.widget_config_preview, clipWrapper, true)
- container.addView(clipWrapper, android.widget.FrameLayout.LayoutParams(
- android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
- android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
- android.view.Gravity.CENTER,
- ))
+ container.addView(
+ clipWrapper, android.widget.FrameLayout.LayoutParams(
+ android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
+ android.view.Gravity.CENTER,
+ )
+ )
container
},
@@ -744,7 +753,14 @@ private fun HexColorInput(
) {
val hexString = color?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
- var textFieldValue by remember { mutableStateOf(TextFieldValue(text = hexString, selection = TextRange(hexString.length))) }
+ var textFieldValue by remember {
+ mutableStateOf(
+ TextFieldValue(
+ text = hexString,
+ selection = TextRange(hexString.length)
+ )
+ )
+ }
// Sync from external color changes (e.g. swatch clicks) only when content genuinely differs
LaunchedEffect(hexString) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e135163e..2018f2dc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -117,6 +117,7 @@
darken
A widget showing the last known device status.
+ Widget Configuration
Select Device
Choose which device profile this widget should display.
This feature requires CAPod Pro.
diff --git a/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreLocales.kt b/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreLocales.kt
new file mode 100644
index 00000000..3215c9ae
--- /dev/null
+++ b/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreLocales.kt
@@ -0,0 +1,162 @@
+package eu.darken.capod.screenshots
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * Multi-preview annotation generating one preview per Play Store-supported locale (light mode).
+ * Each [name] is the fastlane metadata directory name for direct use in the copy script.
+ */
+@Preview(locale = "en", name = "en-US", device = DS)
+@Preview(locale = "af", name = "af", device = DS)
+@Preview(locale = "am", name = "am", device = DS)
+@Preview(locale = "ar", name = "ar", device = DS)
+@Preview(locale = "az", name = "az-AZ", device = DS)
+@Preview(locale = "be", name = "be", device = DS)
+@Preview(locale = "bg", name = "bg", device = DS)
+@Preview(locale = "bn-BD", name = "bn-BD", device = DS)
+@Preview(locale = "ca", name = "ca", device = DS)
+@Preview(locale = "cs", name = "cs-CZ", device = DS)
+@Preview(locale = "da", name = "da-DK", device = DS)
+@Preview(locale = "de", name = "de-DE", device = DS)
+@Preview(locale = "el", name = "el-GR", device = DS)
+@Preview(locale = "es", name = "es-ES", device = DS)
+@Preview(locale = "es-MX", name = "es-419", device = DS)
+@Preview(locale = "et-EE", name = "et", device = DS)
+@Preview(locale = "eu-ES", name = "eu-ES", device = DS)
+@Preview(locale = "fa", name = "fa", device = DS)
+@Preview(locale = "fi", name = "fi-FI", device = DS)
+@Preview(locale = "fil", name = "fil", device = DS)
+@Preview(locale = "fr", name = "fr-FR", device = DS)
+@Preview(locale = "gl-ES", name = "gl-ES", device = DS)
+@Preview(locale = "hi-IN", name = "hi-IN", device = DS)
+@Preview(locale = "hr", name = "hr", device = DS)
+@Preview(locale = "hu", name = "hu-HU", device = DS)
+@Preview(locale = "hy-AM", name = "hy-AM", device = DS)
+@Preview(locale = "in", name = "id", device = DS)
+@Preview(locale = "is", name = "is-IS", device = DS)
+@Preview(locale = "it", name = "it-IT", device = DS)
+@Preview(locale = "iw", name = "iw-IL", device = DS)
+@Preview(locale = "ja", name = "ja-JP", device = DS)
+@Preview(locale = "ka-GE", name = "ka-GE", device = DS)
+@Preview(locale = "km-KH", name = "km-KH", device = DS)
+@Preview(locale = "kn-IN", name = "kn-IN", device = DS)
+@Preview(locale = "ko", name = "ko-KR", device = DS)
+@Preview(locale = "ky-KG", name = "ky-KG", device = DS)
+@Preview(locale = "lo-LA", name = "lo-LA", device = DS)
+@Preview(locale = "lt", name = "lt", device = DS)
+@Preview(locale = "lv", name = "lv", device = DS)
+@Preview(locale = "mk-MK", name = "mk-MK", device = DS)
+@Preview(locale = "ml-IN", name = "ml-IN", device = DS)
+@Preview(locale = "mn-MN", name = "mn-MN", device = DS)
+@Preview(locale = "mr-IN", name = "mr-IN", device = DS)
+@Preview(locale = "ms", name = "ms", device = DS)
+@Preview(locale = "my-MM", name = "my-MM", device = DS)
+@Preview(locale = "nb", name = "no-NO", device = DS)
+@Preview(locale = "ne-NP", name = "ne-NP", device = DS)
+@Preview(locale = "nl", name = "nl-NL", device = DS)
+@Preview(locale = "pl", name = "pl-PL", device = DS)
+@Preview(locale = "pt", name = "pt-PT", device = DS)
+@Preview(locale = "pt-BR", name = "pt-BR", device = DS)
+@Preview(locale = "rm", name = "rm", device = DS)
+@Preview(locale = "ro", name = "ro", device = DS)
+@Preview(locale = "ru", name = "ru-RU", device = DS)
+@Preview(locale = "sk", name = "sk", device = DS)
+@Preview(locale = "sl", name = "sl", device = DS)
+@Preview(locale = "sr", name = "sr", device = DS)
+@Preview(locale = "sv", name = "sv-SE", device = DS)
+@Preview(locale = "sw", name = "sw", device = DS)
+@Preview(locale = "ta-IN", name = "ta-IN", device = DS)
+@Preview(locale = "te-IN", name = "te-IN", device = DS)
+@Preview(locale = "th", name = "th", device = DS)
+@Preview(locale = "tr", name = "tr-TR", device = DS)
+@Preview(locale = "uk", name = "uk", device = DS)
+@Preview(locale = "vi", name = "vi", device = DS)
+@Preview(locale = "zh-CN", name = "zh-CN", device = DS)
+@Preview(locale = "zh-HK", name = "zh-HK", device = DS)
+@Preview(locale = "zh-TW", name = "zh-TW", device = DS)
+annotation class PlayStoreLocales
+
+/**
+ * Same locales but with night mode enabled for dark theme screenshots.
+ */
+@Preview(locale = "en", name = "en-US", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "af", name = "af", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "am", name = "am", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ar", name = "ar", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "az", name = "az-AZ", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "be", name = "be", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "bg", name = "bg", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "bn-BD", name = "bn-BD", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ca", name = "ca", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "cs", name = "cs-CZ", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "da", name = "da-DK", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "de", name = "de-DE", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "el", name = "el-GR", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "es", name = "es-ES", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "es-MX", name = "es-419", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "et-EE", name = "et", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "eu-ES", name = "eu-ES", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "fa", name = "fa", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "fi", name = "fi-FI", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "fil", name = "fil", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "fr", name = "fr-FR", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "gl-ES", name = "gl-ES", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "hi-IN", name = "hi-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "hr", name = "hr", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "hu", name = "hu-HU", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "hy-AM", name = "hy-AM", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "in", name = "id", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "is", name = "is-IS", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "it", name = "it-IT", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "iw", name = "iw-IL", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ja", name = "ja-JP", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ka-GE", name = "ka-GE", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "km-KH", name = "km-KH", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "kn-IN", name = "kn-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ko", name = "ko-KR", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ky-KG", name = "ky-KG", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "lo-LA", name = "lo-LA", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "lt", name = "lt", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "lv", name = "lv", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "mk-MK", name = "mk-MK", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ml-IN", name = "ml-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "mn-MN", name = "mn-MN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "mr-IN", name = "mr-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ms", name = "ms", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "my-MM", name = "my-MM", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "nb", name = "no-NO", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ne-NP", name = "ne-NP", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "nl", name = "nl-NL", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "pl", name = "pl-PL", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "pt", name = "pt-PT", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "pt-BR", name = "pt-BR", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "rm", name = "rm", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ro", name = "ro", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ru", name = "ru-RU", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "sk", name = "sk", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "sl", name = "sl", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "sr", name = "sr", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "sv", name = "sv-SE", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "sw", name = "sw", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "ta-IN", name = "ta-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "te-IN", name = "te-IN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "th", name = "th", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "tr", name = "tr-TR", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "uk", name = "uk", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "vi", name = "vi", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "zh-CN", name = "zh-CN", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "zh-HK", name = "zh-HK", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Preview(locale = "zh-TW", name = "zh-TW", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)
+annotation class PlayStoreLocalesDark
+
+/**
+ * Smoke test subset for fast iteration (6 locales covering LTR, RTL, CJK).
+ */
+@Preview(locale = "en", name = "en-US", device = DS)
+@Preview(locale = "de", name = "de-DE", device = DS)
+@Preview(locale = "ja", name = "ja-JP", device = DS)
+@Preview(locale = "ar", name = "ar", device = DS)
+@Preview(locale = "zh-CN", name = "zh-CN", device = DS)
+@Preview(locale = "pt-BR", name = "pt-BR", device = DS)
+annotation class PlayStoreLocalesSmoke
diff --git a/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreScreenshots.kt b/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreScreenshots.kt
new file mode 100644
index 00000000..b4bba0d6
--- /dev/null
+++ b/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreScreenshots.kt
@@ -0,0 +1,40 @@
+// For IDE design preview, open ScreenshotPreviews.kt instead.
+package eu.darken.capod.screenshots
+
+import androidx.compose.runtime.Composable
+import com.android.tools.screenshot.PreviewTest
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun DashboardLight() = DashboardContent()
+
+@PreviewTest
+@PlayStoreLocalesDark
+@Composable
+fun DashboardDark() = DashboardContent()
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun DeviceProfiles() = DeviceProfilesContent()
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun AddProfile() = AddProfileContent()
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun SettingsIndex() = SettingsIndexContent()
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun ReactionSettings() = ReactionSettingsContent()
+
+@PreviewTest
+@PlayStoreLocales
+@Composable
+fun WidgetConfiguration() = WidgetConfigurationContent()
diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt
index ff244b96..d89100db 100644
--- a/buildSrc/src/main/java/Dependencies.kt
+++ b/buildSrc/src/main/java/Dependencies.kt
@@ -8,17 +8,17 @@ private fun DependencyHandler.implementation(dependencyNotation: Any): Dependenc
private fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? =
add("testImplementation", dependencyNotation)
-private fun DependencyHandler.kapt(dependencyNotation: Any): Dependency? =
- add("kapt", dependencyNotation)
+private fun DependencyHandler.ksp(dependencyNotation: Any): Dependency? =
+ add("ksp", dependencyNotation)
-private fun DependencyHandler.kaptTest(dependencyNotation: Any): Dependency? =
- add("kaptTest", dependencyNotation)
+private fun DependencyHandler.kspTest(dependencyNotation: Any): Dependency? =
+ add("kspTest", dependencyNotation)
private fun DependencyHandler.androidTestImplementation(dependencyNotation: Any): Dependency? =
add("androidTestImplementation", dependencyNotation)
-private fun DependencyHandler.kaptAndroidTest(dependencyNotation: Any): Dependency? =
- add("kaptAndroidTest", dependencyNotation)
+private fun DependencyHandler.kspAndroidTest(dependencyNotation: Any): Dependency? =
+ add("kspAndroidTest", dependencyNotation)
private fun DependencyHandler.testRuntimeOnly(dependencyNotation: Any): Dependency? =
add("testRuntimeOnly", dependencyNotation)
@@ -46,18 +46,18 @@ fun DependencyHandlerScope.addDagger() {
implementation("com.google.dagger:dagger-android:${Versions.Dagger.core}")
implementation("androidx.hilt:hilt-common:1.0.0")
- kapt("com.google.dagger:dagger-compiler:${Versions.Dagger.core}")
- kapt("com.google.dagger:dagger-android-processor:${Versions.Dagger.core}")
+ ksp("com.google.dagger:dagger-compiler:${Versions.Dagger.core}")
+ ksp("com.google.dagger:dagger-android-processor:${Versions.Dagger.core}")
implementation("com.google.dagger:hilt-android:${Versions.Dagger.core}")
- kapt("androidx.hilt:hilt-compiler:1.0.0")
- kapt("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
+ ksp("androidx.hilt:hilt-compiler:1.0.0")
+ ksp("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
testImplementation("com.google.dagger:hilt-android-testing:${Versions.Dagger.core}")
- kaptTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
+ kspTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.Dagger.core}")
- kaptAndroidTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
+ kspAndroidTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
}
fun DependencyHandlerScope.addMoshi() {
diff --git a/fastlane/SCREENSHOT.md b/fastlane/SCREENSHOT.md
deleted file mode 100644
index d79ffaac..00000000
--- a/fastlane/SCREENSHOT.md
+++ /dev/null
@@ -1,25 +0,0 @@
-Prompt for automatic screenshots via /debugbadger:screenshots
-
-First open the app and ensure we start out on "light mode".
-If we are not using the current theme, you can change it via Settings>General>Theme mode
-
-Create a total of 8 phone screenshots in the following order:
-
-* Dashboard in "Light Theme"
- * This is always the starting point.
-* Dashboard in "Dark Theme"
- * Change the theme back to "Light Theme" after taking the screenshot.
-* Device Profiles Page
- * Click the device "Configure" icon the toolbar on the dashbaord to reach it.
-* Add new device profile page
- * Reach it by pressing the FAB (bottom right corner) on the device profiles page.
-* Settings Index Screen
- * Can be reached via settings icon in the dashboard toolbar.
-* Reaction Settings Screen
- * Reached via "Reactions" entry on the settings index screen.
-* Widgets in launcher
- * Press the device home button or completely close the app
- * The widgets will be on the launchers center workspace
-* Widget config screen
- * Long press the widget, then release it and press the edit icon in the widget
- * If there are more than 1 widget, pick any of them
\ No newline at end of file
diff --git a/fastlane/copy_screenshots.sh b/fastlane/copy_screenshots.sh
new file mode 100755
index 00000000..c1febcf8
--- /dev/null
+++ b/fastlane/copy_screenshots.sh
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Copies generated screenshot PNGs from the Compose Preview reference directory
+# into fastlane metadata directories for Play Store upload.
+#
+# Usage:
+# ./fastlane/copy_screenshots.sh # Copy all screenshots
+# ./fastlane/copy_screenshots.sh --clean # Clean target dirs before copying
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+REF_DIR="$PROJECT_DIR/app/src/screenshotTestGplayDebug/reference/eu/darken/capod/screenshots/PlayStoreScreenshotsKt"
+FASTLANE_DIR="$PROJECT_DIR/fastlane/metadata/android"
+
+CLEAN=false
+if [[ "${1:-}" == "--clean" ]]; then
+ CLEAN=true
+fi
+
+# Map composable function names to screenshot filenames.
+# Format: number_label — number controls Play Store ordering, label aids humans.
+declare -A SCREEN_MAP=(
+ [DashboardLight]="1_dashboard_light"
+ [DashboardDark]="2_dashboard_dark"
+ [DeviceProfiles]="3_device_profiles"
+ [AddProfile]="4_add_profile"
+ [SettingsIndex]="5_settings"
+ [ReactionSettings]="6_reaction_settings"
+ [WidgetConfiguration]="7_widget_configuration"
+)
+
+if [[ ! -d "$REF_DIR" ]]; then
+ echo "ERROR: Reference directory not found: $REF_DIR"
+ echo "Run ./fastlane/generate_screenshots.sh first."
+ exit 1
+fi
+
+# Count available images
+AVAILABLE=$(find "$REF_DIR" -name "*.png" | wc -l)
+if (( AVAILABLE == 0 )); then
+ echo "ERROR: No PNG files found in $REF_DIR"
+ exit 1
+fi
+echo "Found $AVAILABLE screenshot images"
+
+if $CLEAN; then
+ echo "Cleaning existing phoneScreenshots directories..."
+ find "$FASTLANE_DIR" -path "*/images/phoneScreenshots" -type d -exec rm -rf {} + 2>/dev/null || true
+fi
+
+COPIED=0
+ERRORS=0
+
+for png in "$REF_DIR"/*.png; do
+ filename=$(basename "$png")
+
+ # Filename format: FunctionName_previewName_hash_index.png
+ # Split from the right: remove _index.png, then _hash
+ # FunctionName has no underscores (camelCase), previewName may have hyphens
+
+ # Remove .png extension
+ stem="${filename%.png}"
+
+ # Remove trailing _index (digit(s))
+ stem="${stem%_[0-9]*}"
+
+ # Remove trailing _hash (hex string)
+ stem="${stem%_[a-f0-9]*}"
+
+ # Now stem = FunctionName_previewName
+ # Split at first underscore
+ func_name="${stem%%_*}"
+ locale_name="${stem#*_}"
+
+ if [[ -z "${SCREEN_MAP[$func_name]+x}" ]]; then
+ echo "WARNING: Unknown function name '$func_name' in $filename — skipping"
+ (( ERRORS++ )) || true
+ continue
+ fi
+
+ screen_name="${SCREEN_MAP[$func_name]}"
+ target_dir="$FASTLANE_DIR/$locale_name/images/phoneScreenshots"
+
+ mkdir -p "$target_dir"
+ cp "$png" "$target_dir/${screen_name}.png"
+ (( COPIED++ )) || true
+done
+
+echo ""
+echo "=== Copy Complete ==="
+echo "Copied: $COPIED images"
+if (( ERRORS > 0 )); then
+ echo "Errors: $ERRORS"
+fi
+
+# Validate: check each locale has the expected number of screenshots
+echo ""
+echo "Validation:"
+INCOMPLETE=0
+for locale_dir in "$FASTLANE_DIR"/*/images/phoneScreenshots; do
+ if [[ ! -d "$locale_dir" ]]; then
+ continue
+ fi
+ locale=$(echo "$locale_dir" | sed "s|$FASTLANE_DIR/||" | sed 's|/images/phoneScreenshots||')
+ count=$(find "$locale_dir" -name "*.png" | wc -l)
+ expected=${#SCREEN_MAP[@]}
+ if (( count != expected )); then
+ echo " INCOMPLETE: $locale has $count/${expected} screenshots"
+ (( INCOMPLETE++ )) || true
+ fi
+done
+
+if (( INCOMPLETE == 0 )); then
+ echo " All locales have complete screenshot sets."
+else
+ echo " $INCOMPLETE locale(s) have incomplete screenshot sets."
+fi
diff --git a/fastlane/generate_screenshots.sh b/fastlane/generate_screenshots.sh
new file mode 100755
index 00000000..ab429bf6
--- /dev/null
+++ b/fastlane/generate_screenshots.sh
@@ -0,0 +1,225 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Generates localized Play Store screenshots in batches to work around
+# layoutlib ImagePoolImpl memory leak (accumulates rendered images without release).
+#
+# Usage:
+# ./fastlane/generate_screenshots.sh # Full run (all locales, ~12 batches)
+# ./fastlane/generate_screenshots.sh --smoke # Smoke test (6 locales, 1 batch)
+# ./fastlane/generate_screenshots.sh --batch-size 10 # Custom batch size
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+LOCALES_FILE="$PROJECT_DIR/app/src/screenshotTest/kotlin/eu/darken/capod/screenshots/PlayStoreLocales.kt"
+REF_DIR="$PROJECT_DIR/app/src/screenshotTestGplayDebug/reference"
+
+# Default batch size — 6 locales × 7 composables = 42 renders per batch.
+# Kept small to avoid layoutlib OOM (leaks ~4MB per rendered image at 720p; more at 1080p).
+BATCH_SIZE=4
+SMOKE=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --smoke) SMOKE=true; shift ;;
+ --batch-size) BATCH_SIZE="$2"; shift 2 ;;
+ *) echo "Unknown option: $1"; exit 1 ;;
+ esac
+done
+
+# Each entry: "android_locale:fastlane_name"
+ALL_LOCALES=(
+ "en:en-US"
+ "af:af"
+ "am:am"
+ "ar:ar"
+ "az:az-AZ"
+ "be:be"
+ "bg:bg"
+ "bn-BD:bn-BD"
+ "ca:ca"
+ "cs:cs-CZ"
+ "da:da-DK"
+ "de:de-DE"
+ "el:el-GR"
+ "es:es-ES"
+ "es-MX:es-419"
+ "et-EE:et"
+ "eu-ES:eu-ES"
+ "fa:fa"
+ "fi:fi-FI"
+ "fil:fil"
+ "fr:fr-FR"
+ "gl-ES:gl-ES"
+ "hi-IN:hi-IN"
+ "hr:hr"
+ "hu:hu-HU"
+ "hy-AM:hy-AM"
+ "in:id"
+ "is:is-IS"
+ "it:it-IT"
+ "iw:iw-IL"
+ "ja:ja-JP"
+ "ka-GE:ka-GE"
+ "km-KH:km-KH"
+ "kn-IN:kn-IN"
+ "ko:ko-KR"
+ "ky-KG:ky-KG"
+ "lo-LA:lo-LA"
+ "lt:lt"
+ "lv:lv"
+ "mk-MK:mk-MK"
+ "ml-IN:ml-IN"
+ "mn-MN:mn-MN"
+ "mr-IN:mr-IN"
+ "ms:ms"
+ "my-MM:my-MM"
+ "nb:no-NO"
+ "ne-NP:ne-NP"
+ "nl:nl-NL"
+ "pl:pl-PL"
+ "pt:pt-PT"
+ "pt-BR:pt-BR"
+ "rm:rm"
+ "ro:ro"
+ "ru:ru-RU"
+ "sk:sk"
+ "sl:sl"
+ "sr:sr"
+ "sv:sv-SE"
+ "sw:sw"
+ "ta-IN:ta-IN"
+ "te-IN:te-IN"
+ "th:th"
+ "tr:tr-TR"
+ "uk:uk"
+ "vi:vi"
+ "zh-CN:zh-CN"
+ "zh-HK:zh-HK"
+ "zh-TW:zh-TW"
+)
+
+SMOKE_LOCALES=(
+ "en:en-US"
+ "de:de-DE"
+ "ja:ja-JP"
+ "ar:ar"
+ "zh-CN:zh-CN"
+ "pt-BR:pt-BR"
+)
+
+if $SMOKE; then
+ LOCALES=("${SMOKE_LOCALES[@]}")
+ BATCH_SIZE=${#LOCALES[@]} # Single batch for smoke
+else
+ LOCALES=("${ALL_LOCALES[@]}")
+fi
+
+TOTAL=${#LOCALES[@]}
+NUM_BATCHES=$(( (TOTAL + BATCH_SIZE - 1) / BATCH_SIZE ))
+
+echo "=== Localized Screenshot Generation ==="
+echo "Locales: $TOTAL | Batch size: $BATCH_SIZE | Batches: $NUM_BATCHES"
+echo ""
+
+# Back up the original file
+cp "$LOCALES_FILE" "$LOCALES_FILE.bak"
+trap 'mv "$LOCALES_FILE.bak" "$LOCALES_FILE"; echo "Restored original PlayStoreLocales.kt"' EXIT
+
+# Clean reference directory from previous runs
+rm -rf "$REF_DIR"
+echo "Cleaned reference directory"
+
+generate_locales_file() {
+ local -n batch_locales=$1
+ local file="$2"
+
+ cat > "$file" << 'HEADER'
+package eu.darken.capod.screenshots
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * Multi-preview annotation generating one preview per Play Store-supported locale (light mode).
+ * Each [name] is the fastlane metadata directory name for direct use in the copy script.
+ */
+HEADER
+
+ # Light annotations
+ for entry in "${batch_locales[@]}"; do
+ local locale="${entry%%:*}"
+ local name="${entry##*:}"
+ echo "@Preview(locale = \"$locale\", name = \"$name\", device = DS)" >> "$file"
+ done
+ echo "annotation class PlayStoreLocales" >> "$file"
+ echo "" >> "$file"
+
+ # Dark annotations
+ echo "/**" >> "$file"
+ echo " * Same locales but with night mode enabled for dark theme screenshots." >> "$file"
+ echo " */" >> "$file"
+ for entry in "${batch_locales[@]}"; do
+ local locale="${entry%%:*}"
+ local name="${entry##*:}"
+ echo "@Preview(locale = \"$locale\", name = \"$name\", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES)" >> "$file"
+ done
+ echo "annotation class PlayStoreLocalesDark" >> "$file"
+ echo "" >> "$file"
+
+ # Smoke annotation (single entry placeholder)
+ echo "/**" >> "$file"
+ echo " * Smoke test subset for fast iteration (6 locales covering LTR, RTL, CJK)." >> "$file"
+ echo " */" >> "$file"
+ echo "@Preview(locale = \"en\", name = \"en-US\", device = DS)" >> "$file"
+ echo "annotation class PlayStoreLocalesSmoke" >> "$file"
+}
+
+for (( batch=0; batch < NUM_BATCHES; batch++ )); do
+ start=$(( batch * BATCH_SIZE ))
+ end=$(( start + BATCH_SIZE ))
+ if (( end > TOTAL )); then
+ end=$TOTAL
+ fi
+
+ # Extract batch slice
+ BATCH_SLICE=("${LOCALES[@]:$start:$((end - start))}")
+ batch_num=$(( batch + 1 ))
+
+ echo "--- Batch $batch_num/$NUM_BATCHES (locales $((start+1))-$end of $TOTAL) ---"
+
+ # Generate locales file for this batch
+ generate_locales_file BATCH_SLICE "$LOCALES_FILE"
+
+ # Stop daemon to release memory from previous batch
+ echo "Stopping Gradle daemon..."
+ cd "$PROJECT_DIR"
+ ./gradlew --stop 2>/dev/null || true
+
+ # Run screenshot generation
+ echo "Generating screenshots..."
+ if ! ./gradlew updateGplayDebugScreenshotTest --no-daemon 2>&1; then
+ echo "ERROR: Batch $batch_num failed! Check output above."
+ echo "Generated images so far are preserved in: $REF_DIR"
+ exit 1
+ fi
+
+ count=$(find "$REF_DIR" -name "*.png" 2>/dev/null | wc -l)
+ echo "Batch $batch_num complete. Total images so far: $count"
+ echo ""
+done
+
+# Count final results
+FINAL_COUNT=$(find "$REF_DIR" -name "*.png" 2>/dev/null | wc -l)
+EXPECTED=$(( TOTAL * 7 )) # 7 composables per locale
+
+echo "=== Generation Complete ==="
+echo "Generated: $FINAL_COUNT images (expected: $EXPECTED)"
+
+if (( FINAL_COUNT != EXPECTED )); then
+ echo "WARNING: Count mismatch! Some screenshots may be missing."
+ echo "Check $REF_DIR for details."
+fi
+
+echo ""
+echo "Next step: run ./fastlane/copy_screenshots.sh to sort into fastlane directories."
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
deleted file mode 100644
index 69ee5d93..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
deleted file mode 100644
index 94fd26f5..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
deleted file mode 100644
index 98c040f7..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
deleted file mode 100644
index c6ff45d1..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
deleted file mode 100644
index c6d95ab1..00000000
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/wearScreenshots/1.png b/fastlane/metadata/android/en-US/images/wearScreenshots/1.png
deleted file mode 100644
index 8f75d2a6..00000000
Binary files a/fastlane/metadata/android/en-US/images/wearScreenshots/1.png and /dev/null differ
diff --git a/fastlane/metadata/android/en-US/images/wearScreenshots/2.png b/fastlane/metadata/android/en-US/images/wearScreenshots/2.png
deleted file mode 100644
index fb7859a2..00000000
Binary files a/fastlane/metadata/android/en-US/images/wearScreenshots/2.png and /dev/null differ
diff --git a/gradle.properties b/gradle.properties
index db0e4a22..282aba85 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
# Project-wide Gradle settings.
-org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Required by androidx.navigation.safeargs plugin
@@ -7,3 +7,5 @@ android.useAndroidX=true
# Temporary AGP 9 opt-outs (must migrate before AGP 10)
android.builtInKotlin=false
android.newDsl=false
+# Compose Preview Screenshot Testing
+android.experimental.enableScreenshotTest=true
diff --git a/settings.gradle b/settings.gradle
index 4fc256ae..72c36181 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,10 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
rootProject.name = "Companion App for AirPods"
include ':app'