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
@@ -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/
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
Before Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 938 KiB |
|
Before Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -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
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Companion App for AirPods"
|
||||
include ':app'
|
||||
|
||||