diff --git a/.agent/skills/fetch_pr_comments/SKILL.md b/.agent/skills/fetch_pr_comments/SKILL.md index 72d0966049..137d193c54 100644 --- a/.agent/skills/fetch_pr_comments/SKILL.md +++ b/.agent/skills/fetch_pr_comments/SKILL.md @@ -27,7 +27,7 @@ The script outputs Markdown text to stdout, containing: - PR Title and URL - Reviews (Approved/Changes Requested) - General Comments (Pull Request level) -- Code Comments (Grouped by file and line number) +- Code Comments (Grouped by file and line number, unresolved threads only) ## Dependencies diff --git a/.agent/skills/fetch_pr_comments/scripts/fetch_comments.py b/.agent/skills/fetch_pr_comments/scripts/fetch_comments.py old mode 100644 new mode 100755 index 508caa4e9c..173f815aa4 --- a/.agent/skills/fetch_pr_comments/scripts/fetch_comments.py +++ b/.agent/skills/fetch_pr_comments/scripts/fetch_comments.py @@ -54,22 +54,103 @@ def get_repo_owner_name(pr_url): return match.group(1), match.group(2) return None, None -def get_code_comments(owner, repo, pr_number): - cmd = f"gh api repos/{owner}/{repo}/pulls/{pr_number}/comments --paginate" +GRAPHQL_QUERY = """ +query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 50, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + isResolved + path + comments(first: 50) { + nodes { + author { login } + body + path + line + originalLine + createdAt + url + } + } + } + } + } + } +} +""" + +def run_graphql_query(query, variables): try: - return json.loads(run_command(cmd)) - except Exception as e: - print(f"Warning: Failed to fetch code comments: {e}", file=sys.stderr) - return [] + result = subprocess.run( + ["gh", "api", "graphql", "--input", "-"], + input=json.dumps({"query": query, "variables": variables}), + capture_output=True, + text=True, + check=True + ) + return json.loads(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error running GraphQL query: {e}", file=sys.stderr) + print(f"Stderr: {e.stderr}", file=sys.stderr) + return None + +def get_code_comments(owner, repo, pr_number): + comments = [] + cursor = None + has_next = True + + while has_next: + variables = { + "owner": owner, + "repo": repo, + "pr": int(pr_number), + "cursor": cursor + } + + data = run_graphql_query(GRAPHQL_QUERY, variables) + if not data: + break + + pr_data = data.get("data", {}).get("repository", {}).get("pullRequest", {}) + if not pr_data: + break + + threads = pr_data.get("reviewThreads", {}) + page_info = threads.get("pageInfo", {}) + has_next = page_info.get("hasNextPage", False) + cursor = page_info.get("endCursor") + + for thread in threads.get("nodes", []): + if thread.get("isResolved"): + continue + + for comment in thread.get("comments", {}).get("nodes", []): + mapped = { + "path": comment.get("path"), + "line": comment.get("line"), + "original_line": comment.get("originalLine"), + "body": comment.get("body"), + "user": {"login": comment.get("author", {}).get("login") if comment.get("author") else "Unknown"}, + "created_at": comment.get("createdAt"), + "html_url": comment.get("url") + } + comments.append(mapped) + + return comments def main(): # Allow optional PR argument (number or URL) pr_arg = "" if len(sys.argv) > 1: pr_arg = f" {sys.argv[1]}" - + cmd = f"gh pr view{pr_arg} --json number,title,url,state,comments,reviews,latestReviews" - + try: json_output = run_command(cmd) pr_data = json.loads(json_output) @@ -80,7 +161,7 @@ def main(): title = pr_data.get('title') url = pr_data.get('url') state = pr_data.get('state') - + owner, repo = get_repo_owner_name(url) code_comments = [] if owner and repo: @@ -134,19 +215,19 @@ def main(): if path not in comments_by_file: comments_by_file[path] = [] comments_by_file[path].append(cc) - + for path, comments in comments_by_file.items(): print(f"### File: `{path}`\n") # Sort by line number (or position if line is None) comments.sort(key=lambda x: (x.get('line') or x.get('original_line') or 0)) - + for cc in comments: author = cc.get('user', {}).get('login', 'Unknown') body = cc.get('body', '').strip() date = format_date(cc.get('created_at', '')) line = cc.get('line') or cc.get('original_line') or "Outdated" html_url = cc.get('html_url', '') - + print(f"#### Line {line} - {author} ({date})") print(f"[Link]({html_url})\n") print(body) 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 3e066700ff..8eab7158b5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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. @@ -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 @@ -384,3 +385,4 @@ secrets { // checked in version control. defaultPropertiesFileName = "local.defaults.properties" } + diff --git a/app/src/androidTest/java/org/groundplatform/android/CustomTestRunner.kt b/app/src/androidTest/java/org/groundplatform/android/CustomTestRunner.kt new file mode 100644 index 0000000000..9e829916d7 --- /dev/null +++ b/app/src/androidTest/java/org/groundplatform/android/CustomTestRunner.kt @@ -0,0 +1,29 @@ +/* + * 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 + +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 = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/androidTest/java/org/groundplatform/android/ui/compose/ComposeE2ETest.kt b/app/src/androidTest/java/org/groundplatform/android/ui/compose/ComposeE2ETest.kt new file mode 100644 index 0000000000..91999e4dc3 --- /dev/null +++ b/app/src/androidTest/java/org/groundplatform/android/ui/compose/ComposeE2ETest.kt @@ -0,0 +1,71 @@ +/* + * 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.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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() + + @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() + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/basemapselector/BasemapSelectorScreen.kt b/app/src/main/java/org/groundplatform/android/ui/basemapselector/BasemapSelectorScreen.kt index 04efbbeeb6..f52707208e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/basemapselector/BasemapSelectorScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/basemapselector/BasemapSelectorScreen.kt @@ -71,7 +71,7 @@ fun BasemapSelectorScreen( ) { val currentMapType by viewModel.currentMapType.collectAsStateWithLifecycle() val isOfflineImageryEnabled by viewModel.isOfflineImageryEnabled.collectAsStateWithLifecycle() - val sheetState = rememberModalBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() fun onMapTypeSelected(mapType: MapType) { diff --git a/app/src/main/java/org/groundplatform/android/ui/common/AbstractMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/common/AbstractMapContainerFragment.kt index 845e80c6d3..fa9fecea39 100644 --- a/app/src/main/java/org/groundplatform/android/ui/common/AbstractMapContainerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/common/AbstractMapContainerFragment.kt @@ -45,6 +45,7 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + (childFragmentManager.findFragmentById(R.id.map) as? MapFragment)?.let { map = it } map.attachToParent(this, R.id.map) { onMapAttached(it) } if (view is ViewGroup) { @@ -68,7 +69,7 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { ) } - private fun onMapAttached(map: MapFragment) { + protected fun onMapAttached(map: MapFragment) { val viewModel = getMapViewModel() // Removes all markers, overlays, polylines and polygons from the map. @@ -189,6 +190,11 @@ abstract class AbstractMapContainerFragment : AbstractFragment() { /** Configuration to enable/disable base map features. */ open fun getMapConfig() = DEFAULT_MAP_CONFIG + override fun onDestroyView() { + map.disableCurrentLocationIndicator() + super.onDestroyView() + } + companion object { private val DEFAULT_MAP_CONFIG: MapConfig = MapConfig(showOfflineImagery = true) } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/DataSharingTermsDialog.kt b/app/src/main/java/org/groundplatform/android/ui/home/DataSharingTermsDialog.kt index bf88d0e6de..0bd47544ee 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/DataSharingTermsDialog.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/DataSharingTermsDialog.kt @@ -22,8 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -65,20 +63,12 @@ private fun generateDataSharingTermsHtml(dataSharingTerms: Survey.DataSharingTer @Composable fun DataSharingTermsDialog( dataSharingTerms: Survey.DataSharingTerms, - consentGivenCallback: () -> Unit = {}, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {}, ) { - val showDialog = remember { mutableStateOf(true) } - - fun dismissDialog() { - showDialog.value = false - } - - if (!showDialog.value) { - return - } AlertDialog( - onDismissRequest = { dismissDialog() }, + onDismissRequest = onDismiss, title = { Text( text = stringResource(R.string.data_consent_dialog_title), @@ -90,17 +80,10 @@ fun DataSharingTermsDialog( HtmlText(html, Modifier.height(450.dp).fillMaxWidth()) }, dismissButton = { - TextButton(onClick = { dismissDialog() }) { Text(text = stringResource(R.string.cancel)) } + TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.cancel)) } }, confirmButton = { - TextButton( - onClick = { - consentGivenCallback() - dismissDialog() - } - ) { - Text(text = stringResource(R.string.agree_checkbox)) - } + TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.agree_checkbox)) } }, ) } 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..98270c797e --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeDrawer.kt @@ -0,0 +1,253 @@ +/* + * 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, + versionText: String, +) { + Column( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + ) { + AppInfoHeader(user = user) + SurveySelector(survey = survey, onSwitchSurvey = onSwitchSurvey) + HorizontalDivider() + DrawerItems( + onNavigateToOfflineAreas, + onNavigateToSyncStatus, + onNavigateToSettings, + onNavigateToAbout, + onNavigateToTerms, + onSignOut, + versionText, + ) + } +} + +@Composable +private fun DrawerItems( + onNavigateToOfflineAreas: () -> Unit, + onNavigateToSyncStatus: () -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToAbout: () -> Unit, + onNavigateToTerms: () -> Unit, + onSignOut: () -> Unit, + versionText: String, +) { + val navItems = + listOf( + DrawerItem( + label = stringResource(R.string.offline_map_imagery), + icon = IconSource.Drawable(R.drawable.ic_offline_pin), + onClick = onNavigateToOfflineAreas, + ), + DrawerItem( + label = stringResource(R.string.sync_status), + icon = IconSource.Drawable(R.drawable.ic_sync), + onClick = onNavigateToSyncStatus, + ), + DrawerItem( + label = stringResource(R.string.settings), + icon = IconSource.Vector(Icons.Default.Settings), + onClick = onNavigateToSettings, + ), + DrawerItem( + label = stringResource(R.string.about), + icon = IconSource.Drawable(R.drawable.info_outline), + onClick = onNavigateToAbout, + ), + DrawerItem( + label = stringResource(R.string.terms_of_service), + icon = IconSource.Drawable(R.drawable.feed), + onClick = onNavigateToTerms, + ), + DrawerItem( + label = stringResource(R.string.sign_out), + icon = IconSource.Vector(Icons.AutoMirrored.Filled.ExitToApp), + onClick = onSignOut, + ), + DrawerItem(label = versionText, icon = IconSource.Vector(Icons.Default.Build), onClick = {}), + ) + + navItems.forEach { item -> + NavigationDrawerItem( + label = { Text(item.label) }, + selected = false, + onClick = item.onClick, + icon = { + val description = item.label + 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), + ) + } +} + +private data class DrawerItem(val label: String, val icon: IconSource, val onClick: () -> Unit) + +@Composable +private fun AppInfoHeader(user: User?) { + 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), + ) + } + } + } +} + +@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(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), + ) + } +} + +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 +} diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreen.kt new file mode 100644 index 0000000000..5cd836051f --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreen.kt @@ -0,0 +1,35 @@ +/* + * 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.material3.DrawerState +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.runtime.Composable + +@Composable +fun HomeScreen( + drawerState: DrawerState, + drawerContent: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { ModalDrawerSheet { drawerContent() } }, + content = content, + ) +} 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 4875defa89..62fadbdf99 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 @@ -16,36 +16,35 @@ package org.groundplatform.android.ui.home import android.os.Bundle +import android.view.Gravity import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.TextView +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView import androidx.core.view.GravityCompat -import androidx.core.view.WindowInsetsCompat +import androidx.core.view.ViewCompat import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.lifecycleScope +import androidx.fragment.app.FragmentContainerView +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.model.User 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.components.ConfirmationDialog -import org.groundplatform.android.ui.main.MainViewModel -import org.groundplatform.android.util.setComposableContent -import org.groundplatform.android.util.systemInsets +import org.groundplatform.android.ui.theme.AppTheme /** * Fragment containing the map container and location of interest sheet fragments and NavigationView @@ -53,18 +52,16 @@ 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 - private lateinit var user: User + private lateinit var drawerLayout: DrawerLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - getViewModel(MainViewModel::class.java).windowInsets.observe(this) { onApplyWindowInsets(it) } homeScreenViewModel = getViewModel(HomeScreenViewModel::class.java) } @@ -74,160 +71,248 @@ class HomeScreenFragment : savedInstanceState: Bundle?, ): View { 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 - } + drawerLayout = DrawerLayout(requireContext()) + drawerLayout.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) - binding.navView.setNavigationItemSelectedListener(this) - val navHeader = binding.navView.getHeaderView(0) - navHeader.findViewById(R.id.switch_survey_button).setOnClickListener { - findNavController() - .navigate( - HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment(false) - ) - } - viewLifecycleOwner.lifecycleScope.launch { user = userRepository.getAuthenticatedUser() } - navHeader.findViewById(R.id.user_image).setOnClickListener { - showSignOutConfirmationDialogs() - } - updateNavHeader() - // Re-open data collection screen if draft submission is present. - viewLifecycleOwner.lifecycleScope.launch { - homeScreenViewModel.getDraftSubmission()?.let { draft -> - findNavController() - .navigate( - HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment( - draft.loiId, - draft.loiName ?: "", - draft.jobId, - true, - SubmissionDeltasConverter.toString(draft.deltas), - draft.currentTaskId ?: "", - ) - ) + val contentRoot = createContentRoot() + val drawerView = createDrawerView() - if (!homeScreenViewModel.awaitingPhotoCapture) { - ephemeralPopups - .InfoPopup(binding.root, R.string.draft_restored, EphemeralPopups.PopupDuration.SHORT) - .show() - } else { - // We're restoring after an instantaneous photo capture for a photo task; don't show a - // draft restored toast. - homeScreenViewModel.awaitingPhotoCapture = false - } - } - } + drawerLayout.addView(contentRoot) + drawerLayout.addView(drawerView) - 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) + ensureMapFragmentAdded() + configureDrawerContent(drawerView) + configureOverlaysContent(contentRoot.findViewById(R.id.overlays_view)) + + ViewCompat.setOnApplyWindowInsetsListener(drawerLayout) { _, insets -> insets } + + return drawerLayout } - 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 createContentRoot(): ViewGroup { + val contentRoot = FrameLayout(requireContext()) + contentRoot.layoutParams = + DrawerLayout.LayoutParams( + DrawerLayout.LayoutParams.MATCH_PARENT, + DrawerLayout.LayoutParams.MATCH_PARENT, + ) + + val mapContainer = FragmentContainerView(requireContext()) + mapContainer.id = R.id.map_container_fragment + mapContainer.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + contentRoot.addView(mapContainer) + + val overlaysView = ComposeView(requireContext()) + overlaysView.id = R.id.overlays_view + overlaysView.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + contentRoot.addView(overlaysView) - private fun openDrawer() { - binding.drawerLayout.openDrawer(GravityCompat.START) + return contentRoot } - private fun closeDrawer() { - binding.drawerLayout.closeDrawer(GravityCompat.START) + private fun createDrawerView(): ComposeView { + val drawerView = ComposeView(requireContext()) + val drawerParams = + DrawerLayout.LayoutParams( + DrawerLayout.LayoutParams.WRAP_CONTENT, + DrawerLayout.LayoutParams.MATCH_PARENT, + ) + drawerParams.gravity = Gravity.START + drawerView.layoutParams = drawerParams + return drawerView } - private fun onApplyWindowInsets(insets: WindowInsetsCompat) { - val headerView = binding.navView.getHeaderView(0) - headerView.setPadding(0, insets.systemInsets().top, 0, 0) + private fun ensureMapFragmentAdded() { + if (childFragmentManager.findFragmentById(R.id.map_container_fragment) == null) { + val mapFragment = + org.groundplatform.android.ui.home.mapcontainer.HomeScreenMapContainerFragment() + childFragmentManager + .beginTransaction() + .replace(R.id.map_container_fragment, mapFragment) + .commit() + } } - override fun onBack(): Boolean = false + private fun configureDrawerContent(drawerView: ComposeView) { + drawerView.setContent { + AppTheme { + DrawerContent( + userRepository = userRepository, + homeScreenViewModel = homeScreenViewModel, + drawerLayout = drawerLayout, + ) + } + } + } - override fun onNavigationItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.sync_status -> { - findNavController().navigate(HomeScreenFragmentDirections.showSyncStatus()) + @Composable + private fun DrawerContent( + userRepository: UserRepository, + homeScreenViewModel: HomeScreenViewModel, + drawerLayout: DrawerLayout, + ) { + val user by + androidx.compose.runtime.produceState(initialValue = null) { + value = userRepository.getAuthenticatedUser() } - R.id.nav_offline_areas -> { - lifecycleScope.launch { - if (homeScreenViewModel.getOfflineAreas().isEmpty()) - findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreaSelector()) - else findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreas()) + val survey by + homeScreenViewModel.surveyRepository.activeSurveyFlow.collectAsStateWithLifecycle(null) + val scope = androidx.compose.runtime.rememberCoroutineScope() + val offlineAreasEnabled by homeScreenViewModel.showOfflineAreaMenuItem.observeAsState(true) + + val closeDrawer: () -> Unit = { scope.launch { drawerLayout.closeDrawer(GravityCompat.START) } } + + HomeDrawer( + user = user, + survey = survey, + onSwitchSurvey = { + findNavController() + .navigate( + HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment(false) + ) + closeDrawer() + }, + onNavigateToOfflineAreas = { + if (offlineAreasEnabled) { + scope.launch { + if (homeScreenViewModel.getOfflineAreas().isEmpty()) + findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreaSelector()) + else findNavController().navigate(HomeScreenFragmentDirections.showOfflineAreas()) + closeDrawer() + } } - } - R.id.nav_settings -> { + }, + onNavigateToSyncStatus = { + findNavController().navigate(HomeScreenFragmentDirections.showSyncStatus()) + closeDrawer() + }, + onNavigateToSettings = { findNavController() .navigate(HomeScreenFragmentDirections.actionHomeScreenFragmentToSettingsActivity()) - } - R.id.about -> { + closeDrawer() + }, + onNavigateToAbout = { findNavController().navigate(HomeScreenFragmentDirections.showAbout()) - } - R.id.terms_of_service -> { + closeDrawer() + }, + onNavigateToTerms = { findNavController().navigate(HomeScreenFragmentDirections.showTermsOfService(true)) - } - } - closeDrawer() - return true + closeDrawer() + }, + onSignOut = { + homeScreenViewModel.showSignOutDialog() + closeDrawer() + }, + versionText = + String.format( + getString(R.string.build), + org.groundplatform.android.BuildConfig.VERSION_NAME, + ), + ) } - private fun showSignOutConfirmationDialogs() { - val showUserDetailsDialog = mutableStateOf(false) - val showSignOutDialog = mutableStateOf(false) + private fun configureOverlaysContent(overlaysView: ComposeView) { + overlaysView.setContent { + org.groundplatform.android.ui.theme.AppTheme { + androidx.compose.runtime.LaunchedEffect(Unit) { + homeScreenViewModel.openDrawerRequestsFlow.collect { + drawerLayout.openDrawer(GravityCompat.START) + } + } - fun showUserDetailsDialog() { - showUserDetailsDialog.value = true - showSignOutDialog.value = false - } + val showSignOutDialog = remember { mutableStateOf(false) } + + androidx.compose.runtime.LaunchedEffect(Unit) { + homeScreenViewModel.showSignOutDialog.collect { showSignOutDialog.value = true } + } - fun showSignOutDialog() { - showUserDetailsDialog.value = false - showSignOutDialog.value = true + HomeScreenContent( + homeScreenViewModel = homeScreenViewModel, + ephemeralPopups = ephemeralPopups, + showSignOutDialog = showSignOutDialog, + onNavigateToDataCollection = { loiId, loiName, jobId, restore, deltas, taskId -> + findNavController() + .navigate( + HomeScreenFragmentDirections.actionHomeScreenFragmentToDataCollectionFragment( + loiId, + loiName, + jobId, + restore, + deltas, + taskId, + ) + ) + }, + ) + } } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } - fun hideAllDialogs() { - showUserDetailsDialog.value = false - showSignOutDialog.value = false + override fun onBack(): Boolean { + if (::drawerLayout.isInitialized && drawerLayout.isDrawerOpen(GravityCompat.START)) { + drawerLayout.closeDrawer(GravityCompat.START) + return true } + return false + } +} - // Init state for composition - showUserDetailsDialog() +@Composable +private fun HomeScreenContent( + homeScreenViewModel: HomeScreenViewModel, + ephemeralPopups: EphemeralPopups, + showSignOutDialog: androidx.compose.runtime.MutableState, + onNavigateToDataCollection: (String, String?, String, Boolean, String?, String) -> Unit, +) { + val view = androidx.compose.ui.platform.LocalView.current - // Note: Adding a compose view to the fragment's view dynamically causes the navigation click to - // stop working after 1st time. Revisit this once the navigation drawer is also generated using - // compose. - binding.composeView.setComposableContent { - if (showUserDetailsDialog.value) { - UserDetailsDialog(user, { showSignOutDialog() }, { hideAllDialogs() }) - } - if (showSignOutDialog.value) { - ConfirmationDialog( - title = R.string.sign_out_dialog_title, - description = R.string.sign_out_dialog_body, - confirmButtonText = R.string.sign_out, - onConfirmClicked = { homeScreenViewModel.signOut() }, - ) + // Sign Out Confirmation + if (showSignOutDialog.value) { + ConfirmationDialog( + title = R.string.sign_out_dialog_title, + description = R.string.sign_out_dialog_body, + confirmButtonText = R.string.sign_out, + onConfirmClicked = { + homeScreenViewModel.signOut() + showSignOutDialog.value = false + }, + onDismiss = { showSignOutDialog.value = false }, + ) + } + + androidx.compose.runtime.LaunchedEffect(Unit) { + homeScreenViewModel.getDraftSubmission()?.let { draft -> + onNavigateToDataCollection( + draft.loiId ?: "", + draft.loiName, + draft.jobId, + true, + SubmissionDeltasConverter.toString(draft.deltas), + draft.currentTaskId ?: "", + ) + if (!homeScreenViewModel.awaitingPhotoCapture) { + ephemeralPopups + .InfoPopup(view, R.string.draft_restored, EphemeralPopups.PopupDuration.SHORT) + .show() + } else { + homeScreenViewModel.awaitingPhotoCapture = false } } } 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 72d82335b3..aa45cf1718 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 @@ -60,6 +60,9 @@ internal constructor( // Issue URL: https://github.com/google/ground-android/issues/1730 val showOfflineAreaMenuItem: LiveData = MutableLiveData(true) + private val _showSignOutDialog = MutableSharedFlow() + val showSignOutDialog: SharedFlow = _showSignOutDialog.asSharedFlow() + /* Indicates the application is being restored after a photo capture. * * We need to persist this state here to control [HomeScreenFragement] UI treatments when returning @@ -119,6 +122,10 @@ internal constructor( suspend fun getOfflineAreas() = offlineAreaRepository.offlineAreas().first() + fun showSignOutDialog() { + viewModelScope.launch { _showSignOutDialog.emit(true) } + } + fun signOut() { viewModelScope.launch { userRepository.signOut() } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt index 678c1bebfc..4a05c5ff73 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt @@ -20,19 +20,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.groundplatform.android.R -import org.groundplatform.android.databinding.BasemapLayoutBinding import org.groundplatform.android.model.locationofinterest.LOI_NAME_PROPERTY -import org.groundplatform.android.proto.Survey.DataSharingTerms import org.groundplatform.android.ui.common.AbstractMapContainerFragment import org.groundplatform.android.ui.common.BaseMapViewModel import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.home.DataSharingTermsDialog import org.groundplatform.android.ui.home.HomeScreenFragmentDirections import org.groundplatform.android.ui.home.HomeScreenViewModel import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData @@ -41,9 +38,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase -import org.groundplatform.android.util.renderComposableDialog -import org.groundplatform.android.util.setComposableContent +import org.groundplatform.android.ui.theme.AppTheme import timber.log.Timber /** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */ @@ -54,7 +49,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { private lateinit var mapContainerViewModel: HomeScreenMapContainerViewModel private lateinit var homeScreenViewModel: HomeScreenViewModel - private lateinit var binding: BasemapLayoutBinding + private lateinit var bottomContainer: ViewGroup override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -71,18 +66,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() } - private fun showDataSharingTermsDialog( - cardUiData: DataCollectionEntryPointData, - dataSharingTerms: DataSharingTerms, - ) { - renderComposableDialog { - DataSharingTermsDialog(dataSharingTerms) { - mapContainerViewModel.grantDataSharingConsent() - navigateToDataCollectionFragment(cardUiData) - } - } - } - /** Invoked when user clicks on the map cards to collect data. */ private fun onCollectData(cardUiData: DataCollectionEntryPointData) { if (!cardUiData.canCollectData) { @@ -98,28 +81,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { return } - mapContainerViewModel - .getDataSharingTerms() - .onSuccess { terms -> - if (terms == null) { - // Data sharing terms already accepted or missing. - navigateToDataCollectionFragment(cardUiData) - } else { - showDataSharingTermsDialog(cardUiData, terms) - } - } - .onFailure { - Timber.e(it, "Failed to get data sharing terms") - ephemeralPopups - .ErrorPopup() - .show( - if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { - R.string.invalid_data_sharing_terms - } else { - R.string.something_went_wrong - } - ) - } + mapContainerViewModel.queueDataCollection(cardUiData) } /** Invoked when user clicks delete on a site. */ @@ -133,38 +95,33 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { savedInstanceState: Bundle?, ): View { super.onCreateView(inflater, container, savedInstanceState) - binding = BasemapLayoutBinding.inflate(inflater, container, false) - return binding.root + bottomContainer = + androidx.coordinatorlayout.widget.CoordinatorLayout(requireContext()).apply { + id = R.id.bottom_container + } + + return createComposeView() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.composeContent.apply { + private fun createComposeView(): View = + ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setComposableContent { - val locationLockButton by - mapContainerViewModel.locationLockIconType.collectAsStateWithLifecycle() - val jobMapComponentState by - mapContainerViewModel.jobMapComponentState.collectAsStateWithLifecycle() - val shouldShowMapActions by - mapContainerViewModel.shouldShowMapActions.collectAsStateWithLifecycle() - val shouldShowRecenter by - mapContainerViewModel.shouldShowRecenterButton.collectAsStateWithLifecycle() - - HomeScreenMapContainerScreen( - locationLockButtonType = locationLockButton, - shouldShowMapActions = shouldShowMapActions, - shouldShowRecenter = shouldShowRecenter, - jobComponentState = jobMapComponentState, - onBaseMapAction = { handleMapAction(it) }, - onJobComponentAction = { - handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) - }, - ) + setContent { + AppTheme { + HomeScreenMapContainerContent( + map = map, + mapContainerViewModel = mapContainerViewModel, + bottomContainer = bottomContainer, + fragment = this@HomeScreenMapContainerFragment, + ) + } } } - binding.bottomContainer.bringToFront() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // super.onViewCreated(view, savedInstanceState) + + bottomContainer.bringToFront() showDataCollectionHint() // LOIs associated with the survey have been synced to the local db by this point. We can @@ -172,7 +129,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { launchWhenStarted { mapContainerViewModel.maybeEnableLocationLock() } } - private fun handleMapAction(action: BaseMapAction) { + internal fun handleMapAction(action: BaseMapAction) { when (action) { BaseMapAction.OnLocationLockClicked -> mapContainerViewModel.onLocationLockClick() BaseMapAction.OnMapTypeClicked -> showMapTypeSelectorDialog() @@ -180,7 +137,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - private fun handleJobMapComponentAction( + internal fun handleJobMapComponentAction( jobMapComponentState: JobMapComponentState, action: JobMapComponentAction, ) { @@ -208,9 +165,9 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { if (!this::mapContainerViewModel.isInitialized) { return Timber.w("showDataCollectionHint() called before mapContainerViewModel initialized") } - if (!this::binding.isInitialized) { - return Timber.w("showDataCollectionHint() called before binding initialized") - } + + // binding check no longer valid. + // composeView and bottomContainer are initialized in onCreateView. // Decides which survey-related popup to show based on the current survey. mapContainerViewModel.surveyUpdateFlow.launchWhenStartedAndCollectFirst { surveyProperties -> @@ -225,13 +182,18 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { null } + // ... showInfoPopup ... private fun showInfoPopup(messageId: Int) { - ephemeralPopups - .InfoPopup(binding.bottomContainer, messageId, EphemeralPopups.PopupDuration.LONG) - .show() + ephemeralPopups.InfoPopup(bottomContainer, messageId, EphemeralPopups.PopupDuration.LONG).show() } - private fun navigateToDataCollectionFragment(cardUiData: DataCollectionEntryPointData) { + internal fun navigateToDataCollectionFragment(cardUiData: DataCollectionEntryPointData) { + if (findNavController().currentDestination?.id != R.id.home_screen_fragment) { + Timber.w( + "Refusing to navigate to data collection from ${findNavController().currentDestination?.label}" + ) + return + } when (cardUiData) { is SelectedLoiSheetData -> findNavController() @@ -260,9 +222,23 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } + fun onMapReadyPublic(map: MapFragment) { + onMapAttached(map) + } + override fun onMapReady(map: MapFragment) { mapContainerViewModel.mapLoiFeatures.launchWhenStartedAndCollect { map.setFeatures(it) } } + override fun onDestroyView() { + // Remove the map fragment to prevent a crash during restoration (No view found for id). + // The fragment will be re-added by Compose when the view is recreated. + val mapFragment = childFragmentManager.findFragmentById(R.id.map) + if (mapFragment != null) { + childFragmentManager.beginTransaction().remove(mapFragment).commitNowAllowingStateLoss() + } + super.onDestroyView() + } + override fun getMapViewModel(): BaseMapViewModel = mapContainerViewModel } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt index 25dc2464ab..b742f8f096 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.android.ui.home.mapcontainer +import android.view.ViewGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,20 +29,36 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import org.groundplatform.android.R import org.groundplatform.android.model.job.Job import org.groundplatform.android.model.job.Style +import org.groundplatform.android.model.map.MapType +import org.groundplatform.android.proto.Survey.DataSharingTerms +import org.groundplatform.android.ui.basemapselector.BasemapSelectorScreen import org.groundplatform.android.ui.components.MapFloatingActionButton import org.groundplatform.android.ui.components.MapFloatingActionButtonType import org.groundplatform.android.ui.components.RecenterButton +import org.groundplatform.android.ui.home.DataSharingTermsDialog import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponent import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState +import org.groundplatform.android.ui.map.MapFragment import org.groundplatform.android.ui.theme.AppTheme +import timber.log.Timber @Composable fun HomeScreenMapContainerScreen( @@ -50,26 +67,47 @@ fun HomeScreenMapContainerScreen( shouldShowMapActions: Boolean, shouldShowRecenter: Boolean, jobComponentState: JobMapComponentState, + dataSharingTerms: DataSharingTerms?, + showMapTypeSelector: Boolean, + mapTypes: List, onBaseMapAction: (BaseMapAction) -> Unit, onJobComponentAction: (JobMapComponentAction) -> Unit, + onTermsConsentGiven: () -> Unit = {}, + onTermsConsentDismissed: () -> Unit = {}, + onMapTypeSelectorDismiss: () -> Unit = {}, ) { Box(modifier = modifier.fillMaxSize()) { - if (shouldShowMapActions) { - MapFloatingActionButton( - modifier = - Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) - .align(Alignment.TopStart), - type = MapFloatingActionButtonType.OpenNavDrawer, - onClick = { onBaseMapAction(BaseMapAction.OnOpenNavDrawerClicked) }, + if (showMapTypeSelector) { + BasemapSelectorScreen(mapTypes = mapTypes, onDismissRequest = onMapTypeSelectorDismiss) + } + + if (dataSharingTerms != null) { + DataSharingTermsDialog( + dataSharingTerms = dataSharingTerms, + onConfirm = onTermsConsentGiven, + onDismiss = onTermsConsentDismissed, ) + } - MapFloatingActionButton( + if (shouldShowMapActions) { + Box( modifier = - Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) - .align(Alignment.TopEnd), - type = MapFloatingActionButtonType.MapType, - onClick = { onBaseMapAction(BaseMapAction.OnMapTypeClicked) }, - ) + Modifier.fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + MapFloatingActionButton( + modifier = Modifier.align(Alignment.TopStart), + type = MapFloatingActionButtonType.OpenNavDrawer, + onClick = { onBaseMapAction(BaseMapAction.OnOpenNavDrawerClicked) }, + ) + + MapFloatingActionButton( + modifier = Modifier.align(Alignment.TopEnd), + type = MapFloatingActionButtonType.MapType, + onClick = { onBaseMapAction(BaseMapAction.OnMapTypeClicked) }, + ) + } } Column( @@ -100,14 +138,13 @@ private fun LocationLockComponent( modifier = modifier .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (shouldShowRecenter) - RecenterButton( - modifier = Modifier.padding(start = 16.dp), - onClick = { onAction(BaseMapAction.OnLocationLockClicked) }, - ) + if (shouldShowRecenter) { + RecenterButton(onClick = { onAction(BaseMapAction.OnLocationLockClicked) }) + } Spacer(modifier = Modifier.weight(1f)) @@ -151,8 +188,108 @@ private fun HomeScreenMapContainerScreenPreview() { ), shouldShowMapActions = true, shouldShowRecenter = true, + dataSharingTerms = null, + showMapTypeSelector = false, + mapTypes = listOf(MapType.ROAD, MapType.TERRAIN, MapType.SATELLITE), onBaseMapAction = {}, onJobComponentAction = {}, ) } } + +@Composable +internal fun MapFrame(map: MapFragment, fragment: HomeScreenMapContainerFragment) { + AndroidView( + factory = { context -> + android.widget.FrameLayout(context).apply { + id = R.id.map + layoutParams = + android.widget.FrameLayout.LayoutParams( + android.widget.FrameLayout.LayoutParams.MATCH_PARENT, + android.widget.FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + }, + modifier = Modifier.fillMaxSize(), + update = { + val container = it + val mapFragment = fragment.childFragmentManager.findFragmentById(R.id.map) as? MapFragment + val currentMap = mapFragment ?: map + if (map !== currentMap) { + fragment.map = currentMap + } + + val fragmentView = (mapFragment as? Fragment)?.view + if (mapFragment == null || fragmentView == null || fragmentView.parent != container) { + Timber.d("Attaching map fragment to container: $container") + try { + map.attachToParent(fragment, R.id.map) { fragment.onMapReadyPublic(it) } + } catch (e: Exception) { + Timber.e(e, "Failed to attach map fragment") + } + } + }, + ) +} + +@Composable +internal fun HomeScreenMapContainerContent( + map: MapFragment, + mapContainerViewModel: HomeScreenMapContainerViewModel, + bottomContainer: ViewGroup, + fragment: HomeScreenMapContainerFragment, +) { + Box(modifier = Modifier.fillMaxSize()) { + MapFrame(map, fragment) + + val locationLockButton by + mapContainerViewModel.locationLockIconType.collectAsStateWithLifecycle() + val jobMapComponentState by + mapContainerViewModel.jobMapComponentState.collectAsStateWithLifecycle() + val shouldShowMapActions by + mapContainerViewModel.shouldShowMapActions.collectAsStateWithLifecycle() + val shouldShowRecenter by + mapContainerViewModel.shouldShowRecenterButton.collectAsStateWithLifecycle() + val dataSharingTerms by mapContainerViewModel.dataSharingTerms.collectAsStateWithLifecycle() + val showMapTypeSelector by + mapContainerViewModel.showMapTypeSelector.collectAsStateWithLifecycle() + + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + mapContainerViewModel.navigateToDataCollectionFragment.collect { + fragment.navigateToDataCollectionFragment(it) + } + } + launch { + mapContainerViewModel.termsError.collect { + fragment.ephemeralPopups.ErrorPopup().show(fragment.getString(it)) + } + } + } + } + + HomeScreenMapContainerScreen( + locationLockButtonType = locationLockButton, + shouldShowMapActions = shouldShowMapActions, + shouldShowRecenter = shouldShowRecenter, + jobComponentState = jobMapComponentState, + dataSharingTerms = dataSharingTerms, + showMapTypeSelector = showMapTypeSelector, + mapTypes = map.supportedMapTypes, + onBaseMapAction = { fragment.handleMapAction(it) }, + onJobComponentAction = { + fragment.handleJobMapComponentAction( + jobMapComponentState = jobMapComponentState, + action = it, + ) + }, + onTermsConsentGiven = { mapContainerViewModel.onTermsConsentGiven() }, + onTermsConsentDismissed = { mapContainerViewModel.onTermsConsentDismissed() }, + onMapTypeSelectorDismiss = { mapContainerViewModel.showMapTypeSelector.value = false }, + ) + + AndroidView(factory = { bottomContainer }, modifier = Modifier.fillMaxSize()) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index ba1095d9dc..01296fd6c8 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -15,17 +15,20 @@ */ package org.groundplatform.android.ui.home.mapcontainer -import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow 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.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -34,12 +37,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.groundplatform.android.R import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.model.Survey import org.groundplatform.android.model.job.Job +import org.groundplatform.android.model.job.Style import org.groundplatform.android.model.job.getDefaultColor import org.groundplatform.android.model.locationofinterest.LocationOfInterest import org.groundplatform.android.proto.Survey.DataSharingTerms @@ -60,6 +65,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase +import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @SharedViewModel @@ -113,10 +119,17 @@ internal constructor( * List of [LocationOfInterest] for the active survey that are present within the viewport and * zoom level is clustering threshold or higher. */ - private val loisInViewport: StateFlow> + val loisInViewport: Flow> + private val featureClicked = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) - /** [Feature] clicked by the user. */ - val featureClicked: MutableStateFlow = MutableStateFlow(null) + init { + featureClicked.tryEmit(null) + } /** * List of [Job]s which allow LOIs to be added during field collection, populated only when zoomed @@ -133,6 +146,18 @@ internal constructor( */ val jobMapComponentState: StateFlow + private val _dataSharingTerms = MutableStateFlow(null) + val dataSharingTerms = _dataSharingTerms.asStateFlow() + + private val _navigateToDataCollectionFragment = + MutableSharedFlow(extraBufferCapacity = 1) + val navigateToDataCollectionFragment = _navigateToDataCollectionFragment.asSharedFlow() + + private val _termsError = MutableSharedFlow(extraBufferCapacity = 1) + val termsError = _termsError.asSharedFlow() + + private var pendingDataCollectionEntryPointData: DataCollectionEntryPointData? = null + init { // THIS SHOULD NOT BE CALLED ON CONFIG CHANGE @@ -150,17 +175,26 @@ internal constructor( } isZoomedInFlow = - getCurrentCameraPosition().mapNotNull { it.zoomLevel }.map { it >= CLUSTERING_ZOOM_THRESHOLD } + getCurrentCameraPosition() + .map { it.zoomLevel } + .filterNotNull() + .map { it >= CLUSTERING_ZOOM_THRESHOLD } + // TODO: Verify if we can use flow on results of getWithinBounds directly. loisInViewport = - activeSurvey - .combine(isZoomedInFlow) { survey, isZoomedIn -> Pair(survey, isZoomedIn) } - .flatMapLatest { (survey, isZoomedIn) -> - val bounds = currentCameraPosition.value?.bounds - if (bounds == null || survey == null || !isZoomedIn) flowOf(listOf()) - else loiRepository.getWithinBounds(survey, bounds) - } - .stateIn(viewModelScope, SharingStarted.Lazily, listOf()) + activeSurvey.flatMapLatest { survey -> + if (survey == null) flowOf(listOf()) + else + getCurrentCameraPosition().flatMapLatest { cameraPosition -> + val bounds = cameraPosition.bounds + val zoomLevel = cameraPosition.zoomLevel + if (zoomLevel != null && zoomLevel >= CLUSTERING_ZOOM_THRESHOLD && bounds != null) { + loiRepository.getWithinBounds(survey, bounds) + } else { + flowOf(listOf()) + } + } + } adHocLoiJobs = activeSurvey.combine(isZoomedInFlow) { survey, isZoomedIn -> @@ -171,11 +205,7 @@ internal constructor( jobMapComponentState = processDataCollectionEntryPoints() .map { (loiCard, jobCards) -> JobMapComponentState(loiCard, jobCards) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = JobMapComponentState(), - ) + .stateIn(viewModelScope, SharingStarted.Eagerly, JobMapComponentState()) } /** Enables the location lock if the active survey doesn't have a default camera position. */ @@ -195,15 +225,66 @@ internal constructor( fun getDataSharingTerms(): Result = getDataSharingTermsUseCase() + fun queueDataCollection(data: DataCollectionEntryPointData) { + if (!data.canCollectData) { + // TODO: Handle view-only mode or error if needed, though this check might belong in UI or use + // case. + // For now, mirroring fragment logic if acceptable, or just proceed to check terms. + return + } + + // If tasks are invalid, UI usually handles it, but we can verify here if needed. + // For now, assuming data is valid enough to check terms. + + getDataSharingTermsUseCase() + .onSuccess { terms -> + if (terms == null) { + viewModelScope.launch { _navigateToDataCollectionFragment.emit(data) } + } else { + pendingDataCollectionEntryPointData = data + _dataSharingTerms.value = terms + Timber.d("pendingData set to $data") + } + } + .onFailure { + Timber.e(it, "Failed to get data sharing terms") + if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { + viewModelScope.launch { _termsError.emit(R.string.invalid_data_sharing_terms) } + } + } + } + + fun onTermsConsentGiven() { + val data = pendingDataCollectionEntryPointData + if (_dataSharingTerms.value == null) { + return + } + Timber.d("onTermsConsentGiven data=$data") + if (data != null) { + viewModelScope.launch { + Timber.d("Emitting navigation") + _navigateToDataCollectionFragment.emit(data) + } + } + _dataSharingTerms.value = null + pendingDataCollectionEntryPointData = null + grantDataSharingConsent() + } + + fun onTermsConsentDismissed() { + _dataSharingTerms.value = null + pendingDataCollectionEntryPointData = null + } + /** * Returns a flow of [DataCollectionEntryPointData] associated with the active survey's LOIs and * adhoc jobs for displaying the cards. */ - @VisibleForTesting fun processDataCollectionEntryPoints(): Flow>> = combine(loisInViewport, featureClicked, adHocLoiJobs) { loisInView, feature, jobs -> val canUserSubmitData = userRepository.canUserSubmitData() + val loiCard = loisInView .firstOrNull { it.geometry == feature?.geometry } @@ -218,11 +299,11 @@ internal constructor( } if (loiCard == null && feature != null) { // The feature is not in view anymore. - featureClicked.value = null + featureClicked.tryEmit(null) } - val jobCard = + val jobCards = jobs.map { AdHocDataCollectionButtonData(canCollectData = canUserSubmitData, job = it) } - Pair(loiCard, jobCard) + Pair(loiCard, jobCards) } private fun updatedLoiSelectedStates( @@ -244,7 +325,15 @@ internal constructor( * list of provided features is empty. */ fun onFeatureClicked(features: Set) { - featureClicked.value = features.minByOrNull { it.geometry.area } + println("DEBUG: onFeatureClicked: features.size=${features.size}") + val feature = features.minByOrNull { it.geometry.area } + println( + "DEBUG: onFeatureClicked: setting value to $feature on ${System.identityHashCode(featureClicked)}" + ) + viewModelScope.launch { + featureClicked.emit(feature) + println("DEBUG: onFeatureClicked: emit completed, replayCache=${featureClicked.replayCache}") + } } fun grantDataSharingConsent() { @@ -277,7 +366,7 @@ internal constructor( fun selectLocationOfInterest(id: String?) { selectedLoiIdFlow.value = id if (id == null) { - featureClicked.value = null + featureClicked.tryEmit(null) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt index 23cc046515..58bd5129ab 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt @@ -95,7 +95,7 @@ private fun AddLoiButton(onClick: () -> Unit) { modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) .align(Alignment.BottomCenter) - .padding(bottom = 36.dp), + .padding(bottom = 16.dp), icon = Icons.Filled.Add, contentDescription = stringResource(id = R.string.add_site), onClick = onClick, diff --git a/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt index 9529ea1afb..05da2d9c04 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/MapFragment.kt @@ -108,4 +108,7 @@ interface MapFragment { /** Removes all markers, overlays, polylines and polygons from the map. */ fun clear() + + /** Disables the current location indicator. */ + fun disableCurrentLocationIndicator() } diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt index 278ad8e0c5..8176ac318e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/GoogleMapsFragment.kt @@ -143,6 +143,11 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> onApplyWindowInsets(view, insets) } + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) } } @@ -151,7 +156,27 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { @IdRes containerId: Int, onMapReadyCallback: (MapFragment) -> Unit, ) { - containerFragment.replaceFragment(containerId, this) + val container = containerFragment.view?.findViewById(containerId) + if (container == null) { + Timber.e("Container view not found for id: $containerId") + return + } + // Always replace to ensure view re-attachment + // Use commitNow to ensure the view is added synchronously before AndroidView update completes + containerFragment.childFragmentManager.beginTransaction().replace(containerId, this).commitNow() + + view?.addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + Timber.v("Map View attached to window: $v") + } + + override fun onViewDetachedFromWindow(v: View) { + Timber.v("Map View detached from window: $v") + } + } + ) + getMapAsync { googleMap: GoogleMap -> onMapReady(googleMap) onMapReadyCallback(this) @@ -237,6 +262,21 @@ class GoogleMapsFragment : SupportMapFragment(), MapFragment { } } + @SuppressLint("MissingPermission") + override fun disableCurrentLocationIndicator() { + if (!::map.isInitialized) { + return + } + + try { + if (map.isMyLocationEnabled) { + map.isMyLocationEnabled = false + } + } catch (e: Exception) { + Timber.e(e, "Error disabling location indicator") + } + } + override fun setFeatures(newFeatures: Set) { featureManager.setFeatures(newFeatures) } diff --git a/app/src/main/java/org/groundplatform/android/util/ComposeExt.kt b/app/src/main/java/org/groundplatform/android/util/ComposeExt.kt index a5549f4d44..98fce39aa6 100644 --- a/app/src/main/java/org/groundplatform/android/util/ComposeExt.kt +++ b/app/src/main/java/org/groundplatform/android/util/ComposeExt.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. 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/drawable-anydpi/ic_history.xml b/app/src/main/res/drawable-anydpi/ic_history.xml deleted file mode 100644 index 885eb0601c..0000000000 --- a/app/src/main/res/drawable-anydpi/ic_history.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/cloud_off.xml b/app/src/main/res/drawable/cloud_off.xml deleted file mode 100644 index eb8b3d88f5..0000000000 --- a/app/src/main/res/drawable/cloud_off.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - 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/basemap_layout.xml b/app/src/main/res/layout/basemap_layout.xml deleted file mode 100644 index bc85a1f972..0000000000 --- a/app/src/main/res/layout/basemap_layout.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/home_screen_frag.xml b/app/src/main/res/layout/home_screen_frag.xml deleted file mode 100644 index d6696c54d8..0000000000 --- a/app/src/main/res/layout/home_screen_frag.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ 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/layout/task_frag_with_header.xml b/app/src/main/res/layout/task_frag_with_header.xml index ccd6e0dfd2..9ee2802b79 100644 --- a/app/src/main/res/layout/task_frag_with_header.xml +++ b/app/src/main/res/layout/task_frag_with_header.xml @@ -36,7 +36,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:drawablePadding="16dp" - android:fontFamily="@font/text_500" + android:fontFamily="@font/manrope_medium" android:padding="16dp" android:text="@{viewModel.task.label}" android:textColor="@color/md_theme_onSurface" 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 3e7cb5f569..211206f00b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -30,7 +30,6 @@ 4dp - 36dp 50dp diff --git a/app/src/main/res/color/task_transparent_btn_selector.xml b/app/src/main/res/values/ids.xml similarity index 69% rename from app/src/main/res/color/task_transparent_btn_selector.xml rename to app/src/main/res/values/ids.xml index b30f3494b1..de9d0b9676 100644 --- a/app/src/main/res/color/task_transparent_btn_selector.xml +++ b/app/src/main/res/values/ids.xml @@ -1,7 +1,7 @@ - - - - - \ No newline at end of file + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 0f4adfadb9..45a9d80f51 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -26,7 +26,7 @@ @android:transition/move true true - @font/text_500 + @font/manrope_medium @style/Widget.App.Toolbar @style/Widget.App.Toolbar.Button.Navigation @@ -69,20 +69,7 @@ - - - - - - - @@ -153,6 +133,6 @@ diff --git a/app/src/test/java/org/groundplatform/android/ui/common/AbstractMapContainerFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/common/AbstractMapContainerFragmentTest.kt new file mode 100644 index 0000000000..5eff9e1606 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/common/AbstractMapContainerFragmentTest.kt @@ -0,0 +1,55 @@ +/* + * 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.ui.common + +import org.groundplatform.android.ui.map.MapFragment +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AbstractMapContainerFragmentTest { + + @Mock lateinit var mapFragment: MapFragment + + @Mock lateinit var viewModel: BaseMapViewModel + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `onDestroyView disables current location indicator`() { + val fragment = TestMapContainerFragment(viewModel) + fragment.map = mapFragment + + // Call onDestroyView + fragment.onDestroyView() + + verify(mapFragment).disableCurrentLocationIndicator() + } + + // Concrete implementation for testing + class TestMapContainerFragment(private val viewModel: BaseMapViewModel) : + AbstractMapContainerFragment() { + override fun getMapViewModel(): BaseMapViewModel = viewModel + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/home/DataSharingTermsDialogTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/DataSharingTermsDialogTest.kt index 404d4ad9a5..1249d6a2d0 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/DataSharingTermsDialogTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/DataSharingTermsDialogTest.kt @@ -16,6 +16,7 @@ package org.groundplatform.android.ui.home import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.isDisplayed @@ -110,11 +111,18 @@ class DataSharingTermsDialogTest : BaseHiltTest() { @Test fun `cancel button click dismisses the dialog`() { + val isVisible = mutableStateOf(true) composeTestRule.setContent { - DataSharingTermsDialog( - dataSharingTerms = - Survey.DataSharingTerms.newBuilder().setType(Survey.DataSharingTerms.Type.PRIVATE).build() - ) + if (isVisible.value) { + DataSharingTermsDialog( + dataSharingTerms = + Survey.DataSharingTerms.newBuilder() + .setType(Survey.DataSharingTerms.Type.PRIVATE) + .build(), + onDismiss = { isVisible.value = false }, + onConfirm = {}, + ) + } } composeTestRule @@ -127,15 +135,22 @@ class DataSharingTermsDialogTest : BaseHiltTest() { @Test fun `agree button click invokes consent callback`() { var callbackCalled = false + val isVisible = mutableStateOf(true) composeTestRule.setContent { - DataSharingTermsDialog( - dataSharingTerms = - Survey.DataSharingTerms.newBuilder() - .setType(Survey.DataSharingTerms.Type.PRIVATE) - .build(), - consentGivenCallback = { callbackCalled = true }, - ) + if (isVisible.value) { + DataSharingTermsDialog( + dataSharingTerms = + Survey.DataSharingTerms.newBuilder() + .setType(Survey.DataSharingTerms.Type.PRIVATE) + .build(), + onConfirm = { + callbackCalled = true + isVisible.value = false + }, + onDismiss = {}, + ) + } } composeTestRule diff --git a/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenFragmentTest.kt index 6892a9eb88..b10ba9d936 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenFragmentTest.kt @@ -15,31 +15,31 @@ */ package org.groundplatform.android.ui.home +import android.content.Context +import android.util.Log import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.drawerlayout.widget.DrawerLayout +import androidx.compose.ui.test.performScrollTo import androidx.navigation.NavController -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.swipeUp -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.DrawerMatchers.isClosed -import androidx.test.espresso.contrib.DrawerMatchers.isOpen -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isEnabled -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.work.Configuration +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject -import kotlin.test.assertFalse import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest @@ -59,6 +59,7 @@ import org.robolectric.RobolectricTestRunner abstract class AbstractHomeScreenFragmentTest : BaseHiltTest() { + @Inject @ApplicationContext lateinit var context: Context @Inject lateinit var localSurveyStore: LocalSurveyStore lateinit var fragment: HomeScreenFragment protected lateinit var navController: NavController @@ -66,6 +67,13 @@ abstract class AbstractHomeScreenFragmentTest : BaseHiltTest() { @Before override fun setUp() { super.setUp() + val config = + Configuration.Builder() + .setMinimumLoggingLevel(Log.INFO) + .setExecutor(SynchronousExecutor()) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + launchFragmentWithNavController( destId = R.id.home_screen_fragment, navControllerCallback = { navController = it }, @@ -74,48 +82,38 @@ abstract class AbstractHomeScreenFragmentTest : BaseHiltTest() { } } + // ... (inside class) protected fun openDrawer( composeTestRule: AndroidComposeTestRule, ComponentActivity> ) { - onView(withId(R.id.drawer_layout)).check(matches(isClosed())) composeTestRule.onNodeWithTag(MapFloatingActionButtonType.OpenNavDrawer.testTag).performClick() composeTestRule.waitForIdle() - verifyDrawerOpen() - onView(withId(R.id.nav_view)).check(matches(isDisplayed())) - } - - protected fun swipeUpDrawer() { - onView(withId(R.id.drawer_layout)).perform(swipeUp()) + verifyDrawerOpen(composeTestRule) } - protected fun verifyDrawerOpen() { - computeScrollForDrawerLayout() - onView(withId(R.id.drawer_layout)).check(matches(isOpen())) - } - - protected fun verifyDrawerClosed() { - computeScrollForDrawerLayout() - onView(withId(R.id.drawer_layout)).check(matches(isClosed())) + @Suppress("SwallowedException") + protected fun verifyDrawerOpen( + composeTestRule: + AndroidComposeTestRule, ComponentActivity> + ) { + composeTestRule.waitUntil(timeoutMillis = 5000) { + try { + composeTestRule.onNodeWithText("Offline map imagery").assertIsDisplayed() + true + } catch (e: AssertionError) { + false + } + } + composeTestRule.onNodeWithText("Offline map imagery").assertIsDisplayed() } - /** - * Invoke this method before doing any verifications on navigation drawer after performing an - * action on it. - */ - private fun computeScrollForDrawerLayout() { - val drawerLayout = fragment.requireView().findViewById(R.id.drawer_layout) - // Note that this only initiates a single computeScroll() in Robolectric. Normally, Android - // will compute several of these across multiple draw calls, but one seems sufficient for - // Robolectric. Note that Robolectric is also *supposed* to handle the animation loop one call - // to this method initiates in the view choreographer class, but it seems to not actually - // flush the choreographer per observation. In Espresso, this method is automatically called - // during draw (and a few other situations), but it's fine to call it directly once to kick it - // off (to avoid disparity between Espresso/Robolectric runs of the tests). - // NOTE TO DEVELOPERS: if this ever flakes, we can probably put this in a loop with fake time - // adjustments to simulate the render loop. - // Tracking bug: https://github.com/robolectric/robolectric/issues/5954 - drawerLayout.computeScroll() + protected fun verifyDrawerClosed( + composeTestRule: + AndroidComposeTestRule, ComponentActivity> + ) { + // Drawer content should not be displayed + composeTestRule.onNodeWithText("Offline map imagery").assertIsNotDisplayed() } } @@ -126,92 +124,77 @@ class HomeScreenFragmentTest : AbstractHomeScreenFragmentTest() { @Inject lateinit var surveyRepository: SurveyRepository - /** - * composeTestRule has to be created in the specific test file in order to access the required - * activity. [composeTestRule.activity] - */ @get:Rule override val composeTestRule = createAndroidComposeRule() @Test fun `all menu item is always enabled`() = runWithTestDispatcher { openDrawer(composeTestRule) - onView(withId(R.id.nav_offline_areas)).check(matches(isEnabled())) - onView(withId(R.id.sync_status)).check(matches(isEnabled())) - onView(withId(R.id.nav_settings)).check(matches(isEnabled())) - onView(withId(R.id.about)).check(matches(isEnabled())) - - swipeUpDrawer() - - onView(withId(R.id.terms_of_service)).check(matches(isEnabled())) - onView(withId(R.id.nav_log_version)).check(matches(isEnabled())) + composeTestRule.onNodeWithText("Offline map imagery").assertIsDisplayed().assertIsEnabled() + composeTestRule.onNodeWithText("Data sync status").assertIsDisplayed().assertIsEnabled() + composeTestRule + .onNodeWithText(fragment.getString(R.string.settings)) + .performScrollTo() + .assertIsDisplayed() + .assertIsEnabled() + composeTestRule.onNodeWithText("About").performScrollTo().assertIsDisplayed().assertIsEnabled() + composeTestRule + .onNodeWithText("Terms of service") + .performScrollTo() + .assertIsDisplayed() + .assertIsEnabled() + composeTestRule + .onNodeWithText("Build ${org.groundplatform.android.BuildConfig.VERSION_NAME}") + .performScrollTo() + .assertIsDisplayed() } @Test fun `sign out dialog is displayed`() = runWithTestDispatcher { openDrawer(composeTestRule) - onView(withId(R.id.user_image)).check(matches(isDisplayed())) - openSignOutDialog() - - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out)) - .assertIsDisplayed() composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.close)) - .assertIsDisplayed() - - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.close)) + .onNode( + hasText(fragment.getString(R.string.sign_out)) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab) + ) + .performScrollTo() .performClick() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.close)) - .assertIsNotDisplayed() - - openSignOutWarningDialog() - - advanceUntilIdle() composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out_dialog_title)) + .onNodeWithText(fragment.getString(R.string.sign_out_dialog_title)) .assertIsDisplayed() composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out_dialog_body)) - .assertIsDisplayed() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) - .assertIsDisplayed() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out)) + .onNodeWithText(fragment.getString(R.string.sign_out_dialog_body)) .assertIsDisplayed() + composeTestRule.onNodeWithText(fragment.getString(R.string.cancel)).assertIsDisplayed() + + composeTestRule.onNodeWithText(fragment.getString(R.string.cancel)).performClick() + composeTestRule.onNodeWithText(fragment.getString(R.string.cancel)).assertIsNotDisplayed() + // Drawer should still be open. Click "Sign out" again. composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) + .onNode( + hasText(fragment.getString(R.string.sign_out)) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab) + ) + .performScrollTo() .performClick() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.cancel)) - .assertIsNotDisplayed() - openSignOutWarningDialog() + // Click "Sign out" in the dialog. composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out)) + .onNode( + hasText(fragment.getString(R.string.sign_out)) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button) + ) .performClick() - composeTestRule - .onNodeWithText(composeTestRule.activity.getString(R.string.sign_out)) - .assertIsNotDisplayed() + + // Verify dialog closed and potential sign out behavior. + composeTestRule.onNodeWithText(fragment.getString(R.string.sign_out)).assertIsNotDisplayed() } @Test fun `on back returns false and does nothing`() { - assertFalse(fragment.onBack()) - } - - private fun openSignOutDialog() { - onView(withId(R.id.user_image)).perform(click()) - } - - private fun openSignOutWarningDialog() { - openSignOutDialog() - composeTestRule.onNodeWithText("Sign out").performClick() + assertThat(fragment.onBack()).isFalse() } } @@ -222,7 +205,6 @@ class NavigationDrawerItemClickTest( private val menuItemLabel: String, private val expectedNavDirection: Int?, private val survey: Survey, - private val shouldDrawerCloseAfterClick: Boolean, ) : AbstractHomeScreenFragmentTest() { @Inject lateinit var surveyRepository: SurveyRepository @@ -235,21 +217,22 @@ class NavigationDrawerItemClickTest( surveyRepository.activateSurvey(survey.id) advanceUntilIdle() - openDrawer(composeTestRule) + // openDrawer via helper in Abstract? + // We should implement openDrawer in Abstract using composeTestRule. + // AbstractHomeScreenFragmentTest needs composeTestRule reference? + // It takes it as arg. - onView(withId(R.id.drawer_layout)).perform(swipeUp()) + // Using MapFloatingActionButtonType.OpenNavDrawer.testTag + composeTestRule.onNodeWithTag(MapFloatingActionButtonType.OpenNavDrawer.testTag).performClick() + composeTestRule.waitForIdle() - onView(withText(menuItemLabel)).check(matches(isEnabled())).perform(click()) + composeTestRule.onNodeWithText(menuItemLabel).performScrollTo().performClick() if (expectedNavDirection != null) { assertThat(navController.currentDestination?.id).isEqualTo(expectedNavDirection) } - if (shouldDrawerCloseAfterClick) { - verifyDrawerClosed() - } else { - verifyDrawerOpen() - } + verifyDrawerClosed(composeTestRule) } companion object { @@ -261,11 +244,11 @@ class NavigationDrawerItemClickTest( listOf( // TODO: Restore tests deleted in #2382. // Issue URL: https://github.com/google/ground-android/issues/2385 - arrayOf("Data sync status", R.id.sync_status_fragment, TEST_SURVEY, true), - arrayOf("Terms of service", R.id.terms_of_service_fragment, TEST_SURVEY, true), - arrayOf("About", R.id.aboutFragment, TEST_SURVEY, true), - arrayOf("Offline map imagery", R.id.offline_area_selector_fragment, TEST_SURVEY, true), - arrayOf("Settings", R.id.settings_activity, TEST_SURVEY, true), + arrayOf("Data sync status", R.id.sync_status_fragment, TEST_SURVEY), + arrayOf("Terms of service", R.id.terms_of_service_fragment, TEST_SURVEY), + arrayOf("About", R.id.aboutFragment, TEST_SURVEY), + arrayOf("Offline map imagery", R.id.offline_area_selector_fragment, TEST_SURVEY), + arrayOf("Settings", R.id.settings_activity, TEST_SURVEY), ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt index 54d7528432..24632fcb7b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt @@ -29,6 +29,7 @@ import kotlin.test.assertTrue import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.R +import org.groundplatform.android.model.map.MapType import org.groundplatform.android.ui.components.LOCATION_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.LOCATION_NOT_LOCKED_TEST_TAG import org.groundplatform.android.ui.components.MapFloatingActionButtonType @@ -178,6 +179,8 @@ class HomeScreenMapContainerScreenTest : BaseHiltTest() { shouldShowMapActions: Boolean = true, shouldShowRecenterButton: Boolean = true, jobComponentState: JobMapComponentState = JobMapComponentState(), + showMapTypeSelector: Boolean = false, + mapTypes: List = emptyList(), onBaseMapAction: (BaseMapAction) -> Unit = {}, onJobComponentAction: (JobMapComponentAction) -> Unit = {}, ) { @@ -187,8 +190,13 @@ class HomeScreenMapContainerScreenTest : BaseHiltTest() { shouldShowMapActions = shouldShowMapActions, shouldShowRecenter = shouldShowRecenterButton, jobComponentState = jobComponentState, + dataSharingTerms = null, + showMapTypeSelector = showMapTypeSelector, + mapTypes = mapTypes, onBaseMapAction = onBaseMapAction, onJobComponentAction = onJobComponentAction, + onTermsConsentGiven = {}, + onTermsConsentDismissed = {}, ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index 7622ed84e9..add4be3043 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -16,78 +16,259 @@ package org.groundplatform.android.ui.home.mapcontainer +import androidx.lifecycle.viewModelScope import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.setMain import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_FEATURE import org.groundplatform.android.FakeData.SURVEY import org.groundplatform.android.FakeData.USER +import org.groundplatform.android.R +import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.model.geometry.Coordinates import org.groundplatform.android.model.map.Bounds import org.groundplatform.android.model.map.CameraPosition import org.groundplatform.android.repository.LocationOfInterestRepository +import org.groundplatform.android.repository.MapStateRepository import org.groundplatform.android.repository.SurveyRepository import org.groundplatform.android.repository.UserRepository import org.groundplatform.android.system.auth.FakeAuthenticationManager import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @HiltAndroidTest @RunWith(RobolectricTestRunner::class) class HomeScreenMapContainerViewModelTest : BaseHiltTest() { @Inject lateinit var viewModel: HomeScreenMapContainerViewModel - @Inject lateinit var surveyRepository: SurveyRepository + @BindValue + val surveyRepository: SurveyRepository = org.mockito.Mockito.mock(SurveyRepository::class.java) @Inject lateinit var authenticationManager: FakeAuthenticationManager @Inject lateinit var remoteDataStore: FakeRemoteDataStore @Inject lateinit var userRepository: UserRepository - @Inject lateinit var activateSurvey: ActivateSurveyUseCase - @BindValue @Mock lateinit var loiRepository: LocationOfInterestRepository + @BindValue + val activateSurvey: ActivateSurveyUseCase = + org.mockito.Mockito.mock(ActivateSurveyUseCase::class.java) + @BindValue + val mapStateRepository: MapStateRepository = + org.mockito.Mockito.mock(MapStateRepository::class.java) + @BindValue + val localValueStore: LocalValueStore = org.mockito.Mockito.mock(LocalValueStore::class.java) + @BindValue + val loiRepository: LocationOfInterestRepository = + org.mockito.Mockito.mock(LocationOfInterestRepository::class.java) - @OptIn(ExperimentalCoroutinesApi::class) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Before override fun setUp() { + val localTestDispatcher = kotlinx.coroutines.test.UnconfinedTestDispatcher() + Dispatchers.setMain(localTestDispatcher) + val context = + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val config = + androidx.work.Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(androidx.work.testing.SynchronousExecutor()) + .build() + androidx.work.testing.WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + + whenever(surveyRepository.activeSurveyFlow) + .thenReturn(kotlinx.coroutines.flow.MutableStateFlow(SURVEY)) + whenever(surveyRepository.activeSurvey).thenReturn(SURVEY) + + whenever(mapStateRepository.mapTypeFlow) + .thenReturn( + kotlinx.coroutines.flow.MutableStateFlow( + org.groundplatform.android.model.map.MapType.TERRAIN + ) + ) + whenever(mapStateRepository.offlineImageryEnabledFlow) + .thenReturn(kotlinx.coroutines.flow.MutableStateFlow(false)) + whenever(mapStateRepository.isLocationLockEnabled).thenReturn(false) + + MockitoAnnotations.openMocks(this) super.setUp() + // Overwrite the injected testDispatcher with our local one to ensure consistency + testDispatcher = localTestDispatcher + runWithTestDispatcher { // Setup user authenticationManager.setUser(USER) + authenticationManager.signIn() userRepository.saveUserDetails(USER) + // Spy userRepository to prevent actual survey role checks if needed, but if sign-in works, it + // might be fine. + // Reverting spy for now to test if signIn() fixes it. + // userRepository = org.mockito.Mockito.spy(userRepository) + // org.mockito.kotlin.doReturn(true).whenever(userRepository).canUserSubmitData() // Setup survey and LOIs remoteDataStore.surveys = listOf(SURVEY) remoteDataStore.predefinedLois = listOf(LOCATION_OF_INTEREST) - activateSurvey(SURVEY.id) advanceUntilIdle() - `when`(loiRepository.getWithinBounds(SURVEY, BOUNDS)) - .thenReturn(flowOf(listOf(LOCATION_OF_INTEREST))) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + `when`(loiRepository.getWithinBounds(org.mockito.kotlin.any(), org.mockito.kotlin.any())) + .thenReturn( + flow { + emit(listOf(LOCATION_OF_INTEREST)) + awaitCancellation() + } + ) viewModel.onMapCameraMoved(CAMERA_POSITION) advanceUntilIdle() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + advanceUntilIdle() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() } } + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @org.junit.After + fun tearDown() { + viewModel.viewModelScope.cancel() + } + @Test fun `renders the job card when zoomed into LOI and clicked on`() = runWithTestDispatcher { + val job = launch { viewModel.jobMapComponentState.collect() } viewModel.onFeatureClicked(features = setOf(LOCATION_OF_INTEREST_FEATURE)) - val pair = viewModel.processDataCollectionEntryPoints().first() - assertThat(pair.first) + advanceUntilIdle() + + val state = viewModel.jobMapComponentState.value + assertThat(state.selectedLoi) .isEqualTo(SelectedLoiSheetData(canCollectData = true, LOCATION_OF_INTEREST, 0, true)) - assertThat(pair.second) + assertThat(state.adHocDataCollectionButtonData) .isEqualTo(listOf(AdHocDataCollectionButtonData(canCollectData = true, ADHOC_JOB))) + + job.cancel() + } + + @Test + fun `queueDataCollection shows terms dialog when consent not given`() = runWithTestDispatcher { + whenever(localValueStore.getDataSharingConsent(anyString())).thenReturn(false) + val buttonData = AdHocDataCollectionButtonData(canCollectData = true, ADHOC_JOB) + + viewModel.queueDataCollection(buttonData) + advanceUntilIdle() + + assertThat(viewModel.dataSharingTerms.value).isEqualTo(SURVEY.dataSharingTerms) + } + + @Test + fun `queueDataCollection navigates when consent already given`() = runWithTestDispatcher { + whenever(localValueStore.getDataSharingConsent(anyString())).thenReturn(true) + val buttonData = AdHocDataCollectionButtonData(canCollectData = true, ADHOC_JOB) + val navigationEvents = mutableListOf() + // We launch a collector for the SharedFlow + val job = launch { + viewModel.navigateToDataCollectionFragment.collect { navigationEvents.add(it) } + } + advanceUntilIdle() + + viewModel.queueDataCollection(buttonData) + advanceUntilIdle() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + advanceUntilIdle() + + assertThat(viewModel.dataSharingTerms.value).isNull() + assertThat(navigationEvents).contains(buttonData) + job.cancel() + } + + @Test + fun `onTermsConsentGiven updates consent and navigates`() = runWithTestDispatcher { + whenever(localValueStore.getDataSharingConsent(anyString())).thenReturn(false) + val buttonData = AdHocDataCollectionButtonData(canCollectData = true, ADHOC_JOB) + val navigationEvents = mutableListOf() + val job = launch { + viewModel.navigateToDataCollectionFragment.collect { navigationEvents.add(it) } + } + + viewModel.queueDataCollection(buttonData) + advanceUntilIdle() + + // TERMS shown + assertThat(viewModel.dataSharingTerms.value).isNotNull() + + viewModel.onTermsConsentGiven() + advanceUntilIdle() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + advanceUntilIdle() + + // Dialog hidden, navigation happened, consent saved + assertThat(viewModel.dataSharingTerms.value).isNull() + assertThat(navigationEvents).contains(buttonData) + verify(localValueStore).setDataSharingConsent(SURVEY.id, true) + job.cancel() + } + + @Test + fun `onTermsConsentGiven does nothing when terms not shown`() = runWithTestDispatcher { + val navigationEvents = mutableListOf() + val job = launch { + viewModel.navigateToDataCollectionFragment.collect { navigationEvents.add(it) } + } + + // TERMS hidden (default) + assertThat(viewModel.dataSharingTerms.value).isNull() + + viewModel.onTermsConsentGiven() + advanceUntilIdle() + + // No navigation happened + assertThat(navigationEvents).isEmpty() + job.cancel() + } + + @Test + fun `queueDataCollection emits error when terms are invalid`() = runWithTestDispatcher { + whenever(localValueStore.getDataSharingConsent(anyString())).thenReturn(false) + val buttonData = AdHocDataCollectionButtonData(canCollectData = true, ADHOC_JOB) + + // Create survey with invalid terms (custom type but empty text) + // Use unique ID to ensure we don't use cached survey from setUp + val invalidTerms = + org.groundplatform.android.proto.Survey.DataSharingTerms.newBuilder() + .setType(org.groundplatform.android.proto.Survey.DataSharingTerms.Type.CUSTOM) + .setCustomText("") + .build() + val invalidSurvey = SURVEY.copy(id = "invalid_survey_id", dataSharingTerms = invalidTerms) + remoteDataStore.surveys = listOf(invalidSurvey) + whenever(surveyRepository.activeSurvey).thenReturn(invalidSurvey) + advanceUntilIdle() + + val errorEvents = mutableListOf() + val job = launch { viewModel.termsError.collect { errorEvents.add(it) } } + + viewModel.queueDataCollection(buttonData) + advanceUntilIdle() + + assertThat(errorEvents).contains(R.string.invalid_data_sharing_terms) + job.cancel() } companion object { diff --git a/config/lint/lint.xml b/config/lint/lint.xml index 7402d7ee2d..2de1d5a865 100644 --- a/config/lint/lint.xml +++ b/config/lint/lint.xml @@ -53,6 +53,7 @@ + diff --git a/gradle.properties b/gradle.properties index 32d84a544b..e191aec6d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + #Thu Dec 26 12:25:19 EST 2019 android.useAndroidX=true diff --git a/window_dump.xml b/window_dump.xml new file mode 100644 index 0000000000..48f6c5a1e8 --- /dev/null +++ b/window_dump.xml @@ -0,0 +1 @@ + \ No newline at end of file