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 += `${name}
 | \n`;
+ if (i % 2 === 1 || i === files.length - 1) body += '
\n';
+ }
+
+ body += '
\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" }