Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ These rules apply to all interactions in this project.

When making changes to the code:

1. Add unit tests of all new or changed behaviors, and remove obsolete unit tests for removed behaviors.
2. Run and fix all unit tests with `./gradlew :app:testLocalDebugUnitTest`.
1. Ensure all new files have the standard copyright header with the current year.
2. Add unit tests of all new or changed behaviors and remove obsolete unit tests for removed behaviors.
3. Run and fix all unit tests with `./gradlew :app:testLocalDebugUnitTest`.

Before pushing changes:

1. Run checkstyle with `./gradlew :app:checkstyle` and fix all errors and warnings.
1. Remove any commented out code or temporary comments added in the process of debugging and authoring new or changed code.
2. Run `./gradlew ktfmtFormat` to fix lint errors.
3. Run checks with `./gradlew :app:checkCode` and fix all errors and warnings.

When asked to resolve pending comments:

1. Get the current PR number using `gh pr view $(git branch --show-current) --json number`.
2. Fetch comments with `gh api -H "Accept: application/vnd.github.v3.full+json" /repos/google/ground-android/pulls/<PR number>/comments` to get the pending comments.
3. Resolve the pending comments.
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ android {
buildConfigField "String", "SIGNUP_FORM_LINK", "\"\""
manifestPlaceholders.usesCleartextTraffic = true

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "org.groundplatform.android.CustomTestRunner"
}

// Use flag -PtestBuildType with desired variant to change default behavior.
Expand Down Expand Up @@ -213,6 +213,7 @@ dependencies {
implementation libs.androidx.ui.tooling.preview.android
stagingImplementation libs.androidx.ui.test.manifest
testImplementation libs.androidx.ui.test.junit4
androidTestImplementation libs.androidx.ui.test.junit4
implementation libs.androidx.navigation.compose
implementation libs.androidx.hilt.navigation.compose

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.android

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?,
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.android.ui.compose

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.groundplatform.android.R
import org.groundplatform.android.ui.main.MainActivity
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@HiltAndroidTest
class ComposeE2ETest {

@get:Rule(order = 0) var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>()

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun testNavigationToSyncStatus() {
// 1. Open Drawer
composeTestRule.onNodeWithTag("open_nav_drawer").performClick()

// 2. Click "Sync Status"
val syncStatusText = composeTestRule.activity.getString(R.string.sync_status)
composeTestRule.onNodeWithText(syncStatusText).performClick()

// 3. Verify Sync Status Screen is shown (Title in TopAppBar)
val syncStatusTitle = composeTestRule.activity.getString(R.string.data_sync_status)
composeTestRule.onNodeWithText(syncStatusTitle).assertIsDisplayed()
}

@Test
fun testNavigationToOfflineAreas() {
// 1. Open Drawer
composeTestRule.onNodeWithTag("open_nav_drawer").performClick()

// 2. Click "Offline Map Imagery"
val offlineMapText = composeTestRule.activity.getString(R.string.offline_map_imagery)
composeTestRule.onNodeWithText(offlineMapText).performClick()

// 3. Verify Offline Areas Screen is shown (Title in TopAppBar)
// Note: OfflineAreasFragment label is @string/offline_map_imagery
val offlineMapTitle = composeTestRule.activity.getString(R.string.offline_map_imagery)
composeTestRule.onNodeWithText(offlineMapTitle).assertIsDisplayed()
}
}
244 changes: 244 additions & 0 deletions app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.android.ui.home

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.groundplatform.android.R
import org.groundplatform.android.model.Survey
import org.groundplatform.android.model.User

@Composable
fun HomeDrawer(
user: User?,
survey: Survey?,
onSwitchSurvey: () -> Unit,
onNavigateToOfflineAreas: () -> Unit,
onNavigateToSyncStatus: () -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToAbout: () -> Unit,
onNavigateToTerms: () -> Unit,
onSignOut: () -> Unit,
offlineAreasEnabled: Boolean = true,
versionText: String,
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
// App Info Header
Column(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 24.dp, horizontal = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Image(
painter = painterResource(R.drawable.ground_logo),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.app_name),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
)
}
if (user?.photoUrl != null) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { context ->
android.widget.ImageView(context).apply {
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
}
},
update = { imageView ->
com.bumptech.glide.Glide.with(imageView)
.load(user.photoUrl)
.circleCrop()
.into(imageView)
},
modifier = Modifier.size(32.dp).clip(CircleShape),
)
}
}
}

// Survey Info
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter =
painterResource(
R.drawable.ic_content_paste
), // Ensure this drawable exists or use Vector
contentDescription = stringResource(R.string.current_survey),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(R.string.current_survey),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))

if (survey == null) {
Text(stringResource(R.string.no_survey_selected))
} else {
Text(
text = survey.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (survey.description.isNotEmpty()) {
Text(
text = survey.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
}
}

Spacer(Modifier.height(16.dp))

Text(
text = stringResource(R.string.switch_survey),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(onClick = onSwitchSurvey).padding(vertical = 8.dp),
)
}

HorizontalDivider()

// Navigation Items
NavigationDrawerItem(
label = { Text(stringResource(R.string.offline_map_imagery)) },
selected = false,
onClick = onNavigateToOfflineAreas,
icon = {
Icon(
painterResource(R.drawable.ic_offline_pin),
contentDescription = stringResource(R.string.offline_map_imagery),
)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.sync_status)) },
selected = false,
onClick = onNavigateToSyncStatus,
icon = {
Icon(
painterResource(R.drawable.ic_sync),
contentDescription = stringResource(R.string.sync_status),
)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.settings)) },
selected = false,
onClick = onNavigateToSettings,
icon = {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.about)) },
selected = false,
onClick = onNavigateToAbout,
icon = {
Icon(
painterResource(R.drawable.info_outline),
contentDescription = stringResource(R.string.about),
)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.terms_of_service)) },
selected = false,
onClick = onNavigateToTerms,
icon = {
Icon(
painterResource(R.drawable.feed),
contentDescription = stringResource(R.string.terms_of_service),
)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.sign_out)) },
selected = false,
onClick = onSignOut,
icon = {
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.sign_out),
)
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
label = { Text(versionText) },
selected = false,
onClick = {},
icon = { Icon(Icons.Default.Build, contentDescription = stringResource(R.string.build)) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
}
Loading
Loading