feat(screenshots): Set up localized Play Store screenshot pipeline

- Add Compose screenshot test infrastructure (build config, screenshotTest source set)

- Add 7 localized screenshots covering all key app screens

- Device spec set to Pixel 8 (1080x2400, 428dpi); batch size 4 for 1080p memory budget

- Add screen title heading and fix appearance card header layout in WidgetConfigurationScreen

- Balance padding on requires-Pro notice in widget config bottom bar
This commit is contained in:
darken
2026-02-25 22:57:34 +01:00
committed by Matthias Urhahn
parent c4e58917da
commit fc9bcafae9
21 changed files with 973 additions and 60 deletions
+3 -1
View File
@@ -14,4 +14,6 @@
/fastlane/report.xml
/fastlane/Appfile
/fastlane/README.md
.kotlin
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
+7 -1
View File
@@ -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")
}
@@ -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()
@@ -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
@@ -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) {
+1
View File
@@ -117,6 +117,7 @@
<string name="translators_thanks_description">darken</string>
<string name="widget_description">A widget showing the last known device status.</string>
<string name="widget_config_screen_title">Widget Configuration</string>
<string name="widget_configuration_title">Select Device</string>
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
@@ -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
@@ -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()
+12 -12
View File
@@ -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() {
-25
View File
@@ -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
+118
View File
@@ -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
+225
View File
@@ -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."
Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

+3 -1
View File
@@ -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
+8
View File
@@ -1,2 +1,10 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "Companion App for AirPods"
include ':app'