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'