From 9556c93295f0a0a06cc6372fd16adee9cde5cf4c Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Wed, 11 Mar 2026 11:25:31 +0300 Subject: [PATCH 01/20] Fix isse 3541, reset hasSelfIntersection to false in updateVertices and refactor checkVertexIntersection to call updateVertices --- .../ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index b4bb72a891..0545a6c6cf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -321,7 +321,7 @@ internal constructor( private fun checkVertexIntersection(): Boolean { hasSelfIntersection = isSelfIntersecting(vertices) if (hasSelfIntersection) { - vertices = vertices.dropLast(1) + updateVertices(vertices.dropLast(1)) onSelfIntersectionDetected() } return hasSelfIntersection @@ -349,6 +349,7 @@ internal constructor( private fun updateVertices(newVertices: List) { this.vertices = newVertices + hasSelfIntersection = false refreshMap() } From 5cb0e8564f4e13c4969d40097ce6bd43d5a8daa1 Mon Sep 17 00:00:00 2001 From: Nsubuga Hassan Date: Thu, 12 Mar 2026 11:55:23 +0300 Subject: [PATCH 02/20] Add tests to DrawAreaTaskViewModelTest to convert resetting of hasSelfIntersection if it causes self intersection non completion when self intersection happened --- .../polygon/DrawAreaTaskViewModelTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 487e429bd6..21abdc1eaf 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt @@ -620,6 +620,45 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { assertThat(viewModel.isMarkedComplete()).isTrue() } + @Test + fun `onButtonClick ADD_POINT removes last vertex and resets hasSelfIntersection if it causes self-intersection`() = + runWithTestDispatcher { + setupViewModel() + // Create a non-intersecting path: (0,0) -> (10,10) -> (0,10) + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_2) + updateLastVertexAndAdd(COORDINATE_6) + + // Move cursor to a point that crosses the first segment: (10,0) + updateLastVertex(COORDINATE_5) + + viewModel.onButtonClick(ButtonAction.ADD_POINT) + advanceUntilIdle() + + // The intersecting vertex (10,0) should be dropped. + // Result: 3 fixed vertices + 1 temp cursor vertex = 4 total in geometry + assertGeometry(4, isLineString = true) + assertThat(viewModel.hasSelfIntersection).isFalse() + } + + @Test + fun `onButtonClick COMPLETE does not mark complete if it causes self-intersection`() = + runWithTestDispatcher { + setupViewModel() + // Create a path that will intersect when closed: (0,0) -> (10,0) -> (0,10) -> (10,10) + updateLastVertexAndAdd(COORDINATE_1) + updateLastVertexAndAdd(COORDINATE_5) + updateLastVertexAndAdd(COORDINATE_6) + updateLastVertex(COORDINATE_2) + + viewModel.onButtonClick(ButtonAction.COMPLETE) + advanceUntilIdle() + + // Completion should fail and flag should be true + assertThat(viewModel.isMarkedComplete()).isFalse() + assertThat(viewModel.hasSelfIntersection).isTrue() + } + private fun assertGeometry( expectedVerticesCount: Int, isLineString: Boolean = false, @@ -679,6 +718,8 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { private val COORDINATE_2 = Coordinates(10.0, 10.0) private val COORDINATE_3 = Coordinates(20.0, 20.0) private val COORDINATE_4 = Coordinates(30.0, 30.0) + private val COORDINATE_5 = Coordinates(10.0, 0.0) + private val COORDINATE_6 = Coordinates(0.0, 10.0) private val TASK = Task( From 520a374eb861053d08eb6e5506c34b1216104894 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:11:49 +0530 Subject: [PATCH 03/20] Bump com.google.maps.android:android-maps-utils from 4.1.0 to 4.1.1 (#3610) Bumps [com.google.maps.android:android-maps-utils](https://github.com/googlemaps/android-maps-utils) from 4.1.0 to 4.1.1. - [Release notes](https://github.com/googlemaps/android-maps-utils/releases) - [Changelog](https://github.com/googlemaps/android-maps-utils/blob/main/CHANGELOG.md) - [Commits](https://github.com/googlemaps/android-maps-utils/compare/v4.1.0...v4.1.1) --- updated-dependencies: - dependency-name: com.google.maps.android:android-maps-utils dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Andreia Ferreira <51242456+andreia-ferreira@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7748256ebb..30f5703dc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ androidCompileSdk = "36" androidMinSdk = "24" androidTargetSdk = "36" -androidMapsUtilsVersion = "4.1.0" +androidMapsUtilsVersion = "4.1.1" androidXLifecycleVersion = "2.9.6" appcompatVersion = "1.7.1" autoValueVersion = "1.11.1" From 951c359aa3c987c031ff2f7ffab7a588231d2f0c Mon Sep 17 00:00:00 2001 From: Andreia Ferreira <51242456+andreia-ferreira@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:34:51 +0100 Subject: [PATCH 04/20] fix codecov failing dependabot CI (#3612) --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26f30ddef7..add7441f6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,12 +50,13 @@ jobs: run: ./gradlew jacocoLocalDebugUnitTestReport - name: Upload coverage to Codecov + if: github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: "**/build/reports/jacoco/**/*.xml" flags: service - fail_ci_if_error: true + fail_ci_if_error: false instrumentation-tests: needs: build From 6f8ee64e16203a605dabb1495a2fa59daef44170 Mon Sep 17 00:00:00 2001 From: Gino Miceli <228050+gino-m@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:09:56 -0400 Subject: [PATCH 05/20] Migrate home screen drawer to Compose (#3554) * chore: cleanup and tooling updates * feat: add HomeDrawer component and ViewModel logic * chore: remove build output file * Clean up PR * refactor(home): migrate drawer to Compose and remove XML * Restore deleted files * fix(home): add system bars padding to drawer * refactor: clean up unused imports and reformat Compose state observation calls. * feat: move version text display from a clickable drawer item to a static row at the bottom of the drawer * Update app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix checkCode issues: Refactor HomeDrawer/HomeScreenFragment, fix formatting and unused resources * Refactor HomeDrawer and fix HomeScreenFragmentTest - Refactor HomeScreenFragment to reduce method length - Refactor HomeDrawer to extract composables - Migrate HomeScreenViewModel dialog state to StateFlow - Initialize WorkManager in HomeScreenFragmentTest - Ignore flaky sign out dialog test in Robolectric - Fix static analysis issues (detekt, ktfmt) * Fix checkCode issues and test regressions * Fix ktfmt formatting issues * Refactor HomeDrawer to use HomeDrawerAction interface - Introduce `HomeDrawerAction` sealed interface to represent navigation and sign-out events. - Update `HomeDrawer` to expose a single `onAction` callback instead of multiple parameters. - Consolidate navigation logic in `HomeScreenFragment` within the `onAction` handler. * Make user parameter non-null in HomeDrawer * refactor: remove unnecessary comments from `openSignOutWarningDialog` helper. * Refactor HomeDrawerState into ViewModel * test: assert build version string with BuildConfig.VERSION_NAME in HomeScreenFragmentTest. * Replace Glide with Coil AsyncImage in HomeDrawer * Fix duplicate coil-compose in libs.versions.toml * Address PR #3554 feedback * Fix UI Home Drawer regressions - Restored original icons - Reordered items - Fixed typography for App title and list items - Fixed AppTheme import issue * Fix UI Home Drawer dividers - Apply 24dp end padding to drawer divider to match original design * Remove unused style * Refactor version constant * Fix UI Home Drawer typography and padding - Apply 24dp horizontal padding to drawer divider - Use titleSmall typography for drawer navigation items to match original NavigationView styles * Fix exact CSS layout spacings and typography - Matched AppInfoHeader paddings and SurveySelector gaps exactly to FIGMA CSS design specs - Replaced titleSmall with labelLarge (Manrope 600) for navigation items * Complete typography with explicit Google Sans and Manrope fonts per CSS * Suppress LongMethod detekt check on SurveySelector * Restore user photo size to 32dp * Restore Font Weights for Survey Selector and Nav Items per original CSS * Use typography instead of font literals * Use existing Typography styles --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 17 +- app/build.gradle | 3 +- .../android/ui/home/HomeDrawer.kt | 314 ++++++++++++++++++ .../android/ui/home/HomeDrawerAction.kt | 34 ++ .../android/ui/home/HomeScreenFragment.kt | 157 ++++----- .../android/ui/home/HomeScreenViewModel.kt | 20 +- .../home/mapcontainer/jobs/JobSelectionRow.kt | 3 +- .../ui/home/mapcontainer/jobs/LoiJobSheet.kt | 11 +- app/src/main/res/color/nav_drawer_item.xml | 22 -- .../color/task_transparent_btn_selector.xml | 22 -- app/src/main/res/drawable/ic_settings.xml | 28 -- app/src/main/res/layout/home_screen_frag.xml | 11 +- app/src/main/res/layout/nav_drawer_header.xml | 153 --------- app/src/main/res/menu/nav_drawer_menu.xml | 47 --- app/src/main/res/values/dimens.xml | 3 - app/src/main/res/values/styles.xml | 26 +- .../android/ui/home/HomeScreenFragmentTest.kt | 120 +++---- gradle/libs.versions.toml | 2 +- 18 files changed, 522 insertions(+), 471 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/home/HomeDrawerAction.kt delete mode 100644 app/src/main/res/color/nav_drawer_item.xml delete mode 100644 app/src/main/res/color/task_transparent_btn_selector.xml delete mode 100644 app/src/main/res/drawable/ic_settings.xml delete mode 100644 app/src/main/res/layout/nav_drawer_header.xml delete mode 100644 app/src/main/res/menu/nav_drawer_menu.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e740d722ee..2b673fefff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,7 +137,6 @@ to the base repository using a pull request. ## Initial build configuration -### Add Google Maps API Key(s) ### Set up Firebase @@ -157,15 +156,17 @@ to the base repository using a pull request. 4. Download the config file for the Android app to `app/src/debug/google-services.json` -5. Create a file named `secrets.properties` in the root of the project with the following contents: +### Add Google Maps API Key(s) - ``` - MAPS_API_KEY= - ``` +Create a file named `secrets.properties` in the root of the project with the following contents: + +``` +MAPS_API_KEY= +``` - You can find the Maps SDK key for your Firebase project at - http://console.cloud.google.com/google/maps-apis/credentials under - "Android key (auto created by Firebase)". +You can find the Maps SDK key for your Firebase project at +http://console.cloud.google.com/google/maps-apis/credentials under +"Android key (auto created by Firebase)". ### Troubleshooting diff --git a/app/build.gradle b/app/build.gradle index de85ba3ab9..ea74057e70 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -89,8 +89,6 @@ android { buildConfigField "int", "AUTH_EMULATOR_PORT", "9099" buildConfigField "String", "SIGNUP_FORM_LINK", "\"\"" manifestPlaceholders.usesCleartextTraffic = true - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } // Use flag -PtestBuildType with desired variant to change default behavior. @@ -385,3 +383,4 @@ secrets { // checked in version control. defaultPropertiesFileName = "local.defaults.properties" } + diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt new file mode 100644 index 0000000000..8e7a490329 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt @@ -0,0 +1,314 @@ +/* + * 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.systemBarsPadding +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.platform.testTag +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.tooling.preview.Preview +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 +import org.groundplatform.ui.theme.AppTheme + +@Composable +fun HomeDrawer( + user: User, + survey: Survey?, + versionText: String, + onAction: (HomeDrawerAction) -> Unit, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .systemBarsPadding() + .verticalScroll(rememberScrollState()) + ) { + AppInfoHeader(user = user, onAction = onAction) + SurveySelector(survey = survey, onSwitchSurvey = { onAction(HomeDrawerAction.OnSwitchSurvey) }) + HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp)) + DrawerItems(onAction, versionText) + } +} + +private val NAV_ITEMS = + listOf( + DrawerItem( + labelId = R.string.sync_status, + icon = IconSource.Drawable(R.drawable.ic_history), + action = HomeDrawerAction.OnNavigateToSyncStatus, + ), + DrawerItem( + labelId = R.string.offline_map_imagery, + icon = IconSource.Drawable(R.drawable.cloud_off), + action = HomeDrawerAction.OnNavigateToOfflineAreas, + ), + DrawerItem( + labelId = R.string.settings, + icon = IconSource.Vector(Icons.Default.Settings), + action = HomeDrawerAction.OnNavigateToSettings, + ), + DrawerItem( + labelId = R.string.about, + icon = IconSource.Drawable(R.drawable.info_outline), + action = HomeDrawerAction.OnNavigateToAbout, + ), + DrawerItem( + labelId = R.string.terms_of_service, + icon = IconSource.Drawable(R.drawable.feed), + action = HomeDrawerAction.OnNavigateToTerms, + ), + DrawerItem( + labelId = R.string.sign_out, + icon = IconSource.Vector(Icons.AutoMirrored.Filled.ExitToApp), + action = HomeDrawerAction.OnSignOut, + ), + ) + +@Composable +private fun DrawerItems(onAction: (HomeDrawerAction) -> Unit, versionText: String) { + NAV_ITEMS.forEach { item -> DrawerNavigationItem(item, onAction) } + + DrawerVersionFooter(versionText) +} + +@Composable +private fun DrawerNavigationItem(item: DrawerItem, onAction: (HomeDrawerAction) -> Unit) { + val label = stringResource(item.labelId) + NavigationDrawerItem( + label = { Text(text = label, style = MaterialTheme.typography.titleSmall) }, + selected = false, + onClick = { onAction(item.action) }, + icon = { + val description = null + when (item.icon) { + is IconSource.Vector -> Icon(item.icon.imageVector, contentDescription = description) + is IconSource.Drawable -> + Icon(painterResource(item.icon.id), contentDescription = description) + } + }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding).testTag(label), + ) +} + +@Composable +private fun DrawerVersionFooter(versionText: String) { + Row( + modifier = + Modifier.fillMaxWidth() + .padding(NavigationDrawerItemDefaults.ItemPadding) + .padding(start = 16.dp, end = 24.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = versionText, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private data class DrawerItem( + @androidx.annotation.StringRes val labelId: Int, + val icon: IconSource, + val action: HomeDrawerAction, +) + +@Composable +private fun AppInfoHeader(user: User, onAction: (HomeDrawerAction) -> Unit) { + 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, + fontFamily = + androidx.compose.ui.text.font.FontFamily( + androidx.compose.ui.text.font.Font(R.font.google_sans) + ), + fontWeight = FontWeight.Normal, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (user.photoUrl != null) { + coil.compose.AsyncImage( + model = user.photoUrl, + contentDescription = null, + modifier = + Modifier.size(32.dp).clip(CircleShape).clickable { + onAction(HomeDrawerAction.OnUserDetails) + }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + ) + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun SurveySelector(survey: Survey?, onSwitchSurvey: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_content_paste), + contentDescription = stringResource(R.string.current_survey), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.current_survey), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = + androidx.compose.ui.text.font.FontFamily( + androidx.compose.ui.text.font.Font(R.font.google_sans) + ), + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(16.dp)) + + if (survey == null) { + Text(stringResource(R.string.no_survey_selected)) + } else { + Text( + text = survey.title, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = + androidx.compose.ui.text.font.FontFamily( + androidx.compose.ui.text.font.Font(R.font.google_sans) + ), + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + if (survey.description.isNotEmpty()) { + Text( + text = survey.description, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + fontFamily = + androidx.compose.ui.text.font.FontFamily( + androidx.compose.ui.text.font.Font(R.font.google_sans) + ), + lineHeight = 20.sp, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.switch_survey), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = + androidx.compose.ui.text.font.FontFamily( + androidx.compose.ui.text.font.Font(R.font.manrope_bold, FontWeight.SemiBold), + androidx.compose.ui.text.font.Font(R.font.manrope_medium, FontWeight.Medium), + ), + lineHeight = 20.sp, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier.clip(CircleShape).clickable(onClick = onSwitchSurvey).padding(vertical = 10.dp), + ) + } +} + +private sealed interface IconSource { + data class Vector(val imageVector: androidx.compose.ui.graphics.vector.ImageVector) : IconSource + + data class Drawable(@androidx.annotation.DrawableRes val id: Int) : IconSource +} + +@Preview(showBackground = true) +@Composable +private fun HomeDrawerPreview() { + val mockUser = + User(id = "1", email = "test@example.com", displayName = "Jane Doe", photoUrl = null) + val mockSurvey = + Survey( + id = "1", + title = "Tree Survey", + description = "A comprehensive survey for mapping urban tree canopy and assessing health.", + jobMap = emptyMap(), + generalAccess = org.groundplatform.android.proto.Survey.GeneralAccess.PUBLIC, + ) + AppTheme { + HomeDrawer(user = mockUser, survey = mockSurvey, versionText = "1.0.0-preview", onAction = {}) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawerAction.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawerAction.kt new file mode 100644 index 0000000000..1c0cbc4fa7 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawerAction.kt @@ -0,0 +1,34 @@ +/* + * 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 + +sealed interface HomeDrawerAction { + data object OnSwitchSurvey : HomeDrawerAction + + data object OnNavigateToOfflineAreas : HomeDrawerAction + + data object OnNavigateToSyncStatus : HomeDrawerAction + + data object OnNavigateToSettings : HomeDrawerAction + + data object OnNavigateToAbout : HomeDrawerAction + + data object OnNavigateToTerms : HomeDrawerAction + + data object OnSignOut : HomeDrawerAction + + data object OnUserDetails : HomeDrawerAction +} diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt index 538c7ddeaa..0b5f004e20 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt @@ -17,35 +17,25 @@ package org.groundplatform.android.ui.home import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.core.view.GravityCompat -import androidx.core.view.WindowInsetsCompat -import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.google.android.material.imageview.ShapeableImageView -import com.google.android.material.navigation.NavigationView import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch -import org.groundplatform.android.BuildConfig import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.databinding.HomeScreenFragBinding -import org.groundplatform.android.databinding.NavDrawerHeaderBinding -import org.groundplatform.android.repository.UserRepository import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.main.MainViewModel import org.groundplatform.android.util.setComposableContent -import org.groundplatform.android.util.systemInsets /** * Fragment containing the map container and location of interest sheet fragments and NavigationView @@ -53,17 +43,14 @@ import org.groundplatform.android.util.systemInsets * fragments (e.g., view submission and edit submission) at runtime. */ @AndroidEntryPoint -class HomeScreenFragment : - AbstractFragment(), BackPressListener, NavigationView.OnNavigationItemSelectedListener { +class HomeScreenFragment : AbstractFragment(), BackPressListener { @Inject lateinit var ephemeralPopups: EphemeralPopups - @Inject lateinit var userRepository: UserRepository private lateinit var binding: HomeScreenFragBinding private lateinit var homeScreenViewModel: HomeScreenViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - getViewModel(MainViewModel::class.java).windowInsets.observe(this) { onApplyWindowInsets(it) } homeScreenViewModel = getViewModel(HomeScreenViewModel::class.java) } @@ -75,31 +62,86 @@ class HomeScreenFragment : super.onCreateView(inflater, container, savedInstanceState) binding = HomeScreenFragBinding.inflate(inflater, container, false) binding.lifecycleOwner = this - lifecycleScope.launch { homeScreenViewModel.openDrawerRequestsFlow.collect { openDrawer() } } return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // Ensure nav drawer cannot be swiped out, which would conflict with map pan gestures. - binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - homeScreenViewModel.showOfflineAreaMenuItem.observe(viewLifecycleOwner) { - binding.navView.menu.findItem(R.id.nav_offline_areas).isEnabled = it + val binding = binding + + setupComposeView(binding) + setupDrawerContent(binding) + restoreDraftSubmission(binding) + } + + private fun setupComposeView(binding: HomeScreenFragBinding) { + binding.composeView.setComposableContent { + LaunchedEffect(Unit) { homeScreenViewModel.openDrawerRequestsFlow.collect { openDrawer() } } + SetupUserConfirmationDialog() } + } - binding.navView.setNavigationItemSelectedListener(this) - val navHeader = binding.navView.getHeaderView(0) - navHeader.findViewById(R.id.switch_survey_button).setOnClickListener { - findNavController() - .navigate( - HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment(false) + private fun setupDrawerContent(binding: HomeScreenFragBinding) { + binding.drawerView.setComposableContent { + val drawerState by homeScreenViewModel.drawerState.collectAsStateWithLifecycle() + + drawerState?.let { state -> + HomeDrawer( + user = state.user, + survey = state.survey, + versionText = String.format(getString(R.string.build), state.appVersion), + onAction = { action -> + when (action) { + HomeDrawerAction.OnSwitchSurvey -> { + findNavController() + .navigate( + HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment( + false + ) + ) + } + HomeDrawerAction.OnNavigateToOfflineAreas -> { + lifecycleScope.launch { + if (homeScreenViewModel.getOfflineAreas().isEmpty()) + findNavController() + .navigate(HomeScreenFragmentDirections.showOfflineAreaSelector()) + else findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreas()) + } + closeDrawer() + } + HomeDrawerAction.OnNavigateToSyncStatus -> { + findNavController().navigate(HomeScreenFragmentDirections.showSyncStatus()) + closeDrawer() + } + HomeDrawerAction.OnNavigateToSettings -> { + findNavController() + .navigate( + HomeScreenFragmentDirections.actionHomeScreenFragmentToSettingsActivity() + ) + closeDrawer() + } + HomeDrawerAction.OnNavigateToAbout -> { + findNavController().navigate(HomeScreenFragmentDirections.showAbout()) + closeDrawer() + } + HomeDrawerAction.OnNavigateToTerms -> { + findNavController().navigate(HomeScreenFragmentDirections.showTermsOfService(true)) + closeDrawer() + } + HomeDrawerAction.OnSignOut -> { + homeScreenViewModel.showSignOutConfirmation() + } + HomeDrawerAction.OnUserDetails -> { + homeScreenViewModel.showUserDetails() + } + } + }, ) + } } + } - navHeader.findViewById(R.id.user_image).setOnClickListener { - homeScreenViewModel.showUserDetails() - } - updateNavHeader() + private fun restoreDraftSubmission(binding: HomeScreenFragBinding) { // Re-open data collection screen if draft submission is present. viewLifecycleOwner.lifecycleScope.launch { homeScreenViewModel.getDraftSubmission()?.let { draft -> @@ -126,31 +168,8 @@ class HomeScreenFragment : } } } - - val navigationView = view.findViewById(R.id.nav_view) - val menuItem = navigationView.menu.findItem(R.id.nav_log_version) - menuItem.title = String.format(getString(R.string.build), BuildConfig.VERSION_NAME) - - binding.composeView.setComposableContent { SetupUserConfirmationDialog() } } - private fun updateNavHeader() = - lifecycleScope.launch { - val navHeader = binding.navView.getHeaderView(0) - val headerBinding = NavDrawerHeaderBinding.bind(navHeader) - headerBinding.user = userRepository.getAuthenticatedUser() - homeScreenViewModel.surveyRepository.activeSurveyFlow.collect { - if (it == null) { - headerBinding.surveyInfo.visibility = View.GONE - headerBinding.noSurveysInfo.visibility = View.VISIBLE - } else { - headerBinding.noSurveysInfo.visibility = View.GONE - headerBinding.surveyInfo.visibility = View.VISIBLE - headerBinding.survey = it - } - } - } - private fun openDrawer() { binding.drawerLayout.openDrawer(GravityCompat.START) } @@ -159,40 +178,8 @@ class HomeScreenFragment : binding.drawerLayout.closeDrawer(GravityCompat.START) } - private fun onApplyWindowInsets(insets: WindowInsetsCompat) { - val headerView = binding.navView.getHeaderView(0) - headerView.setPadding(0, insets.systemInsets().top, 0, 0) - } - override fun onBack(): Boolean = false - override fun onNavigationItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.sync_status -> { - findNavController().navigate(HomeScreenFragmentDirections.showSyncStatus()) - } - R.id.nav_offline_areas -> { - lifecycleScope.launch { - if (homeScreenViewModel.getOfflineAreas().isEmpty()) - findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreaSelector()) - else findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreas()) - } - } - R.id.nav_settings -> { - findNavController() - .navigate(HomeScreenFragmentDirections.actionHomeScreenFragmentToSettingsActivity()) - } - R.id.about -> { - findNavController().navigate(HomeScreenFragmentDirections.showAbout()) - } - R.id.terms_of_service -> { - findNavController().navigate(HomeScreenFragmentDirections.showTermsOfService(true)) - } - } - closeDrawer() - return true - } - @Composable private fun SetupUserConfirmationDialog() { val state by homeScreenViewModel.accountDialogState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt index 472b576cf9..ee0d861332 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt @@ -24,15 +24,20 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.groundplatform.android.data.sync.MediaUploadWorkManager import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.model.Survey import org.groundplatform.android.model.User import org.groundplatform.android.model.submission.DraftSubmission import org.groundplatform.android.repository.MutationRepository @@ -45,6 +50,8 @@ import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.ui.common.SharedViewModel import timber.log.Timber +data class HomeDrawerState(val user: User, val survey: Survey?, val appVersion: String) + private const val AWAITING_PHOTO_CAPTURE_KEY = "awaiting_photo_capture" @SharedViewModel @@ -93,6 +100,17 @@ internal constructor( viewModelScope.launch { kickLocalMutationSyncWorkers() } } + val drawerState: StateFlow = + flow { emit(userRepository.getAuthenticatedUser()) } + .combine(surveyRepository.activeSurveyFlow) { user, survey -> + HomeDrawerState( + user = user, + survey = survey, + appVersion = org.groundplatform.android.BuildConfig.VERSION_NAME, + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, null) + /** * Enqueue data and photo upload workers for all pending mutations when home screen is first * opened as a workaround the get stuck mutations (i.e., PENDING or FAILED mutations with no @@ -108,7 +126,7 @@ internal constructor( } } - /** Attempts to return draft submission for the currently active survey. */ + /** Attempts to return draft submission for the currently active active survey. */ suspend fun getDraftSubmission(): DraftSubmission? { val draftId = submissionRepository.getDraftSubmissionsId() val survey = surveyRepository.activeSurveyFlow.first() diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobSelectionRow.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobSelectionRow.kt index e01b3df8df..3d79f47477 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobSelectionRow.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobSelectionRow.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.groundplatform.android.R import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.Style @@ -73,7 +72,7 @@ fun JobSelectionRow(job: Job, onClick: () -> Unit) { Text( job.name ?: stringResource(R.string.unnamed_job), modifier = Modifier.padding(16.dp), - fontSize = 20.sp, + style = MaterialTheme.typography.titleLarge, ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt index 00c36856be..1b3356a24e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheet.kt @@ -45,7 +45,6 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import org.groundplatform.android.R import org.groundplatform.android.model.AuditInfo @@ -130,7 +129,11 @@ private fun ModalContents( @Composable private fun JobName(loiHelper: LocationOfInterestHelper, loi: LocationOfInterest) { loiHelper.getJobName(loi)?.let { - Text(it, color = MaterialTheme.colorScheme.onSurface, fontSize = 18.sp) + Text( + it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) } } @@ -171,14 +174,14 @@ private fun SubmissionRow( if (submissionCount <= 0) stringResource(R.string.no_submissions) else pluralStringResource(R.plurals.submission_count, submissionCount, submissionCount), color = MaterialTheme.colorScheme.onSurface, - fontSize = 16.sp, + style = MaterialTheme.typography.bodyLarge, ) // NOTE(#2539): Avoid crash when there are no non-LOI tasks. val showAddData = canUserSubmitData && loi.job.hasNonLoiTasks() && loi.isPredefined == true if (showAddData) { Button(onClick = onCollectClicked) { - Text(stringResource(R.string.add_data), modifier = Modifier.padding(4.dp), fontSize = 18.sp) + Text(stringResource(R.string.add_data), modifier = Modifier.padding(4.dp)) } } } diff --git a/app/src/main/res/color/nav_drawer_item.xml b/app/src/main/res/color/nav_drawer_item.xml deleted file mode 100644 index 9e0a118926..0000000000 --- a/app/src/main/res/color/nav_drawer_item.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/task_transparent_btn_selector.xml b/app/src/main/res/color/task_transparent_btn_selector.xml deleted file mode 100644 index b30f3494b1..0000000000 --- a/app/src/main/res/color/task_transparent_btn_selector.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index ee9f81c935..0000000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/home_screen_frag.xml b/app/src/main/res/layout/home_screen_frag.xml index d6696c54d8..fb27f2b6ac 100644 --- a/app/src/main/res/layout/home_screen_frag.xml +++ b/app/src/main/res/layout/home_screen_frag.xml @@ -53,17 +53,12 @@ - + android:fitsSystemWindows="true" /> \ No newline at end of file diff --git a/app/src/main/res/layout/nav_drawer_header.xml b/app/src/main/res/layout/nav_drawer_header.xml deleted file mode 100644 index e88c07a706..0000000000 --- a/app/src/main/res/layout/nav_drawer_header.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/nav_drawer_menu.xml b/app/src/main/res/menu/nav_drawer_menu.xml deleted file mode 100644 index 31df413277..0000000000 --- a/app/src/main/res/menu/nav_drawer_menu.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e8cfac489a..6d93e93bea 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -26,9 +26,6 @@ 48dp 4dp - - 36dp - 50dp diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 8ff2754e40..f9fe817491 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,7 @@ ~ limitations under the License. --> - + - - - - - - - - - - -