diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4625f4792..971076fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,13 @@ jobs: name: logcat-output path: logcat-output.txt + - name: Upload build folder + uses: actions/upload-artifact@v4 + if: always() + with: + name: build + path: ${{ github.workspace }}/app/build/ + # Generate coverage report - name: Generate coverage diff --git a/app/src/androidTest/java/ch/hikemate/app/endtoend/EndToEndTest4.kt b/app/src/androidTest/java/ch/hikemate/app/endtoend/EndToEndTest4.kt new file mode 100644 index 000000000..0eddea7c7 --- /dev/null +++ b/app/src/androidTest/java/ch/hikemate/app/endtoend/EndToEndTest4.kt @@ -0,0 +1,322 @@ +package ch.hikemate.app.endtoend + +import android.content.Context +import android.location.Location +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso +import ch.hikemate.app.MainActivity +import ch.hikemate.app.R +import ch.hikemate.app.ui.auth.CreateAccountScreen +import ch.hikemate.app.ui.auth.SignInScreen +import ch.hikemate.app.ui.auth.SignInWithEmailScreen +import ch.hikemate.app.ui.map.HikeDetailScreen +import ch.hikemate.app.ui.map.MapScreen +import ch.hikemate.app.ui.map.RunHikeScreen +import ch.hikemate.app.ui.map.ZoomMapButton +import ch.hikemate.app.ui.navigation.Screen +import ch.hikemate.app.utils.LocationUtils +import ch.hikemate.app.utils.MapUtils +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationResult +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import java.util.UUID +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class EndToEndTest4 { + @get:Rule val composeTestRule = createEmptyComposeRule() + private var scenario: ActivityScenario? = null + private val auth = FirebaseAuth.getInstance() + private val myUuid = UUID.randomUUID() + private val myUuidAsString = myUuid.toString() + private val email = "$myUuidAsString@gmail.com" + private val password = "password" + + private var locationCallback = mockk() + + @OptIn(ExperimentalPermissionsApi::class) + @Before + fun setupFirebase() { + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.initializeApp(context) + + var signedOut = false + + // Wait for sign out to complete + FirebaseAuth.getInstance().addAuthStateListener { + if (it.currentUser == null) { + signedOut = true + } + } + + auth.signOut() + + val timeout = System.currentTimeMillis() + 10000 // 10 seconds + while (!signedOut && System.currentTimeMillis() < timeout) { + Thread.sleep(100) + } + + if (!signedOut) { + throw Exception("Failed to sign out") + } + + mockkObject(LocationUtils) + mockkObject(MapUtils) + every { LocationUtils.hasLocationPermission(any()) } returns true + every { LocationUtils.getUserLocation(any(), any(), any(), any()) } answers + { + val locCallback = arg<(Location?) -> Unit>(1) + locCallback( + Location("gps").apply { + latitude = 46.5775927207486 + longitude = 6.551607112518172 + }) + } + every { + LocationUtils.onLocationPermissionsUpdated( + any(), any(), any(), any(), any(), any()) + } answers + { + val locCallback = arg(3) + locationCallback = locCallback + } + + // Make sure the log out is considered in the MainActivity + scenario = ActivityScenario.launch(MainActivity::class.java) + } + + @After + fun deleteUser() { + // Sign out after deleting for sanity check and un-reliability + val credential = EmailAuthProvider.getCredential(email, password) + auth.currentUser?.reauthenticate(credential) + auth.currentUser?.delete() + auth.signOut() + } + + @After + fun tearDown() { + scenario?.close() + } + + @Test + @OptIn(ExperimentalTestApi::class) + fun test() { + val context = ApplicationProvider.getApplicationContext() + // ---- Sign-In ---- + + // Perform sign in with email and password + composeTestRule.onNodeWithTag(SignInScreen.TEST_TAG_SIGN_IN_WITH_EMAIL).performClick() + + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(Screen.SIGN_IN_WITH_EMAIL), timeoutMillis = 10000) + + composeTestRule + .onNodeWithTag(SignInWithEmailScreen.TEST_TAG_GO_TO_SIGN_UP_BUTTON) + .assertIsDisplayed() + .assertHasClickAction() + .performClick() + composeTestRule.onNodeWithTag(Screen.CREATE_ACCOUNT).assertIsDisplayed() + + composeTestRule + .onNodeWithTag(CreateAccountScreen.TEST_TAG_NAME_INPUT) + .assertIsDisplayed() + .performTextInput(myUuidAsString) + + Espresso.closeSoftKeyboard() + + composeTestRule + .onNodeWithTag(CreateAccountScreen.TEST_TAG_EMAIL_INPUT) + .assertIsDisplayed() + .performTextInput(email) + + Espresso.closeSoftKeyboard() + + composeTestRule + .onNodeWithTag(CreateAccountScreen.TEST_TAG_PASSWORD_INPUT) + .assertIsDisplayed() + .performTextInput(password) + + Espresso.closeSoftKeyboard() + + composeTestRule + .onNodeWithTag(CreateAccountScreen.TEST_TAG_CONFIRM_PASSWORD_INPUT) + .assertIsDisplayed() + .performTextInput(password) + + Espresso.closeSoftKeyboard() + + composeTestRule + .onNodeWithTag(CreateAccountScreen.TEST_TAG_SIGN_UP_BUTTON) + .assertHasClickAction() + .assertIsDisplayed() + .performClick() + + composeTestRule.waitUntilExactlyOneExists(hasTestTag(Screen.MAP), timeoutMillis = 30000) + + // ---- Navigate to a hike's details screen ---- + + composeTestRule.onNodeWithTag(MapScreen.TEST_TAG_CENTER_MAP_BUTTON).performClick() + + composeTestRule.waitForIdle() + + // We want to zoom in to be sure of the selected hike + composeTestRule.onNodeWithTag(ZoomMapButton.ZOOM_IN_BUTTON).performClick() + Thread.sleep(1000) + composeTestRule.onNodeWithTag(ZoomMapButton.ZOOM_IN_BUTTON).performClick() + Thread.sleep(1000) + composeTestRule.onNodeWithTag(ZoomMapButton.ZOOM_IN_BUTTON).performClick() + Thread.sleep(1000) + composeTestRule.onNodeWithTag(ZoomMapButton.ZOOM_IN_BUTTON).performClick() + Thread.sleep(1000) + composeTestRule.onNodeWithTag(ZoomMapButton.ZOOM_IN_BUTTON).performClick() + Thread.sleep(1000) + + composeTestRule + .onNodeWithTag(MapScreen.TEST_TAG_SEARCH_BUTTON) + .assertIsDisplayed() + .performClick() + + // Click on the first of the found hikes + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(MapScreen.TEST_TAG_HIKE_ITEM), timeoutMillis = 30000) + + composeTestRule.onAllNodesWithTag(MapScreen.TEST_TAG_HIKE_ITEM)[0].performClick() + + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(HikeDetailScreen.TEST_TAG_MAP), timeoutMillis = 30000) + + // ---- Navigate to RunHike Screen ---- + + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(HikeDetailScreen.TEST_TAG_BOTTOM_SHEET), timeoutMillis = 30000) + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(HikeDetailScreen.TEST_TAG_BOTTOM_SHEET).performTouchInput { + swipeUp() + } + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(HikeDetailScreen.TEST_TAG_RUN_HIKE_BUTTON) + .assertExists() + .assertIsDisplayed() + .performClick() + + composeTestRule.waitUntilExactlyOneExists(hasTestTag(Screen.RUN_HIKE), timeoutMillis = 10000) + + composeTestRule.onNodeWithTag(RunHikeScreen.TEST_TAG_BOTTOM_SHEET).performTouchInput { + swipeUp() + } + + composeTestRule.waitForIdle() + + locationCallback.onLocationResult( + LocationResult.create( + listOf( + Location("gps").apply { + latitude = 46.5775927207486 + longitude = 6.551607112518172 + }))) + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_PROGRESS_TEXT) + .assertIsDisplayed() + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_progress_percentage_format), 0)) + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_CURRENT_ELEVATION_TEXT) + .assertIsDisplayed() + .onChildAt(1) + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_value_format_current_elevation), 485)) + + locationCallback.onLocationResult( + LocationResult.create( + listOf( + Location("gps").apply { + latitude = 46.57808286327073 + longitude = 6.551269708196024 + }))) + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_PROGRESS_TEXT) + .assertIsDisplayed() + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_progress_percentage_format), 5)) + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_CURRENT_ELEVATION_TEXT) + .assertIsDisplayed() + .onChildAt(1) + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_value_format_current_elevation), 485)) + + locationCallback.onLocationResult( + LocationResult.create( + listOf( + Location("gps").apply { + latitude = 46.579277394466864 + longitude = 6.543243182558365 + }))) + + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_PROGRESS_TEXT) + .assertIsDisplayed() + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_progress_percentage_format), 62)) + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_CURRENT_ELEVATION_TEXT) + .assertIsDisplayed() + .onChildAt(1) + .assertTextEquals( + String.format( + context.getString(R.string.run_hike_screen_value_format_current_elevation), 476)) + + // ---- RunHike Screen ---- + + composeTestRule + .onNodeWithTag(RunHikeScreen.TEST_TAG_STOP_HIKE_BUTTON) + .assertIsDisplayed() + .performClick() + + // ---- Back to HikeDetail Screen by stopping the run---- + + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(HikeDetailScreen.TEST_TAG_MAP), timeoutMillis = 10000) + } +} diff --git a/app/src/androidTest/java/ch/hikemate/app/navigation/BottomBarNavigationTest.kt b/app/src/androidTest/java/ch/hikemate/app/navigation/BottomBarNavigationTest.kt new file mode 100644 index 000000000..2ef970ee6 --- /dev/null +++ b/app/src/androidTest/java/ch/hikemate/app/navigation/BottomBarNavigationTest.kt @@ -0,0 +1,84 @@ +package ch.hikemate.app.navigation + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.hikemate.app.ui.navigation.BottomBarNavigation +import ch.hikemate.app.ui.navigation.LIST_TOP_LEVEL_DESTINATIONS +import ch.hikemate.app.ui.navigation.Route +import ch.hikemate.app.ui.navigation.TEST_TAG_BOTTOM_BAR +import ch.hikemate.app.ui.navigation.TEST_TAG_MENU_ITEM_PREFIX +import ch.hikemate.app.ui.navigation.TopLevelDestinations +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BottomBarNavigationTest { + // Set up the Compose test rule + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun clickingOnAnItemChangesScreen() { + var wantedRoute = Route.MAP + var countDownLatch = 3 + composeTestRule.setContent { + BottomBarNavigation( + onTabSelect = { + if (it.route != wantedRoute) { + fail("Expected route $wantedRoute but got ${it.route}") + } else { + countDownLatch-- + } + }, + tabList = LIST_TOP_LEVEL_DESTINATIONS, + selectedItem = Route.MAP) {} + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TEST_TAG_BOTTOM_BAR).assertIsDisplayed() + + wantedRoute = Route.SAVED_HIKES + composeTestRule + .onNodeWithTag(TEST_TAG_MENU_ITEM_PREFIX + TopLevelDestinations.SAVED_HIKES.route) + .performClick() + + wantedRoute = Route.PROFILE + composeTestRule + .onNodeWithTag(TEST_TAG_MENU_ITEM_PREFIX + TopLevelDestinations.PROFILE.route) + .performClick() + + wantedRoute = Route.TUTORIAL + composeTestRule + .onNodeWithTag(TEST_TAG_MENU_ITEM_PREFIX + TopLevelDestinations.TUTORIAL.route) + .performClick() + + composeTestRule.waitForIdle() + assertEquals(0, countDownLatch) + } + + @Test + fun clickingTwiceOnTheSameItemDoesNotChangeTheScreen() { + composeTestRule.setContent { + BottomBarNavigation( + onTabSelect = { fail("The screen should not change") }, + tabList = LIST_TOP_LEVEL_DESTINATIONS, + selectedItem = Route.MAP) {} + } + + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(TEST_TAG_BOTTOM_BAR).assertIsDisplayed() + + composeTestRule + .onNodeWithTag(TEST_TAG_MENU_ITEM_PREFIX + TopLevelDestinations.MAP.route) + .performClick() + + composeTestRule.waitForIdle() + } +} diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/map/HikeDetailsScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/map/HikeDetailsScreenTest.kt index 2288903be..f5c550bc6 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/map/HikeDetailsScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/map/HikeDetailsScreenTest.kt @@ -5,8 +5,8 @@ import android.graphics.drawable.Drawable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.test.* import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertAny import androidx.compose.ui.test.assertCountEquals @@ -21,6 +21,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.test.core.app.ApplicationProvider @@ -173,17 +174,28 @@ class HikeDetailScreenTest { } } + @OptIn(ExperimentalTestApi::class) private fun setUpBottomSheetScaffold( hike: DetailedHike = detailedHike, onRunThisHike: () -> Unit = {} ) { composeTestRule.setContent { - HikesDetailsBottomScaffold( + HikeDetailsBottomScaffold( detailedHike = hike, hikesViewModel = hikesViewModel, userHikingLevel = HikingLevel.BEGINNER, onRunThisHike = onRunThisHike) } + + // Open the bottom sheet + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(HikeDetailScreen.TEST_TAG_BOTTOM_SHEET), timeoutMillis = 10000) + + composeTestRule.onNodeWithTag(HikeDetailScreen.TEST_TAG_BOTTOM_SHEET).performTouchInput { + down(1, position = Offset(centerX, centerY)) + moveTo(1, position = Offset(centerX, 100f)) + up(1) + } } private suspend fun setUpSelectedHike( @@ -296,7 +308,7 @@ class HikeDetailScreenTest { id = "1", name = "John Doe", email = "john-doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) @Before diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/map/MapScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/map/MapScreenTest.kt index 4ab2d50e3..6a1d0a57e 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/map/MapScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/map/MapScreenTest.kt @@ -76,7 +76,7 @@ class MapScreenTest : TestCase() { id = "1", name = "John Doe", email = "john-doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) private fun setUpMap( diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/map/RunHikeScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/map/RunHikeScreenTest.kt index d7734b856..51840bc50 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/map/RunHikeScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/map/RunHikeScreenTest.kt @@ -1,6 +1,7 @@ package ch.hikemate.app.ui.map import android.location.Location +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals @@ -12,6 +13,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import ch.hikemate.app.model.elevation.ElevationRepository import ch.hikemate.app.model.facilities.FacilitiesRepository import ch.hikemate.app.model.facilities.FacilitiesViewModel @@ -126,12 +128,13 @@ class RunHikeScreenTest { ) /** @param hike The hike to display on the screen. For test purposes, should always be saved. */ - @OptIn(ExperimentalPermissionsApi::class) + @OptIn(ExperimentalTestApi::class) private suspend fun setupCompleteScreenWithSelected( hike: DetailedHike, waypointsRetrievalSucceeds: Boolean = true, elevationRetrievalSucceeds: Boolean = true, - alreadyLoadData: Boolean = true + alreadyLoadData: Boolean = true, + openTheBottomSheet: Boolean = true ) { // This setup function is designed for saved hikes only. If the provided hike is not saved, it @@ -214,6 +217,18 @@ class RunHikeScreenTest { navigationActions = mockNavigationActions, facilitiesViewModel = facilitiesViewModel) } + + if (openTheBottomSheet) { + // Open the bottom sheet + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(RunHikeScreen.TEST_TAG_BOTTOM_SHEET), timeoutMillis = 10000) + + composeTestRule.onNodeWithTag(RunHikeScreen.TEST_TAG_BOTTOM_SHEET).performTouchInput { + down(1, position = Offset(centerX, centerY)) + moveTo(1, position = Offset(centerX, 100f)) + up(1) + } + } } @OptIn(ExperimentalPermissionsApi::class) @@ -235,7 +250,8 @@ class RunHikeScreenTest { @Test fun runHikeScreen_displaysError_whenWaypointsRetrievalFails() = runTest(timeout = 5.seconds) { - setupCompleteScreenWithSelected(detailedHike, waypointsRetrievalSucceeds = false) + setupCompleteScreenWithSelected( + detailedHike, waypointsRetrievalSucceeds = false, openTheBottomSheet = false) // So far, the waypoints retrieval should have happened once verify(hikesRepository, times(1)).getRoutesByIds(any(), any(), any()) @@ -255,7 +271,8 @@ class RunHikeScreenTest { @Test fun runHikeScreen_displaysError_whenElevationRetrievalFails() = runTest(timeout = 5.seconds) { - setupCompleteScreenWithSelected(detailedHike, elevationRetrievalSucceeds = false) + setupCompleteScreenWithSelected( + detailedHike, elevationRetrievalSucceeds = false, openTheBottomSheet = false) // So far, the elevation retrieval should have happened once verify(elevationRepository, times(1)).getElevation(any(), any(), any()) @@ -436,7 +453,6 @@ class RunHikeScreenTest { hikesViewModel.selectHike(hikeId) } - @OptIn(ExperimentalPermissionsApi::class) @Test fun runHikeScreen_clickingOnCenterButtonWithPermissionCentersMapOnLocation() = runTest(timeout = 5.seconds) { diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/map/ZoomMapButtonTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/map/ZoomMapButtonTest.kt index 37beb35d8..6919795c8 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/map/ZoomMapButtonTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/map/ZoomMapButtonTest.kt @@ -47,7 +47,7 @@ class ZoomMapButtonTest { id = "1", name = "John Doe", email = "john-doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/profile/EditProfileScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/profile/EditProfileScreenTest.kt index 0f08fade2..f73ddd589 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/profile/EditProfileScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/profile/EditProfileScreenTest.kt @@ -51,7 +51,7 @@ class EditProfileScreenTest : TestCase() { id = "1", name = "John Doe", email = "john-doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) @Before @@ -90,7 +90,7 @@ class EditProfileScreenTest : TestCase() { .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER) .assertIsDisplayed() composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .assertIsDisplayed() composeTestRule .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_EXPERT) @@ -114,7 +114,7 @@ class EditProfileScreenTest : TestCase() { .assertTextContains(profile.name) composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .assertIsSelected() composeTestRule .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER) @@ -154,7 +154,7 @@ class EditProfileScreenTest : TestCase() { .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER) .assertIsSelected() composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .assertIsNotSelected() composeTestRule .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_EXPERT) @@ -170,14 +170,14 @@ class EditProfileScreenTest : TestCase() { .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER) .assertIsNotSelected() composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .assertIsNotSelected() composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .performClick() composeTestRule - .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE) + .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR) .assertIsSelected() composeTestRule .onNodeWithTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER) diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/profile/ProfileScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/profile/ProfileScreenTest.kt index 3901336a4..061bc86ab 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/profile/ProfileScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/profile/ProfileScreenTest.kt @@ -52,7 +52,7 @@ class ProfileScreenTest : TestCase() { id = "1", name = "John Doe", email = "john-doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) @Before @@ -107,7 +107,7 @@ class ProfileScreenTest : TestCase() { id = "1", name = "John Doe", email = "john.doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp.now()) `when`(profileRepository.getProfileById(any(), any(), any())).thenAnswer { val onSuccess = it.getArgument<(Profile) -> Unit>(1) @@ -123,8 +123,8 @@ class ProfileScreenTest : TestCase() { (when (profile.hikingLevel) { HikingLevel.BEGINNER -> context.getString(R.string.profile_screen_hiking_level_info_beginner) - HikingLevel.INTERMEDIATE -> - context.getString(R.string.profile_screen_hiking_level_info_intermediate) + HikingLevel.AMATEUR -> + context.getString(R.string.profile_screen_hiking_level_info_amateur) HikingLevel.EXPERT -> context.getString(R.string.profile_screen_hiking_level_info_expert) })) @@ -172,8 +172,8 @@ class ProfileScreenTest : TestCase() { (when (profile.hikingLevel) { HikingLevel.BEGINNER -> context.getString(R.string.profile_screen_hiking_level_info_beginner) - HikingLevel.INTERMEDIATE -> - context.getString(R.string.profile_screen_hiking_level_info_intermediate) + HikingLevel.AMATEUR -> + context.getString(R.string.profile_screen_hiking_level_info_amateur) HikingLevel.EXPERT -> context.getString(R.string.profile_screen_hiking_level_info_expert) })) @@ -209,8 +209,8 @@ class ProfileScreenTest : TestCase() { (when (profile.hikingLevel) { HikingLevel.BEGINNER -> context.getString(R.string.profile_screen_hiking_level_info_beginner) - HikingLevel.INTERMEDIATE -> - context.getString(R.string.profile_screen_hiking_level_info_intermediate) + HikingLevel.AMATEUR -> + context.getString(R.string.profile_screen_hiking_level_info_amateur) HikingLevel.EXPERT -> context.getString(R.string.profile_screen_hiking_level_info_expert) })) diff --git a/app/src/androidTest/java/ch/hikemate/app/ui/saved/SavedHikesScreenTest.kt b/app/src/androidTest/java/ch/hikemate/app/ui/saved/SavedHikesScreenTest.kt index 103fd4352..caf6c7ef3 100644 --- a/app/src/androidTest/java/ch/hikemate/app/ui/saved/SavedHikesScreenTest.kt +++ b/app/src/androidTest/java/ch/hikemate/app/ui/saved/SavedHikesScreenTest.kt @@ -20,6 +20,7 @@ import ch.hikemate.app.model.route.LatLong import ch.hikemate.app.model.route.saved.SavedHike import ch.hikemate.app.model.route.saved.SavedHikesRepository import ch.hikemate.app.ui.components.CenteredErrorAction +import ch.hikemate.app.ui.components.HikeCard import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.TEST_TAG_BOTTOM_BAR import com.google.firebase.Timestamp @@ -360,4 +361,30 @@ class SavedHikesScreenTest : TestCase() { .onAllNodesWithTag(SavedHikesScreen.TEST_TAG_SAVED_HIKES_HIKE_CARD) .assertCountEquals(2) } + + @Test + fun plannedHikesSectionDisplaysDates() = + runTest(timeout = 5.seconds) { + val hikes = + listOf( + detailedHike.copy( + id = "1", name = "Hike 1", plannedDate = Timestamp.now(), isSaved = true), + detailedHike.copy( + id = "2", name = "Hike 2", plannedDate = Timestamp.now(), isSaved = true)) + setupSavedHikes(hikes) + + composeTestRule.setContent { SavedHikesScreen(hikesViewModel, navigationActions) } + + // Select the planned hikes tab + composeTestRule + .onNodeWithTag( + SavedHikesScreen.TEST_TAG_SAVED_HIKES_TABS_MENU_ITEM_PREFIX + + SavedHikesSection.Planned.name) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule + .onAllNodesWithTag(HikeCard.TEST_TAG_IS_SUITABLE_TEXT, useUnmergedTree = true) + .assertCountEquals(2) + } } diff --git a/app/src/main/java/ch/hikemate/app/model/facilities/FacilitiesViewModel.kt b/app/src/main/java/ch/hikemate/app/model/facilities/FacilitiesViewModel.kt index 0400a78be..ab27e63b5 100644 --- a/app/src/main/java/ch/hikemate/app/model/facilities/FacilitiesViewModel.kt +++ b/app/src/main/java/ch/hikemate/app/model/facilities/FacilitiesViewModel.kt @@ -42,10 +42,10 @@ class FacilitiesViewModel( val MAX_FACILITIES_PER_ZOOM = mapOf( (13.0..14.0) to 15, - (14.0..15.0) to 20, - (15.0..16.0) to 30, - (16.0..17.0) to 50, - (17.0..18.0) to 100) + (14.0..15.0) to 15, + (15.0..16.0) to 20, + (16.0..17.0) to 30, + (17.0..18.0) to 50) } private val _isFiltering = MutableStateFlow(false) @@ -53,6 +53,7 @@ class FacilitiesViewModel( private val _facilities = MutableStateFlow?>(null) val facilities = _facilities.asStateFlow() + /** * Filters facilities for display based on the current map view state and zoom level. The function * performs several checks to determine which facilities should be displayed: @@ -110,6 +111,8 @@ class FacilitiesViewModel( .toList() onSuccess(filteredFacilities) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error while filtering facilities: $e") } finally { _isFiltering.value = false } diff --git a/app/src/main/java/ch/hikemate/app/model/profile/Profile.kt b/app/src/main/java/ch/hikemate/app/model/profile/Profile.kt index 86acf236b..e45da095f 100644 --- a/app/src/main/java/ch/hikemate/app/model/profile/Profile.kt +++ b/app/src/main/java/ch/hikemate/app/model/profile/Profile.kt @@ -7,7 +7,7 @@ import com.google.firebase.Timestamp /** An enum class representing the hiking level of a user. */ enum class HikingLevel { BEGINNER, - INTERMEDIATE, + AMATEUR, EXPERT; /** @@ -18,7 +18,7 @@ enum class HikingLevel { fun getDisplayString(context: Context): String { return when (this) { BEGINNER -> context.getString(R.string.hiking_level_beginner) - INTERMEDIATE -> context.getString(R.string.hiking_level_intermediate) + AMATEUR -> context.getString(R.string.hiking_level_amateur) EXPERT -> context.getString(R.string.hiking_level_expert) } } diff --git a/app/src/main/java/ch/hikemate/app/model/route/DetailedHikeRoute.kt b/app/src/main/java/ch/hikemate/app/model/route/DetailedHikeRoute.kt deleted file mode 100644 index 273e85f7a..000000000 --- a/app/src/main/java/ch/hikemate/app/model/route/DetailedHikeRoute.kt +++ /dev/null @@ -1,61 +0,0 @@ -package ch.hikemate.app.model.route - -import ch.hikemate.app.model.elevation.ElevationRepository -import ch.hikemate.app.model.elevation.ElevationRepositoryCopernicus -import ch.hikemate.app.utils.RouteUtils -import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient - -/** - * Represents a detailed hike route, a hike route with additional computed attributes: - * - totalDistance: The total distance of the route in meters - * - estimatedTime: The estimated time to complete the route in minutes - * - elevationGain: The total elevation gain in meters - * - difficulty: The difficulty level of the route - * - * @param route The base route data, including ID, bounds, waypoints, name, and description - * @param totalDistance The total distance of the route in km - * @param estimatedTime The estimated time to complete the route in minutes - * @param elevationGain The total elevation gain in meters - * @param difficulty The difficulty level of the route - */ -data class DetailedHikeRoute( - val route: HikeRoute, - val totalDistance: Double, - val estimatedTime: Double, - val elevationGain: Double, - val difficulty: HikeDifficulty -) { - - /** Companion object that creates the detailed attributes for the hike route */ - companion object { - /** - * Creates a detailed hike route with computed attributes - * - * @param hikeRoute The route for which detailed information will be computed - * @param elevationRepository The elevation repository to use for computing elevation gain. - * Initialized automatically by default - * @return A DetailedHikeRoute object with the computed attributes: totalDistance, - * elevationGain, estimatedTime, and difficulty - */ - fun create( - hikeRoute: HikeRoute, - elevationRepository: ElevationRepository = ElevationRepositoryCopernicus(OkHttpClient()) - ): DetailedHikeRoute { - - val totalDistance = RouteUtils.computeTotalDistance(hikeRoute.ways) - val elevationGain = runBlocking { - RouteUtils.getElevationGain(hikeRoute.ways, elevationRepository) - } - val estimatedTime = RouteUtils.estimateTime(totalDistance, elevationGain) - val difficulty = RouteUtils.determineDifficulty(totalDistance, elevationGain) - - return DetailedHikeRoute( - route = hikeRoute, - totalDistance = totalDistance, - estimatedTime = estimatedTime, - elevationGain = elevationGain, - difficulty = difficulty) - } - } -} diff --git a/app/src/main/java/ch/hikemate/app/model/route/HikeDifficulty.kt b/app/src/main/java/ch/hikemate/app/model/route/HikeDifficulty.kt index 9d1ff93c2..9a98d748d 100644 --- a/app/src/main/java/ch/hikemate/app/model/route/HikeDifficulty.kt +++ b/app/src/main/java/ch/hikemate/app/model/route/HikeDifficulty.kt @@ -1,8 +1,11 @@ package ch.hikemate.app.model.route -import androidx.annotation.ColorRes import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color import ch.hikemate.app.R +import ch.hikemate.app.ui.theme.hikeDifficultyDifficultColor +import ch.hikemate.app.ui.theme.hikeDifficultyEasyColor +import ch.hikemate.app.ui.theme.hikeDifficultyModerateColor /** * Represents the difficulty level of a hike route. @@ -10,10 +13,10 @@ import ch.hikemate.app.R * Three possible levels: easy, moderate or difficult. * * @param nameResourceId The string resource ID of the localizable name of the difficulty level - * @param colorResourceId The color resource ID of the color associated with the difficulty level + * @param color The color associated with the difficulty level */ -enum class HikeDifficulty(@StringRes val nameResourceId: Int, @ColorRes val colorResourceId: Int) { - EASY(R.string.hike_difficulty_easy, R.color.hike_difficulty_easy), - MODERATE(R.string.hike_difficulty_moderate, R.color.hike_difficulty_moderate), - DIFFICULT(R.string.hike_difficulty_difficult, R.color.hike_difficulty_difficult) +enum class HikeDifficulty(@StringRes val nameResourceId: Int, val color: Color) { + EASY(R.string.hike_difficulty_easy, hikeDifficultyEasyColor), + MODERATE(R.string.hike_difficulty_moderate, hikeDifficultyModerateColor), + DIFFICULT(R.string.hike_difficulty_difficult, hikeDifficultyDifficultColor) } diff --git a/app/src/main/java/ch/hikemate/app/model/route/HikesViewModel.kt b/app/src/main/java/ch/hikemate/app/model/route/HikesViewModel.kt index 745d09a21..2879c64ab 100644 --- a/app/src/main/java/ch/hikemate/app/model/route/HikesViewModel.kt +++ b/app/src/main/java/ch/hikemate/app/model/route/HikesViewModel.kt @@ -87,6 +87,22 @@ class HikesViewModel( private val _hikeFlowsList = MutableStateFlow>>(emptyList()) + /** + * Indicates whether when unselecting it, the selected hike in [_selectedHike] should also be + * removed from [_hikeFlowsMap] (and consequently [_hikeFlowsList]). + * + * This was introduced to solve a bug where + * 1. Opening the details of a hike from the saved hikes screen + * 2. Unsaving the hike + * + * Would result in the hike being removed from the saved hikes list, hence unselected, and hence + * the user would be thrown out of the details screen. + * + * Instead of this behavior, we now keep the selected hike in the list as long as it is selected. + * When we unselect it, we check this flag to see if it should be removed from the list. + */ + private var _selectedHikeShouldBeRemoved = false + private val _selectedHike = MutableStateFlow(null) private val _mapState = MutableStateFlow(MapUtils.MapViewState()) @@ -533,6 +549,10 @@ class HikesViewModel( /** * Helper function to update the selected hike once (or before) [hikeFlows] has been updated. * + * In particular, this function will check that the selected hike stays in [_hikeFlowsMap] as long + * as it is selected (only [unselectHike] should remove the selected hike from the map if + * necessary. Uses the [_selectedHikeShouldBeRemoved] flag. + * * This function does not acquire the [_hikesMutex]. It is the responsibility of the caller to * call this function inside of a [Mutex.withLock] block. * @@ -547,8 +567,16 @@ class HikesViewModel( val selectedHikeFlow = _hikeFlowsMap[selectedHike.id] if (selectedHikeFlow == null) { - // The selected hike is not in the map, unselect it. - _selectedHike.value = null + // Set the flag for removing the hike from the list when it is unselected + _selectedHikeShouldBeRemoved = true + + // The selected hike is not in the map, add it back and update its saved status + val saved = _savedHikesMap[selectedHike.id] + val newValue = selectedHike.copy(isSaved = saved != null, plannedDate = saved?.date) + _hikeFlowsMap[selectedHike.id] = MutableStateFlow(newValue) + if (selectedHike != newValue) { + _selectedHike.value = newValue + } } else { // The selected hike is still in the map, update it. val flowValue = selectedHikeFlow.value @@ -594,10 +622,18 @@ class HikesViewModel( */ private suspend fun unselectHikeAsync() = _hikesMutex.withLock { - // Only emit null as a value if the selected hike was not already null - if (_selectedHike.value != null) { - _selectedHike.value = null + val selectedHike = _selectedHike.value ?: return@withLock + + // See if the selected hike should be removed from the hike flows map + if (_selectedHikeShouldBeRemoved) { + _selectedHikeShouldBeRemoved = false + _hikeFlowsMap.remove(selectedHike.id) + updateHikeFlowsListAndOsmDataStatus() } + + // We checked before the selected hike was currently not null, hence no need to check before + // emitting a value of null + _selectedHike.value = null } /** @@ -688,7 +724,7 @@ class HikesViewModel( } } - // Update the selected hike's saved status, unselect it if it's not loaded anymore + // Update the selected hike's saved status, add it back to the hike flows list if it was removed updateSelectedHike() // Update the exposed list of hikes based on the map of hikes @@ -859,15 +895,18 @@ class HikesViewModel( if (_loadedHikesType.value == LoadedHikes.FromSaved) { // Only saved hikes may stay in the list, delete the unsaved hike from the list _hikeFlowsMap.remove(hikeId) + // Update the selected hike if necessary, meaning add it back to the map if needed + // This must be done before updating the list from the map + updateSelectedHike() + // Update the exposed list of loaded hikes from the internal map updateHikeFlowsListAndOsmDataStatus() } else { // The hike can stay even if it is not saved, so update it hikeFlow.value = hikeFlow.value.copy(isSaved = false, plannedDate = null) + // Update the selected hike if necessary + updateSelectedHike() } - // Update the selected hike if necessary - updateSelectedHike() - successful = true } @@ -1032,7 +1071,7 @@ class HikesViewModel( } .toMap(mutableMapOf()) - // Update the selected hike if necessary + // Update the selected hike or add it back to the hike flows map if necessary updateSelectedHike() // Update the exposed list of hikes based on the map of hikes diff --git a/app/src/main/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModel.kt b/app/src/main/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModel.kt deleted file mode 100644 index d1bffa5a1..000000000 --- a/app/src/main/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModel.kt +++ /dev/null @@ -1,201 +0,0 @@ -package ch.hikemate.app.model.route - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import ch.hikemate.app.model.elevation.ElevationRepository -import ch.hikemate.app.model.elevation.ElevationRepositoryCopernicus -import ch.hikemate.app.model.extensions.crossesDateLine -import ch.hikemate.app.model.extensions.splitByDateLine -import ch.hikemate.app.model.extensions.toBounds -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import org.osmdroid.util.BoundingBox - -/** ViewModel for the list of hike routes */ -open class ListOfHikeRoutesViewModel( - private val hikeRoutesRepository: HikeRoutesRepository, - private val elevationRepository: ElevationRepository, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : ViewModel() { - // List of all routes in the database - private val hikeRoutes_ = MutableStateFlow>(emptyList()) - val hikeRoutes: StateFlow> = hikeRoutes_.asStateFlow() - - // Selected route, i.e the route for the detail view - private val selectedHikeRoute_ = MutableStateFlow(null) - open val selectedHikeRoute: StateFlow = selectedHikeRoute_.asStateFlow() - - private val area_ = MutableStateFlow(null) - - // Creates a factory and stores the tag used for logging - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return ListOfHikeRoutesViewModel( - HikeRoutesRepositoryOverpass(OkHttpClient()), - ElevationRepositoryCopernicus(OkHttpClient())) - as T - } - } - - private const val LOG_TAG = "ListOfHikeRoutesViewModel" - } - - private suspend fun getRoutesAsync(onSuccess: () -> Unit = {}, onFailure: () -> Unit = {}) { - withContext(dispatcher) { - val area = area_.value ?: return@withContext - - // Check if the area is on the date line - if (area.crossesDateLine()) { - val (bounds1, bounds2) = area.splitByDateLine() - hikeRoutesRepository.getRoutes( - bounds = bounds1.toBounds(), - onSuccess = { routes1 -> - hikeRoutesRepository.getRoutes( - bounds = bounds2.toBounds(), - onSuccess = { routes2 -> - hikeRoutes_.value = routes1 + routes2 - onSuccess() - }, - onFailure = { _ -> onFailure() }) - }, - onFailure = { _ -> onFailure() }) - return@withContext - } - - hikeRoutesRepository.getRoutes( - bounds = area.toBounds(), - onSuccess = { routes -> - hikeRoutes_.value = routes - onSuccess() - }, - onFailure = { exception -> - Log.e(LOG_TAG, "[getRoutesAsync] Failed to get routes", exception) - onFailure() - }) - } - } - - /** Gets all the routes from the database and updates the routes_ variable */ - fun getRoutes(onSuccess: () -> Unit = {}, onFailure: () -> Unit = {}) { - viewModelScope.launch { getRoutesAsync(onSuccess = onSuccess, onFailure = onFailure) } - } - - /** Gets the routes with the given IDs */ - fun getRoutesByIds( - routeIds: List, - onSuccess: (List) -> Unit = {}, - onFailure: () -> Unit = {} - ) { - viewModelScope.launch { - getRoutesByIdsAsync(routeIds, onSuccess = onSuccess, onFailure = onFailure) - } - } - - private suspend fun getRoutesByIdsAsync( - routeIds: List, - onSuccess: (List) -> Unit = {}, - onFailure: () -> Unit = {} - ) { - withContext(dispatcher) { - hikeRoutesRepository.getRoutesByIds( - routeIds = routeIds, - onSuccess = { routes -> - hikeRoutes_.value = routes - onSuccess(routes) - }, - onFailure = { exception -> - Log.e(LOG_TAG, "[getRoutesFromIds] Failed to get routes", exception) - onFailure() - }) - } - } - - private suspend fun getRoutesElevationAsync( - route: HikeRoute, - onSuccess: (List) -> Unit = {}, - onFailure: () -> Unit = {} - ) { - withContext(dispatcher) { - elevationRepository.getElevation( - coordinates = route.ways, - onSuccess = { elevationData -> onSuccess(elevationData) }, - onFailure = { exception -> - Log.d(LOG_TAG, "[getRoutesElevationAsync] Failed to get elevation data: $exception") - onFailure() - }) - } - } - - /** - * Gets the elevation data asynchronously for a route and return it as a list of doubles on - * success. - */ - fun getRoutesElevation( - route: HikeRoute, - onSuccess: (List) -> Unit = {}, - onFailure: () -> Unit = {} - ) { - viewModelScope.launch { - getRoutesElevationAsync(route, onSuccess = onSuccess, onFailure = onFailure) - } - } - - /** - * Sets the current displayed area on the map and updates the list of routes displayed in the - * list. - * - * @param area The area to be displayed - */ - fun setArea(area: BoundingBox, onSuccess: () -> Unit = {}, onFailure: () -> Unit = {}) { - area_.value = area - getRoutes(onSuccess = onSuccess, onFailure = onFailure) - } - - /** - * Selects a route to be displayed in the detail view - * - * @param hikeRoute The route to be displayed - */ - fun selectRoute(hikeRoute: HikeRoute) { - selectedHikeRoute_.value = hikeRoute - } - - /** Clears the selected route */ - fun clearSelectedRoute() { - selectedHikeRoute_.value = null - } - - private suspend fun selectRouteByIdAsync(hikeId: String) { - withContext(dispatcher) { - hikeRoutesRepository.getRouteById( - routeId = hikeId, - onSuccess = { route -> selectedHikeRoute_.value = route }, - onFailure = { exception -> - Log.e(LOG_TAG, "[selectRouteByIdAsync] Failed to get route", exception) - }) - } - } - - /** - * Selects a particular hike (for example to then display it in the details screen). - * - * Use this function if only the route ID is available. If a [HikeRoute] instance is available, - * use [selectRoute] instead. - * - * @param hikeId The ID of the route to be selected - */ - fun selectRouteById(hikeId: String) { - viewModelScope.launch { selectRouteByIdAsync(hikeId) } - } -} diff --git a/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesRepositoryFirestore.kt b/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesRepositoryFirestore.kt index d5c8596ed..c2609ec7a 100644 --- a/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesRepositoryFirestore.kt +++ b/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesRepositoryFirestore.kt @@ -5,6 +5,7 @@ import com.google.firebase.firestore.FieldValue.arrayRemove import com.google.firebase.firestore.FieldValue.arrayUnion import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeout class SavedHikesRepositoryFirestore( private val db: FirebaseFirestore, @@ -25,25 +26,29 @@ class SavedHikesRepositoryFirestore( override suspend fun addSavedHike(hike: SavedHike) { checkNotNull(auth.currentUser) { ERROR_MSG_USER_NOT_AUTHENTICATED } + withTimeout(TIMEOUT_IN_MILLIS) { + val documentReference = db.collection(SAVED_HIKES_COLLECTION).document(auth.currentUser!!.uid) - val documentReference = db.collection(SAVED_HIKES_COLLECTION).document(auth.currentUser!!.uid) + // Check that the document exists before updating it + val documentExists = documentReference.get().await().exists() - // Check that the document exists before updating it - val documentExists = documentReference.get().await().exists() - - if (!documentExists) { - documentReference.set(UserSavedHikes(listOf(hike))) - } else { - documentReference.update(UserSavedHikes::savedHikes.name, arrayUnion(hike)).await() + if (!documentExists) { + documentReference.set(UserSavedHikes(listOf(hike))) + } else { + documentReference.update(UserSavedHikes::savedHikes.name, arrayUnion(hike)).await() + } } } override suspend fun removeSavedHike(hike: SavedHike) { checkNotNull(auth.currentUser) { ERROR_MSG_USER_NOT_AUTHENTICATED } - db.collection(SAVED_HIKES_COLLECTION) - .document(auth.currentUser!!.uid) - .update(UserSavedHikes::savedHikes.name, arrayRemove(hike)) - .await() + + withTimeout(TIMEOUT_IN_MILLIS) { + db.collection(SAVED_HIKES_COLLECTION) + .document(auth.currentUser!!.uid) + .update(UserSavedHikes::savedHikes.name, arrayRemove(hike)) + .await() + } } override suspend fun getSavedHike(id: String): SavedHike? { @@ -59,5 +64,6 @@ class SavedHikesRepositoryFirestore( companion object { const val SAVED_HIKES_COLLECTION = "savedHikes" private const val ERROR_MSG_USER_NOT_AUTHENTICATED = "User is not authenticated" + private const val TIMEOUT_IN_MILLIS = 3000L } } diff --git a/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesViewModel.kt b/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesViewModel.kt deleted file mode 100644 index d4f7fc260..000000000 --- a/app/src/main/java/ch/hikemate/app/model/route/saved/SavedHikesViewModel.kt +++ /dev/null @@ -1,235 +0,0 @@ -package ch.hikemate.app.model.route.saved - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import ch.hikemate.app.R -import ch.hikemate.app.model.route.HikeRoute -import com.google.firebase.Timestamp -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.FirebaseFirestore -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class SavedHikesViewModel( - private val repository: SavedHikesRepository, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) : ViewModel() { - - data class HikeDetailState( - val hike: HikeRoute, - val bookmark: Int, - val isSaved: Boolean, - val plannedDate: Timestamp? - ) - - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return SavedHikesViewModel( - SavedHikesRepositoryFirestore( - FirebaseFirestore.getInstance(), FirebaseAuth.getInstance())) - as T - } - } - - /** Tag used for logging. */ - private const val LOG_TAG = "SavedHikesViewModel" - } - - private val _hikeDetailState = MutableStateFlow(null) - val hikeDetailState: StateFlow = _hikeDetailState.asStateFlow() - - private val _savedHikes = MutableStateFlow>(emptyList()) - /** - * The list of saved hikes for the user as a state flow. Observe this to get updates when the - * saved hikes change. - */ - val savedHike: StateFlow?> = _savedHikes.asStateFlow() - - private val _errorMessageId = MutableStateFlow(null) - /** - * If an error occurs while performing an operation related to saved hikes, the resource ID of an - * appropriate error message will be set in this state flow. - */ - val errorMessageId: StateFlow = _errorMessageId.asStateFlow() - - private val _loadingSavedHikes = MutableStateFlow(false) - /** Whether the saved hikes list is currently being loaded or reloaded. */ - val loadingSavedHikes: StateFlow = _loadingSavedHikes.asStateFlow() - - /** - * Set the provided route as the "selected" hike for this view model. - * - * This function will update the [hikeDetailState] state flow with the details of this hike, - * whether it is saved and/or planned. - */ - fun updateHikeDetailState(route: HikeRoute) = - viewModelScope.launch { updateHikeDetailStateAsync(route) } - - private suspend fun updateHikeDetailStateAsync(route: HikeRoute) = - withContext(dispatcher) { - val savedHike = isHikeSaved(route.id) - _hikeDetailState.value = - HikeDetailState( - hike = route, - isSaved = savedHike != null, - bookmark = - if (savedHike != null) R.drawable.bookmark_filled_blue - else R.drawable.bookmark_no_fill, - plannedDate = savedHike?.date) - } - - /** Toggles the saved state of the currently selected hike. */ - fun toggleSaveState() = viewModelScope.launch { toggleSaveStateAsync() } - - private suspend fun toggleSaveStateAsync() = - withContext(dispatcher) { - // We want to toggle the saved state of the currently selected hike - val current = _hikeDetailState.value ?: return@withContext - - // If current is already saved, we unsave it - if (current.isSaved) { - val savedHikeInfos = savedHike.value?.find { it.id == current.hike.id } - if (savedHikeInfos != null) { - // This will mark the current hike as not saved and update the saved hikes list - removeSavedHikeAsync(savedHikeInfos) - } - } else { - val savedHikeInfo = - SavedHike(id = current.hike.id, name = current.hike.name, date = current.plannedDate) - // This will mark the current hike as saved and update the saved hikes list - addSavedHikeAsync(savedHikeInfo) - } - - // Update the state after toggling - _hikeDetailState.value = - current.copy( - isSaved = !current.isSaved, - bookmark = - if (!current.isSaved) R.drawable.bookmark_filled_blue - else R.drawable.bookmark_no_fill) - } - - fun updatePlannedDate(timestamp: Timestamp?) { - viewModelScope.launch { updatePlannedDateAsync(timestamp) } - } - - private suspend fun updatePlannedDateAsync(timestamp: Timestamp?) = - withContext(dispatcher) { - val current = _hikeDetailState.value ?: return@withContext - - val updated = SavedHike(id = current.hike.id, name = current.hike.name, date = timestamp) - - // If the hike is already saved, we unsave it to save the new instance of it instead - if (current.isSaved) { - savedHike.value?.find { it.id == current.hike.id }?.let { removeSavedHikeAsync(it) } - } - - // We save the hike with its new planned date - addSavedHikeAsync(updated) - - // We set the new state with the new planned date - _hikeDetailState.value = current.copy(plannedDate = timestamp) - } - - private fun isHikeSaved(): Boolean { - return _hikeDetailState.value?.isSaved ?: false - } - - /** - * Get the saved hike with the provided ID. - * - * @param id The ID of the hike to get. - * @return The saved hike with the provided ID, or null if no such hike is found. - */ - fun isHikeSaved(id: String): SavedHike? { - return savedHike.value?.find { id == it.id } - } - - /** - * Add the provided hike as a saved hike for the user. - * - * This function will update the [savedHike] state flow. - * - * @param hike The hike to add as a saved hike. - */ - fun addSavedHike(hike: SavedHike) = viewModelScope.launch { addSavedHikeAsync(hike) } - - private suspend fun addSavedHikeAsync(hike: SavedHike) = - withContext(dispatcher) { - try { - repository.addSavedHike(hike) - // Makes no sense to reset the error message here, an error might still occur when - // loading hikes again - } catch (e: Exception) { - Log.e(LOG_TAG, "Error adding saved hike", e) - _errorMessageId.value = R.string.saved_hikes_screen_generic_error - return@withContext - } - // As a side-effect, this call will reset the error message if no error occurs - loadSavedHikesAsync() - } - - /** - * Remove the provided hike from the saved hikes for the user. - * - * This function will update the [savedHike] state flow. - * - * @param hike The hike to remove from the saved hikes. - */ - fun removeSavedHike(hike: SavedHike) = viewModelScope.launch { removeSavedHikeAsync(hike) } - - private suspend fun removeSavedHikeAsync(hike: SavedHike) = - withContext(dispatcher) { - try { - repository.removeSavedHike(hike) - // Makes no sense to reset the error message here, an error might still occur when - // loading hikes again - } catch (e: Exception) { - Log.e(LOG_TAG, "Error removing saved hike", e) - _errorMessageId.value = R.string.saved_hikes_screen_generic_error - return@withContext - } - // As a side-effect, this call will reset the error message if no error occurs - loadSavedHikesAsync() - } - - /** Load saved hikes from the repository and update the [savedHike] state flow. */ - fun loadSavedHikes() = viewModelScope.launch { loadSavedHikesAsync() } - - private suspend fun loadSavedHikesAsync() { - withContext(dispatcher) { - try { - _loadingSavedHikes.value = true - _savedHikes.value = repository.loadSavedHikes() - _errorMessageId.value = null - - // If the current hike state is not null, update its saved state and planned date - val current = _hikeDetailState.value - if (current != null) { - val savedHike = isHikeSaved(current.hike.id) - _hikeDetailState.value = - current.copy( - isSaved = savedHike != null, - bookmark = - if (savedHike != null) R.drawable.bookmark_filled_blue - else R.drawable.bookmark_no_fill, - plannedDate = savedHike?.date) - } - } catch (e: Exception) { - Log.e(LOG_TAG, "Error loading saved hikes", e) - _errorMessageId.value = R.string.saved_hikes_screen_generic_error - } - _loadingSavedHikes.value = false - } - } -} diff --git a/app/src/main/java/ch/hikemate/app/ui/auth/CreateAccountScreen.kt b/app/src/main/java/ch/hikemate/app/ui/auth/CreateAccountScreen.kt index 9d157365d..7b6ca2235 100644 --- a/app/src/main/java/ch/hikemate/app/ui/auth/CreateAccountScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/auth/CreateAccountScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,10 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel import ch.hikemate.app.ui.components.BackButton @@ -81,6 +79,10 @@ fun CreateAccountScreen( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\$".toRegex()) -> { Toast.makeText(context, emailWrongFormatErrorMessage, Toast.LENGTH_SHORT).show() } + password.length < 6 -> { + Toast.makeText(context, R.string.create_account_password_requirements, Toast.LENGTH_SHORT) + .show() + } else -> { authViewModel.createAccountWithEmailAndPassword( name, @@ -113,7 +115,7 @@ fun CreateAccountScreen( BackButton(navigationActions) Text( stringResource(R.string.create_account_title), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.testTag(CreateAccountScreen.TEST_TAG_TITLE)) CustomTextField( diff --git a/app/src/main/java/ch/hikemate/app/ui/auth/SignInScreen.kt b/app/src/main/java/ch/hikemate/app/ui/auth/SignInScreen.kt index 440aaaad3..bae70fdcb 100644 --- a/app/src/main/java/ch/hikemate/app/ui/auth/SignInScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/auth/SignInScreen.kt @@ -32,16 +32,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel import ch.hikemate.app.ui.components.AppIcon @@ -49,7 +45,6 @@ import ch.hikemate.app.ui.components.AsyncStateHandler import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.Screen import ch.hikemate.app.ui.navigation.TopLevelDestinations -import ch.hikemate.app.ui.theme.kaushanTitleFontFamily import ch.hikemate.app.ui.theme.primaryColor object SignInScreen { @@ -76,7 +71,7 @@ fun SignInScreen( contract = ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> Toast.makeText( context, - context.getString(R.string.google_account_created_confirmation), + context.getString(R.string.google_account_connection_ended_or_cancelled), Toast.LENGTH_LONG) .show() // startAddAccountIntentLauncher is null, since it is only called when the user has no @@ -136,14 +131,7 @@ fun SignInScreen( Text( modifier = Modifier.testTag(SignInScreen.TEST_TAG_TITLE), text = stringResource(R.string.app_name), - style = - TextStyle( - color = Color.White, - fontFamily = kaushanTitleFontFamily, - fontSize = 60.sp, - fontWeight = FontWeight.Bold, - ), - ) + style = MaterialTheme.typography.displayLarge) } // Sign in with email button @@ -211,9 +199,8 @@ fun SignInButton( // Text for the button Text( text = text, - color = MaterialTheme.colorScheme.onSurface, // Text color - fontSize = 18.sp, // Font size - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, ) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/auth/SignInWithEmailScreen.kt b/app/src/main/java/ch/hikemate/app/ui/auth/SignInWithEmailScreen.kt index 5119385ab..09d64daea 100644 --- a/app/src/main/java/ch/hikemate/app/ui/auth/SignInWithEmailScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/auth/SignInWithEmailScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -25,10 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel import ch.hikemate.app.ui.components.BackButton @@ -82,7 +80,7 @@ fun SignInWithEmailScreen( BackButton(navigationActions) Text( stringResource(R.string.sign_in_with_email_title), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.testTag(SignInWithEmailScreen.TEST_TAG_TITLE)) CustomTextField( @@ -134,7 +132,7 @@ fun SignInWithEmailScreen( ) { Text( stringResource(R.string.sign_in_with_email_go_to_sign_up), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp), + style = MaterialTheme.typography.labelLarge, ) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/components/BigButton.kt b/app/src/main/java/ch/hikemate/app/ui/components/BigButton.kt index ad78334c1..3af9868ab 100644 --- a/app/src/main/java/ch/hikemate/app/ui/components/BigButton.kt +++ b/app/src/main/java/ch/hikemate/app/ui/components/BigButton.kt @@ -7,15 +7,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import ch.hikemate.app.ui.theme.onPrimaryColor +import ch.hikemate.app.ui.theme.onSecondaryColor import ch.hikemate.app.ui.theme.primaryColor +import ch.hikemate.app.ui.theme.secondaryColor /** * An enum class representing the different types of buttons that can be displayed. @@ -24,9 +25,13 @@ import ch.hikemate.app.ui.theme.primaryColor * @property textColor The text color of the button. * @property border The border of the button. */ -enum class ButtonType(val backgroundColor: Color, val textColor: Color, val border: BorderStroke) { - PRIMARY(primaryColor, Color.White, BorderStroke(0.dp, Color.Transparent)), - SECONDARY(Color.White, Color.Black, BorderStroke(1.dp, Color.Black)), +enum class ButtonType( + val backgroundColor: Color, + val textColor: Color, + val border: BorderStroke? = null +) { + PRIMARY(primaryColor, onPrimaryColor), + SECONDARY(secondaryColor, onSecondaryColor, BorderStroke(1.dp, onSecondaryColor)), } /** @@ -54,11 +59,13 @@ fun BigButton( modifier .fillMaxWidth() .height(44.dp) - .border(buttonType.border, shape = RoundedCornerShape(20))) { + .border( + buttonType.border ?: BorderStroke(0.dp, Color.Transparent), + shape = RoundedCornerShape(20))) { Text( text = label, - style = - TextStyle( - color = buttonType.textColor, fontSize = 20.sp, fontWeight = FontWeight(600))) + color = buttonType.textColor, + style = MaterialTheme.typography.titleLarge, + ) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/components/CenteredLoadingAnimation.kt b/app/src/main/java/ch/hikemate/app/ui/components/CenteredLoadingAnimation.kt index 1797a0c1b..fdc1122bc 100644 --- a/app/src/main/java/ch/hikemate/app/ui/components/CenteredLoadingAnimation.kt +++ b/app/src/main/java/ch/hikemate/app/ui/components/CenteredLoadingAnimation.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -34,13 +35,13 @@ fun CenteredLoadingAnimation(text: String? = null, textAboveLoadingAnimation: Bo horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { if (text != null && textAboveLoadingAnimation) { - Text(text = text) + Text(text = text, style = MaterialTheme.typography.bodyLarge) } CircularProgressIndicator( modifier = Modifier.testTag(CenteredLoadingAnimation.TEST_TAG_CENTERED_LOADING_ANIMATION)) if (text != null && !textAboveLoadingAnimation) { - Text(text = text) + Text(text = text, style = MaterialTheme.typography.bodyLarge) } } } diff --git a/app/src/main/java/ch/hikemate/app/ui/components/DetailRow.kt b/app/src/main/java/ch/hikemate/app/ui/components/DetailRow.kt index 545e6d34a..36ade2dbe 100644 --- a/app/src/main/java/ch/hikemate/app/ui/components/DetailRow.kt +++ b/app/src/main/java/ch/hikemate/app/ui/components/DetailRow.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp object DetailRow { @@ -30,18 +29,19 @@ object DetailRow { fun DetailRow( label: String, value: String, + modifier: Modifier = Modifier, valueColor: Color = MaterialTheme.colorScheme.onSurface ) { Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text( text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.testTag(DetailRow.TEST_TAG_DETAIL_ROW_TAG)) Text( text = value, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + style = MaterialTheme.typography.bodyMedium, color = valueColor, modifier = Modifier.testTag(DetailRow.TEST_TAG_DETAIL_ROW_VALUE)) } diff --git a/app/src/main/java/ch/hikemate/app/ui/components/ElevationGraph.kt b/app/src/main/java/ch/hikemate/app/ui/components/ElevationGraph.kt index 4e1135de2..6830d9cbe 100644 --- a/app/src/main/java/ch/hikemate/app/ui/components/ElevationGraph.kt +++ b/app/src/main/java/ch/hikemate/app/ui/components/ElevationGraph.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.core.math.MathUtils import ch.hikemate.app.R +import ch.hikemate.app.ui.theme.primaryColor import ch.hikemate.app.utils.MapUtils import kotlin.math.ceil @@ -36,9 +37,9 @@ import kotlin.math.ceil * @param locationMarkerSize The size of the location marker drawable (default to 24f) */ data class ElevationGraphStyleProperties( - val strokeColor: Color = Color.Black, - val fillColor: Color = Color.Black, - val strokeWidth: Float = 3f, + val strokeColor: Color = primaryColor, + val fillColor: Color = primaryColor, + val strokeWidth: Float = 7f, val locationMarkerSize: Float = 24f // Size for the location marker drawable ) diff --git a/app/src/main/java/ch/hikemate/app/ui/components/HikeCard.kt b/app/src/main/java/ch/hikemate/app/ui/components/HikeCard.kt index 9296c3952..1b9823dae 100644 --- a/app/src/main/java/ch/hikemate/app/ui/components/HikeCard.kt +++ b/app/src/main/java/ch/hikemate/app/ui/components/HikeCard.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import ch.hikemate.app.R import ch.hikemate.app.ui.map.MapScreen @@ -56,6 +55,10 @@ data class HikeCardStyleProperties( /** * A card that displays information about a hike. * + * Can display a message as well. The message is only displayed if all three of [messageContent], + * and the icon and color of [styleProperties] are not null. If one of them is null, the message + * won't be displayed. + * * @param title The title of the hike. * @param elevationData The elevation data to display in the graph. * @param onClick The callback to be called when the card is clicked. @@ -73,7 +76,11 @@ fun HikeCard( styleProperties: HikeCardStyleProperties = HikeCardStyleProperties(), showGraph: Boolean = true, ) { - val displayMessage = !elevationData.isNullOrEmpty() && messageContent != null + // The message requires a content, a color and an icon to be displayed, otherwise don't display it + val displayMessage = + messageContent != null && + styleProperties.messageColor != null && + styleProperties.messageIcon != null Log.i( "HikeCard", "displayMessage: $displayMessage, title: $title, elevationData: $elevationData, messageContent: $messageContent") @@ -92,7 +99,6 @@ fun HikeCard( Text( text = title, style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, modifier = Modifier.testTag(HikeCard.TEST_TAG_HIKE_CARD_TITLE)) Spacer(modifier = Modifier.height(8.dp)) @@ -110,7 +116,7 @@ fun HikeCard( fillColor = (styleProperties.graphColor ?: MaterialTheme.colorScheme.primary) - .copy(0.1f))) + .copy(0.5f))) Spacer(modifier = Modifier.width(8.dp)) @@ -126,13 +132,13 @@ fun HikeCard( stringResource( R.string.hike_card_elevation_gain_value_template, elevationGain), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold) + style = MaterialTheme.typography.bodyMedium, + ) } } if (displayMessage) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -145,7 +151,7 @@ fun HikeCard( Text( modifier = Modifier.testTag(HikeCard.TEST_TAG_IS_SUITABLE_TEXT), text = messageContent!!, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = styleProperties.messageColor) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/guide/GuideScreen.kt b/app/src/main/java/ch/hikemate/app/ui/guide/GuideScreen.kt index 309cdf3a3..f783b54e0 100644 --- a/app/src/main/java/ch/hikemate/app/ui/guide/GuideScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/guide/GuideScreen.kt @@ -189,7 +189,7 @@ private fun GuideHeader() { Spacer(modifier = Modifier.width(GuideScreen.HEADER_SPACING_DP.dp)) Text( text = stringResource(R.string.guide_title), - style = MaterialTheme.typography.headlineMedium) + style = MaterialTheme.typography.headlineLarge) } } @@ -199,7 +199,7 @@ private fun HikingGuideSection() { Spacer(modifier = Modifier.height(GuideScreen.SECTION_SPACING_DP.dp)) Text( text = stringResource(R.string.guide_hiking_section_title), - style = MaterialTheme.typography.headlineMedium) + style = MaterialTheme.typography.headlineLarge) Spacer(modifier = Modifier.height(GuideScreen.HEADER_SPACING_DP.dp)) } @@ -257,7 +257,8 @@ private fun TopicCardContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(topic.titleResId), style = MaterialTheme.typography.titleMedium) + text = stringResource(topic.titleResId), + style = MaterialTheme.typography.headlineSmall) Icon( imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp diff --git a/app/src/main/java/ch/hikemate/app/ui/map/HikeDetailsScreen.kt b/app/src/main/java/ch/hikemate/app/ui/map/HikeDetailsScreen.kt index 3c8cc1677..e5fa78f17 100644 --- a/app/src/main/java/ch/hikemate/app/ui/map/HikeDetailsScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/map/HikeDetailsScreen.kt @@ -1,8 +1,10 @@ package ch.hikemate.app.ui.map import android.annotation.SuppressLint -import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size @@ -53,16 +56,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel import ch.hikemate.app.model.facilities.FacilitiesViewModel -import ch.hikemate.app.model.facilities.Facility import ch.hikemate.app.model.profile.HikingLevel import ch.hikemate.app.model.profile.ProfileViewModel import ch.hikemate.app.model.route.DetailedHike @@ -76,6 +76,7 @@ import ch.hikemate.app.ui.components.DetailRow import ch.hikemate.app.ui.components.ElevationGraph import ch.hikemate.app.ui.components.ElevationGraphStyleProperties import ch.hikemate.app.ui.components.WithDetailedHike +import ch.hikemate.app.ui.map.HikeDetailScreen.LOG_TAG import ch.hikemate.app.ui.map.HikeDetailScreen.MAP_MAX_ZOOM import ch.hikemate.app.ui.map.HikeDetailScreen.TEST_TAG_ADD_DATE_BUTTON import ch.hikemate.app.ui.map.HikeDetailScreen.TEST_TAG_BOOKMARK_ICON @@ -90,18 +91,17 @@ import ch.hikemate.app.ui.map.HikeDetailScreen.TEST_TAG_RUN_HIKE_BUTTON import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.Route import ch.hikemate.app.ui.navigation.Screen +import ch.hikemate.app.ui.theme.challengingColor +import ch.hikemate.app.ui.theme.onPrimaryColor +import ch.hikemate.app.ui.theme.primaryColor +import ch.hikemate.app.ui.theme.suitableColor import ch.hikemate.app.utils.MapUtils import ch.hikemate.app.utils.humanReadableFormat import com.google.firebase.Timestamp import java.util.Date import java.util.Locale -import kotlin.math.max -import kotlin.math.min import kotlin.math.roundToInt -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController @@ -113,9 +113,9 @@ object HikeDetailScreen { const val MAP_MIN_LONGITUDE = -180.0 const val MAP_BOUNDS_MARGIN: Int = 100 - const val DEBOUNCE_DURATION = 300L + const val DEBOUNCE_DURATION = 500L - val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 400.dp + val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 190.dp val MAP_BOTTOM_PADDING_ADJUSTMENT = 20.dp const val LOG_TAG = "HikeDetailScreen" @@ -131,6 +131,7 @@ object HikeDetailScreen { const val TEST_TAG_DATE_PICKER_CONFIRM_BUTTON = "HikeDetailDatePickerConfirmButton" const val TEST_TAG_RUN_HIKE_BUTTON = "HikeDetailRunHikeButton" const val TEST_TAG_APPROPRIATENESS_MESSAGE = "HikeDetailAppropriatenessMessage" + const val TEST_TAG_BOTTOM_SHEET = "HikeDetailBottomSheet" } @Composable @@ -144,7 +145,7 @@ fun HikeDetailScreen( // Load the user's profile to get their hiking level LaunchedEffect(Unit) { if (authViewModel.currentUser.value == null) { - Log.e("HikeDetailScreen", "User is not signed in") + Log.e(LOG_TAG, "User is not signed in") return@LaunchedEffect } profileViewModel.getProfileById(authViewModel.currentUser.value!!.uid) @@ -160,7 +161,7 @@ fun HikeDetailScreen( // If the selected hike is null, save the map's state and go back to the map screen LaunchedEffect(selectedHike) { if (selectedHike == null) { - Log.e(HikeDetailScreen.LOG_TAG, "No selected hike, going back") + Log.e(LOG_TAG, "No selected hike, going back") if (mapViewState.value != null) { hikesViewModel.setMapState( center = @@ -244,7 +245,12 @@ fun HikeDetailsContent( // Display the back button on top of the map BackButton( navigationActions = navigationActions, - modifier = Modifier.padding(start = 16.dp, end = 16.dp).safeDrawingPadding(), + modifier = + Modifier.padding( + start = 16.dp, + end = 16.dp, + ) + .safeDrawingPadding(), onClick = { hikesViewModel.unselectHike() }) // Zoom buttons at the bottom right of the screen @@ -253,7 +259,7 @@ fun HikeDetailsContent( onZoomOut = { mapViewState.value?.controller?.zoomOut() }, modifier = Modifier.align(Alignment.BottomEnd) - .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp)) + .padding(bottom = HikeDetailScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp)) // Prevent the app from crashing when the "run hike" button is spammed var wantToRunHike by remember { mutableStateOf(false) } @@ -264,7 +270,7 @@ fun HikeDetailsContent( } } // Display the details of the hike at the bottom of the screen - HikesDetailsBottomScaffold( + HikeDetailsBottomScaffold( hike, hikesViewModel, userHikingLevel, onRunThisHike = { wantToRunHike = true }) } } @@ -302,13 +308,13 @@ fun hikeDetailsMap(hike: DetailedHike, facilitiesViewModel: FacilitiesViewModel) // Create state values that we can actually observe in the LaunchedEffect // We keep our StateFlows for debouncing - val boundingBoxState = remember { MutableStateFlow(mapView.boundingBox) } - val zoomLevelState = remember { MutableStateFlow(mapView.zoomLevelDouble) } + val boundingBoxState = remember { MutableStateFlow(null) } + val zoomLevelState = remember { MutableStateFlow(null) } // This effect handles both initial facility display and subsequent updates // It triggers when facilities are loaded or when the map view changes shouldLoadFacilities = - launchedEffectLoadingOfFacilities( + MapUtils.launchedEffectLoadingOfFacilities( facilities, shouldLoadFacilities, mapView, facilitiesViewModel, hike, context) // This solves the bug of the screen freezing by properly cleaning up resources @@ -324,122 +330,40 @@ fun hikeDetailsMap(hike: DetailedHike, facilitiesViewModel: FacilitiesViewModel) } // This LaunchedEffect handles map updates with debouncing to prevent too frequent refreshes - LaunchedEffectFacilitiesDisplay( + MapUtils.LaunchedEffectFacilitiesDisplay( mapView, boundingBoxState, zoomLevelState, facilitiesViewModel, hike, context) // When the map is ready, it will have computed its bounding box - LaunchedEffectMapviewListener(mapView, hike) + MapUtils.LaunchedEffectMapviewListener(mapView, hike, boundingBoxState, zoomLevelState) // Show the selected hike on the map // OnLineClick does nothing, the line should not be clickable - Log.d(HikeDetailScreen.LOG_TAG, "Drawing hike on map: ${hike.bounds}") + Log.d(LOG_TAG, "Drawing hike on map: ${hike.bounds}") MapUtils.showHikeOnMap( - mapView = mapView, waypoints = hike.waypoints, color = hike.color, onLineClick = {}) + mapView = mapView, + waypoints = hike.waypoints, + color = hike.color, + onLineClick = {}, + withMarker = true) // Display the map as a composable AndroidView( factory = { mapView }, modifier = Modifier.fillMaxWidth() + // To avoid a bug where the map is not fully loaded at the top + .offset(y = (-MapScreen.MAP_BOTTOM_PADDING_ADJUSTMENT)) // Reserve space for the scaffold at the bottom, -20.dp to avoid the map being to // small under the bottomSheet .padding( bottom = - HikeDetailScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - - HikeDetailScreen.MAP_BOTTOM_PADDING_ADJUSTMENT) + (HikeDetailScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - + HikeDetailScreen.MAP_BOTTOM_PADDING_ADJUSTMENT) + .div(2)) .testTag(TEST_TAG_MAP)) return mapView } -@OptIn(FlowPreview::class) -@Composable -private fun LaunchedEffectFacilitiesDisplay( - mapView: MapView, - boundingBoxState: MutableStateFlow, - zoomLevelState: MutableStateFlow, - facilitiesViewModel: FacilitiesViewModel, - hike: DetailedHike, - context: Context -) { - LaunchedEffect(Unit) { - MapUtils.setMapViewListenerForStates(mapView, boundingBoxState, zoomLevelState) - - // Create our combined flow which is limited by a debounce - val combinedFlow = - combine( - boundingBoxState.debounce(HikeDetailScreen.DEBOUNCE_DURATION), - zoomLevelState.debounce(HikeDetailScreen.DEBOUNCE_DURATION)) { boundingBox, zoomLevel -> - boundingBox to zoomLevel - } - - try { - combinedFlow.collect { (boundingBox, zoomLevel) -> - facilitiesViewModel.filterFacilitiesForDisplay( - bounds = boundingBox, - zoomLevel = zoomLevel, - hikeRoute = hike, - onSuccess = { newFacilities -> - MapUtils.clearFacilities(mapView) - if (newFacilities.isNotEmpty()) { - MapUtils.displayFacilities(newFacilities, mapView, context) - } - }, - onNoFacilitiesForState = { MapUtils.clearFacilities(mapView) }) - } - } catch (e: Exception) { - Log.e(HikeDetailScreen.LOG_TAG, "Error in facility updates flow", e) - } - } -} - -@Composable -private fun LaunchedEffectMapviewListener(mapView: MapView, hike: DetailedHike) { - mapView.addOnFirstLayoutListener { _, _, _, _, _ -> - // Limit the vertical scrollable area to avoid the user scrolling too far from the hike - mapView.setScrollableAreaLimitLatitude( - min(MapScreen.MAP_MAX_LATITUDE, mapView.boundingBox.latNorth), - max(MapScreen.MAP_MIN_LATITUDE, mapView.boundingBox.latSouth), - HikeDetailScreen.MAP_BOUNDS_MARGIN) - if (hike.bounds.maxLon < HikeDetailScreen.MAP_MAX_LONGITUDE || - hike.bounds.minLon > HikeDetailScreen.MAP_MIN_LONGITUDE) { - mapView.setScrollableAreaLimitLongitude( - max(HikeDetailScreen.MAP_MIN_LONGITUDE, mapView.boundingBox.lonWest), - min(HikeDetailScreen.MAP_MAX_LONGITUDE, mapView.boundingBox.lonEast), - HikeDetailScreen.MAP_BOUNDS_MARGIN) - } - } -} - -@Composable -private fun launchedEffectLoadingOfFacilities( - facilities: List?, - shouldLoadFacilities: Boolean, - mapView: MapView, - facilitiesViewModel: FacilitiesViewModel, - hike: DetailedHike, - context: Context -): Boolean { - var shouldLoadFacilities1 = shouldLoadFacilities - LaunchedEffect(facilities, shouldLoadFacilities1) { - if (facilities != null && mapView.repository != null) { - facilitiesViewModel.filterFacilitiesForDisplay( - bounds = mapView.boundingBox, - zoomLevel = mapView.zoomLevelDouble, - hikeRoute = hike, - onSuccess = { newFacilities -> - MapUtils.clearFacilities(mapView) - if (newFacilities.isNotEmpty()) { - MapUtils.displayFacilities(newFacilities, mapView, context) - } - }, - onNoFacilitiesForState = { MapUtils.clearFacilities(mapView) }) - // Reset the flag after loading - shouldLoadFacilities1 = false - } - } - return shouldLoadFacilities1 -} - /** * A composable that displays details about a hike in a bottom sheet. * @@ -450,13 +374,14 @@ private fun launchedEffectLoadingOfFacilities( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HikesDetailsBottomScaffold( +fun HikeDetailsBottomScaffold( detailedHike: DetailedHike, hikesViewModel: HikesViewModel, userHikingLevel: HikingLevel, onRunThisHike: () -> Unit ) { val scaffoldState = rememberBottomSheetScaffoldState() + val context = LocalContext.current val hikeColor = Color(detailedHike.color) val isSuitable = detailedHike.difficulty.ordinal <= userHikingLevel.ordinal @@ -467,19 +392,24 @@ fun HikesDetailsBottomScaffold( scaffoldState = scaffoldState, sheetContainerColor = MaterialTheme.colorScheme.surface, sheetPeekHeight = HikeDetailScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT, + // Overwrites the device's max sheet width to avoid the bottomSheet not being wide enough + sheetMaxWidth = Integer.MAX_VALUE.dp, sheetContent = { Column( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = + Modifier.testTag(HikeDetailScreen.TEST_TAG_BOTTOM_SHEET) + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Row { Column( - modifier = Modifier.padding(16.dp).weight(1f), + modifier = Modifier.weight(1f), ) { Text( text = detailedHike.name ?: stringResource(R.string.map_screen_hike_title_default), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Left, modifier = Modifier.testTag(TEST_TAG_HIKE_NAME)) AppropriatenessMessage(isSuitable) @@ -494,25 +424,45 @@ fun HikesDetailsBottomScaffold( modifier = Modifier.size(60.dp, 80.dp).testTag(TEST_TAG_BOOKMARK_ICON).clickable { if (detailedHike.isSaved) { - hikesViewModel.unsaveHike(detailedHike.id) + hikesViewModel.unsaveHike( + detailedHike.id, + onFailure = { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + context.getString( + R.string.generic_error_message_internet_connection), + Toast.LENGTH_SHORT) + .show() + } + }) } else { - hikesViewModel.saveHike(detailedHike.id) + hikesViewModel.saveHike( + detailedHike.id, + onFailure = { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + context.getString( + R.string.generic_error_message_internet_connection), + Toast.LENGTH_SHORT) + .show() + } + }) } }, contentScale = ContentScale.FillBounds, ) } - ElevationGraph( - elevations = detailedHike.elevation, - modifier = - Modifier.fillMaxWidth() - .height(60.dp) - .padding(4.dp) - .testTag(TEST_TAG_ELEVATION_GRAPH), - styleProperties = - ElevationGraphStyleProperties( - strokeColor = hikeColor, fillColor = hikeColor.copy(0.1f))) + Column(modifier = Modifier.padding(bottom = 24.dp)) { + ElevationGraph( + elevations = detailedHike.elevation, + modifier = Modifier.fillMaxWidth().height(60.dp).testTag(TEST_TAG_ELEVATION_GRAPH), + styleProperties = + ElevationGraphStyleProperties( + strokeColor = hikeColor, fillColor = hikeColor.copy(0.5f))) + } val distanceString = String.format( @@ -553,16 +503,25 @@ fun HikesDetailsBottomScaffold( DetailRow( label = stringResource(R.string.hike_detail_screen_label_difficulty), value = stringResource(detailedHike.difficulty.nameResourceId), - valueColor = - Color( - ContextCompat.getColor( - LocalContext.current, detailedHike.difficulty.colorResourceId)), + valueColor = detailedHike.difficulty.color, ) DateDetailRow( isSaved = detailedHike.isSaved, plannedDate = detailedHike.plannedDate, updatePlannedDate = { timestamp: Timestamp? -> - hikesViewModel.setPlannedDate(detailedHike.id, timestamp) + hikesViewModel.setPlannedDate( + detailedHike.id, + timestamp, + onFailure = { + Handler(Looper.getMainLooper()).post { + Toast.makeText( + context, + context.getString( + R.string.generic_error_message_internet_connection), + Toast.LENGTH_SHORT) + .show() + } + }) }) // "Run This Hike" button @@ -571,7 +530,9 @@ fun HikesDetailsBottomScaffold( label = stringResource(R.string.hike_detail_screen_run_this_hike_button_label), onClick = { onRunThisHike() }, modifier = - Modifier.padding(top = 16.dp).fillMaxWidth().testTag(TEST_TAG_RUN_HIKE_BUTTON)) + Modifier.padding(vertical = 16.dp) + .fillMaxWidth() + .testTag(TEST_TAG_RUN_HIKE_BUTTON)) } }, ) {} @@ -591,7 +552,7 @@ fun DateDetailRow( initialSelectedDateMillis = plannedDate?.toDate()?.time ?: System.currentTimeMillis(), ) - var previouslySelectedDate by remember { mutableStateOf(plannedDate?.toDate()?.time) } + val previouslySelectedDate = plannedDate?.toDate()?.time fun showDatePicker() { showingDatePicker.value = true @@ -619,9 +580,7 @@ fun DateDetailRow( Button( modifier = Modifier.testTag(TEST_TAG_DATE_PICKER_CONFIRM_BUTTON), onClick = { - previouslySelectedDate = - confirmDateDetailButton( - datePickerState, previouslySelectedDate, updatePlannedDate) + confirmDateDetailButton(datePickerState, previouslySelectedDate, updatePlannedDate) dismissDatePicker() }, colors = @@ -629,7 +588,8 @@ fun DateDetailRow( ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary) else - ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary), ) { Text( text = @@ -651,9 +611,9 @@ fun DateDetailRow( DetailRow( label = stringResource(R.string.hike_detail_screen_label_status), value = stringResource(R.string.hike_detail_screen_value_saved), - valueColor = Color(0xFF3B9DE8)) + valueColor = primaryColor) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( @@ -666,14 +626,13 @@ fun DateDetailRow( contentPadding = PaddingValues(0.dp), colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF4285F4), // Blue color to match the image - contentColor = Color.White), + containerColor = primaryColor, // Blue color to match the image + contentColor = onPrimaryColor), onClick = { showDatePicker() }, ) { Text( text = stringResource(R.string.hike_detail_screen_add_a_date_button_text), style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, ) } } @@ -681,9 +640,9 @@ fun DateDetailRow( DetailRow( label = stringResource(R.string.hike_detail_screen_label_status), value = stringResource(R.string.hike_detail_screen_value_planned), - valueColor = Color(0xFF3B9DE8)) + valueColor = primaryColor) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( @@ -749,7 +708,7 @@ private fun confirmDateDetailButton( fun AppropriatenessMessage(isSuitable: Boolean) { // The text, icon and color of the card's message are chosen based on whether the hike is suitable // or not - val suitableLabelColor = if (isSuitable) Color(0xFF4CAF50) else Color(0xFFFFC107) + val suitableLabelColor = if (isSuitable) suitableColor else challengingColor val suitableLabelText = if (isSuitable) LocalContext.current.getString(R.string.map_screen_suitable_hike_label) @@ -770,7 +729,7 @@ fun AppropriatenessMessage(isSuitable: Boolean) { Text( modifier = Modifier.testTag(HikeDetailScreen.TEST_TAG_APPROPRIATENESS_MESSAGE), text = suitableLabelText, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = suitableLabelColor) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/map/MapScreen.kt b/app/src/main/java/ch/hikemate/app/ui/map/MapScreen.kt index 6ec0c76c3..401c41592 100644 --- a/app/src/main/java/ch/hikemate/app/ui/map/MapScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/map/MapScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.BottomSheetScaffold @@ -35,8 +36,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -91,13 +95,13 @@ object MapScreen { * (Config) Height of the bottom sheet when it is collapsed. The height is defined empirically to * show a few items of the list of hikes and allow the user to expand it to see more. */ - val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 400.dp + val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 250.dp /** * (Config) Adjustment to the map's bottom padding to prevent rendering issues. Without this * adjustment, the map sometimes fails to load properly beneath the bottom sheet. */ - val MAP_BOTTOM_PADDING_ADJUSTMENT = 20.dp + val MAP_BOTTOM_PADDING_ADJUSTMENT = 40.dp /** * (Config) Initial zoom level of the map. The zoom level is defined empirically to show a @@ -398,6 +402,18 @@ fun MapScreen( val errorMessageIdState = profileViewModel.errorMessageId.collectAsState() val profileState = profileViewModel.profile.collectAsState() + var searchButtonBounds by remember { mutableStateOf(null) } + var zoomButtonsBounds by remember { mutableStateOf(null) } + // Flag gets set to true if the search button overlaps with the zoom buttons + var shortTextFlag by remember { mutableStateOf(false) } + LaunchedEffect(searchButtonBounds, zoomButtonsBounds) { + if (searchButtonBounds != null && + zoomButtonsBounds != null && + searchButtonBounds!!.overlaps(zoomButtonsBounds!!)) { + shortTextFlag = true + } + } + AsyncStateHandler( errorMessageIdState = errorMessageIdState, actionContentDescriptionStringId = R.string.go_back, @@ -410,36 +426,43 @@ fun MapScreen( tabList = LIST_TOP_LEVEL_DESTINATIONS, selectedItem = Route.MAP) { p -> Box(modifier = Modifier.fillMaxSize().padding(p).testTag(Screen.MAP)) { - // Jetpack Compose is a relatively recent framework for implementing Android UIs. - // OSMDroid - // is - // an older library that uses Activities, the previous way of doing. The composable + // Jetpack Compose is a relatively recent framework for implementing Android + // UIs. + // OSMDroid is + // an older library that uses Activities, the previous way of doing. The + // composable // AndroidView // allows us to use OSMDroid's legacy MapView in a Jetpack Compose layout. AndroidView( factory = { mapView }, modifier = Modifier.fillMaxSize() + // To avoid a bug where the map is not fully loaded at the top + .offset(y = (-MapScreen.MAP_BOTTOM_PADDING_ADJUSTMENT)) .testTag(MapScreen.TEST_TAG_MAP) - // Reserve space for the scaffold at the bottom, -20.dp to avoid the map + // Reserve space for the scaffold at the bottom, -20.dp to avoid the + // map // being too small under the bottomSheet .padding( bottom = - MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - - MapScreen.MAP_BOTTOM_PADDING_ADJUSTMENT)) + (MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - + MapScreen.MAP_BOTTOM_PADDING_ADJUSTMENT) + .div(2))) // Button to center the map on the user's location MapMyLocationButton( onClick = { val hasLocationPermission = LocationUtils.hasLocationPermission(locationPermissionState) - // If the user has granted at least one of the two permissions, center the map + // If the user has granted at least one of the two permissions, center the + // map // on // the user's location if (hasLocationPermission) { MapUtils.centerMapOnLocation(context, mapView, userLocationMarker) } - // If the user yet needs to grant the permission, show a custom educational + // If the user yet needs to grant the permission, show a custom + // educational // alert else { showLocationPermissionDialog = true @@ -459,7 +482,12 @@ fun MapScreen( !isSearching.value, modifier = Modifier.align(Alignment.BottomCenter) - .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp)) + .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp) + .onGloballyPositioned { coordinates -> + searchButtonBounds = coordinates.boundsInRoot() + }, + shortText = shortTextFlag, + ) // The zoom buttons are displayed on the bottom left of the screen ZoomMapButton( onZoomIn = { @@ -472,9 +500,11 @@ fun MapScreen( }, modifier = Modifier.align(Alignment.BottomEnd) - .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp)) + .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp) + .onGloballyPositioned { coordinates -> + zoomButtonsBounds = coordinates.boundsInRoot() + }) CollapsibleHikesList(hikesViewModel, profile.hikingLevel, isSearching.value) - // Put SideBarNavigation after to make it appear on top of the map and HikeList } } } @@ -546,7 +576,12 @@ private fun LaunchedEffectForHikeUpdate( } @Composable -fun MapSearchButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { +fun MapSearchButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shortText: Boolean +) { Button( onClick = onClick, modifier = modifier.testTag(MapScreen.TEST_TAG_SEARCH_BUTTON), @@ -557,7 +592,11 @@ fun MapSearchButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: ), enabled = enabled) { Text( - text = LocalContext.current.getString(R.string.map_screen_search_button_text), + text = + if (shortText) + LocalContext.current.getString(R.string.map_screen_search_button_text_short) + else LocalContext.current.getString(R.string.map_screen_search_button_text), + style = MaterialTheme.typography.bodyMedium, color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) @@ -604,8 +643,11 @@ fun CollapsibleHikesList( BottomSheetScaffold( scaffoldState = scaffoldState, sheetContainerColor = MaterialTheme.colorScheme.surface, + sheetPeekHeight = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT, + // Overwrites the device's max sheet width to avoid the bottomSheet not being wide enough + sheetMaxWidth = Integer.MAX_VALUE.dp, sheetContent = { - Column(modifier = Modifier.fillMaxSize().testTag(MapScreen.TEST_TAG_HIKES_LIST)) { + Column(modifier = Modifier.testTag(MapScreen.TEST_TAG_HIKES_LIST)) { when { // A search for hikes on the map is ongoing, display a loading animation isSearching -> { @@ -655,26 +697,26 @@ fun CollapsibleHikesList( items(hikes.size, key = { hikes[it].value.id }) { index: Int -> val hike by hikes[index].collectAsState() val elevation: List? - val suitable: Boolean + val suitable: Boolean? when { // The hike's elevation data was retrieved, but an error occurred hike.elevation is DeferredData.Error -> { elevation = emptyList() - suitable = false + suitable = null } // The hike has no elevation data and it wasn't requested yet !hike.elevation.obtained() -> { hikesViewModel.retrieveElevationDataFor(hike.id) elevation = null - suitable = false + suitable = null } // The hike has elevation data but no details computed !hikesViewModel.areDetailsComputedFor(hike) -> { hikesViewModel.computeDetailsFor(hike.id) elevation = hike.elevation.getOrThrow() - suitable = false + suitable = null } // The hike has elevation data and details computed @@ -696,7 +738,7 @@ fun CollapsibleHikesList( } } }, - sheetPeekHeight = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT) {} + ) {} } @Composable diff --git a/app/src/main/java/ch/hikemate/app/ui/map/RunHikeScreen.kt b/app/src/main/java/ch/hikemate/app/ui/map/RunHikeScreen.kt index 2fc07661f..d5b50ed29 100644 --- a/app/src/main/java/ch/hikemate/app/ui/map/RunHikeScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/map/RunHikeScreen.kt @@ -2,7 +2,6 @@ package ch.hikemate.app.ui.map import android.Manifest import android.annotation.SuppressLint -import android.content.Context import android.location.Location import android.util.Log import android.view.MotionEvent @@ -13,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -34,15 +34,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import ch.hikemate.app.R import ch.hikemate.app.model.facilities.FacilitiesViewModel -import ch.hikemate.app.model.facilities.Facility import ch.hikemate.app.model.route.DetailedHike import ch.hikemate.app.model.route.HikesViewModel import ch.hikemate.app.model.route.LatLong @@ -56,25 +53,26 @@ import ch.hikemate.app.ui.components.ElevationGraphStyleProperties import ch.hikemate.app.ui.components.LocationPermissionAlertDialog import ch.hikemate.app.ui.components.WithDetailedHike import ch.hikemate.app.ui.map.HikeDetailScreen.MAP_MAX_ZOOM +import ch.hikemate.app.ui.map.RunHikeScreen.LOG_TAG import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.Screen import ch.hikemate.app.utils.LocationUtils import ch.hikemate.app.utils.MapUtils +import ch.hikemate.app.utils.RouteUtils import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationResult import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import org.osmdroid.util.BoundingBox import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.Marker object RunHikeScreen { - val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 400.dp + const val LOG_TAG = "RunHikeScreen" + val BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT = 170.dp val MAP_BOTTOM_PADDING_ADJUSTMENT = 20.dp const val TEST_TAG_MAP = "runHikeScreenMap" @@ -87,6 +85,7 @@ object RunHikeScreen { const val TEST_TAG_TOTAL_DISTANCE_TEXT = "runHikeScreenTotalDistanceText" const val TEST_TAG_PROGRESS_TEXT = "runHikeScreenProgressText" const val TEST_TAG_CENTER_MAP_BUTTON = "runHikeScreenCenterMapButton" + const val TEST_TAG_CURRENT_ELEVATION_TEXT = "runHikeScreenCurrentElevationText" // The maximum distance to be considered on a hike const val MAX_DISTANCE_TO_CONSIDER_HIKE = 50.0 // meters @@ -173,7 +172,8 @@ private fun RunHikeContent( } var userLocationMarker: Marker? by remember { mutableStateOf(null) } - var completionPercentage: Int? by remember { mutableStateOf(null) } + var completionRatio: Double? by remember { mutableStateOf(null) } + var userElevation: Double? by remember { mutableStateOf(null) } // We need to keep a reference to the instance of location callback, this way we can unregister @@ -181,11 +181,13 @@ private fun RunHikeContent( val locationUpdatedCallback = remember { object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { + val locationParsed = parseLocationUpdate(locationResult, userLocationMarker, mapView, hike) userLocationMarker = locationParsed.first - completionPercentage = locationParsed.second + completionRatio = locationParsed.second userElevation = locationParsed.third + if (centerMapOnUserPosition && userLocationMarker != null && userLocationMarker?.position != null) @@ -210,7 +212,7 @@ private fun RunHikeContent( DisposableEffect(Unit) { val hasLocationPermission = LocationUtils.hasLocationPermission(locationPermissionState) - Log.d("RunHikeScreen", "Has location permission: $hasLocationPermission") + Log.d(LOG_TAG, "Has location permission: $hasLocationPermission") // If the user has granted at least one of the two permissions, center the map // on the user's location if (hasLocationPermission) { @@ -246,12 +248,12 @@ private fun RunHikeContent( locationPermissionState = locationPermissionState, context = context) - Box(modifier = Modifier.fillMaxSize().testTag(Screen.RUN_HIKE)) { + Box(modifier = Modifier.fillMaxSize()) { // Back Button at the top of the screen BackButton( navigationActions = navigationActions, modifier = - Modifier.padding(top = 40.dp, start = 16.dp, end = 16.dp) + Modifier.padding(start = 16.dp, end = 16.dp) .testTag(RunHikeScreen.TEST_TAG_BACK_BUTTON), onClick = { wantToNavigateBack = true }) @@ -264,7 +266,7 @@ private fun RunHikeContent( }, modifier = Modifier.align(Alignment.BottomStart) - .padding(bottom = MapScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp) + .padding(bottom = RunHikeScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT + 8.dp) .testTag(RunHikeScreen.TEST_TAG_CENTER_MAP_BUTTON)) // Zoom buttons at the bottom right of the screen @@ -279,9 +281,10 @@ private fun RunHikeContent( // Display the bottom sheet with the hike details RunHikeBottomSheet( hike = hike, - completionPercentage = completionPercentage, + completionRatio = completionRatio, userElevation = userElevation, - onStopTheRun = { wantToNavigateBack = true }) + onStopTheRun = { wantToNavigateBack = true }, + ) } } } @@ -301,9 +304,11 @@ private fun parseLocationUpdate( userLocationMarker: Marker?, mapView: MapView, hike: DetailedHike -): Triple { +): Triple { if (locationResult.lastLocation == null) { + Log.d(LOG_TAG, "Location null") MapUtils.clearUserPosition(userLocationMarker, mapView, invalidate = true) + return Triple(null, null, null) } @@ -334,11 +339,14 @@ private fun parseLocationUpdate( val completionPercentage = if (routeProjectionResponse.distanceFromRoute > RunHikeScreen.MAX_DISTANCE_TO_CONSIDER_HIKE) null - else (routeProjectionResponse.progressDistance * 0.1 / hike.distance).toInt() + else + (routeProjectionResponse.progressDistance / + (hike.distance * RouteUtils.METERS_PER_KIlOMETER)) val currentElevation = if (routeProjectionResponse.distanceFromRoute > RunHikeScreen.MAX_DISTANCE_TO_CONSIDER_HIKE) null else routeProjectionResponse.projectedLocationElevation + Log.d(LOG_TAG, "completion:$completionPercentage") return Triple(marker, completionPercentage, currentElevation) } @@ -375,13 +383,13 @@ fun runHikeMap(hike: DetailedHike, facilitiesViewModel: FacilitiesViewModel): Ma // Create state values that we can actually observe in the LaunchedEffect // We keep our StateFlows for debouncing - val boundingBoxState = remember { MutableStateFlow(mapView.boundingBox) } - val zoomLevelState = remember { MutableStateFlow(mapView.zoomLevelDouble) } + val boundingBoxState = remember { MutableStateFlow(null) } + val zoomLevelState = remember { MutableStateFlow(null) } // This effect handles both initial facility display and subsequent updates // It triggers when facilities are loaded or when the map view changes shouldLoadFacilities = - launchedEffectLoadingOfFacilities( + MapUtils.launchedEffectLoadingOfFacilities( facilities, shouldLoadFacilities, mapView, facilitiesViewModel, hike, context) // This solves the bug of the screen freezing by properly cleaning up resources @@ -397,105 +405,44 @@ fun runHikeMap(hike: DetailedHike, facilitiesViewModel: FacilitiesViewModel): Ma } // This LaunchedEffect handles map updates with debouncing to prevent too frequent refreshes - LaunchedEffectFacilitiesDisplay( + MapUtils.LaunchedEffectFacilitiesDisplay( mapView, boundingBoxState, zoomLevelState, facilitiesViewModel, hike, context) + MapUtils.LaunchedEffectMapviewListener(mapView, hike, boundingBoxState, zoomLevelState, false) + // Show the selected hike on the map // OnLineClick does nothing, the line should not be clickable - Log.d(HikeDetailScreen.LOG_TAG, "Drawing hike on map: ${hike.bounds}") + Log.d(LOG_TAG, "Drawing hike on map: ${hike.bounds}") MapUtils.showHikeOnMap( - mapView = mapView, waypoints = hike.waypoints, color = hike.color, onLineClick = {}) + mapView = mapView, + waypoints = hike.waypoints, + color = hike.color, + onLineClick = {}, + withMarker = true) // Map AndroidView( factory = { mapView }, modifier = Modifier.fillMaxWidth() + // To avoid a bug where the map is not fully loaded at the top + .offset(y = (-MapScreen.MAP_BOTTOM_PADDING_ADJUSTMENT)) // Reserve space for the scaffold at the bottom, -20.dp to avoid the map being to // small under the bottomSheet .padding( bottom = - RunHikeScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - - RunHikeScreen.MAP_BOTTOM_PADDING_ADJUSTMENT) + (RunHikeScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT - + RunHikeScreen.MAP_BOTTOM_PADDING_ADJUSTMENT) + .div(2)) .testTag(RunHikeScreen.TEST_TAG_MAP)) return mapView } -@Composable -private fun LaunchedEffectFacilitiesDisplay( - mapView: MapView, - boundingBoxState: MutableStateFlow, - zoomLevelState: MutableStateFlow, - facilitiesViewModel: FacilitiesViewModel, - hike: DetailedHike, - context: Context -) { - LaunchedEffect(Unit) { - MapUtils.setMapViewListenerForStates(mapView, boundingBoxState, zoomLevelState) - - // Create our combined flow which is limited by a debounce - val combinedFlow = - combine( - boundingBoxState.debounce(HikeDetailScreen.DEBOUNCE_DURATION), - zoomLevelState.debounce(HikeDetailScreen.DEBOUNCE_DURATION)) { boundingBox, zoomLevel -> - boundingBox to zoomLevel - } - - try { - combinedFlow.collect { (boundingBox, zoomLevel) -> - facilitiesViewModel.filterFacilitiesForDisplay( - bounds = boundingBox, - zoomLevel = zoomLevel, - hikeRoute = hike, - onSuccess = { newFacilities -> - MapUtils.clearFacilities(mapView) - if (newFacilities.isNotEmpty()) { - MapUtils.displayFacilities(newFacilities, mapView, context) - } - }, - onNoFacilitiesForState = { MapUtils.clearFacilities(mapView) }) - } - } catch (e: Exception) { - Log.e(HikeDetailScreen.LOG_TAG, "Error in facility updates flow", e) - } - } -} - -@Composable -private fun launchedEffectLoadingOfFacilities( - facilities: List?, - shouldLoadFacilities: Boolean, - mapView: MapView, - facilitiesViewModel: FacilitiesViewModel, - hike: DetailedHike, - context: Context -): Boolean { - var shouldLoadFacilities1 = shouldLoadFacilities - LaunchedEffect(facilities, shouldLoadFacilities1) { - if (facilities != null && mapView.repository != null) { - facilitiesViewModel.filterFacilitiesForDisplay( - bounds = mapView.boundingBox, - zoomLevel = mapView.zoomLevelDouble, - hikeRoute = hike, - onSuccess = { newFacilities -> - MapUtils.clearFacilities(mapView) - if (newFacilities.isNotEmpty()) { - MapUtils.displayFacilities(newFacilities, mapView, context) - } - }, - onNoFacilitiesForState = { MapUtils.clearFacilities(mapView) }) - // Reset the flag after loading - shouldLoadFacilities1 = false - } - } - return shouldLoadFacilities1 -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable private fun RunHikeBottomSheet( hike: DetailedHike, - completionPercentage: Int? = null, + completionRatio: Double? = null, userElevation: Double? = null, onStopTheRun: () -> Unit, ) { @@ -505,53 +452,56 @@ private fun RunHikeBottomSheet( scaffoldState = scaffoldState, sheetContainerColor = MaterialTheme.colorScheme.surface, sheetPeekHeight = RunHikeScreen.BOTTOM_SHEET_SCAFFOLD_MID_HEIGHT, - modifier = Modifier.testTag(RunHikeScreen.TEST_TAG_BOTTOM_SHEET), + // Overwrites the device's max sheet width to avoid the bottomSheet not being wide enough + sheetMaxWidth = Integer.MAX_VALUE.dp, sheetContent = { Column( - modifier = Modifier.padding(16.dp).weight(1f), + modifier = + Modifier.testTag(RunHikeScreen.TEST_TAG_BOTTOM_SHEET) + .padding(start = 16.dp, end = 16.dp) + .fillMaxWidth(), ) { Text( text = hike.name ?: stringResource(R.string.map_screen_hike_title_default), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Left, modifier = Modifier.testTag(RunHikeScreen.TEST_TAG_HIKE_NAME)) - // Elevation graph and the progress details below the graph Column { + // Progress details below the graph + // Elevation graph and the progress details below the graph val hikeColor = Color(hike.color) ElevationGraph( elevations = hike.elevation, styleProperties = ElevationGraphStyleProperties( - strokeColor = hikeColor, fillColor = hikeColor.copy(0.1f)), + strokeColor = hikeColor, fillColor = hikeColor.copy(0.5f)), modifier = Modifier.fillMaxWidth() .height(60.dp) .padding(4.dp) - .testTag(RunHikeScreen.TEST_TAG_ELEVATION_GRAPH)) - - // Progress details below the graph + .testTag(RunHikeScreen.TEST_TAG_ELEVATION_GRAPH), + progressThroughHike = (completionRatio)?.toFloat()) Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 20.dp), horizontalArrangement = Arrangement.SpaceBetween) { Text( text = stringResource(R.string.run_hike_screen_zero_distance_progress_value), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Left, ) Text( // Displays the progress percentage below the graph text = - if (completionPercentage == null) + if (completionRatio == null) stringResource(R.string.run_hike_screen_progress_percentage_no_data) - else - stringResource( - R.string.run_hike_screen_progress_percentage_format, - completionPercentage), - style = MaterialTheme.typography.bodyLarge, + else { + val percentage = (completionRatio * 100).roundToInt() + stringResource( + R.string.run_hike_screen_progress_percentage_format, percentage) + }, + style = MaterialTheme.typography.bodyMedium, color = hikeColor, - fontWeight = FontWeight.Bold, textAlign = TextAlign.Right, modifier = Modifier.testTag(RunHikeScreen.TEST_TAG_PROGRESS_TEXT), ) @@ -560,8 +510,7 @@ private fun RunHikeBottomSheet( stringResource( R.string.run_hike_screen_distance_progress_value_format, hike.distance), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Right, modifier = Modifier.testTag(RunHikeScreen.TEST_TAG_TOTAL_DISTANCE_TEXT), ) @@ -578,7 +527,9 @@ private fun RunHikeBottomSheet( else stringResource( R.string.run_hike_screen_value_format_current_elevation, - userElevation.roundToInt())) + userElevation.roundToInt()), + modifier = Modifier.testTag(RunHikeScreen.TEST_TAG_CURRENT_ELEVATION_TEXT), + ) DetailRow( label = stringResource(R.string.run_hike_screen_label_elevation_gain), value = @@ -599,15 +550,16 @@ private fun RunHikeBottomSheet( DetailRow( label = stringResource(R.string.run_hike_screen_label_difficulty), value = stringResource(hike.difficulty.nameResourceId), - valueColor = colorResource(hike.difficulty.colorResourceId)) + valueColor = hike.difficulty.color) BigButton( buttonType = ButtonType.PRIMARY, label = stringResource(R.string.run_hike_screen_stop_run_button_label), onClick = onStopTheRun, modifier = - Modifier.padding(top = 16.dp).testTag(RunHikeScreen.TEST_TAG_STOP_HIKE_BUTTON), - fillColor = colorResource(R.color.red), + Modifier.padding(vertical = 16.dp) + .testTag(RunHikeScreen.TEST_TAG_STOP_HIKE_BUTTON), + fillColor = MaterialTheme.colorScheme.tertiary, ) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/navigation/BottomBarNavigation.kt b/app/src/main/java/ch/hikemate/app/ui/navigation/BottomBarNavigation.kt index 6d22a39fd..93a2d6b09 100644 --- a/app/src/main/java/ch/hikemate/app/ui/navigation/BottomBarNavigation.kt +++ b/app/src/main/java/ch/hikemate/app/ui/navigation/BottomBarNavigation.kt @@ -36,6 +36,7 @@ fun BottomBarNavigation( modifier = Modifier.testTag(TEST_TAG_BOTTOM_BAR), ) { tabList.forEach { tab -> + val selected = tab.route == selectedItem NavigationBarItem( icon = { Icon( @@ -44,8 +45,8 @@ fun BottomBarNavigation( ) }, label = { Text(stringResource(tab.textId)) }, - selected = tab.route == selectedItem, - onClick = { onTabSelect(tab) }, + selected = selected, + onClick = { if (!selected) onTabSelect(tab) }, modifier = Modifier.testTag(TEST_TAG_MENU_ITEM_PREFIX + tab.route), ) } diff --git a/app/src/main/java/ch/hikemate/app/ui/profile/DeleteAccountScreen.kt b/app/src/main/java/ch/hikemate/app/ui/profile/DeleteAccountScreen.kt index b63c35581..6854484e8 100644 --- a/app/src/main/java/ch/hikemate/app/ui/profile/DeleteAccountScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/profile/DeleteAccountScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,10 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel import ch.hikemate.app.ui.components.BackButton @@ -68,12 +66,12 @@ fun DeleteAccountScreen(navigationActions: NavigationActions, authViewModel: Aut BackButton(navigationActions) Text( stringResource(R.string.delete_account_title), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.testTag(DeleteAccountScreen.TEST_TAG_TITLE)) Text( stringResource(R.string.delete_account_info), - style = TextStyle(fontSize = 16.sp), + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.testTag(DeleteAccountScreen.TEST_TAG_INFO_TEXT)) if (authViewModel.isEmailProvider()) diff --git a/app/src/main/java/ch/hikemate/app/ui/profile/EditProfileScreen.kt b/app/src/main/java/ch/hikemate/app/ui/profile/EditProfileScreen.kt index 21f05d60c..3f2cd3b6a 100644 --- a/app/src/main/java/ch/hikemate/app/ui/profile/EditProfileScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/profile/EditProfileScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -22,13 +23,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel @@ -44,6 +41,7 @@ import ch.hikemate.app.ui.components.CustomTextField import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.Route import ch.hikemate.app.ui.navigation.Screen +import ch.hikemate.app.ui.theme.onPrimaryColor import ch.hikemate.app.ui.theme.primaryColor import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -55,8 +53,7 @@ object EditProfileScreen { const val TEST_TAG_NAME_INPUT = "editProfileScreenNameInput" const val TEST_TAG_HIKING_LEVEL_LABEL = "editProfileScreenHikingLevelLabel" const val TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER = "editProfileScreenHikingLevelChoiceBeginner" - const val TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE = - "editProfileScreenHikingLevelChoiceIntermediate" + const val TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR = "editProfileScreenHikingLevelChoiceAmateur" const val TEST_TAG_HIKING_LEVEL_CHOICE_EXPERT = "editProfileScreenHikingLevelChoiceExpert" const val TEST_TAG_SAVE_BUTTON = "editProfileScreenSaveButton" } @@ -127,7 +124,7 @@ fun EditProfileScreen( BackButton(navigationActions) Text( context.getString(R.string.edit_profile_screen_title), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.testTag(EditProfileScreen.TEST_TAG_TITLE)) CustomTextField( @@ -143,7 +140,7 @@ fun EditProfileScreen( ) { Text( context.getString(R.string.profile_screen_hiking_level_label), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp), + style = MaterialTheme.typography.labelLarge, modifier = Modifier.testTag(EditProfileScreen.TEST_TAG_HIKING_LEVEL_LABEL)) SingleChoiceSegmentedButtonRow { HikingLevel.values().forEachIndexed { index, fitLevel -> @@ -153,8 +150,8 @@ fun EditProfileScreen( when (fitLevel) { HikingLevel.BEGINNER -> EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_BEGINNER - HikingLevel.INTERMEDIATE -> - EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_INTERMEDIATE + HikingLevel.AMATEUR -> + EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_AMATEUR HikingLevel.EXPERT -> EditProfileScreen.TEST_TAG_HIKING_LEVEL_CHOICE_EXPERT }), @@ -165,12 +162,14 @@ fun EditProfileScreen( SegmentedButtonDefaults.colors() .copy( activeContainerColor = primaryColor, - activeContentColor = Color.White, + activeContentColor = onPrimaryColor, ), onClick = { hikingLevel = index }, selected = hikingLevel == index, ) { - Text(fitLevel.getDisplayString(context)) + Text( + fitLevel.getDisplayString(context), + style = MaterialTheme.typography.titleMedium) } } } diff --git a/app/src/main/java/ch/hikemate/app/ui/profile/ProfileScreen.kt b/app/src/main/java/ch/hikemate/app/ui/profile/ProfileScreen.kt index d73a6eaf5..9877c0bf5 100644 --- a/app/src/main/java/ch/hikemate/app/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/profile/ProfileScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -17,11 +16,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import ch.hikemate.app.R import ch.hikemate.app.model.authentication.AuthViewModel @@ -57,8 +53,8 @@ fun DisplayInfo(label: String, value: String, modifier: Modifier = Modifier) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Text(label, style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp)) - Text(value, style = TextStyle(fontSize = 20.sp), modifier = modifier) + Text(label, style = MaterialTheme.typography.titleLarge) + Text(value, style = MaterialTheme.typography.bodyLarge, modifier = modifier) } } @@ -98,19 +94,19 @@ fun ProfileScreen( BottomBarNavigation( onTabSelect = { navigationActions.navigateTo(it) }, tabList = LIST_TOP_LEVEL_DESTINATIONS, - selectedItem = Route.PROFILE) { _ -> + selectedItem = Route.PROFILE) { p -> Column( modifier = Modifier.testTag(Screen.PROFILE) + .padding(p) .padding( start = 16.dp, end = 16.dp, - ) - .safeDrawingPadding(), + ), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( context.getString(R.string.profile_screen_title), - style = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.testTag(ProfileScreen.TEST_TAG_TITLE)) DisplayInfo( @@ -126,8 +122,8 @@ fun ProfileScreen( (when (profile.hikingLevel) { HikingLevel.BEGINNER -> context.getString(R.string.profile_screen_hiking_level_info_beginner) - HikingLevel.INTERMEDIATE -> - context.getString(R.string.profile_screen_hiking_level_info_intermediate) + HikingLevel.AMATEUR -> + context.getString(R.string.profile_screen_hiking_level_info_amateur) HikingLevel.EXPERT -> context.getString(R.string.profile_screen_hiking_level_info_expert) }), @@ -159,11 +155,9 @@ fun ProfileScreen( Text( stringResource(R.string.delete_account_button_label), style = - TextStyle( + MaterialTheme.typography.labelLarge.copy( textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = MaterialTheme.colorScheme.error), + color = MaterialTheme.colorScheme.tertiary), ) } } diff --git a/app/src/main/java/ch/hikemate/app/ui/saved/SavedHikesScreen.kt b/app/src/main/java/ch/hikemate/app/ui/saved/SavedHikesScreen.kt index c8515db30..db9bd2cba 100644 --- a/app/src/main/java/ch/hikemate/app/ui/saved/SavedHikesScreen.kt +++ b/app/src/main/java/ch/hikemate/app/ui/saved/SavedHikesScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -44,6 +45,8 @@ import ch.hikemate.app.ui.navigation.LIST_TOP_LEVEL_DESTINATIONS import ch.hikemate.app.ui.navigation.NavigationActions import ch.hikemate.app.ui.navigation.Route import ch.hikemate.app.ui.navigation.Screen +import ch.hikemate.app.ui.theme.primaryColor +import ch.hikemate.app.utils.humanReadablePlannedLabel import kotlinx.coroutines.flow.StateFlow object SavedHikesScreen { @@ -162,7 +165,7 @@ private fun PlannedHikes(hikes: List>?, hikesViewModel: HikesVie Column(modifier = Modifier.fillMaxSize()) { Text( context.getString(R.string.saved_hikes_screen_planned_section_title), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.padding(16.dp).testTag(SavedHikesScreen.TEST_TAG_SAVED_HIKES_PLANNED_TITLE)) @@ -182,7 +185,7 @@ private fun PlannedHikes(hikes: List>?, hikesViewModel: HikesVie LazyColumn { items(plannedHikes.size, key = { plannedHikes[it].value.id }) { index -> val hike by plannedHikes[index].collectAsState() - SavedHikeCardFor(hike, hikesViewModel) + SavedHikeCardFor(hike, hikesViewModel, true) } } } @@ -198,7 +201,7 @@ private fun SavedHikes(savedHikes: List>?, hikesViewModel: Hikes Column(modifier = Modifier.fillMaxSize()) { Text( context.getString(R.string.saved_hikes_screen_saved_section_title), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineLarge, modifier = Modifier.padding(16.dp).testTag(SavedHikesScreen.TEST_TAG_SAVED_HIKES_SAVED_TITLE)) @@ -215,7 +218,7 @@ private fun SavedHikes(savedHikes: List>?, hikesViewModel: Hikes LazyColumn { items(savedHikes.size, key = { savedHikes[it].value.id }) { index -> val hike by savedHikes[index].collectAsState() - SavedHikeCardFor(hike, hikesViewModel) + SavedHikeCardFor(hike, hikesViewModel, false) } } } @@ -223,7 +226,7 @@ private fun SavedHikes(savedHikes: List>?, hikesViewModel: Hikes } @Composable -private fun SavedHikeCardFor(hike: Hike, hikesViewModel: HikesViewModel) { +private fun SavedHikeCardFor(hike: Hike, hikesViewModel: HikesViewModel, displayDate: Boolean) { // This variable contains the current state of the hike's elevation data. It can be: // - null: the elevation data is not available yet // - emptyList(): the elevation data is not available because of an error @@ -241,13 +244,32 @@ private fun SavedHikeCardFor(hike: Hike, hikesViewModel: HikesViewModel) { elevation = hike.elevation.getOrNull() } + // Only display the planned date text if needed + val messageContent: String? + val messageIcon: Painter? + val messageColor: Color? + if (displayDate) { + messageContent = hike.plannedDate?.humanReadablePlannedLabel(LocalContext.current) + messageIcon = painterResource(R.drawable.calendar_today) + messageColor = primaryColor + } else { + messageContent = null + messageIcon = null + messageColor = null + } + // Display the hike card HikeCard( title = hike.name ?: stringResource(R.string.map_screen_hike_title_default), elevationData = elevation, onClick = { hikesViewModel.selectHike(hike.id) }, modifier = Modifier.testTag(SavedHikesScreen.TEST_TAG_SAVED_HIKES_HIKE_CARD), - styleProperties = HikeCardStyleProperties(graphColor = Color(hike.getColor()))) + messageContent = messageContent, + styleProperties = + HikeCardStyleProperties( + messageIcon = messageIcon, + messageColor = messageColor, + graphColor = Color(hike.getColor()))) } @Composable diff --git a/app/src/main/java/ch/hikemate/app/ui/theme/Color.kt b/app/src/main/java/ch/hikemate/app/ui/theme/Color.kt index 06f4f2c9e..5787ad722 100644 --- a/app/src/main/java/ch/hikemate/app/ui/theme/Color.kt +++ b/app/src/main/java/ch/hikemate/app/ui/theme/Color.kt @@ -3,6 +3,17 @@ package ch.hikemate.app.ui.theme import androidx.compose.ui.graphics.Color val primaryColor = Color(0xFF3B9DE8) +val onPrimaryColor = Color.White + +val secondaryColor = Color.White +val onSecondaryColor = Color.Black + +// For destructive actions like delete +val tertiary = Color(0xFFEF5350) + +val hikeDifficultyEasyColor = Color(0xFF1B9E4D) +val hikeDifficultyModerateColor = Color(0xFFFCCA03) +val hikeDifficultyDifficultColor = Color(0xFFF74E05) val suitableColor = Color(0xFF4CAF50) val challengingColor = Color(0xFFFFC107) diff --git a/app/src/main/java/ch/hikemate/app/ui/theme/Theme.kt b/app/src/main/java/ch/hikemate/app/ui/theme/Theme.kt index 71b9845ad..b8f417718 100644 --- a/app/src/main/java/ch/hikemate/app/ui/theme/Theme.kt +++ b/app/src/main/java/ch/hikemate/app/ui/theme/Theme.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -private val colorScheme = lightColorScheme(primary = primaryColor) +private val colorScheme = lightColorScheme(primary = primaryColor, tertiary = tertiary) @Composable fun HikeMateTheme( @@ -27,8 +27,9 @@ fun HikeMateTheme( when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) - else dynamicLightColorScheme(context).copy(primary = primaryColor) + if (darkTheme) + dynamicDarkColorScheme(context).copy(primary = primaryColor, tertiary = tertiary) + else dynamicLightColorScheme(context).copy(primary = primaryColor, tertiary = tertiary) } else -> colorScheme } diff --git a/app/src/main/java/ch/hikemate/app/ui/theme/Type.kt b/app/src/main/java/ch/hikemate/app/ui/theme/Type.kt index 3b21f106b..2dca4ea75 100644 --- a/app/src/main/java/ch/hikemate/app/ui/theme/Type.kt +++ b/app/src/main/java/ch/hikemate/app/ui/theme/Type.kt @@ -1,6 +1,7 @@ package ch.hikemate.app.ui.theme import androidx.compose.material3.Typography +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -8,32 +9,31 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import ch.hikemate.app.R +val kaushanTitleFontFamily = FontFamily(Font(R.font.kaushan_script, FontWeight.Bold)) + // Set of Material typography styles to start with val Typography = Typography( + displayLarge = + TextStyle( + color = Color.White, + fontFamily = kaushanTitleFontFamily, + fontSize = 60.sp, + fontWeight = FontWeight.Bold, + ), + headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp), + headlineSmall = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp), bodyLarge = TextStyle( - fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ - ) - -val kaushanTitleFontFamily = FontFamily(Font(R.font.kaushan_script, FontWeight.Bold)) + ), + bodyMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp), + labelLarge = + TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ), + titleLarge = TextStyle(fontWeight = FontWeight(600), fontSize = 20.sp), + titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp), + ) diff --git a/app/src/main/java/ch/hikemate/app/utils/MapUtils.kt b/app/src/main/java/ch/hikemate/app/utils/MapUtils.kt index dd9f5a6df..15c633658 100644 --- a/app/src/main/java/ch/hikemate/app/utils/MapUtils.kt +++ b/app/src/main/java/ch/hikemate/app/utils/MapUtils.kt @@ -3,23 +3,32 @@ package ch.hikemate.app.utils import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Paint import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.location.Location import android.util.Log import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import ch.hikemate.app.R +import ch.hikemate.app.model.facilities.FacilitiesViewModel import ch.hikemate.app.model.facilities.Facility import ch.hikemate.app.model.facilities.FacilityType.Companion.mapFacilityTypeToDrawable import ch.hikemate.app.model.route.Bounds +import ch.hikemate.app.model.route.DetailedHike import ch.hikemate.app.model.route.LatLong +import ch.hikemate.app.ui.map.HikeDetailScreen import ch.hikemate.app.ui.map.MapInitialValues import ch.hikemate.app.ui.map.MapScreen import kotlin.math.cos import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import org.osmdroid.events.MapListener import org.osmdroid.events.ScrollEvent import org.osmdroid.events.ZoomEvent @@ -31,8 +40,9 @@ import org.osmdroid.views.overlay.Polyline object MapUtils { private const val LOG_TAG = "MapUtils" - const val MIN_DISTANCE_BETWEEN_FACILITIES = 15 + private const val MIN_DISTANCE_BETWEEN_FACILITIES = 15 const val FACILITIES_RELATED_OBJECT_NAME = "facility_marker" + const val ROUTE_PRIORITY_DISPLAY = 0 /** * Shows a hike on the map. @@ -40,12 +50,14 @@ object MapUtils { * @param mapView The map view where the hike will be shown. * @param waypoints The points that compose the line to show on the map. * @param color The color of the hike. + * @param withMarker Whether to show a marker at the starting point of the hike. * @param onLineClick To be called when the line on the map is clicked. */ fun showHikeOnMap( mapView: MapView, waypoints: List, color: Int, + withMarker: Boolean = false, onLineClick: () -> Unit, ) { val line = Polyline() @@ -59,25 +71,28 @@ object MapUtils { true } - val startingMarker = - Marker(mapView).apply { - // Dynamically create the custom icon - icon = - createCircularIcon( - context = mapView.context, - fillColor = color, - ) - - position = GeoPoint(waypoints.first().lat, waypoints.first().lon) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - setOnMarkerClickListener({ _, _ -> - onLineClick() - true - }) - } + // The index provides the lowest priority so that the facilities and other overlays + // are always displayed on top of it. + mapView.overlays.add(ROUTE_PRIORITY_DISPLAY, line) - mapView.overlays.add(line) - mapView.overlays.add(startingMarker) + if (withMarker) { + val startingMarker = + Marker(mapView).apply { + // Dynamically create the custom icon + icon = + AppCompatResources.getDrawable(mapView.context, R.drawable.location_on)?.apply { + setTint(color) + } + position = GeoPoint(waypoints.first().lat, waypoints.first().lon) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setOnMarkerClickListener({ _, _ -> + onLineClick() + true + }) + } + mapView.overlays.add(startingMarker) + } + mapView.invalidate() } /** @@ -213,41 +228,6 @@ object MapUtils { return BitmapDrawable(context.resources, bitmap) } - /** - * Creates a circular icon with a fill color and a stroke color. The icon is used to represent the - * starting point of a hike on the map. - * - * @param context The context where the icon will be used - * @param fillColor The color to fill the circle - * @return The circular icon with the specified fill and stroke colors - */ - private fun createCircularIcon(context: Context, fillColor: Int): BitmapDrawable { - // Create a mutable bitmap - val bitmap = - Bitmap.createBitmap( - MapScreen.HIKE_STARTING_MARKER_ICON_SIZE, - MapScreen.HIKE_STARTING_MARKER_ICON_SIZE, - Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - - // Paint for fill color - val fillPaint = - Paint().apply { - style = Paint.Style.FILL - color = fillColor - isAntiAlias = true - } - - // Calculate center and radius - val center = MapScreen.HIKE_STARTING_MARKER_ICON_SIZE / 2f - - // Draw the circle - canvas.drawCircle(center, center, center, fillPaint) // Filled circle - - // Convert bitmap to drawable - return BitmapDrawable(context.resources, bitmap) - } - /** * Draws a new marker on the map representing the user's position. The previous marker is cleared * to avoid duplicates. The map is invalidated to redraw the map with the new marker. @@ -379,15 +359,24 @@ object MapUtils { * @see [displayFacilities] */ fun clearFacilities(mapView: MapView) { - mapView.overlays.removeAll { overlay -> - // The relatedObject was defined in displayFacilities for easier removal of the markers. - overlay is Marker && overlay.relatedObject == FACILITIES_RELATED_OBJECT_NAME + // Keep track of removed overlays to avoid concurrent modification + val overlaysToRemove = + mapView.overlays.filter { overlay -> + overlay is Marker && overlay.relatedObject == FACILITIES_RELATED_OBJECT_NAME + } + + mapView.overlays.removeAll(overlaysToRemove) + + // Force garbage collection of markers + overlaysToRemove.forEach { overlay -> + if (overlay is Marker) { + overlay.onDetach(mapView) + } } - // Trigger the map to be drawn again + // Clear the MapView's cache mapView.invalidate() } - /** * Utility function designed to set the mapView listener which updates the state of the * BoundingBox and the ZoomLevel @@ -398,8 +387,8 @@ object MapUtils { */ fun setMapViewListenerForStates( mapView: MapView, - boundingBoxState: MutableStateFlow, - zoomLevelState: MutableStateFlow + boundingBoxState: MutableStateFlow, + zoomLevelState: MutableStateFlow ) { // Update the map listener to just update the StateFlows mapView.addMapListener( @@ -444,4 +433,140 @@ object MapUtils { val center: GeoPoint = MapInitialValues().mapInitialCenter, val zoom: Double = MapInitialValues().mapInitialZoomLevel, ) + + /** + * This LaunchedEffect is used for the flow that updates the facilities for the current state of + * the MapView. It uses a combinedFlow to make debounced updates to the facilities + * + * @param mapView + * @param boundingBoxState + * @param zoomLevelState + * @param facilitiesViewModel + * @param hike + * @param context + */ + @OptIn(FlowPreview::class) + @Composable + fun LaunchedEffectFacilitiesDisplay( + mapView: MapView, + boundingBoxState: MutableStateFlow, + zoomLevelState: MutableStateFlow, + facilitiesViewModel: FacilitiesViewModel, + hike: DetailedHike, + context: Context + ) { + LaunchedEffect(Unit) { + setMapViewListenerForStates(mapView, boundingBoxState, zoomLevelState) + + // Create our combined flow which is limited by a debounce + val combinedFlow = + combine( + boundingBoxState.debounce(HikeDetailScreen.DEBOUNCE_DURATION), + zoomLevelState.debounce(HikeDetailScreen.DEBOUNCE_DURATION)) { boundingBox, zoomLevel + -> + boundingBox to zoomLevel + } + + try { + combinedFlow.collectLatest { (boundingBox, zoomLevel) -> + if (boundingBoxState.value == null || + zoomLevelState.value == null || + mapView.repository == null) + return@collectLatest + facilitiesViewModel.filterFacilitiesForDisplay( + bounds = boundingBox!!, + zoomLevel = zoomLevel!!, + hikeRoute = hike, + onSuccess = { newFacilities -> + clearFacilities(mapView) + if (newFacilities.isNotEmpty()) { + displayFacilities(newFacilities, mapView, context) + } + }, + onNoFacilitiesForState = { clearFacilities(mapView) }) + } + } catch (e: Exception) { + Log.e(HikeDetailScreen.LOG_TAG, "Error in facility updates flow", e) + } + } + } + + /** + * This LaunchedEffect used for the HikeDetails and RunHike screens is the one that sets the first + * display of the facilities. + * + * @param facilities + * @param shouldLoadFacilities + * @param mapView + * @param facilitiesViewModel + * @param hike + * @param context + * @return the value of the shouldLoadFacilities + */ + @Composable + fun launchedEffectLoadingOfFacilities( + facilities: List?, + shouldLoadFacilities: Boolean, + mapView: MapView, + facilitiesViewModel: FacilitiesViewModel, + hike: DetailedHike, + context: Context + ): Boolean { + var shouldLoadFacilitiesCopy = shouldLoadFacilities + LaunchedEffect(facilities, shouldLoadFacilitiesCopy) { + if (facilities != null && mapView.repository != null) { + facilitiesViewModel.filterFacilitiesForDisplay( + bounds = mapView.boundingBox, + zoomLevel = mapView.zoomLevelDouble, + hikeRoute = hike, + onSuccess = { newFacilities -> + clearFacilities(mapView) + if (newFacilities.isNotEmpty()) { + displayFacilities(newFacilities, mapView, context) + } + }, + onNoFacilitiesForState = { clearFacilities(mapView) }) + // Reset the flag after loading + shouldLoadFacilitiesCopy = false + } + } + return shouldLoadFacilitiesCopy + } + + /** + * Handles mapview first layout listener. + * + * @param mapView + * @param hike + * @param boundingBoxState + * @param zoomLevelState + * @param withBoundLimits whether or not to limit the Bounds of the Hike. + */ + @Composable + fun LaunchedEffectMapviewListener( + mapView: MapView, + hike: DetailedHike, + boundingBoxState: MutableStateFlow, + zoomLevelState: MutableStateFlow, + withBoundLimits: Boolean = true + ) { + mapView.addOnFirstLayoutListener { _, _, _, _, _ -> + // Limit the vertical scrollable area to avoid the user scrolling too far from the hike + if (withBoundLimits) { + mapView.setScrollableAreaLimitLatitude( + min(MapScreen.MAP_MAX_LATITUDE, mapView.boundingBox.latNorth), + max(MapScreen.MAP_MIN_LATITUDE, mapView.boundingBox.latSouth), + HikeDetailScreen.MAP_BOUNDS_MARGIN) + if (hike.bounds.maxLon < HikeDetailScreen.MAP_MAX_LONGITUDE || + hike.bounds.minLon > HikeDetailScreen.MAP_MIN_LONGITUDE) { + mapView.setScrollableAreaLimitLongitude( + max(HikeDetailScreen.MAP_MIN_LONGITUDE, mapView.boundingBox.lonWest), + min(HikeDetailScreen.MAP_MAX_LONGITUDE, mapView.boundingBox.lonEast), + HikeDetailScreen.MAP_BOUNDS_MARGIN) + } + boundingBoxState.value = mapView.boundingBox + zoomLevelState.value = mapView.zoomLevelDouble + } + } + } } diff --git a/app/src/main/java/ch/hikemate/app/utils/RouteUtils.kt b/app/src/main/java/ch/hikemate/app/utils/RouteUtils.kt index 47392ada0..fb4ce8b2a 100644 --- a/app/src/main/java/ch/hikemate/app/utils/RouteUtils.kt +++ b/app/src/main/java/ch/hikemate/app/utils/RouteUtils.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking object RouteUtils { + const val METERS_PER_KIlOMETER = 1000 /** * Helper function to calculate the elevation gain based on a list of elevations. * @@ -33,7 +34,8 @@ object RouteUtils { * @return The total distance of the hike in kilometers as a `Double`. */ fun computeTotalDistance(ways: List): Double { - return ways.zipWithNext { point1, point2 -> point1.distanceTo(point2) }.sum() / 1000 + return ways.zipWithNext { point1, point2 -> point1.distanceTo(point2) }.sum() / + METERS_PER_KIlOMETER } /** diff --git a/app/src/main/res/drawable/location_on.xml b/app/src/main/res/drawable/location_on.xml new file mode 100644 index 000000000..31bafca6f --- /dev/null +++ b/app/src/main/res/drawable/location_on.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 49c13a9b1..a7caaed5d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,9 +3,4 @@ #FF3B9DE8 #FF000000 #FFFFFFFF - #FFE83B3D - - #1B9E4D - #FCCA03 - #F74E05 \ No newline at end of file diff --git a/app/src/main/res/values/guide.xml b/app/src/main/res/values/guide.xml index 638f57136..ae4f7eaf2 100644 --- a/app/src/main/res/values/guide.xml +++ b/app/src/main/res/values/guide.xml @@ -17,7 +17,7 @@ Find hike routes near you and navigate through your adventures with HikeMate\'s location features:\n\n1. Getting Started\n• When first using HikeMate, tap the location button ⊕ at the bottom of the map\n• You\'ll be asked to grant location permissions this helps find hikes near you\n• Once accepted, your position will appear as a blue dot on the map\n\n2. Using Location Features\n• Tap the location button anytime to center the map on your position\n• Your location updates in real-time as you move\n• Use this feature to track your progress while following a hike route\n\nThis feature helps you both discover nearby hikes and stay on track during your adventures! 👤 Your HikeMate Profile - Personalize your hiking experience with your HikeMate profile:\n\n1. Hiking Level\n• Choose your hiking level: Beginner, Intermediate, or Expert\n• HikeMate uses this to recommend appropriate hike routes\n• Green checkmark indicates a hike suitable for your level\n• Yellow warning shows when a hike might be challenging\n\n2. Adjusting Your Level\n• Tap the Edit Profile button to change your hiking level\n• Update it as you gain more experience\n• Feel free to change it anytime to match your current abilities\n\nPro Tip: 🎯 Start with a comfortable level and gradually increase it as you complete more challenging hikes! + Personalize your hiking experience with your HikeMate profile:\n\n1. Hiking Level\n• Choose your hiking level: Beginner, Amateur, or Expert\n• HikeMate uses this to recommend appropriate hike routes\n• Green checkmark indicates a hike suitable for your level\n• Yellow warning shows when a hike might be challenging\n\n2. Adjusting Your Level\n• Tap the Edit Profile button to change your hiking level\n• Update it as you gain more experience\n• Feel free to change it anytime to match your current abilities\n\nPro Tip: 🎯 Start with a comfortable level and gradually increase it as you complete more challenging hikes! 🥾 Hiking Guide diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bf2fd60e..00f9b6079 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,8 +31,8 @@ No data - Menu Search hikes here + Search here Error while searching for hikes No hikes found in the displayed area.\n\nTry zooming out or moving the map to see more hikes. @@ -84,9 +84,6 @@ An error was encountered while loading the hike\'s elevation. - Loading hike data… - Loading hike elevation… - Last computations… Unsave hike Save hike Distance @@ -111,7 +108,7 @@ 0km %.2fkm %d%% complete - - + You\'re too far from the hike! Current Elevation %dm - @@ -127,16 +124,11 @@ Moderate Difficult - Saved Planned Loading saved hikes… - - An error occurred while loading saved hikes. - Please check your Internet connection and try again. - Refresh Saved hikes No saved hikes to display @@ -159,7 +151,7 @@ Beginner - Intermediate + Amateur Expert @@ -168,7 +160,7 @@ Email address Hiking level You are a beginner hiker! - You are an intermediate hiker! + You are an amateur hiker! You are an expert hiker! Joined HikeMate on Edit profile @@ -186,6 +178,7 @@ Nature image, blurred Sign in with Email Sign in with Google + Retry Google sign-in Sign in Email @@ -202,6 +195,7 @@ Passwords do not match Invalid email format All fields must be filled + Password must have at least six characters. Email and password must not be empty An error occurred while creating the account @@ -227,11 +221,10 @@ Error deleting user profile and saved hikes Error re-authenticating the user No user is currently signed in + Delete Account Retry - - Delete Account - Connected Google Account to your device successfully. Please wait while we retry the signup. - \ No newline at end of file + An error occurred. Check your internet connection. The operation might not be saved. + diff --git a/app/src/test/java/ch/hikemate/app/model/profile/ProfileRepositoryFirestoreTest.kt b/app/src/test/java/ch/hikemate/app/model/profile/ProfileRepositoryFirestoreTest.kt index fcc6b9e2d..8edcd39bd 100644 --- a/app/src/test/java/ch/hikemate/app/model/profile/ProfileRepositoryFirestoreTest.kt +++ b/app/src/test/java/ch/hikemate/app/model/profile/ProfileRepositoryFirestoreTest.kt @@ -45,7 +45,7 @@ class ProfileRepositoryFirestoreTest { id = "1", name = "John Doe", email = "john.doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp(1609459200, 0)) @Before @@ -109,7 +109,7 @@ class ProfileRepositoryFirestoreTest { `when`(mockDocumentSnapshot.id).thenReturn("1") `when`(mockDocumentSnapshot.getString("name")).thenReturn("John Doe") `when`(mockDocumentSnapshot.getString("email")).thenReturn("john.doe@gmail.com") - `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("INTERMEDIATE") + `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("AMATEUR") `when`(mockDocumentSnapshot.getTimestamp("joinedDate")).thenReturn(testTimestamp) val profile = repository.documentToProfile(mockDocumentSnapshot) @@ -117,7 +117,7 @@ class ProfileRepositoryFirestoreTest { assert(profile!!.id == "1") assert(profile.name == "John Doe") assert(profile.email == "john.doe@gmail.com") - assert(profile.hikingLevel == HikingLevel.INTERMEDIATE) + assert(profile.hikingLevel == HikingLevel.AMATEUR) assert(profile.joinedDate == testTimestamp) } @@ -126,7 +126,7 @@ class ProfileRepositoryFirestoreTest { `when`(mockDocumentSnapshot.id).thenReturn("1") `when`(mockDocumentSnapshot.getString("name")).thenReturn("John Doe") `when`(mockDocumentSnapshot.getString("email")).thenThrow(RuntimeException("No email field")) - `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("INTERMEDIATE") + `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("AMATEUR") `when`(mockDocumentSnapshot.getTimestamp("joinedDate")).thenReturn(Timestamp(1609459200, 0)) val profile = repository.documentToProfile(mockDocumentSnapshot) @@ -160,7 +160,7 @@ class ProfileRepositoryFirestoreTest { `when`(mockDocumentSnapshot.id).thenReturn("1") `when`(mockDocumentSnapshot.getString("name")).thenReturn("John Doe") `when`(mockDocumentSnapshot.getString("email")).thenReturn("john.doe@gmail.com") - `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("INTERMEDIATE") + `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("AMATEUR") `when`(mockDocumentSnapshot.getTimestamp("joinedDate")).thenReturn(Timestamp(1609459200, 0)) // Simulate the task being completed @@ -190,7 +190,7 @@ class ProfileRepositoryFirestoreTest { `when`(mockDocumentSnapshot.id).thenReturn("1") `when`(mockDocumentSnapshot.getString("name")).thenReturn("John Doe") `when`(mockDocumentSnapshot.getString("email")).thenThrow(RuntimeException("No email field")) - `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("INTERMEDIATE") + `when`(mockDocumentSnapshot.getString("hikingLevel")).thenReturn("AMATEUR") `when`(mockDocumentSnapshot.getTimestamp("joinedDate")).thenReturn(Timestamp(1609459200, 0)) // Simulate the task being completed diff --git a/app/src/test/java/ch/hikemate/app/model/profile/ProfileViewModelTest.kt b/app/src/test/java/ch/hikemate/app/model/profile/ProfileViewModelTest.kt index f23d1b88e..604d02fb4 100644 --- a/app/src/test/java/ch/hikemate/app/model/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/ch/hikemate/app/model/profile/ProfileViewModelTest.kt @@ -31,7 +31,7 @@ class ProfileViewModelTest { id = "1", name = "John Doe", email = "john.doe@gmail.com", - hikingLevel = HikingLevel.INTERMEDIATE, + hikingLevel = HikingLevel.AMATEUR, joinedDate = Timestamp(1609459200, 0)) @Mock private lateinit var repository: ProfileRepository diff --git a/app/src/test/java/ch/hikemate/app/model/route/DetailedHikeRouteTest.kt b/app/src/test/java/ch/hikemate/app/model/route/DetailedHikeRouteTest.kt deleted file mode 100644 index f78409598..000000000 --- a/app/src/test/java/ch/hikemate/app/model/route/DetailedHikeRouteTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -import ch.hikemate.app.model.elevation.ElevationRepository -import ch.hikemate.app.model.route.Bounds -import ch.hikemate.app.model.route.DetailedHikeRoute -import ch.hikemate.app.model.route.HikeRoute -import ch.hikemate.app.model.route.LatLong -import ch.hikemate.app.utils.RouteUtils -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.CompletableDeferred -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -class DetailedHikeRouteTest { - - private lateinit var mockElevationRepository: ElevationRepository - private lateinit var mockDeferred: CompletableDeferred - private var mockElevations = listOf(0.0) - - @Before - fun setUp() { - mockElevationRepository = mockk() - mockDeferred = mockk() - - coEvery { - mockElevationRepository.getElevation( - any(), - any(), - any(), - ) - } answers - { - val onSuccess = secondArg<(List) -> Unit>() - onSuccess(mockElevations) - } - } - - @Test - fun testCreateDetailHikeRoute() { - val hikeRoute = - HikeRoute( - id = "1", - name = "Test Hike", - description = "This is a test hike", - bounds = Bounds(1.0, 2.0, 3.0, 4.0), - ways = listOf(LatLong(1.0, 2.0), LatLong(2.0, 3.0), LatLong(3.0, 4.0))) - - val detailedHikeRoute = DetailedHikeRoute.create(hikeRoute, mockElevationRepository) - - // assert the rest of the data is still accessible as expected - assertEquals(hikeRoute, detailedHikeRoute.route) - - // assert detail calculations are correct - val expectedTotalDistance = RouteUtils.computeTotalDistance(hikeRoute.ways) - val expectedElevationGain = RouteUtils.calculateElevationGain(mockElevations) - val expectedEstimatedTime = - RouteUtils.estimateTime(expectedTotalDistance, expectedElevationGain) - val expectedDifficulty = - RouteUtils.determineDifficulty(expectedTotalDistance, expectedElevationGain) - - assertEquals(hikeRoute, detailedHikeRoute.route) - - assertEquals(expectedTotalDistance, detailedHikeRoute.totalDistance, 1.0) - assertEquals(expectedElevationGain, detailedHikeRoute.elevationGain, 0.0001) - assertEquals(expectedEstimatedTime, detailedHikeRoute.estimatedTime, 1.0) - assertEquals(expectedDifficulty, detailedHikeRoute.difficulty) - } -} diff --git a/app/src/test/java/ch/hikemate/app/model/route/HikesViewModelTest.kt b/app/src/test/java/ch/hikemate/app/model/route/HikesViewModelTest.kt index 234217931..48c111207 100644 --- a/app/src/test/java/ch/hikemate/app/model/route/HikesViewModelTest.kt +++ b/app/src/test/java/ch/hikemate/app/model/route/HikesViewModelTest.kt @@ -455,7 +455,7 @@ class HikesViewModelTest { } @Test - fun `refreshSavedHikesCache clears selected hike if it is unloaded`() = + fun `refreshSavedHikesCache flags selected hike as unloaded`() = runTest(dispatcher) { // Load some hikes to be selected loadSavedHikes(singleSavedHike1) @@ -473,8 +473,24 @@ class HikesViewModelTest { // Refresh the saved hikes cache hikesViewModel.refreshSavedHikesCache() - // Check that the selected hike is now unselected + // The selected hike should still be loaded anyway + assertEquals(1, hikesViewModel.hikeFlows.value.size) + assertEquals(hikeId, hikesViewModel.hikeFlows.value[0].value.id) + assertFalse(hikesViewModel.hikeFlows.value[0].value.isSaved) + + // The selected hike should still be selected, but is not saved anymore + assertNotNull(hikesViewModel.selectedHike.value) + assertFalse(hikesViewModel.selectedHike.value!!.isSaved) + assertNull(hikesViewModel.selectedHike.value!!.plannedDate) + + // Unselect the hike + hikesViewModel.unselectHike() + + // The selected hike should now be null assertNull(hikesViewModel.selectedHike.value) + + // The selected hike should be unloaded when unselected + assertEquals(0, hikesViewModel.hikeFlows.value.size) } // ========================================================================== @@ -589,7 +605,7 @@ class HikesViewModelTest { } @Test - fun `loadSavedHikes clears selected hike if it is unloaded`() = + fun `loadSavedHikes flags selected hike as unloaded`() = runTest(dispatcher) { // Load some hikes to be selected loadSavedHikes(singleSavedHike1) @@ -607,8 +623,24 @@ class HikesViewModelTest { // Load the saved hikes hikesViewModel.loadSavedHikes() - // Check that the selected hike is now unselected + // The selected hike should still be loaded anyway + assertEquals(1, hikesViewModel.hikeFlows.value.size) + assertEquals(hikeId, hikesViewModel.hikeFlows.value[0].value.id) + assertFalse(hikesViewModel.hikeFlows.value[0].value.isSaved) + + // The selected hike should still be selected, but is not saved anymore + assertNotNull(hikesViewModel.selectedHike.value) + assertFalse(hikesViewModel.selectedHike.value!!.isSaved) + assertNull(hikesViewModel.selectedHike.value!!.plannedDate) + + // Unselect the hike + hikesViewModel.unselectHike() + + // The selected hike should now be null assertNull(hikesViewModel.selectedHike.value) + + // The selected hike should be unloaded when unselected + assertEquals(0, hikesViewModel.hikeFlows.value.size) } // ========================================================================== @@ -856,7 +888,7 @@ class HikesViewModelTest { } @Test - fun `unsaveHike clears selected hike if it is unloaded`() = + fun `unsaveHike flags selected hike as unloaded`() = runTest(dispatcher) { // Load some hikes to be selected loadSavedHikes(singleSavedHike1) @@ -874,8 +906,24 @@ class HikesViewModelTest { // Unsave the selected hike hikesViewModel.unsaveHike(hikeId) - // Check that the selected hike is now unselected + // The selected hike should still be loaded anyway + assertEquals(1, hikesViewModel.hikeFlows.value.size) + assertEquals(hikeId, hikesViewModel.hikeFlows.value[0].value.id) + assertFalse(hikesViewModel.hikeFlows.value[0].value.isSaved) + + // The selected hike should still be selected, but is not saved anymore + assertNotNull(hikesViewModel.selectedHike.value) + assertFalse(hikesViewModel.selectedHike.value!!.isSaved) + assertNull(hikesViewModel.selectedHike.value!!.plannedDate) + + // Unselect the hike + hikesViewModel.unselectHike() + + // The selected hike should now be null assertNull(hikesViewModel.selectedHike.value) + + // The selected hike should be unloaded when unselected + assertEquals(0, hikesViewModel.hikeFlows.value.size) } @Test @@ -1309,13 +1357,14 @@ class HikesViewModelTest { } @Test - fun `loadHikesInBounds clears the selected hike if it is unloaded`() = + fun `loadHikesInBounds flags the selected hike as unloaded`() = runTest(dispatcher) { // Load some hikes to be selected loadSavedHikes(singleSavedHike1) // Select the hike to be updated - hikesViewModel.selectHike(singleSavedHike1[0].id) + val hikeId = singleSavedHike1[0].id + hikesViewModel.selectHike(hikeId) // Check that the hike is selected assertNotNull(hikesViewModel.selectedHike.value) assertEquals(singleSavedHike1[0].id, hikesViewModel.selectedHike.value?.id) @@ -1330,8 +1379,25 @@ class HikesViewModelTest { // Load hikes in bounds hikesViewModel.loadHikesInBounds(BoundingBox(0.0, 0.0, 0.0, 0.0)) - // Check that the selected hike is now unselected + // The selected hike should still be loaded anyway + assertEquals(doubleOsmHikes1.size + 1, hikesViewModel.hikeFlows.value.size) + assertNotNull( + hikesViewModel.hikeFlows.value.find { it.value.id == hikeId && it.value.isSaved }) + + // The selected hike should still be selected, but is not saved anymore + assertNotNull(hikesViewModel.selectedHike.value) + assertTrue(hikesViewModel.selectedHike.value!!.isSaved) + assertNull(hikesViewModel.selectedHike.value!!.plannedDate) + + // Unselect the hike + hikesViewModel.unselectHike() + + // The selected hike should now be null assertNull(hikesViewModel.selectedHike.value) + + // The selected hike should be unloaded when unselected + assertEquals(doubleOsmHikes1.size, hikesViewModel.hikeFlows.value.size) + assertNull(hikesViewModel.hikeFlows.value.find { it.value.id == hikeId }) } // ========================================================================== diff --git a/app/src/test/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModelTest.kt b/app/src/test/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModelTest.kt deleted file mode 100644 index b1ce7efb0..000000000 --- a/app/src/test/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModelTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package ch.hikemate.app.model.route - -import ch.hikemate.app.model.elevation.ElevationRepository -import ch.hikemate.app.model.extensions.toBounds -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.setMain -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.osmdroid.util.BoundingBox - -/** Testing the ListOfRoutesViewModel class */ -class ListOfHikeRoutesViewModelTest { - private lateinit var hikesRepository: HikeRoutesRepository - private lateinit var elevationRepository: ElevationRepository - private lateinit var listOfHikeRoutesViewModel: ListOfHikeRoutesViewModel - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - - hikesRepository = mock(HikeRoutesRepository::class.java) - elevationRepository = mock(ElevationRepository::class.java) - listOfHikeRoutesViewModel = - ListOfHikeRoutesViewModel(hikesRepository, elevationRepository, UnconfinedTestDispatcher()) - } - - @Test - fun canBeCreatedAsFactory() { - val factory = ListOfHikeRoutesViewModel.Factory - val viewModel = factory.create(ListOfHikeRoutesViewModel::class.java) - assertNotNull(viewModel) - } - - @Test - fun getRoutesWithoutBoundingBoxDoesNotCallRepository() { - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutes() - verify(hikesRepository, times(0)).getRoutes(any(), any(), any()) - } - - @Test - fun getRoutesWithBoundingBoxCallsRepositoryWithCorrectBounds() { - // Calling getRoutes() without setting a bounding box won't call repo, as tested above - val providedBounds = BoundingBox(0.0, 0.0, 0.0, 0.0) - listOfHikeRoutesViewModel.setArea(providedBounds) - - `when`(hikesRepository.getRoutes(any(), any(), any())).thenAnswer { - val bounds = it.getArgument(0) - assertEquals(bounds, providedBounds.toBounds()) - } - - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutes() - - verify(hikesRepository, times(2)).getRoutes(eq(providedBounds.toBounds()), any(), any()) - } - - @Test - fun getRoutesUpdatesHikeRoutes() { - // Calling getRoutes() without setting a bounding box won't call repo, as tested above - listOfHikeRoutesViewModel.setArea(BoundingBox(0.0, 0.0, 0.0, 0.0)) - - `when`(hikesRepository.getRoutes(any(), any(), any())).thenAnswer { - val onSuccess = it.getArgument<(List) -> Unit>(1) - onSuccess(listOf(HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList()))) - } - - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutes() - - assertEquals(1, listOfHikeRoutesViewModel.hikeRoutes.value.size) - } - - @Test - fun getRouteElevationCallsElevationRepository() { - val route = HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList()) - listOfHikeRoutesViewModel.getRoutesElevation(route) - verify(elevationRepository, times(1)).getElevation(any(), any(), any()) - } - - @Test - fun getRouteElevationReturnsCorrectElevation() { - val route = HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList()) - `when`(elevationRepository.getElevation(any(), any(), any())).thenAnswer { - val onSuccess = it.getArgument<(List) -> Unit>(1) - onSuccess(listOf(1.0, 2.0, 3.0)) - } - - listOfHikeRoutesViewModel.getRoutesElevation( - route, - { - val elevationData = it - assertEquals(3, elevationData.size) - assertEquals(1.0, elevationData[0], 0.0) - assertEquals(2.0, elevationData[1], 0.0) - assertEquals(3.0, elevationData[2], 0.0) - }, - { fail("Should not have failed") }) - } - - @Test - fun getRouteElevationCallsOnFailure() { - val route = HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList()) - `when`(elevationRepository.getElevation(any(), any(), any())).thenAnswer { - val onFailure = it.getArgument<(Exception) -> Unit>(2) - onFailure(Exception("Test exception")) - } - - listOfHikeRoutesViewModel.getRoutesElevation( - route, - { fail("Should not have succeeded") }, - { // Should be called - }) - - // Verify that the onFailure function was called - verify(elevationRepository, times(1)).getElevation(any(), any(), any()) - } - - @Test - fun canSelectRoute() { - listOfHikeRoutesViewModel.selectRoute( - HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList())) - val route = listOfHikeRoutesViewModel.selectedHikeRoute.value - assertNotNull(route) - assertEquals(route!!.id, "Route 1") - assertEquals(route.bounds, Bounds(0.0, 0.0, 0.0, 0.0)) - assertEquals(route.ways.size, 0) - assertEquals(route.ways, emptyList()) - } - - @Test - fun setAreaCallsRepository() { - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.setArea(BoundingBox(0.0, 0.0, 0.0, 0.0)) - - verify(hikesRepository, times(1)).getRoutes(eq(Bounds(0.0, 0.0, 0.0, 0.0)), any(), any()) - } - - @Test - fun setAreaCrossingDateLineCallsRepositoryTwice() { - // When the hike repository calls the getRoutes method, return on success - `when`(hikesRepository.getRoutes(any(), any(), any())).thenAnswer { - val onSuccess = it.getArgument<(List) -> Unit>(1) - onSuccess(emptyList()) - } - - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.setArea(BoundingBox(50.0, -170.0, 40.0, 170.0)) - - verify(hikesRepository, times(2)).getRoutes(any(), any(), any()) - } - - @Test - fun selectRouteByIdCallsRepoAndSelectsHike() { - // Given - val hike = - HikeRoute( - id = "Route 1", - bounds = Bounds(0.0, 0.0, 0.0, 0.0), - ways = emptyList(), - name = "Name of Route 1", - description = "Description of Route 1") - - `when`(hikesRepository.getRouteById(eq(hike.id), any(), any())).thenAnswer { - val onSuccess = it.getArgument<(HikeRoute) -> Unit>(1) - onSuccess(hike) - } - - // When - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.selectRouteById(hike.id) - - // Then - verify(hikesRepository, times(1)).getRouteById(eq(hike.id), any(), any()) - assertEquals(hike, listOfHikeRoutesViewModel.selectedHikeRoute.value) - } - - @Test - fun getRoutesByIdsCallsRepo() { - val hikesIds = listOf("Route 1", "Route 2") - - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutesByIds(hikesIds) - - verify(hikesRepository, times(1)).getRoutesByIds(eq(hikesIds), any(), any()) - } - - @Test - fun getRoutesByIdsUpdatesHikeRoutes() { - val hikes = - listOf( - HikeRoute("Route 1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList()), - HikeRoute("Route 2", Bounds(0.0, 0.0, 0.0, 0.0), emptyList())) - - val hikesIds = hikes.map { it.id } - - `when`(hikesRepository.getRoutesByIds(eq(hikesIds), any(), any())).thenAnswer { - val onSuccess = it.getArgument<(List) -> Unit>(1) - onSuccess(hikes) - } - - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutesByIds(hikesIds) - - assertEquals(2, listOfHikeRoutesViewModel.hikeRoutes.value.size) - } - - @Test - fun getRoutesByIdsCallsOnFailure() { - val hikesIds = listOf("Route 1", "Route 2") - - `when`(hikesRepository.getRoutesByIds(eq(hikesIds), any(), any())).thenAnswer { - val onFailure = it.getArgument<(Exception) -> Unit>(2) - onFailure(Exception("Test exception")) - } - - var onFailedCalled = false - // Since we use UnconfinedTestDispatcher, we don't need to wait for the coroutine to finish - listOfHikeRoutesViewModel.getRoutesByIds( - hikesIds, - { fail("Should not have succeeded") }, - { - // Should be called - onFailedCalled = true - }) - - assertTrue(onFailedCalled) - // Verify that the onFailure function was called - verify(hikesRepository, times(1)).getRoutesByIds(eq(hikesIds), any(), any()) - } -} diff --git a/app/src/test/java/ch/hikemate/app/model/route/saved/SavedHikesViewModelTest.kt b/app/src/test/java/ch/hikemate/app/model/route/saved/SavedHikesViewModelTest.kt deleted file mode 100644 index 2e3573b8f..000000000 --- a/app/src/test/java/ch/hikemate/app/model/route/saved/SavedHikesViewModelTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package ch.hikemate.app.model.route.saved - -import ch.hikemate.app.R -import ch.hikemate.app.model.route.Bounds -import ch.hikemate.app.model.route.HikeRoute -import com.google.firebase.Timestamp -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.whenever - -@OptIn(ExperimentalCoroutinesApi::class) -class SavedHikesViewModelTest { - private lateinit var savedHikesRepository: SavedHikesRepository - private lateinit var savedHikesViewModel: SavedHikesViewModel - - @Before - fun setUp() { - val dispatcher = UnconfinedTestDispatcher() - // Testing coroutines is easier if everything is set to run on a single thread - Dispatchers.setMain(dispatcher) - - savedHikesRepository = mock(SavedHikesRepository::class.java) - savedHikesViewModel = SavedHikesViewModel(savedHikesRepository, dispatcher) - } - - @Test - fun loadSavedHikesSuccessUpdatesSavedHikes() = - runTest(timeout = 5.seconds) { - // Given - val savedHikes = listOf(SavedHike("1", "Hike One", null), SavedHike("2", "Hike Two", null)) - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(savedHikes) - - // When - savedHikesViewModel.loadSavedHikes() - - // Then - assertEquals(savedHikes, savedHikesViewModel.savedHike.value) - } - - @Test - fun loadSavedHikesFailureUpdatesErrorMessage() = - runTest(timeout = 5.seconds) { - // Given - `when`(savedHikesRepository.loadSavedHikes()).thenThrow(RuntimeException("Load error")) - - // When - savedHikesViewModel.loadSavedHikes() - - // Then - assertEquals( - R.string.saved_hikes_screen_generic_error, savedHikesViewModel.errorMessageId.value) - } - - @Test - fun addSavedHikeSuccessCallsRepository() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(emptyList()) - - // When - savedHikesViewModel.addSavedHike(savedHike) - - // Then - verify(savedHikesRepository).addSavedHike(savedHike) - verify(savedHikesRepository).loadSavedHikes() - } - - @Test - fun addSavedHikeSuccessUpdatesSavedHikes() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(listOf(savedHike)) - - // When - savedHikesViewModel.addSavedHike(savedHike) - - // Then - assertEquals(listOf(savedHike), savedHikesViewModel.savedHike.value) - } - - @Test - fun addSavedHikeFailureUpdatesErrorMessage() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.addSavedHike(savedHike)) - .thenThrow(RuntimeException("Add error")) - - // When - savedHikesViewModel.addSavedHike(savedHike) - - // Then - assertEquals( - R.string.saved_hikes_screen_generic_error, savedHikesViewModel.errorMessageId.value) - } - - @Test - fun removeSavedHikeSuccessCallsRepository() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(emptyList()) - - // When - savedHikesViewModel.removeSavedHike(savedHike) - - // Then - verify(savedHikesRepository).removeSavedHike(savedHike) - verify(savedHikesRepository).loadSavedHikes() - } - - @Test - fun removeSavedHikeSuccessUpdatesSavedHikes() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(emptyList()) - - // When - savedHikesViewModel.removeSavedHike(savedHike) - - // Then - assertEquals(emptyList(), savedHikesViewModel.savedHike.value) - } - - @Test - fun removeSavedHikeFailureUpdatesErrorMessage() = - runTest(timeout = 5.seconds) { - // Given - val savedHike = SavedHike("1", "Hike One", null) - `when`(savedHikesRepository.removeSavedHike(savedHike)) - .thenThrow(RuntimeException("Remove error")) - - // When - savedHikesViewModel.removeSavedHike(savedHike) - - // Then - assertEquals( - R.string.saved_hikes_screen_generic_error, savedHikesViewModel.errorMessageId.value) - } - - @Test - fun updateHikeDetailStateSetsCorrectState() = runTest { - // Given - val hikeRoute = HikeRoute("1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList(), "Test Hike") - `when`(savedHikesRepository.isHikeSaved(hikeRoute.id)).thenReturn(null) - - // When - savedHikesViewModel.updateHikeDetailState(hikeRoute) - - // Then - val expectedState = - SavedHikesViewModel.HikeDetailState( - hike = hikeRoute, - isSaved = false, - bookmark = R.drawable.bookmark_no_fill, - plannedDate = null) - assertEquals(expectedState, savedHikesViewModel.hikeDetailState.first()) - } - - @Test - fun toggleSaveStateAddsOrRemovesHike() = runTest { - // Given - val hikeRoute = HikeRoute("1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList(), "Test Hike") - - savedHikesViewModel.updateHikeDetailState(hikeRoute) - - // When: toggle to save hike - `when`(savedHikesRepository.loadSavedHikes()).thenReturn(emptyList()) - savedHikesViewModel.toggleSaveState() - - // Then: verify addSavedHike is called and state is updated - val captor = argumentCaptor() - verify(savedHikesRepository).addSavedHike(captor.capture()) - assertEquals(true, savedHikesViewModel.hikeDetailState.first()?.isSaved) - } - - @Test - fun updatePlannedDateSetsCorrectDate() = runTest { - // Given - val hikeRoute = HikeRoute("1", Bounds(0.0, 0.0, 0.0, 0.0), emptyList(), "Test Hike") - val newPlannedDate = Timestamp.now() - - // Mocking initial repository state - whenever(savedHikesRepository.loadSavedHikes()) - .thenReturn(listOf(SavedHike("1", "Test Hike", null))) - - // Setting up initial state in view model - savedHikesViewModel.updateHikeDetailState(hikeRoute) - savedHikesViewModel.toggleSaveState() - - // When - savedHikesViewModel.updatePlannedDate(newPlannedDate) - - // Verifying removal of the old hike entry - verify(savedHikesRepository).removeSavedHike(SavedHike("1", "Test Hike", null)) - - // Then: check that plannedDate is updated in the state - assertNotNull(savedHikesViewModel.hikeDetailState.first()) - assertEquals(newPlannedDate, savedHikesViewModel.hikeDetailState.first()?.plannedDate) - - // Verifying addition of the new hike entry with the updated planned date - verify(savedHikesRepository) - .addSavedHike(SavedHike(hikeRoute.id, hikeRoute.name ?: "", newPlannedDate)) - } -} diff --git a/app/src/test/java/ch/hikemate/app/utils/MapUtilsTest.kt b/app/src/test/java/ch/hikemate/app/utils/MapUtilsTest.kt index e6fe7926e..53270f2e1 100644 --- a/app/src/test/java/ch/hikemate/app/utils/MapUtilsTest.kt +++ b/app/src/test/java/ch/hikemate/app/utils/MapUtilsTest.kt @@ -26,6 +26,7 @@ import io.mockk.verifySequence import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -369,8 +370,8 @@ class MapUtilsTest { // Given val boundingBox = BoundingBox(46.51, 6.62, 46.5, 6.6) val zoom = 15.0 - val boundingBoxState = MutableStateFlow(boundingBox) - val zoomLevelState = MutableStateFlow(zoom) + val boundingBoxState = MutableStateFlow(boundingBox) + val zoomLevelState = MutableStateFlow(zoom) val listenerSlot = slot() every { mapView.addMapListener(capture(listenerSlot)) } returns Unit @@ -390,7 +391,8 @@ class MapUtilsTest { // Then verify(exactly = 1) { mapView.addMapListener(any()) } assertEquals(boundingBox, boundingBoxState.value) - assertEquals(zoom, zoomLevelState.value, 0.01) + assertNotNull(zoomLevelState.value) + assertEquals(zoom, zoomLevelState.value!!, 0.01) } @Test @@ -398,8 +400,8 @@ class MapUtilsTest { // Given val boundingBox = BoundingBox(46.51, 6.62, 46.5, 6.6) val zoom = 15.0 - val boundingBoxState = MutableStateFlow(boundingBox) - val zoomLevelState = MutableStateFlow(zoom) + val boundingBoxState = MutableStateFlow(boundingBox) + val zoomLevelState = MutableStateFlow(zoom) val listenerSlot = slot() every { mapView.addMapListener(capture(listenerSlot)) } returns Unit @@ -415,14 +417,15 @@ class MapUtilsTest { verify(exactly = 1) { mapView.addMapListener(any()) } // States should remain unchanged assertEquals(boundingBox, boundingBoxState.value) - assertEquals(zoom, zoomLevelState.value, 0.01) + assertNotNull(zoomLevelState.value) + assertEquals(zoom, zoomLevelState.value!!, 0.01) } @Test fun setMapViewListenerForStates_returnsCorrectBooleanValues() { // Given - val boundingBoxState = MutableStateFlow(BoundingBox(46.51, 6.62, 46.5, 6.6)) - val zoomLevelState = MutableStateFlow(15.0) + val boundingBoxState = MutableStateFlow(BoundingBox(46.51, 6.62, 46.5, 6.6)) + val zoomLevelState = MutableStateFlow(15.0) val listenerSlot = slot() every { mapView.addMapListener(capture(listenerSlot)) } returns Unit