diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..b9d904a --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,147 @@ +name: PR Screenshots + +on: + pull_request: + types: [opened, synchronize] + +jobs: + screenshots: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Skip if last commit is a screenshot update + id: check + uses: actions/github-script@v7 + with: + script: | + const commits = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 1, + }); + const lastMsg = commits.data.at(-1)?.commit?.message ?? ''; + core.setOutput('skip', lastMsg.includes('[screenshots]') ? 'true' : 'false'); + + - name: Checkout PR branch + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 17 + if: steps.check.outputs.skip != 'true' + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle + if: steps.check.outputs.skip != 'true' + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Create local.properties + if: steps.check.outputs.skip != 'true' + run: echo "sdk.dir=$ANDROID_HOME" > local.properties + + - name: Generate screenshots + if: steps.check.outputs.skip != 'true' + run: ./gradlew :app:updateDebugScreenshotTest --no-daemon + + - name: Commit screenshots to PR branch + if: steps.check.outputs.skip != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + SCREENSHOT_DIR="app/src/screenshotTestDebug/reference" + if [ ! -d "$SCREENSHOT_DIR" ]; then + echo "No screenshot directory found, skipping." + exit 0 + fi + + git add "$SCREENSHOT_DIR" + if git diff --cached --quiet; then + echo "No screenshot changes to commit." + else + git commit -m "[screenshots] Update preview screenshots" + git push + fi + + - name: Post screenshots as PR comment + if: steps.check.outputs.skip != 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const screenshotDir = 'app/src/screenshotTestDebug/reference'; + + if (!fs.existsSync(screenshotDir)) { + core.info('No screenshot directory found, skipping comment.'); + return; + } + + const files = fs.readdirSync(screenshotDir).filter(f => f.endsWith('.png')); + if (files.length === 0) { + core.info('No screenshots found, skipping comment.'); + return; + } + + const branch = context.payload.pull_request.head.ref; + const repo = `${context.repo.owner}/${context.repo.repo}`; + + let body = '## App Screenshots\n\n'; + body += 'Auto-generated preview screenshots for this PR:\n\n'; + body += '\n'; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + // Extract a readable name from the filename + const name = file + .replace(/^com\.example\.myapplication\./, '') + .replace(/_[a-f0-9]+_[a-f0-9]+_\d+\.png$/, '') + .replace(/([A-Z])/g, ' $1') + .trim(); + + const url = `https://raw.githubusercontent.com/${repo}/${branch}/${screenshotDir}/${file}`; + + if (i % 2 === 0) body += '\n'; + body += `\n`; + if (i % 2 === 1 || i === files.length - 1) body += '\n'; + } + + body += '
${name}
\n'; + + // Delete previous screenshot comments to avoid clutter + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + }); + for (const comment of comments.data) { + if (comment.body?.startsWith('## App Screenshots')) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: body, + }); diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2650e1..8e3c70c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.secrets) + alias(libs.plugins.screenshot) } android { @@ -36,6 +37,7 @@ android { compose = true buildConfig = true } + experimentalProperties["android.experimental.enableScreenshotTest"] = true } dependencies { @@ -61,4 +63,6 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/screenshotTest/kotlin/com/example/myapplication/ScreenshotTests.kt b/app/src/screenshotTest/kotlin/com/example/myapplication/ScreenshotTests.kt new file mode 100644 index 0000000..773ed9e --- /dev/null +++ b/app/src/screenshotTest/kotlin/com/example/myapplication/ScreenshotTests.kt @@ -0,0 +1,279 @@ +package com.example.myapplication + +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.tools.screenshot.PreviewTest +import com.example.myapplication.ui.components.AddEditPointDialog +import com.example.myapplication.ui.components.AddRouteDialog +import com.example.myapplication.ui.components.DeleteConfirmDialog +import com.example.myapplication.ui.components.LocationsSection +import com.example.myapplication.ui.theme.MyApplicationTheme + +// ── Locations tab ──────────────────────────────────────────────────── + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun LocationsTabEmptyPreview() { + MyApplicationTheme(dynamicColor = false) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = true, onClick = {}, label = { Text("Locations") }) + FilterChip(selected = false, onClick = {}, label = { Text("Routes") }) + } + LocationsSection( + items = emptyList(), + onPointClick = {}, + onEdit = {}, + onDelete = {}, + ) + } + } +} + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun LocationsTabWithItemsPreview() { + MyApplicationTheme(dynamicColor = false) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = true, onClick = {}, label = { Text("Locations") }) + FilterChip(selected = false, onClick = {}, label = { Text("Routes") }) + } + LocationsSection( + items = listOf( + SavedPoint("1", "Home", 37.7749, -122.4194, 15f), + SavedPoint("2", "Office", 37.3861, -122.0839, 14f), + SavedPoint("3", "Park", 37.7694, -122.4862, 16f), + ), + onPointClick = {}, + onEdit = {}, + onDelete = {}, + ) + } + } +} + +// ── Routes tab ─────────────────────────────────────────────────────── + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun RoutesTabEmptyPreview() { + MyApplicationTheme(dynamicColor = false) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = false, onClick = {}, label = { Text("Locations") }) + FilterChip(selected = true, onClick = {}, label = { Text("Routes") }) + } + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Routes", style = MaterialTheme.typography.titleMedium) + Button(onClick = {}, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text("Add route") + } + } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Speed: 25 m/s (90 km/h)", style = MaterialTheme.typography.labelMedium) + Slider(value = 25f, onValueChange = {}, valueRange = 2f..80f, modifier = Modifier.fillMaxWidth()) + } + Text( + "Add a route, then edit it on the map. Tap Follow to simulate movement.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } +} + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun RoutesTabWithRoutesPreview() { + MyApplicationTheme(dynamicColor = false) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = false, onClick = {}, label = { Text("Locations") }) + FilterChip(selected = true, onClick = {}, label = { Text("Routes") }) + } + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Routes", style = MaterialTheme.typography.titleMedium) + Button(onClick = {}, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text("Add route") + } + } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Speed: 25 m/s (90 km/h)", style = MaterialTheme.typography.labelMedium) + Slider(value = 25f, onValueChange = {}, valueRange = 2f..80f, modifier = Modifier.fillMaxWidth()) + } + // Sample route cards + RouteCardPreview("Morning Commute", 5) + RouteCardPreview("Park Loop", 8) + RouteCardPreview("Downtown Tour", 12) + } + } +} + +@Composable +private fun RouteCardPreview(name: String, waypointCount: Int) { + Row( + Modifier.fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = .5f)) + .padding(12.dp), + Arrangement.SpaceBetween, + Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.titleSmall) + Text("$waypointCount waypoints", style = MaterialTheme.typography.bodySmall) + } + Button(onClick = {}, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { + Icon(Icons.Default.PlayArrow, null, Modifier.padding(end = 2.dp)) + Text("Follow") + } + } +} + +// ── Route player controls ──────────────────────────────────────────── + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun RoutePlayerControlsPreview() { + MyApplicationTheme(dynamicColor = false) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + Modifier.fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Route player", style = MaterialTheme.typography.titleSmall) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = {}, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text("Pause") + } + Column(Modifier.weight(1f)) { + Slider(value = 0.42f, onValueChange = {}, valueRange = 0f..1f, modifier = Modifier.fillMaxWidth()) + Text("42%", style = MaterialTheme.typography.labelSmall) + } + } + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text("Stop") } + } + } + } +} + +// ── Route edit overlay ─────────────────────────────────────────────── + +@PreviewTest +@Preview(showBackground = true, widthDp = 400) +@Composable +fun RouteEditOverlayPreview() { + MyApplicationTheme(dynamicColor = false) { + Box(Modifier.fillMaxWidth()) { + Column( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Morning Commute", style = MaterialTheme.typography.titleMedium) + Text("Tap map to add points, drag nodes to move. 5 point(s).", style = MaterialTheme.typography.bodySmall) + Row(Modifier.fillMaxWidth(), Arrangement.End, Alignment.CenterVertically) { + Button(onClick = {}) { Text("Cancel") } + Box(Modifier.padding(8.dp)) + Button(onClick = {}) { Text("Save") } + } + } + } + } +} + +// ── Dialogs ────────────────────────────────────────────────────────── + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun AddLocationDialogPreview() { + MyApplicationTheme(dynamicColor = false) { + AddEditPointDialog( + point = null, + currentLat = 37.7749, + currentLon = -122.4194, + currentZoom = 15f, + onDismiss = {}, + onSave = { _, _, _, _, _ -> }, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun EditLocationDialogPreview() { + MyApplicationTheme(dynamicColor = false) { + AddEditPointDialog( + point = SavedPoint("1", "Home", 37.7749, -122.4194, 15f), + currentLat = 37.7749, + currentLon = -122.4194, + currentZoom = 15f, + onDismiss = {}, + onSave = { _, _, _, _, _ -> }, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun DeleteLocationDialogPreview() { + MyApplicationTheme(dynamicColor = false) { + DeleteConfirmDialog( + point = SavedPoint("1", "Home", 37.7749, -122.4194, 15f), + onDismiss = {}, + onConfirm = {}, + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun NewRouteDialogPreview() { + MyApplicationTheme(dynamicColor = false) { + AddRouteDialog( + onDismiss = {}, + onSave = {}, + ) + } +} diff --git a/gradle.properties b/gradle.properties index 20e2a01..6eaed49 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.experimental.enableScreenshotTest=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fcad12e..1f590cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ playServicesCronet = "18.0.1" datastore = "1.1.1" gson = "2.10.1" coil = "2.5.0" +screenshot = "0.0.1-alpha13" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,8 +41,10 @@ play-services-cronet = { group = "com.google.android.gms", name = "play-services androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } +screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" }