diff --git a/.gitignore b/.gitignore index 0c21ef5e..2776fc92 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ out/ web/.next/ web/out/ web/next-env.d.ts +*.tsbuildinfo # General build outputs dist/ diff --git a/android/app/src/androidTest/java/com/neph/e2e/AndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/AndroidE2ETest.kt index bdec3ae2..66d2f9ed 100644 --- a/android/app/src/androidTest/java/com/neph/e2e/AndroidE2ETest.kt +++ b/android/app/src/androidTest/java/com/neph/e2e/AndroidE2ETest.kt @@ -45,20 +45,19 @@ class AndroidE2ETest { clickableNode("Continue as Guest").performClick() waitForText("I need help now") - composeRule.onAllNodesWithContentDescription("Open menu")[0].performClick() + composeRule.onAllNodesWithContentDescription("Open menu", useUnmergedTree = true)[0].performClick() waitForClickable("Help Request Map") clickableNode("Help Request Map").performClick() waitForText("Showing waiting help requests by type and priority.") composeRule.onAllNodesWithText("Help Request Map")[0].assertIsDisplayed() - composeRule.waitUntil(5_000) { + composeRule.waitUntil(15_000) { contentDescriptionNodeCount("Crisis marker") == 2 && textNodeCount("First Aid") > 0 && textNodeCount("Shelter") > 0 && textNodeCount("Priority: High") > 0 && textNodeCount("Waiting Requests") > 0 && - textNodeCount("Search and Rescue") == 0 && textNodeCount("sariyer") == 0 } } @@ -106,7 +105,7 @@ class AndroidE2ETest { fun systemBack_whenStackCanPop_navigatesBackWithoutExitDialog() { waitForClickable("Log In") clickableNode("Log In").performClick() - waitForTag("login_email") + openEmailFormIfNeeded("login_email") pressSystemBack() @@ -182,13 +181,13 @@ class AndroidE2ETest { private fun textNodeCount(text: String): Int { return runCatching { - composeRule.onAllNodesWithText(text).fetchSemanticsNodes().size + composeRule.onAllNodesWithText(text, substring = true, useUnmergedTree = true).fetchSemanticsNodes().size }.getOrDefault(0) } private fun contentDescriptionNodeCount(text: String): Int { return runCatching { - composeRule.onAllNodesWithContentDescription(text, substring = true).fetchSemanticsNodes().size + composeRule.onAllNodesWithContentDescription(text, substring = true, useUnmergedTree = true).fetchSemanticsNodes().size }.getOrDefault(0) } } diff --git a/android/app/src/androidTest/java/com/neph/e2e/AuthenticatedSessionAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/AuthenticatedSessionAndroidE2ETest.kt index 234b6dc3..2858b219 100644 --- a/android/app/src/androidTest/java/com/neph/e2e/AuthenticatedSessionAndroidE2ETest.kt +++ b/android/app/src/androidTest/java/com/neph/e2e/AuthenticatedSessionAndroidE2ETest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import com.neph.MainActivity import com.neph.features.auth.data.AuthSessionStore import com.neph.features.profile.data.ProfileData @@ -61,7 +62,7 @@ class AuthenticatedSessionAndroidE2ETest { waitForText("I need help now") composeRule.onNodeWithText("I need help now").assertIsDisplayed() - composeRule.onNodeWithContentDescription("Open menu").performClick() + composeRule.onNodeWithContentDescription("Open menu", useUnmergedTree = true).performClick() waitForClickable("Profile") clickableNode("Profile").performClick() @@ -75,7 +76,7 @@ class AuthenticatedSessionAndroidE2ETest { waitForText("I need help now") composeRule.onNodeWithText("I need help now").assertIsDisplayed() - composeRule.onNodeWithContentDescription("Open menu").performClick() + composeRule.onNodeWithContentDescription("Open menu", useUnmergedTree = true).performClick() waitForClickable("Settings") clickableNode("Settings").performClick() @@ -84,7 +85,7 @@ class AuthenticatedSessionAndroidE2ETest { waitForText("Profile visibility") composeRule.onNodeWithText("Profile visibility").assertIsDisplayed() - composeRule.onNodeWithText("Save Privacy Settings").assertIsDisplayed() + composeRule.onNodeWithText("Save Privacy Settings").performScrollTo().assertIsDisplayed() composeRule.activity.runOnUiThread { composeRule.activity.onBackPressedDispatcher.onBackPressed() @@ -101,7 +102,7 @@ class AuthenticatedSessionAndroidE2ETest { waitForText("I need help now") composeRule.onNodeWithText("I need help now").assertIsDisplayed() - composeRule.onNodeWithContentDescription("Open menu").performClick() + composeRule.onNodeWithContentDescription("Open menu", useUnmergedTree = true).performClick() waitForClickable("Settings") clickableNode("Settings").performClick() diff --git a/android/app/src/androidTest/java/com/neph/e2e/FakeNephBackend.kt b/android/app/src/androidTest/java/com/neph/e2e/FakeNephBackend.kt index f6b7188b..d99d0b5f 100644 --- a/android/app/src/androidTest/java/com/neph/e2e/FakeNephBackend.kt +++ b/android/app/src/androidTest/java/com/neph/e2e/FakeNephBackend.kt @@ -208,6 +208,8 @@ class FakeNephBackend { route == "/availability/status" && method == "GET" -> handleAvailabilityStatus(token) route == "/availability/my-assignment" && method == "GET" -> handleCurrentAssignment(token) route == "/help-requests" && method == "GET" -> handleHelpRequestList(token) + route.startsWith("/help-requests/") && route.endsWith("/status") && method == "PATCH" -> + handlePatchHelpRequestStatus(token, route, body) route == "/help-requests/active" && method == "GET" -> handleActiveHelpRequests(uri) route == "/location/tree" && method == "GET" -> handleLocationTree(uri) route == "/gathering-areas/nearby" && method == "GET" -> handleNearbyGatheringAreas(uri) @@ -359,7 +361,7 @@ class FakeNephBackend { put( JSONObject() .put("requestId", "fake-map-assigned") - .put("type", "search_and_rescue") + .put("type", "search_rescue") .put("status", "PENDING") .put("urgencyLevel", "HIGH") .put("createdAt", "2026-05-01T09:55:00.000Z") @@ -755,6 +757,28 @@ class FakeNephBackend { return JSONObject().put("requests", JSONArray()) } + private fun handlePatchHelpRequestStatus(token: String?, route: String, body: JSONObject?): JSONObject { + requireAuthorizedUser(token) + val requestId = route + .removePrefix("/help-requests/") + .removeSuffix("/status") + .trim() + val status = body?.requiredString("status") ?: "SYNCED" + return JSONObject().put( + "request", + JSONObject() + .put("id", requestId) + .put("status", status) + .put("helpTypes", JSONArray()) + .put("affectedPeopleCount", 1) + .put("riskFlags", JSONArray()) + .put("vulnerableGroups", JSONArray()) + .put("location", JSONObject()) + .put("contact", JSONObject()) + .put("cancelledAt", if (status == "CANCELLED") Instant.now().toString() else JSONObject.NULL) + ) + } + private fun profileResponseJson(user: FakeUserState, profile: FakeProfileState): JSONObject { val expertiseArray = JSONArray() if (!profile.profession.isNullOrBlank() || profile.expertiseAreas.isNotEmpty()) { diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt new file mode 100644 index 00000000..f0fefc14 --- /dev/null +++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt @@ -0,0 +1,240 @@ +package com.neph.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.neph.MainActivity +import com.neph.features.auth.data.AuthSessionStore +import com.neph.features.onboarding.data.MobileOnboardingStore +import com.neph.features.profile.data.ProfileData +import com.neph.features.profile.data.ProfileRepository +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule + +class MobileOnboardingAndroidE2ETest { + private val fakeBackend = FakeNephBackend() + private val seededProfile = FakeProfileState( + firstName = "Mina", + lastName = "Onboard", + phoneNumber = "+905551112244", + age = 28, + gender = "female", + height = 168.0, + weight = 58.0, + country = "Turkey", + city = "Istanbul", + address = "Kadıköy, Test Street 8" + ) + private val environmentRule = NephE2ETestEnvironmentRule(fakeBackend) { context, backend -> + backend.seedVerifiedUser( + email = "mina.onboarding@example.com", + password = "Passw0rd!", + profile = seededProfile + ) + + AuthSessionStore.initialize(context) + AuthSessionStore.saveAccessToken("access-token-1", rememberMe = true, userId = "user-1") + ProfileRepository.initialize(context) + ProfileRepository.saveProfile( + ProfileData( + firstName = "Mina", + lastName = "Onboard", + fullName = "Mina Onboard", + email = "mina.onboarding@example.com" + ) + ) + MobileOnboardingStore.initialize(context) + MobileOnboardingStore.markPendingForCurrentUser() + } + private val composeRule = createAndroidComposeRule() + + @get:Rule + val ruleChain: TestRule = RuleChain + .outerRule(environmentRule) + .around(composeRule) + + @Test + fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() { + reachAssignedRequestsPage() + + continueThroughDrawerPage( + pageTitle = "Emergency Numbers", + menuTargetTag = "mobile_onboarding_target_menu_emergency_info", + menuInstruction = "Tap Emergency Numbers", + pageText = "Emergency Contact List" + ) + continueThroughDrawerPage( + pageTitle = "Help Request Map", + menuTargetTag = "mobile_onboarding_target_menu_help_request_map", + menuInstruction = "Tap Help Request Map", + pageText = "Showing waiting help requests" + ) + continueThroughDrawerPage( + pageTitle = "Nearby Users", + menuTargetTag = "mobile_onboarding_target_menu_nearby_users", + menuInstruction = "Tap Nearby Users", + pageText = "Based on your residential" + ) + continueThroughDrawerPage( + pageTitle = "Gathering Areas", + menuTargetTag = "mobile_onboarding_target_menu_gathering_areas", + menuInstruction = "Tap Gathering Areas", + pageText = "Location-based assembly" + ) + continueThroughDrawerPage( + pageTitle = "Safety Circles", + menuTargetTag = "mobile_onboarding_target_menu_safety_circles", + menuInstruction = "Tap Safety Circles", + pageText = "Create Circle" + ) + continueThroughDrawerPage( + pageTitle = "Notifications", + menuTargetTag = "mobile_onboarding_target_menu_notifications", + menuInstruction = "Tap Notifications", + pageText = "Notifications" + ) + continueThroughDrawerPage( + pageTitle = "Settings", + menuTargetTag = "mobile_onboarding_target_menu_settings", + menuInstruction = "Tap Settings", + pageText = "Appearance" + ) + + composeRule.onNodeWithTag("mobile_onboarding_finish").performClick() + waitUntilTagGone("mobile_onboarding_dialog") + waitForText("I need help now") + + composeRule.activityRule.scenario.recreate() + waitForText("I need help now") + waitUntilTagGone("mobile_onboarding_dialog") + } + + @Test + fun skippingTutorialMidway_returnsUserHome() { + reachAssignedRequestsPage() + continueThroughDrawerPage( + pageTitle = "Emergency Numbers", + menuTargetTag = "mobile_onboarding_target_menu_emergency_info", + menuInstruction = "Tap Emergency Numbers", + pageText = "Emergency Contact List" + ) + + composeRule.onNodeWithTag("mobile_onboarding_skip").performClick() + waitUntilTagGone("mobile_onboarding_dialog") + waitForText("I need help now") + } + + private fun reachAssignedRequestsPage() { + waitForText("I need help now") + waitForTag("mobile_onboarding_dialog") + waitForTag("mobile_onboarding_welcome_message") + composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals("Home Dashboard") + composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled() + + composeRule.onNodeWithTag("home_request_help_action").performClick() + waitForGuideTitle("Request Help") + waitForText("Create a help request") + waitForTag("mobile_onboarding_target_search_rescue") + + composeRule.onNodeWithTag("mobile_onboarding_target_search_rescue").performClick() + waitForGuideTitle("Risk Flags") + waitForText("You selected Search & Rescue") + waitForTag("mobile_onboarding_target_fire_risk") + + composeRule.onNodeWithTag("mobile_onboarding_target_fire_risk").performScrollTo().performClick() + waitForGuideTitle("Confirmation") + waitForText("You marked fire") + waitForTag("mobile_onboarding_confirmation_checkbox") + + composeRule.onNodeWithTag("mobile_onboarding_confirmation_checkbox").performScrollTo().performClick() + waitForGuideTitle("Send Help Request") + composeRule.onNodeWithTag("mobile_onboarding_confirmation_checkbox").assertIsOn() + waitForTag("mobile_onboarding_target_send_help_request") + + composeRule.onNodeWithTag("mobile_onboarding_target_send_help_request").performScrollTo().performClick() + waitForGuideTitle("My Help Requests") + waitForText("No real help request was saved") + waitForText("Guide preview only") + waitForText("Search & Rescue") + + composeRule.onNodeWithTag("mobile_onboarding_continue").performClick() + waitForGuideTitle("Open the Menu") + waitForTag("mobile_onboarding_target_menu") + + composeRule.onNodeWithTag("mobile_onboarding_target_menu").performClick() + waitForGuideTitle("Assigned Request") + waitForText("Tap Assigned Request") + waitForTag("mobile_onboarding_target_assigned_request_menu") + + composeRule.onNodeWithTag("mobile_onboarding_target_assigned_request_menu").performClick() + waitForGuideTitle("Assigned Requests") + waitForText("There is no assigned request for you right now.") + waitForText("No assigned request right now.") + + } + + + private fun continueThroughDrawerPage( + pageTitle: String, + menuTargetTag: String, + menuInstruction: String, + pageText: String + ) { + composeRule.onNodeWithTag("mobile_onboarding_continue").performClick() + waitForGuideTitle("Open the Menu") + waitForTag("mobile_onboarding_target_menu") + + composeRule.onNodeWithTag("mobile_onboarding_target_menu").performClick() + waitForText(menuInstruction) + waitForTag(menuTargetTag) + + composeRule.onNodeWithTag(menuTargetTag).performScrollTo().performClick() + waitForGuideTitle(pageTitle) + waitForText(pageText) + } + + private fun waitForGuideTitle(title: String, timeoutMillis: Long = 15_000) { + composeRule.waitUntil(timeoutMillis) { + runCatching { + composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals(title) + true + }.getOrDefault(false) + } + } + + private fun waitForText(text: String, timeoutMillis: Long = 15_000) { + composeRule.waitUntil(timeoutMillis) { + runCatching { + composeRule.onAllNodesWithText(text, substring = true).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + } + } + + private fun waitForTag(tag: String, timeoutMillis: Long = 15_000) { + composeRule.waitUntil(timeoutMillis) { + hasTag(tag) + } + } + + private fun waitUntilTagGone(tag: String, timeoutMillis: Long = 15_000) { + composeRule.waitUntil(timeoutMillis) { + !hasTag(tag) + } + } + + private fun hasTag(tag: String): Boolean { + return runCatching { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + } +} diff --git a/android/app/src/androidTest/java/com/neph/e2e/NephE2ETestEnvironmentRule.kt b/android/app/src/androidTest/java/com/neph/e2e/NephE2ETestEnvironmentRule.kt index c37b490f..beddfc8a 100644 --- a/android/app/src/androidTest/java/com/neph/e2e/NephE2ETestEnvironmentRule.kt +++ b/android/app/src/androidTest/java/com/neph/e2e/NephE2ETestEnvironmentRule.kt @@ -6,6 +6,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkManager import com.neph.core.database.NephDatabaseProvider import com.neph.features.auth.data.AuthSessionStore +import com.neph.features.onboarding.data.MobileOnboardingStore import com.neph.features.availability.data.AvailabilityRepository import com.neph.features.profile.data.ProfileRepository import com.neph.features.requesthelp.data.RequestHelpRepository @@ -44,6 +45,7 @@ class NephE2ETestEnvironmentRule( ProfileRepository.resetForTesting() AvailabilityRepository.resetForTesting() RequestHelpRepository.resetForTesting() + runCatching { MobileOnboardingStore.resetForTesting() } runCatching { WorkManager.getInstance(context).cancelAllWork() } NephDatabaseProvider.resetForTesting(context) @@ -51,7 +53,8 @@ class NephE2ETestEnvironmentRule( "neph_auth", "neph_profile", "neph_availability", - "neph_guest_help_requests" + "neph_guest_help_requests", + "neph_mobile_onboarding" ).forEach { prefsName -> runCatching { context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) @@ -64,6 +67,17 @@ class NephE2ETestEnvironmentRule( } private fun grantRuntimePermissions(context: Context) { + listOf( + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION" + ).forEach { permission -> + runCatching { + InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand( + "pm grant ${context.packageName} $permission" + ).close() + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { runCatching { InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand( diff --git a/android/app/src/androidTest/java/com/neph/e2e/SafetyStatusRepositoryAndroidTest.kt b/android/app/src/androidTest/java/com/neph/e2e/SafetyStatusRepositoryAndroidTest.kt index ade2bffb..0a55c1ff 100644 --- a/android/app/src/androidTest/java/com/neph/e2e/SafetyStatusRepositoryAndroidTest.kt +++ b/android/app/src/androidTest/java/com/neph/e2e/SafetyStatusRepositoryAndroidTest.kt @@ -2,9 +2,11 @@ package com.neph.e2e import android.content.Context import com.neph.core.NephAppContext +import com.neph.core.database.HelpRequestEntity import com.neph.core.database.NephDatabaseProvider import com.neph.core.database.SafetyStatusEntity import com.neph.core.database.SyncOperationEntity +import com.neph.core.sync.LocalOwnerType import com.neph.core.sync.SyncEntityType import com.neph.core.sync.SyncOperationStatus import com.neph.core.sync.SyncOperationType @@ -15,6 +17,7 @@ import com.neph.features.auth.data.AuthSessionStore import com.neph.features.profile.data.CurrentDeviceLocation import com.neph.features.profile.data.ProfileData import com.neph.features.profile.data.ProfileRepository +import com.neph.features.requesthelp.data.RequestHelpRepository import com.neph.features.safetystatus.data.SafetyStatusRepository import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -94,6 +97,49 @@ class SafetyStatusRepositoryAndroidTest { assertEquals(0, fakeBackend.profileLocationPatchCount()) } + @Test + fun clearSafeStatusForRequestHelpReplacesSafeStatusWithUnknownAndSyncs() = runBlocking { + SafetyStatusRepository.markSafe( + token = "access-token-1", + location = sampleLocation(), + shareLocationConsent = true + ) + + val state = SafetyStatusRepository.clearSafeStatusForRequestHelp("access-token-1") + + assertEquals(SyncStatus.SYNCED, state.syncStatus) + assertEquals("unknown", state.status) + assertFalse(state.shareLocationConsent) + assertFalse(state.hasLocation) + assertEquals("unknown", fakeBackend.currentSafetyStatus().status) + assertNull(fakeBackend.currentSafetyStatus().location) + assertNull(latestSafetyStatusOperation()) + assertEquals(0, fakeBackend.profileLocationPatchCount()) + } + + @Test + fun clearSafeStatusForRequestHelpQueuesUnknownPayloadWhenSyncDeferred() = runBlocking { + SafetyStatusRepository.markSafe( + token = "access-token-1", + location = sampleLocation(), + shareLocationConsent = true + ) + + val state = SafetyStatusRepository.clearSafeStatusForRequestHelp("expired-token") + val operation = latestSafetyStatusOperation() + + assertEquals(SyncStatus.PENDING_UPDATE, state.syncStatus) + assertEquals("unknown", state.status) + assertFalse(state.shareLocationConsent) + assertFalse(state.hasLocation) + assertTrue(state.pendingError.orEmpty().contains("Session expired")) + assertEquals("current", operation?.entityId) + assertTrue(operation?.payloadJson.orEmpty().contains("\"status\":\"unknown\"")) + assertTrue(operation?.payloadJson.orEmpty().contains("\"shareLocationConsent\":false")) + assertTrue(operation?.payloadJson.orEmpty().contains("\"location\":null")) + assertEquals(0, fakeBackend.profileLocationPatchCount()) + } + @Test fun clearLocalCacheRemovesSafetyStatusAndPendingSyncOperation() = runBlocking { SafetyStatusRepository.markSafe( @@ -147,11 +193,64 @@ class SafetyStatusRepositoryAndroidTest { assertEquals(LoginDestination.PROFILE, destination) } + @Test + fun cancelActiveAuthenticatedHelpRequestsForMarkSafeRequiresConfirmedRemoteCancellation() = runBlocking { + val database = NephDatabaseProvider.requireInstance() + database.helpRequestDao().upsert(activeHelpRequestEntity()) + + val result = RequestHelpRepository.cancelActiveAuthenticatedHelpRequestsForMarkSafe("access-token-1") + val request = database.helpRequestDao().getByLocalId("local_help_1") + val operation = database.syncOperationDao().getLatestPendingOperation( + entityType = SyncEntityType.HELP_REQUEST, + entityId = "local_help_1", + operationType = SyncOperationType.UPDATE_HELP_REQUEST_STATUS + ) + + assertTrue(result.canMarkSafe) + assertEquals(1, result.confirmedCount) + assertEquals(0, result.pendingCount) + assertEquals(0, result.failedCount) + assertEquals("CANCELLED", request?.status) + assertEquals(SyncStatus.SYNCED, request?.syncStatus) + assertTrue(request?.cancelledAt.orEmpty().isNotBlank()) + assertNull(operation) + } + + @Test + fun cancelActiveAuthenticatedHelpRequestsForMarkSafeLeavesPendingCreateActive() = runBlocking { + val database = NephDatabaseProvider.requireInstance() + database.helpRequestDao().upsert( + activeHelpRequestEntity( + localId = "local_pending_help_1", + remoteId = null, + syncStatus = SyncStatus.PENDING_CREATE + ) + ) + + val result = RequestHelpRepository.cancelActiveAuthenticatedHelpRequestsForMarkSafe("access-token-1") + val request = database.helpRequestDao().getByLocalId("local_pending_help_1") + val operation = database.syncOperationDao().getLatestPendingOperation( + entityType = SyncEntityType.HELP_REQUEST, + entityId = "local_pending_help_1", + operationType = SyncOperationType.UPDATE_HELP_REQUEST_STATUS + ) + + assertFalse(result.canMarkSafe) + assertEquals(0, result.confirmedCount) + assertEquals(1, result.pendingCount) + assertEquals(0, result.failedCount) + assertEquals("SYNCED", request?.status) + assertEquals(SyncStatus.PENDING_CREATE, request?.syncStatus) + assertNull(request?.cancelledAt) + assertNull(operation) + } + private fun initializeSafetyStatusTestDependencies(context: Context) { NephAppContext.initialize(context) NephDatabaseProvider.initialize(context) AuthSessionStore.initialize(context) ProfileRepository.initialize(context) + RequestHelpRepository.initialize(context) SafetyStatusRepository.initialize(context) } @@ -185,4 +284,42 @@ class SafetyStatusRepositoryAndroidTest { source = "DEVICE_GPS" ) } + + private fun activeHelpRequestEntity( + localId: String = "local_help_1", + remoteId: String? = "remote_help_1", + syncStatus: String = SyncStatus.SYNCED + ): HelpRequestEntity { + return HelpRequestEntity( + localId = localId, + remoteId = remoteId, + ownerType = LocalOwnerType.AUTHENTICATED, + guestAccessToken = null, + helpTypesJson = """["other"]""", + otherHelpText = "Need help", + affectedPeopleCount = 1, + riskFlagsJson = "[]", + vulnerableGroupsJson = "[]", + description = "Need help", + bloodType = "", + country = "Turkey", + city = "Istanbul", + district = "Kadikoy", + neighborhood = "Moda", + extraAddress = "", + contactFullName = "Safe Tester", + contactPhone = "5551234567", + contactAlternativePhone = null, + status = "SYNCED", + helperFirstName = null, + helperLastName = null, + helperPhone = null, + helperProfession = null, + helperExpertise = null, + helpersJson = "[]", + syncStatus = syncStatus, + createdAtEpochMillis = 1000L, + updatedAtEpochMillis = 1000L + ) + } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dbc8570f..060efea2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,10 +8,16 @@ + + diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt index b52ad0e7..d6731262 100644 --- a/android/app/src/main/java/com/neph/MainActivity.kt +++ b/android/app/src/main/java/com/neph/MainActivity.kt @@ -1,13 +1,13 @@ package com.neph import android.Manifest -import android.app.Activity import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme @@ -15,12 +15,12 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -39,6 +39,11 @@ import com.neph.features.profile.data.DeviceLocationProvider import com.neph.features.profile.data.ProfileRepository import com.neph.features.notifications.data.PushTokenSync import com.neph.features.notifications.data.NotificationsBadge +import com.neph.features.onboarding.data.MobileOnboardingJourney +import com.neph.features.onboarding.data.MobileOnboardingPracticeHelpRequest +import com.neph.features.onboarding.data.MobileOnboardingStepId +import com.neph.features.onboarding.data.MobileOnboardingStore +import com.neph.features.onboarding.presentation.MobileOnboardingGuide import com.neph.features.requesthelp.data.RequestHelpRepository import com.neph.features.safetystatus.data.SafetyStatusRepository import com.neph.navigation.AppNavGraph @@ -61,6 +66,7 @@ class MainActivity : ComponentActivity() { NephAppContext.initialize(applicationContext) NephDatabaseProvider.initialize(applicationContext) AuthSessionStore.initialize(applicationContext) + MobileOnboardingStore.initialize(applicationContext) ThemePreferenceStore.initialize(applicationContext) AvailabilityRepository.initialize(applicationContext) OperationalLocationRepository.initialize(applicationContext) @@ -159,16 +165,106 @@ fun NephApp() { ) NephTheme(darkTheme = darkThemeEnabled) { - val activity = LocalContext.current as? Activity + val activity = LocalActivity.current val navController = rememberNavController() val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackStackEntry?.destination?.route + val accessToken by AuthSessionStore.accessTokenFlow.collectAsState() + val isAuthenticated = !accessToken.isNullOrBlank() + var showMobileOnboarding by remember { mutableStateOf(false) } + var activeMobileOnboardingStepId by remember { mutableStateOf(null) } + var mobileOnboardingFeedback by remember { mutableStateOf(null) } + var mobileOnboardingPracticeHelpRequest by remember { + mutableStateOf(null) + } var showExitDialog by remember { mutableStateOf(false) } val canPopBackStack = navController.previousBackStackEntry != null val shouldConfirmExit = !canPopBackStack && ( currentRoute == Routes.Home.route || currentRoute == Routes.Welcome.route ) + fun navigateForMobileOnboarding(route: String) { + navController.navigate(route) { + launchSingleTop = true + restoreState = true + } + } + + fun setMobileOnboardingStep(stepId: MobileOnboardingStepId) { + activeMobileOnboardingStepId = stepId + MobileOnboardingStore.setCurrentStepForCurrentUser(stepId) + MobileOnboardingJourney.stepFor(stepId, isAuthenticated)?.let { step -> + val routeBase = currentRoute?.substringBefore('?') + if (routeBase != step.route) { + navigateForMobileOnboarding(step.route) + } + } + } + + fun restartMobileOnboarding() { + MobileOnboardingStore.restartForCurrentUser() + val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true) + activeMobileOnboardingStepId = firstStep.id + mobileOnboardingFeedback = null + mobileOnboardingPracticeHelpRequest = null + showMobileOnboarding = true + navigateForMobileOnboarding(firstStep.route) + } + + fun closeMobileOnboardingAndReturnHome() { + MobileOnboardingStore.markSeenForCurrentUser() + showMobileOnboarding = false + activeMobileOnboardingStepId = null + mobileOnboardingFeedback = null + mobileOnboardingPracticeHelpRequest = null + navController.navigate(Routes.Home.route) { + popUpTo(navController.graph.id) { inclusive = true } + launchSingleTop = true + } + } + + fun completeMobileOnboardingStep(message: String?) { + val currentStepId = activeMobileOnboardingStepId ?: return + mobileOnboardingFeedback = message + val nextStep = MobileOnboardingJourney.nextStep(currentStepId, isAuthenticated) + if (nextStep == null) { + closeMobileOnboardingAndReturnHome() + } else { + setMobileOnboardingStep(nextStep.id) + } + } + + fun updateMobileOnboardingFeedback(message: String?) { + if (showMobileOnboarding) { + mobileOnboardingFeedback = message + } + } + + fun updateMobileOnboardingPracticeHelpRequest(request: MobileOnboardingPracticeHelpRequest?) { + if (showMobileOnboarding) { + mobileOnboardingPracticeHelpRequest = request + } + } + + LaunchedEffect(accessToken, currentRoute) { + val shouldShow = shouldShowMobileOnboardingForRoute(currentRoute) + showMobileOnboarding = shouldShow + activeMobileOnboardingStepId = if (shouldShow) { + MobileOnboardingStore.currentStepForCurrentUser(isAuthenticated) + } else { + mobileOnboardingFeedback = null + null + } + } + + LaunchedEffect(showMobileOnboarding, activeMobileOnboardingStepId, currentRoute, isAuthenticated) { + val step = activeMobileOnboardingStepId?.let { MobileOnboardingJourney.stepFor(it, isAuthenticated) } + val routeBase = currentRoute?.substringBefore('?') + if (showMobileOnboarding && step != null && routeBase != step.route) { + navigateForMobileOnboarding(step.route) + } + } + BackHandler(enabled = canPopBackStack || shouldConfirmExit) { if (canPopBackStack) { navController.popBackStack() @@ -208,9 +304,73 @@ fun NephApp() { } AuthSessionStore.isGuestMode() -> Routes.Home.route else -> Routes.Welcome.route - } + }, + onRestartMobileOnboarding = ::restartMobileOnboarding, + mobileOnboardingStepId = activeMobileOnboardingStepId.takeIf { showMobileOnboarding }, + onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep, + onMobileOnboardingFeedbackChanged = ::updateMobileOnboardingFeedback, + mobileOnboardingPracticeHelpRequest = mobileOnboardingPracticeHelpRequest, + onMobileOnboardingPracticeHelpRequestChanged = ::updateMobileOnboardingPracticeHelpRequest ) + + val activeStepId = activeMobileOnboardingStepId + val activeStep = activeStepId?.let { MobileOnboardingJourney.stepFor(it, isAuthenticated) } + val activeRouteBase = currentRoute?.substringBefore('?') + if ( + showMobileOnboarding && + activeStepId != null && + activeStep != null && + activeRouteBase == activeStep.route + ) { + val (stepNumber, totalSteps) = MobileOnboardingJourney.progressFor(activeStepId, isAuthenticated) + MobileOnboardingGuide( + step = activeStep, + stepNumber = stepNumber, + totalSteps = totalSteps, + isOnTargetRoute = true, + feedbackMessage = mobileOnboardingFeedback, + onNavigateToStep = { + navigateForMobileOnboarding(activeStep.route) + }, + onContinue = { + completeMobileOnboardingStep(activeStep.completionMessage) + }, + onBack = { + MobileOnboardingJourney.previousStep(activeStepId, isAuthenticated)?.let { previousStep -> + setMobileOnboardingStep(previousStep.id) + mobileOnboardingFeedback = null + } + }, + onSkip = ::closeMobileOnboardingAndReturnHome, + onFinish = ::closeMobileOnboardingAndReturnHome + ) + } + } +} + +private fun shouldShowMobileOnboardingForRoute(currentRoute: String?): Boolean { + if (currentRoute.isNullOrBlank()) { + return false } + + val routeBase = currentRoute.substringBefore('?') + val onboardingSuppressedRoutes = setOf( + Routes.Welcome.route, + Routes.Login.route, + Routes.Signup.route, + Routes.VerifyEmail.route, + Routes.CompleteProfile.route, + Routes.ForgotPassword.route, + Routes.ResetPassword.route, + Routes.TermsOfService.route, + Routes.PrivacyPolicy.route + ) + + if (routeBase in onboardingSuppressedRoutes) { + return false + } + + return MobileOnboardingStore.shouldShowForCurrentUser() } @Preview(showBackground = true, showSystemUi = true) diff --git a/android/app/src/main/java/com/neph/core/database/NephDatabase.kt b/android/app/src/main/java/com/neph/core/database/NephDatabase.kt index 00bc6ee6..b2a2b2c8 100644 --- a/android/app/src/main/java/com/neph/core/database/NephDatabase.kt +++ b/android/app/src/main/java/com/neph/core/database/NephDatabase.kt @@ -19,7 +19,7 @@ import com.neph.BuildConfig SyncOperationEntity::class, SyncMetadataEntity::class ], - version = 11, + version = 12, exportSchema = false ) abstract class NephDatabase : RoomDatabase() { @@ -213,6 +213,15 @@ object NephDatabaseProvider { database.execSQL("ALTER TABLE assigned_requests ADD COLUMN longitude REAL") } } + private val Migration11To12 = object : Migration(11, 12) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE help_requests ADD COLUMN shareProfileHealthInfoWithVolunteer INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE assigned_requests ADD COLUMN shareProfileHealthInfoWithVolunteer INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE assigned_requests ADD COLUMN medicalConditionsJson TEXT NOT NULL DEFAULT '[]'") + database.execSQL("ALTER TABLE assigned_requests ADD COLUMN chronicDiseasesJson TEXT NOT NULL DEFAULT '[]'") + database.execSQL("ALTER TABLE assigned_requests ADD COLUMN allergiesJson TEXT NOT NULL DEFAULT '[]'") + } + } fun initialize(context: Context) { getInstance(context) @@ -234,7 +243,8 @@ object NephDatabaseProvider { Migration7To8, Migration8To9, Migration9To10, - Migration10To11 + Migration10To11, + Migration11To12 ) .build() .also { instance = it } diff --git a/android/app/src/main/java/com/neph/core/database/OfflineEntities.kt b/android/app/src/main/java/com/neph/core/database/OfflineEntities.kt index ab3c6ecf..84ec9bbe 100644 --- a/android/app/src/main/java/com/neph/core/database/OfflineEntities.kt +++ b/android/app/src/main/java/com/neph/core/database/OfflineEntities.kt @@ -27,6 +27,7 @@ data class HelpRequestEntity( val vulnerableGroupsJson: String, val description: String, val bloodType: String, + val shareProfileHealthInfoWithVolunteer: Boolean = false, val country: String, val city: String, val district: String, @@ -136,6 +137,10 @@ data class AssignedRequestEntity( val riskFlagsJson: String, val vulnerableGroupsJson: String, val bloodType: String?, + val shareProfileHealthInfoWithVolunteer: Boolean = false, + val medicalConditionsJson: String = "[]", + val chronicDiseasesJson: String = "[]", + val allergiesJson: String = "[]", val latitude: Double? = null, val longitude: Double? = null, val locationLabel: String, diff --git a/android/app/src/main/java/com/neph/core/format/TimestampFormatters.kt b/android/app/src/main/java/com/neph/core/format/TimestampFormatters.kt new file mode 100644 index 00000000..c20a5edf --- /dev/null +++ b/android/app/src/main/java/com/neph/core/format/TimestampFormatters.kt @@ -0,0 +1,70 @@ +package com.neph.core.format + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +fun parseTimestampToInstant(raw: String?): Instant? { + val value = raw?.trim()?.takeIf { it.isNotBlank() } ?: return null + + return runCatching { Instant.parse(value) } + .recoverCatching { OffsetDateTime.parse(value).toInstant() } + .recoverCatching { LocalDateTime.parse(value.replace(' ', 'T')).toInstant(ZoneOffset.UTC) } + .getOrNull() +} + +fun relativeDayLabel( + instant: Instant, + zoneId: ZoneId = ZoneId.systemDefault(), + nowInstant: Instant = Instant.now() +): String? { + val targetDate = instant.atZone(zoneId).toLocalDate() + val today = LocalDate.ofInstant(nowInstant, zoneId) + + return when (targetDate) { + today -> "Today" + today.minusDays(1) -> "Yesterday" + else -> null + } +} + +fun formatTimestampWithRelativeDay( + raw: String?, + fallbackFormatter: DateTimeFormatter, + timeFormatter: DateTimeFormatter, + zoneId: ZoneId = ZoneId.systemDefault(), + nowInstant: Instant = Instant.now(), + relativeSeparator: String = ", " +): String? { + val instant = parseTimestampToInstant(raw) ?: return null + return formatInstantWithRelativeDay( + instant = instant, + fallbackFormatter = fallbackFormatter, + timeFormatter = timeFormatter, + zoneId = zoneId, + nowInstant = nowInstant, + relativeSeparator = relativeSeparator + ) +} + +fun formatInstantWithRelativeDay( + instant: Instant, + fallbackFormatter: DateTimeFormatter, + timeFormatter: DateTimeFormatter, + zoneId: ZoneId = ZoneId.systemDefault(), + nowInstant: Instant = Instant.now(), + relativeSeparator: String = ", " +): String { + val zonedDateTime = instant.atZone(zoneId) + val relativeLabel = relativeDayLabel(instant, zoneId, nowInstant) + + return if (relativeLabel != null) { + "$relativeLabel$relativeSeparator${timeFormatter.format(zonedDateTime)}" + } else { + fallbackFormatter.withZone(zoneId).format(instant) + } +} diff --git a/android/app/src/main/java/com/neph/features/assignedrequest/data/AssignedRequestRepository.kt b/android/app/src/main/java/com/neph/features/assignedrequest/data/AssignedRequestRepository.kt index 7857b600..889d33b5 100644 --- a/android/app/src/main/java/com/neph/features/assignedrequest/data/AssignedRequestRepository.kt +++ b/android/app/src/main/java/com/neph/features/assignedrequest/data/AssignedRequestRepository.kt @@ -32,6 +32,10 @@ data class AssignedRequestUiModel( val riskFlags: List, val vulnerableGroups: List, val bloodType: String?, + val shareProfileHealthInfoWithVolunteer: Boolean, + val medicalConditions: List, + val chronicDiseases: List, + val allergies: List, val latitude: Double?, val longitude: Double?, val locationLabel: String, @@ -216,6 +220,10 @@ object AssignedRequestRepository { riskFlagsJson = JSONArray(assignment.optJSONArray("risk_flags").toStringList()).toString(), vulnerableGroupsJson = JSONArray(assignment.optJSONArray("vulnerable_groups").toStringList()).toString(), bloodType = assignment.optString("blood_type").trim().takeIf { it.isNotBlank() }, + shareProfileHealthInfoWithVolunteer = assignment.optBoolean("share_profile_health_info_with_volunteer", false), + medicalConditionsJson = JSONArray(assignment.optJSONArray("medical_conditions").toStringList()).toString(), + chronicDiseasesJson = JSONArray(assignment.optJSONArray("chronic_diseases").toStringList()).toString(), + allergiesJson = JSONArray(assignment.optJSONArray("allergies").toStringList()).toString(), latitude = readAssignmentCoordinate(assignment, "latitude"), longitude = readAssignmentCoordinate(assignment, "longitude"), locationLabel = buildLocationLabel(assignment), @@ -260,6 +268,10 @@ object AssignedRequestRepository { riskFlags = riskFlagsJson.jsonArrayToStringList().map(::formatValue), vulnerableGroups = vulnerableGroupsJson.jsonArrayToStringList().map(::formatValue), bloodType = bloodType, + shareProfileHealthInfoWithVolunteer = shareProfileHealthInfoWithVolunteer, + medicalConditions = medicalConditionsJson.jsonArrayToStringList(), + chronicDiseases = chronicDiseasesJson.jsonArrayToStringList(), + allergies = allergiesJson.jsonArrayToStringList(), latitude = latitude, longitude = longitude, locationLabel = locationLabel, @@ -293,7 +305,24 @@ object AssignedRequestRepository { } private fun formatValue(value: String): String { - return value + val normalizedValue = value.trim().lowercase() + val explicitLabel = when (normalizedValue) { + "search_rescue", + "search_and_rescue", + "sar", + "fire_brigade", + "rescue" -> "Search and Rescue" + "food_water", + "food/water" -> "Food / Water" + "first_aid", + "medical" -> "First Aid" + else -> null + } + if (explicitLabel != null) { + return explicitLabel + } + + return normalizedValue .trim() .split('_') .filter { it.isNotBlank() } diff --git a/android/app/src/main/java/com/neph/features/assignedrequest/presentation/AssignedRequestScreen.kt b/android/app/src/main/java/com/neph/features/assignedrequest/presentation/AssignedRequestScreen.kt index b7e4f87e..8fa969fd 100644 --- a/android/app/src/main/java/com/neph/features/assignedrequest/presentation/AssignedRequestScreen.kt +++ b/android/app/src/main/java/com/neph/features/assignedrequest/presentation/AssignedRequestScreen.kt @@ -27,6 +27,7 @@ import com.neph.features.assignedrequest.data.AssignedRequestRepository import com.neph.features.assignedrequest.data.AssignmentRouteUiModel import com.neph.features.assignedrequest.data.AssignedRequestUiModel import com.neph.features.auth.data.AuthSessionStore +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.display.HelperText @@ -43,7 +44,10 @@ fun AssignedRequestScreen( onOpenSettings: () -> Unit, onProfileClick: () -> Unit, profileBadgeText: String, - onNavigateToLogin: () -> Unit + onNavigateToLogin: () -> Unit, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, + onMobileOnboardingFeedbackChanged: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val context = LocalContext.current @@ -166,6 +170,18 @@ fun AssignedRequestScreen( loadRouteInfo(assignmentId) } + LaunchedEffect(mobileOnboardingStepId, loading, currentRequest?.assignmentId) { + if (mobileOnboardingStepId == MobileOnboardingStepId.ASSIGNED_REQUESTS && !loading) { + onMobileOnboardingFeedbackChanged( + if (currentRequest == null) { + "There is no assigned request for you right now." + } else { + "You currently have an assigned request here." + } + ) + } + } + AppDrawerScaffold( title = "Assigned Request", currentRoute = Routes.AssignedRequest.route, @@ -174,7 +190,9 @@ fun AssignedRequestScreen( onOpenSettings = onOpenSettings, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = "Profile" + profileLabel = "Profile", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { when { loading -> { @@ -340,8 +358,28 @@ fun AssignedRequestScreen( ) } - request.bloodType?.let { - DetailLine(label = "Blood type", value = it) + if (request.shareProfileHealthInfoWithVolunteer) { + request.bloodType?.let { + DetailLine(label = "Blood type", value = it) + } + + if (request.medicalConditions.isNotEmpty()) { + DetailLine( + label = "Medical conditions", + value = request.medicalConditions.joinToString(", ") + ) + } + + if (request.chronicDiseases.isNotEmpty()) { + DetailLine( + label = "Chronic diseases", + value = request.chronicDiseases.joinToString(", ") + ) + } + + if (request.allergies.isNotEmpty()) { + DetailLine(label = "Allergies", value = request.allergies.joinToString(", ")) + } } } } diff --git a/android/app/src/main/java/com/neph/features/auth/presentation/CompleteProfileScreen.kt b/android/app/src/main/java/com/neph/features/auth/presentation/CompleteProfileScreen.kt index 7d7e8624..4ccf3e9b 100644 --- a/android/app/src/main/java/com/neph/features/auth/presentation/CompleteProfileScreen.kt +++ b/android/app/src/main/java/com/neph/features/auth/presentation/CompleteProfileScreen.kt @@ -26,6 +26,7 @@ import com.neph.features.profile.data.DeviceLocationProvider import com.neph.features.profile.data.LocationData import com.neph.features.profile.data.LocationTreeRepository import com.neph.features.profile.data.ProfileRepository +import com.neph.features.onboarding.data.MobileOnboardingStore import com.neph.features.profile.data.bloodTypeOptions import com.neph.features.profile.data.calculateAgeFromDateOfBirth import com.neph.features.profile.data.combinePhoneNumber @@ -326,6 +327,7 @@ fun CompleteProfileScreen( info = completionMessage Toast.makeText(context, completionMessage, Toast.LENGTH_LONG).show() + MobileOnboardingStore.markPendingForCurrentUser() onComplete() } catch (cancellationException: CancellationException) { diff --git a/android/app/src/main/java/com/neph/features/emergencyinfo/presentation/EmergencyInfoScreen.kt b/android/app/src/main/java/com/neph/features/emergencyinfo/presentation/EmergencyInfoScreen.kt index 023d236a..e0d0a5d1 100644 --- a/android/app/src/main/java/com/neph/features/emergencyinfo/presentation/EmergencyInfoScreen.kt +++ b/android/app/src/main/java/com/neph/features/emergencyinfo/presentation/EmergencyInfoScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.display.IconListRow import com.neph.ui.components.display.SectionCard @@ -93,7 +94,9 @@ fun EmergencyInfoScreen( onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, profileBadgeText: String, - isAuthenticated: Boolean + isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val context = LocalContext.current @@ -120,7 +123,9 @@ fun EmergencyInfoScreen( onOpenSettings = onOpenSettings, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account" + profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column(verticalArrangement = Arrangement.spacedBy(spacing.lg)) { SectionCard { diff --git a/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt b/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt index a55f309a..ef6a10f7 100644 --- a/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt +++ b/android/app/src/main/java/com/neph/features/gatheringareas/presentation/GatheringAreasScreen.kt @@ -35,6 +35,7 @@ import com.neph.features.gatheringareas.data.GatheringAreasRepository import com.neph.features.gatheringareas.data.NearbyGatheringAreasResult import com.neph.features.profile.data.CurrentLocationShareWarning import com.neph.features.profile.data.DeviceLocationProvider +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.buttons.TextActionButton @@ -171,7 +172,9 @@ fun GatheringAreasScreen( onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, profileBadgeText: String, - isAuthenticated: Boolean + isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val scope = rememberCoroutineScope() @@ -257,7 +260,7 @@ fun GatheringAreasScreen( } } - LaunchedEffect(effectiveLeafletViewportKey(pendingViewport), viewportRefreshNonce) { + LaunchedEffect(pendingViewport, lastFetchedViewportKey, viewportRefreshNonce) { val viewport = pendingViewport ?: return@LaunchedEffect if (!isLeafletViewportDiscoverable(viewport)) return@LaunchedEffect val viewportKey = effectiveLeafletViewportKey(viewport) ?: return@LaunchedEffect @@ -424,7 +427,9 @@ fun GatheringAreasScreen( onOpenSettings = onOpenSettings, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account" + profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( verticalArrangement = Arrangement.spacedBy(spacing.lg) diff --git a/android/app/src/main/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepository.kt b/android/app/src/main/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepository.kt index 31853370..1c52ec7c 100644 --- a/android/app/src/main/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepository.kt +++ b/android/app/src/main/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepository.kt @@ -1,14 +1,15 @@ package com.neph.features.helprequestmap.data +import com.neph.core.format.formatTimestampWithRelativeDay import com.neph.core.network.JsonHttpClient import com.neph.features.auth.data.AuthSessionStore import org.json.JSONArray import org.json.JSONObject import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.text.SimpleDateFormat +import java.time.Instant +import java.time.format.DateTimeFormatter import java.util.Locale -import java.util.TimeZone enum class CrisisRequestType { SHELTER, @@ -138,8 +139,11 @@ object ActiveHelpRequestsRepository { return when (rawType.trim().lowercase(Locale.ROOT)) { "shelter" -> CrisisRequestType.SHELTER "first_aid" -> CrisisRequestType.FIRST_AID + "search_rescue", + "search_and_rescue", + "sar", "fire_brigade", - "search_and_rescue" -> CrisisRequestType.SEARCH_AND_RESCUE + "rescue" -> CrisisRequestType.SEARCH_AND_RESCUE "food", "water", "food_water" -> CrisisRequestType.FOOD_WATER @@ -161,33 +165,27 @@ object ActiveHelpRequestsRepository { return priority.trim().lowercase(Locale.ROOT).replaceFirstChar { it.uppercase() } } - fun formatOpenedAt(createdAt: String): String { - val parsed = runCatching { - IsoDateFormat.get().parse(createdAt) - }.getOrNull() ?: return createdAt - - return DisplayDateFormat.get().format(parsed) + fun formatOpenedAt( + createdAt: String, + nowInstant: Instant = Instant.now() + ): String { + return formatTimestampWithRelativeDay( + raw = createdAt, + fallbackFormatter = DisplayDateFormatter, + timeFormatter = DisplayTimeFormatter, + nowInstant = nowInstant + ) ?: createdAt } private fun urlEncode(value: String): String { return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) } - private val IsoDateFormat = object : ThreadLocal() { - override fun initialValue(): SimpleDateFormat { - return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } - } + private val DisplayDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy, HH:mm", Locale.US) - private val DisplayDateFormat = object : ThreadLocal() { - override fun initialValue(): SimpleDateFormat { - return SimpleDateFormat("MMM d, yyyy, HH:mm", Locale.US).apply { - timeZone = TimeZone.getDefault() - } - } - } + private val DisplayTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm", Locale.US) } private fun JSONObject.optFiniteDouble(key: String): Double? { diff --git a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt index c8c0c0c9..e2c70950 100644 --- a/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt +++ b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt @@ -32,13 +32,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.neph.core.network.ApiException import com.neph.features.helprequestmap.data.ActiveHelpRequestMapItem +import com.neph.features.helprequestmap.data.ActiveHelpRequestsResult import com.neph.features.helprequestmap.data.ActiveHelpRequestsRepository import com.neph.features.helprequestmap.data.CrisisRequestType +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.features.profile.data.CurrentLocationShareWarning import com.neph.features.profile.data.DeviceLocationProvider import com.neph.navigation.Routes @@ -198,6 +202,13 @@ internal fun shouldShowPreviousHelpRequestsDuringViewportFetch( return requests.isNotEmpty() && !manualRefresh } +internal fun markersShouldFitBounds( + requests: List, + mapResetToken: Int +): Boolean { + return mapResetToken == 0 && requests.any { it.hasValidMapCoordinates() } +} + private fun ActiveHelpRequestMapItem.hasValidMapCoordinates(): Boolean { return latitude.isFinite() && longitude.isFinite() && @@ -211,7 +222,9 @@ fun HelpRequestMapScreen( onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, profileBadgeText: String, - isAuthenticated: Boolean + isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val context = LocalContext.current @@ -234,6 +247,20 @@ fun HelpRequestMapScreen( var mapZoom by remember { mutableStateOf(TurkeyOverviewZoom) } var mapResetNonce by remember { mutableStateOf(0) } + fun applyRequestResult(result: ActiveHelpRequestsResult, viewportKey: String?) { + requests = result.requests + viewportKey?.let { lastFetchedViewportKey = it } + viewportRefreshNonce = 0 + selectedRequestId = selectedRequestId + ?.takeIf { selected -> result.requests.any { it.requestId == selected } } + infoMessage = when { + result.requests.isEmpty() -> ResourceEmptyMessage + result.skippedCount > 0 -> + "${result.skippedCount} inactive or malformed request entries were hidden." + else -> "" + } + } + fun queueViewportRefresh() { val viewport = currentViewport if (!isLeafletViewportDiscoverable(viewport)) { @@ -321,7 +348,32 @@ fun HelpRequestMapScreen( } } - LaunchedEffect(effectiveLeafletViewportKey(pendingViewport), viewportRefreshNonce) { + LaunchedEffect(Unit) { + loading = true + errorMessage = "" + infoMessage = ResourceLoadingMessage + + try { + val result = ActiveHelpRequestsRepository.fetchWaitingHelpRequests() + applyRequestResult(result, viewportKey = null) + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (error: ApiException) { + errorMessage = error.message.ifBlank { ResourceErrorMessage } + requests = emptyList() + selectedRequestId = null + infoMessage = "" + } catch (_: Exception) { + errorMessage = ResourceErrorMessage + requests = emptyList() + selectedRequestId = null + infoMessage = "" + } finally { + loading = false + } + } + + LaunchedEffect(pendingViewport, lastFetchedViewportKey, viewportRefreshNonce) { val viewport = pendingViewport ?: return@LaunchedEffect if (!isLeafletViewportDiscoverable(viewport)) return@LaunchedEffect val viewportKey = effectiveLeafletViewportKey(viewport) ?: return@LaunchedEffect @@ -346,16 +398,7 @@ fun HelpRequestMapScreen( bbox = leafletViewportBboxString(viewport) ) if (requestSerial == viewportRequestSerial) { - requests = result.requests - lastFetchedViewportKey = viewportKey - viewportRefreshNonce = 0 - selectedRequestId = selectedRequestId - ?.takeIf { selected -> result.requests.any { it.requestId == selected } } - infoMessage = when { - result.skippedCount > 0 -> - "${result.skippedCount} inactive or malformed request entries were hidden." - else -> "" - } + applyRequestResult(result, viewportKey = viewportKey) } } catch (cancellationException: CancellationException) { throw cancellationException @@ -457,7 +500,9 @@ fun HelpRequestMapScreen( onOpenSettings = onOpenSettings, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account" + profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( verticalArrangement = Arrangement.spacedBy(spacing.lg) @@ -870,7 +915,12 @@ private fun CrisisRequestMapPanel( val tileLoadedInstanceIdState = remember { mutableStateOf(null) } val errorInstanceIdState = remember { mutableStateOf(null) } var mapError by remember { mutableStateOf("") } - val mapInstanceId = remember(mapResetToken) { + val mapInstanceKey = helpRequestMapInstanceKey(requests) + val shouldFitRequestMarkers = markersShouldFitBounds(requests, mapResetToken) + val effectiveCenterLatitude = if (shouldFitRequestMarkers) mapInstanceKey.centerLatitude else mapCenterLatitude + val effectiveCenterLongitude = if (shouldFitRequestMarkers) mapInstanceKey.centerLongitude else mapCenterLongitude + val effectiveZoom = if (shouldFitRequestMarkers) 13 else mapZoom + val mapInstanceId = remember(mapResetToken, mapInstanceKey) { newLeafletMapInstanceId() } val currentMapInstanceIdState = remember { mutableStateOf(mapInstanceId) } @@ -941,14 +991,14 @@ private fun CrisisRequestMapPanel( LeafletMarkerMap( mapInstanceId = mapInstanceId, currentMapInstanceId = { currentMapInstanceIdState.value }, - centerLatitude = mapCenterLatitude, - centerLongitude = mapCenterLongitude, + centerLatitude = effectiveCenterLatitude, + centerLongitude = effectiveCenterLongitude, markers = markers, selectedMarkerId = selectedRequestId, mapHeightCssPx = HelpRequestMapHeightCssPx, - zoom = mapZoom, + zoom = effectiveZoom, showCenterMarker = false, - fitBoundsToMarkers = false, + fitBoundsToMarkers = shouldFitRequestMarkers, onMarkerSelected = { markerInstanceId, markerId -> if (markerInstanceId == currentMapInstanceIdState.value) { onSelectRequest(markerId) @@ -1023,6 +1073,7 @@ private fun PinGlyph(type: CrisisRequestType, selected: Boolean = false) { Box( modifier = Modifier .size(if (selected) 40.dp else 34.dp) + .semantics { contentDescription = "Crisis marker: ${ActiveHelpRequestsRepository.labelForType(type)}" } .background( color = style.dotColor, shape = RoundedCornerShape( diff --git a/android/app/src/main/java/com/neph/features/home/presentation/HomeScreen.kt b/android/app/src/main/java/com/neph/features/home/presentation/HomeScreen.kt index 6c78843d..9e31ac18 100644 --- a/android/app/src/main/java/com/neph/features/home/presentation/HomeScreen.kt +++ b/android/app/src/main/java/com/neph/features/home/presentation/HomeScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -35,9 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,12 +55,13 @@ import com.neph.features.availability.data.AvailabilityRepository import com.neph.features.availability.presentation.AvailableToHelpCard import com.neph.features.availability.presentation.AvailabilitySyncIndicator import com.neph.features.operationallocation.data.OperationalLocationRepository +import com.neph.features.onboarding.data.MobileOnboardingJourney +import com.neph.features.onboarding.data.MobileOnboardingStepId +import com.neph.features.onboarding.presentation.mobileOnboardingPulse import com.neph.features.profile.data.CurrentDeviceLocation import com.neph.features.profile.data.CurrentLocationShareWarning import com.neph.features.profile.data.DeviceLocationProvider import com.neph.features.profile.data.ProfileRepository -import com.neph.features.requesthelp.data.EmergencyDraftRequirementsException -import com.neph.features.requesthelp.data.RequestHelpReverseLocation import com.neph.features.requesthelp.data.RequestHelpRepository import com.neph.features.safetycircles.presentation.CircleStatusCard import com.neph.features.safetystatus.data.SafetyStatusRepository @@ -65,8 +69,10 @@ import com.neph.features.safetystatus.data.SafetyStatusState import com.neph.navigation.Routes import com.neph.ui.layout.AppDrawerScaffold import com.neph.ui.location.rememberForegroundLocationPermissionRequester +import com.neph.ui.components.theme.ThemeIconButton import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephTheme +import com.neph.ui.components.theme.ThemeIconButton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -82,6 +88,8 @@ fun HomeScreen( onProfileClick: () -> Unit, profileBadgeText: String, isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, modifier: Modifier = Modifier ) { val spacing = LocalNephSpacing.current @@ -119,6 +127,8 @@ fun HomeScreen( var requestHelpError by remember { mutableStateOf("") } var markSafeLoading by remember { mutableStateOf(false) } var showMarkSafeLocationConsentDialog by remember { mutableStateOf(false) } + var showActiveHelpRequestMarkSafeDialog by remember { mutableStateOf(false) } + var pendingCancelActiveHelpRequestForMarkSafe by remember { mutableStateOf(false) } var emergencyInfo by remember { mutableStateOf("") } var emergencyError by remember { mutableStateOf("") } var locationPermissionInfo by remember { mutableStateOf("") } @@ -135,6 +145,8 @@ fun HomeScreen( } val lifecycleOwner = LocalLifecycleOwner.current + val isMobileOnboardingActive = mobileOnboardingStepId != null + val isRequestHelpOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.HOME_DASHBOARD DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> @@ -283,14 +295,6 @@ fun HomeScreen( } } - fun RequestHelpReverseLocation?.hasCompleteEmergencyAdministrativeLocation(): Boolean { - return this != null && - !country.isNullOrBlank() && - !city.isNullOrBlank() && - !district.isNullOrBlank() && - !neighborhood.isNullOrBlank() - } - fun handleRequestHelp() { availabilityError = "" availabilityInfo = "" @@ -338,27 +342,9 @@ fun HomeScreen( runCatching { OperationalLocationRepository.saveAndSyncIfAuthenticated(currentLocation) } - } - val reverseLocation = if (currentLocation != null) { - RequestHelpRepository.reverseGeocodeCurrentLocation( - latitude = currentLocation.latitude, - longitude = currentLocation.longitude - ) - } else { - null - } - if (currentLocation != null && !reverseLocation.hasCompleteEmergencyAdministrativeLocation()) { RequestHelpRepository.storePendingCoordinateSnapshot(currentLocation) - onRequestHelp(null) - return@launch } - val draft = RequestHelpRepository.createEmergencyDraft( - token = sessionToken, - profile = ProfileRepository.getProfile(), - currentLocation = currentLocation, - reverseLocation = reverseLocation - ) - onRequestHelp(draft.requestId) + onRequestHelp(null) } } catch (error: ApiException) { if (error.status == 401) { @@ -368,8 +354,6 @@ fun HomeScreen( } else { requestHelpError = "We could not verify your current help request status. Please try again." } - } catch (_: EmergencyDraftRequirementsException) { - onRequestHelp(null) } catch (_: Exception) { requestHelpError = "We could not verify your current help request status. Please try again." } finally { @@ -386,11 +370,51 @@ fun HomeScreen( emergencyInfo = "" if (!isAuthenticated || sessionToken.isNullOrBlank()) { + pendingCancelActiveHelpRequestForMarkSafe = false + emergencyError = "Please log in to mark yourself safe." + return + } + + pendingCancelActiveHelpRequestForMarkSafe = false + val safeSessionToken = sessionToken + markSafeLoading = true + scope.launch { + try { + val hasActiveRequest = RequestHelpRepository.hasActiveHelpRequest(safeSessionToken) + if (hasActiveRequest) { + showActiveHelpRequestMarkSafeDialog = true + } else { + showMarkSafeLocationConsentDialog = true + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (error: ApiException) { + if (error.status == 401) { + AuthRepository.logout() + emergencyError = "Your session expired. Please log in again before marking yourself safe." + onNavigateToLogin() + } else { + emergencyError = "Could not verify your active help request status. Please try again." + } + } catch (_: Exception) { + emergencyError = "Could not verify your active help request status. Please try again." + } finally { + markSafeLoading = false + } + } + } + + fun confirmMarkSafeWithActiveHelpRequest() { + val safeSessionToken = sessionToken + if (!isAuthenticated || safeSessionToken.isNullOrBlank()) { + showActiveHelpRequestMarkSafeDialog = false emergencyError = "Please log in before marking yourself safe." onNavigateToLogin() return } + pendingCancelActiveHelpRequestForMarkSafe = true + showActiveHelpRequestMarkSafeDialog = false showMarkSafeLocationConsentDialog = true } @@ -401,15 +425,26 @@ fun HomeScreen( val safeSessionToken = sessionToken if (!isAuthenticated || safeSessionToken.isNullOrBlank()) { showMarkSafeLocationConsentDialog = false + pendingCancelActiveHelpRequestForMarkSafe = false emergencyError = "Please log in before marking yourself safe." onNavigateToLogin() return } + val shouldCancelActiveHelpRequest = pendingCancelActiveHelpRequestForMarkSafe showMarkSafeLocationConsentDialog = false markSafeLoading = true scope.launch { try { + if (shouldCancelActiveHelpRequest) { + val cancellationResult = + RequestHelpRepository.cancelActiveAuthenticatedHelpRequestsForMarkSafe(safeSessionToken) + if (!cancellationResult.canMarkSafe) { + emergencyError = + "Your active help request could not be cancelled yet. Please try again once it syncs before marking yourself safe." + return@launch + } + } val locationAttempt = if (shareLocation && !permissionDeniedBeforeCapture) { DeviceLocationProvider.captureCurrentLocationForSharing( context = context, @@ -443,13 +478,22 @@ fun HomeScreen( emergencyError = "Your session expired. Please log in again before marking yourself safe." onNavigateToLogin() } else { - emergencyError = error.message.ifBlank { "Could not mark you safe. Please try again." } + emergencyError = if (shouldCancelActiveHelpRequest) { + "Your active help request could not be cancelled yet. Please try again once it syncs before marking yourself safe." + } else { + error.message.ifBlank { "Could not mark you safe. Please try again." } + } } } catch (cancellationException: CancellationException) { throw cancellationException } catch (_: Exception) { - emergencyError = "Could not mark you safe. Please try again." + emergencyError = if (shouldCancelActiveHelpRequest) { + "Your active help request could not be cancelled yet. Please try again once it syncs before marking yourself safe." + } else { + "Could not mark you safe. Please try again." + } } finally { + pendingCancelActiveHelpRequestForMarkSafe = false markSafeLoading = false } } @@ -477,9 +521,44 @@ fun HomeScreen( } } + if (showActiveHelpRequestMarkSafeDialog) { + AlertDialog( + onDismissRequest = { + pendingCancelActiveHelpRequestForMarkSafe = false + showActiveHelpRequestMarkSafeDialog = false + }, + title = { + Text(text = "Mark yourself safe?") + }, + text = { + Text( + text = "You have an active help request. Marking yourself safe will cancel or update that request. Are you sure you want to continue?" + ) + }, + confirmButton = { + TextButton(onClick = ::confirmMarkSafeWithActiveHelpRequest) { + Text("Mark safe") + } + }, + dismissButton = { + TextButton( + onClick = { + pendingCancelActiveHelpRequestForMarkSafe = false + showActiveHelpRequestMarkSafeDialog = false + } + ) { + Text("Keep request active") + } + } + ) + } + if (showMarkSafeLocationConsentDialog) { AlertDialog( - onDismissRequest = { showMarkSafeLocationConsentDialog = false }, + onDismissRequest = { + pendingCancelActiveHelpRequestForMarkSafe = false + showMarkSafeLocationConsentDialog = false + }, title = { Text(text = "Share location with your safe status?") }, @@ -519,7 +598,12 @@ fun HomeScreen( onOpenSettings = onOpenSettings, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account" + profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted, + topBarActions = { + ThemeIconButton() + } ) { Column( modifier = Modifier.fillMaxWidth(), @@ -531,15 +615,32 @@ fun HomeScreen( safetyStatus = safetyStatusState.status ) + if (isRequestHelpOnboardingTarget) { + MobileOnboardingWelcomeMessage() + } + EmergencyHelpAction( loading = requestHelpLoading, - enabled = !availabilityLoading && !markSafeLoading, - onClick = ::handleRequestHelp + enabled = !availabilityLoading && + !markSafeLoading && + (!isMobileOnboardingActive || isRequestHelpOnboardingTarget), + modifier = Modifier.mobileOnboardingPulse(isRequestHelpOnboardingTarget), + onClick = { + if (isRequestHelpOnboardingTarget) { + onRequestHelp(null) + val message = MobileOnboardingJourney + .stepFor(requireNotNull(mobileOnboardingStepId), isAuthenticated) + ?.completionMessage + onMobileOnboardingStepCompleted(message) + } else { + handleRequestHelp() + } + } ) MarkSafeRow( loading = markSafeLoading, - enabled = !availabilityLoading && !requestHelpLoading && !markSafeLoading, + enabled = !isMobileOnboardingActive && !availabilityLoading && !requestHelpLoading && !markSafeLoading, statusMessage = buildSafetyStatusSyncMessage(safetyStatusState), isError = safetyStatusState.isFailedSync, onClick = ::requestMarkSafeConfirmation @@ -582,8 +683,8 @@ fun HomeScreen( else -> "" }, syncIndicator = availabilitySyncIndicator, - onRefreshLocationAndBecomeAvailable = { handleAvailabilityChange(true) }, - onAvailabilityChange = ::handleAvailabilityChange + onRefreshLocationAndBecomeAvailable = { if (!isMobileOnboardingActive) handleAvailabilityChange(true) }, + onAvailabilityChange = { if (!isMobileOnboardingActive) handleAvailabilityChange(it) } ) } @@ -621,7 +722,7 @@ private fun HomeGreetingHero( val safetyTone = when (safetyStatus.lowercase()) { "safe" -> Triple(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary, "You're marked safe") "not_safe", "needs_help" -> Triple(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError, "Help requested") - else -> Triple(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant, "Status unknown") + else -> Triple(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant, "No safety status yet") } val greetingPrimary = if (isAuthenticated) "Hello" else "Welcome" val greetingDetail = if (isAuthenticated) { @@ -688,6 +789,7 @@ private fun HomeGreetingHero( private fun EmergencyHelpAction( loading: Boolean, enabled: Boolean, + modifier: Modifier = Modifier, onClick: () -> Unit ) { val spacing = LocalNephSpacing.current @@ -700,6 +802,8 @@ private fun EmergencyHelpAction( ) Row( modifier = Modifier + .then(modifier) + .testTag("home_request_help_action") .fillMaxWidth() .heightIn(min = 96.dp) .background(brush = gradient, shape = RoundedCornerShape(24.dp)) @@ -750,6 +854,56 @@ private fun EmergencyHelpAction( } } +@Composable +private fun MobileOnboardingWelcomeMessage() { + val spacing = LocalNephSpacing.current + Box( + modifier = Modifier + .fillMaxWidth() + .testTag("mobile_onboarding_welcome_message") + ) { + Box( + modifier = Modifier + .matchParentSize() + .blur(18.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.46f), + shape = RoundedCornerShape(24.dp) + ) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.20f), + shape = RoundedCornerShape(24.dp) + ), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + tonalElevation = 6.dp, + shadowElevation = 2.dp + ) { + Column( + modifier = Modifier.padding(spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { + Text( + text = "Welcome to NEPH", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text = "This short app guide is a safe practice flow. Follow the highlighted controls; it will not create a real help request.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + @Composable private fun MarkSafeRow( loading: Boolean, diff --git a/android/app/src/main/java/com/neph/features/myhelprequests/data/MyHelpRequestsRepository.kt b/android/app/src/main/java/com/neph/features/myhelprequests/data/MyHelpRequestsRepository.kt index fe374b56..9379890f 100644 --- a/android/app/src/main/java/com/neph/features/myhelprequests/data/MyHelpRequestsRepository.kt +++ b/android/app/src/main/java/com/neph/features/myhelprequests/data/MyHelpRequestsRepository.kt @@ -4,6 +4,7 @@ import com.neph.core.NephAppContext import com.neph.core.database.HelpRequestEntity import com.neph.core.database.NephDatabaseProvider import com.neph.core.database.SyncOperationEntity +import com.neph.core.format.formatInstantWithRelativeDay import com.neph.core.network.JsonHttpClient import com.neph.core.sync.LocalOwnerType import com.neph.core.sync.OfflineSyncScheduler @@ -20,6 +21,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.json.JSONArray import org.json.JSONObject +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Locale data class MyHelpRequestUiModel( val id: String, @@ -51,7 +55,8 @@ data class MyHelpRequestUiModel( val openDurationLabel: String?, val syncStatus: String = SyncStatus.SYNCED, val pendingError: String? = null, - val lastSyncedAt: String? = null + val lastSyncedAt: String? = null, + val isGuideOnly: Boolean = false ) { val isPendingSync: Boolean get() = syncStatus == SyncStatus.PENDING_CREATE || syncStatus == SyncStatus.PENDING_UPDATE @@ -422,10 +427,20 @@ private fun buildShortDescription(description: String): String { } private fun formatEpochMillis(raw: Long): String { - val formatter = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.US) - return formatter.format(java.util.Date(raw)) + return formatInstantWithRelativeDay( + instant = Instant.ofEpochMilli(raw), + fallbackFormatter = EpochDisplayFormatter, + timeFormatter = EpochTimeFormatter, + relativeSeparator = " " + ) } +private val EpochDisplayFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US) + +private val EpochTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm", Locale.US) + @Suppress("unused") private fun JSONArray?.toStringList(): List { if (this == null) { diff --git a/android/app/src/main/java/com/neph/features/myhelprequests/presentation/MyHelpRequestsScreen.kt b/android/app/src/main/java/com/neph/features/myhelprequests/presentation/MyHelpRequestsScreen.kt index b37e7b30..f425ff99 100644 --- a/android/app/src/main/java/com/neph/features/myhelprequests/presentation/MyHelpRequestsScreen.kt +++ b/android/app/src/main/java/com/neph/features/myhelprequests/presentation/MyHelpRequestsScreen.kt @@ -50,6 +50,8 @@ import com.neph.features.auth.data.AuthSessionStore import com.neph.features.myhelprequests.data.buildMyHelpRequestsOverview import com.neph.features.myhelprequests.data.MyHelpRequestUiModel import com.neph.features.myhelprequests.data.MyHelpRequestsRepository +import com.neph.features.onboarding.data.MobileOnboardingPracticeHelpRequest +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton @@ -73,7 +75,10 @@ fun MyHelpRequestsScreen( onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, profileBadgeText: String, - isAuthenticated: Boolean + isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, + mobileOnboardingPracticeHelpRequest: MobileOnboardingPracticeHelpRequest? = null ) { val spacing = LocalNephSpacing.current val token = AuthSessionStore.getAccessToken().orEmpty() @@ -89,6 +94,12 @@ fun MyHelpRequestsScreen( var reconnectRefreshInProgress by remember { mutableStateOf(false) } var pendingAction by remember { mutableStateOf(null) } val pullToRefreshState = rememberPullToRefreshState() + val practiceRequest = mobileOnboardingPracticeHelpRequest?.toPracticeUiModel() + val displayedRequests = if (practiceRequest != null) { + listOf(practiceRequest) + requests.filterNot { it.isGuideOnly } + } else { + requests + } fun refreshRequests(showFullPageLoading: Boolean) { if (!showFullPageLoading && (initialRefreshInProgress || reconnectRefreshInProgress)) return @@ -195,7 +206,9 @@ fun MyHelpRequestsScreen( onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", - contentFillMaxSize = true + contentFillMaxSize = true, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { LaunchedEffect(isAuthenticated, token) { refreshRequests(showFullPageLoading = true) @@ -207,7 +220,7 @@ fun MyHelpRequestsScreen( state = pullToRefreshState, modifier = Modifier.fillMaxSize() ) { - val overview = buildMyHelpRequestsOverview(requests) + val overview = buildMyHelpRequestsOverview(displayedRequests) val currentActiveRequest = overview.activeRequests.firstOrNull() val requestHistory = overview.historyRequests @@ -224,7 +237,7 @@ fun MyHelpRequestsScreen( } when { - initialRefreshInProgress && requests.isEmpty() -> { + initialRefreshInProgress && displayedRequests.isEmpty() -> { item { LoadingStateView( modifier = Modifier @@ -234,7 +247,7 @@ fun MyHelpRequestsScreen( } } - requests.isEmpty() -> { + displayedRequests.isEmpty() -> { item { EmptyStateView( onRequestHelp = { onNavigateToRoute(Routes.RequestHelp.route) }, @@ -285,13 +298,17 @@ fun MyHelpRequestsScreen( subtitleOverride = currentActiveRequest.createdAt?.let { "Opened: $it" } ?: "Opened time unavailable", actionMessage = actionMessage, - onEdit = { pendingAction = PendingRequestAction.Edit(currentActiveRequest) }, - onCancel = if (isAuthenticated || currentActiveRequest.localId.isNotBlank()) { + onEdit = if (currentActiveRequest.isGuideOnly) { + null + } else { + { pendingAction = PendingRequestAction.Edit(currentActiveRequest) } + }, + onCancel = if (!currentActiveRequest.isGuideOnly && (isAuthenticated || currentActiveRequest.localId.isNotBlank())) { { pendingAction = PendingRequestAction.Cancel(currentActiveRequest) } } else { null }, - onResolve = if (isAuthenticated || currentActiveRequest.localId.isNotBlank()) { + onResolve = if (!currentActiveRequest.isGuideOnly && (isAuthenticated || currentActiveRequest.localId.isNotBlank())) { { pendingAction = PendingRequestAction.Resolve(currentActiveRequest) } } else { null @@ -637,6 +654,10 @@ private fun MyHelpRequestCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) + if (request.isGuideOnly) { + HelperText(text = "Guide preview only — this help request was not saved.") + } + Text( text = "Location: ${request.locationLabel}", style = MaterialTheme.typography.labelMedium, @@ -800,6 +821,38 @@ private fun MyHelpRequestCard( } } +private fun MobileOnboardingPracticeHelpRequest.toPracticeUiModel(): MyHelpRequestUiModel { + return MyHelpRequestUiModel( + id = "mobile-onboarding-practice-help-request", + localId = "", + helpTypes = helpTypes, + helpTypeSummary = helpTypes.joinToString(", ").ifBlank { "Practice Help Request" }, + description = description, + shortDescription = description, + locationLabel = locationLabel, + status = "OPEN", + statusLabel = "Guide preview", + isActive = true, + contactName = contactName, + contactPhone = contactPhone, + alternativePhone = null, + responders = emptyList(), + helperFirstName = null, + helperLastName = null, + helperPhone = null, + helperProfession = null, + helperExpertise = null, + helperFullName = null, + createdAt = createdAtLabel, + urgencyLabel = null, + priorityLabel = null, + closedAtLabel = null, + closedStateLabel = null, + openDurationLabel = null, + isGuideOnly = true + ) +} + private fun MyHelpRequestUiModel.pendingSyncMessage(): String { return when (status.trim().uppercase()) { "CANCELLED" -> if (syncStatus == com.neph.core.sync.SyncStatus.PENDING_CREATE) { diff --git a/android/app/src/main/java/com/neph/features/nearbyusers/presentation/NearbyVisibleUsersScreen.kt b/android/app/src/main/java/com/neph/features/nearbyusers/presentation/NearbyVisibleUsersScreen.kt index 3623b28e..2d87913b 100644 --- a/android/app/src/main/java/com/neph/features/nearbyusers/presentation/NearbyVisibleUsersScreen.kt +++ b/android/app/src/main/java/com/neph/features/nearbyusers/presentation/NearbyVisibleUsersScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.neph.core.format.formatInstantWithRelativeDay import com.neph.core.network.ApiException import com.neph.features.auth.data.AuthRepository import com.neph.features.auth.data.AuthSessionStore @@ -34,6 +35,7 @@ import com.neph.features.operationallocation.data.OperationalLocationRepository import com.neph.features.profile.data.CurrentLocationShareWarning import com.neph.features.profile.data.DeviceLocationProvider import com.neph.features.profile.data.ProfileRepository +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.display.HelperText @@ -47,8 +49,8 @@ import com.neph.ui.location.rememberForegroundLocationPermissionRequester import com.neph.ui.theme.LocalNephSpacing import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.format.DateTimeFormatter import java.util.Locale @Composable @@ -58,7 +60,9 @@ fun NearbyVisibleUsersScreen( onProfileClick: () -> Unit, onNavigateToLogin: () -> Unit, profileBadgeText: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val scope = rememberCoroutineScope() @@ -228,7 +232,9 @@ fun NearbyVisibleUsersScreen( profileBadgeText = profileBadgeText, profileLabel = "Profile", contentMaxWidth = 560.dp, - contentAlignment = Alignment.TopCenter + contentAlignment = Alignment.TopCenter, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( modifier = Modifier.fillMaxWidth(), @@ -383,5 +389,16 @@ private fun formatSafetyStatus(status: String): String { } private fun formatEpoch(epochMillis: Long): String { - return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(epochMillis)) + return formatInstantWithRelativeDay( + instant = Instant.ofEpochMilli(epochMillis), + fallbackFormatter = EpochDisplayFormatter, + timeFormatter = EpochTimeFormatter, + relativeSeparator = " " + ) } + +private val EpochDisplayFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US) + +private val EpochTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm", Locale.US) diff --git a/android/app/src/main/java/com/neph/features/notifications/presentation/NotificationsScreen.kt b/android/app/src/main/java/com/neph/features/notifications/presentation/NotificationsScreen.kt index 0727f65a..ebb12582 100644 --- a/android/app/src/main/java/com/neph/features/notifications/presentation/NotificationsScreen.kt +++ b/android/app/src/main/java/com/neph/features/notifications/presentation/NotificationsScreen.kt @@ -25,10 +25,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import com.neph.core.format.formatTimestampWithRelativeDay import com.neph.features.auth.data.AuthSessionStore import com.neph.features.notifications.data.NotificationUiModel import com.neph.features.notifications.data.NotificationsBadge import com.neph.features.notifications.data.NotificationsRepository +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.display.EmptyState import com.neph.ui.components.display.SectionCard @@ -39,9 +41,8 @@ import com.neph.ui.layout.AppDrawerScaffold import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephTheme import kotlinx.coroutines.launch -import java.text.SimpleDateFormat +import java.time.format.DateTimeFormatter import java.util.Locale -import java.util.TimeZone @Composable fun NotificationsScreen( @@ -49,7 +50,9 @@ fun NotificationsScreen( onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, profileBadgeText: String, - isAuthenticated: Boolean + isAuthenticated: Boolean, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val coroutineScope = rememberCoroutineScope() @@ -103,7 +106,9 @@ fun NotificationsScreen( profileBadgeText = profileBadgeText, profileLabel = if (isAuthenticated) "Profile" else "Login / Create Account", contentFillMaxSize = true, - contentScrollable = false + contentScrollable = false, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( modifier = Modifier.fillMaxSize(), @@ -286,40 +291,18 @@ fun NotificationsScreen( } private fun formatNotificationTimestamp(raw: String): String { - val isoWithMillis = requireNotNull(NotificationIsoWithMillis.get()) - val isoNoMillis = requireNotNull(NotificationIsoNoMillis.get()) - val displayFormatter = requireNotNull(NotificationDisplayFormat.get()) - - val parsed = runCatching { - isoWithMillis.parse(raw) - }.recoverCatching { - isoNoMillis.parse(raw) - }.getOrNull() ?: return raw - - return displayFormatter.format(parsed) + return formatTimestampWithRelativeDay( + raw = raw, + fallbackFormatter = NotificationDisplayFormatter, + timeFormatter = NotificationTimeFormatter + ) ?: raw } -private val NotificationIsoWithMillis = object : ThreadLocal() { - override fun initialValue(): SimpleDateFormat { - return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } -} +private val NotificationDisplayFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy, HH:mm", Locale.US) -private val NotificationIsoNoMillis = object : ThreadLocal() { - override fun initialValue(): SimpleDateFormat { - return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } -} - -private val NotificationDisplayFormat = object : ThreadLocal() { - override fun initialValue(): SimpleDateFormat { - return SimpleDateFormat("MMM d, yyyy, HH:mm", Locale.US) - } -} +private val NotificationTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm", Locale.US) @Preview(showBackground = true, showSystemUi = true) @Composable diff --git a/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt new file mode 100644 index 00000000..82500154 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt @@ -0,0 +1,416 @@ +package com.neph.features.onboarding.data + +import com.neph.navigation.Routes + +enum class MobileOnboardingStepId { + HOME_DASHBOARD, + REQUEST_HELP_TYPE, + REQUEST_HELP_RISK_FIRE, + REQUEST_HELP_CONFIRM, + REQUEST_HELP_SEND, + MY_HELP_REQUESTS, + OPEN_ASSIGNED_REQUESTS_MENU, + SELECT_ASSIGNED_REQUEST, + ASSIGNED_REQUESTS, + OPEN_EMERGENCY_NUMBERS_MENU, + SELECT_EMERGENCY_NUMBERS, + EMERGENCY_NUMBERS, + OPEN_HELP_REQUEST_MAP_MENU, + SELECT_HELP_REQUEST_MAP, + HELP_REQUEST_MAP, + OPEN_NEARBY_USERS_MENU, + SELECT_NEARBY_USERS, + NEARBY_USERS, + OPEN_GATHERING_AREAS_MENU, + SELECT_GATHERING_AREAS, + GATHERING_AREAS, + OPEN_SAFETY_CIRCLES_MENU, + SELECT_SAFETY_CIRCLES, + SAFETY_CIRCLES, + OPEN_NOTIFICATIONS_MENU, + SELECT_NOTIFICATIONS, + NOTIFICATIONS, + OPEN_SETTINGS_MENU, + SELECT_SETTINGS, + SETTINGS +} + +enum class MobileOnboardingPanelPlacement { + TOP, + CENTER, + BOTTOM +} + +data class MobileOnboardingStep( + val id: MobileOnboardingStepId, + val route: String, + val title: String, + val eyebrow: String, + val description: String, + val actionLabel: String, + val targetHint: String, + val completionMessage: String? = null, + val usesExistingTarget: Boolean = false, + val panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.BOTTOM, + val authenticatedOnly: Boolean = false, + val opensMenu: Boolean = false, + val menuTargetRoute: String? = null +) + +object MobileOnboardingJourney { + private val steps = listOf( + MobileOnboardingStep( + id = MobileOnboardingStepId.HOME_DASHBOARD, + route = Routes.Home.route, + title = "Home Dashboard", + eyebrow = "Your command center", + description = "Start here to request help, mark yourself safe, review updates, and manage your volunteer status.", + actionLabel = "Tap I need help now", + targetHint = "This is the fastest emergency entry point on the home screen.", + completionMessage = "You opened the emergency help request flow.", + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.BOTTOM + ), + MobileOnboardingStep( + id = MobileOnboardingStepId.REQUEST_HELP_TYPE, + route = Routes.RequestHelp.route, + title = "Request Help", + eyebrow = "Ask for support clearly", + description = "Create a help request with accurate type, location, and details so responders understand what is needed. Start by choosing the type of support.", + actionLabel = "Tap Search & Rescue", + targetHint = "Use this chip when the situation requires locating or extracting people.", + completionMessage = "You selected Search & Rescue as the help type.", + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.BOTTOM + ), + MobileOnboardingStep( + id = MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE, + route = Routes.RequestHelp.route, + title = "Risk Flags", + eyebrow = "Add danger context", + description = "Risk flags help responders understand hazards before they arrive.", + actionLabel = "Tap Fire", + targetHint = "Mark Fire when flames, smoke, or fire risk are part of the situation.", + completionMessage = "You marked fire as a risk flag.", + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.TOP + ), + MobileOnboardingStep( + id = MobileOnboardingStepId.REQUEST_HELP_CONFIRM, + route = Routes.RequestHelp.route, + title = "Confirmation", + eyebrow = "Confirm sharing", + description = "Before sending, confirm the request details can be shared for emergency coordination.", + actionLabel = "Tap the confirmation checkbox", + targetHint = "This consent is required before NEPH can send the help request.", + completionMessage = "You confirmed the request can be shared for emergency coordination.", + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.TOP + ), + MobileOnboardingStep( + id = MobileOnboardingStepId.REQUEST_HELP_SEND, + route = Routes.RequestHelp.route, + title = "Send Help Request", + eyebrow = "Practice the final action", + description = "The guide lets you practice the final send action without saving a real help request.", + actionLabel = "Tap Send Help Request", + targetHint = "During the guide, this only advances the tutorial and does not create a real request.", + completionMessage = "Nice — that was only a practice send. No real help request was saved.", + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.TOP + ), + MobileOnboardingStep( + id = MobileOnboardingStepId.MY_HELP_REQUESTS, + route = Routes.MyHelpRequests.route, + title = "My Help Requests", + eyebrow = "Track your request", + description = "After a real help request is sent, NEPH brings you here to track it. During the guide, your practice request is shown only as a temporary preview.", + actionLabel = "Continue", + targetHint = "Review the temporary practice request, then continue to Assigned Request.", + completionMessage = "You saw where your own help requests appear after sending.", + panelPlacement = MobileOnboardingPanelPlacement.BOTTOM + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU, + route = Routes.MyHelpRequests.route, + nextPageName = "Assigned Request", + panelPlacement = MobileOnboardingPanelPlacement.CENTER + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST, + route = Routes.MyHelpRequests.route, + target = Routes.AssignedRequest, + title = "Assigned Request", + description = "Assigned Request shows the emergency support request currently assigned to you, if there is one.", + completionMessage = "You opened Assigned Request." + ), + pageStep( + id = MobileOnboardingStepId.ASSIGNED_REQUESTS, + route = Routes.AssignedRequest.route, + title = "Assigned Requests", + eyebrow = "Follow work assigned to you", + description = "This is where assigned support requests and next actions appear. If you have an assignment, it appears here; otherwise NEPH tells you there is no assigned request right now.", + completionMessage = "You checked whether there is an assigned request for you." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_EMERGENCY_NUMBERS_MENU, + route = Routes.AssignedRequest.route, + nextPageName = "Emergency Numbers" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_EMERGENCY_NUMBERS, + route = Routes.AssignedRequest.route, + target = Routes.EmergencyInfo, + title = "Emergency Numbers", + description = "Emergency Numbers keeps quick-call public emergency contacts close when you need immediate outside help.", + completionMessage = "You opened Emergency Numbers." + ), + pageStep( + id = MobileOnboardingStepId.EMERGENCY_NUMBERS, + route = Routes.EmergencyInfo.route, + title = "Emergency Numbers", + eyebrow = "Call critical services", + description = "Use this page to quickly reach emergency services such as 112, police, fire, and other public support lines.", + completionMessage = "You reviewed where emergency contact numbers live." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_HELP_REQUEST_MAP_MENU, + route = Routes.EmergencyInfo.route, + nextPageName = "Help Request Map" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_HELP_REQUEST_MAP, + route = Routes.EmergencyInfo.route, + target = Routes.HelpRequestMap, + title = "Help Request Map", + description = "The map helps you understand active needs around you and where support may be required.", + completionMessage = "You opened Help Request Map." + ), + pageStep( + id = MobileOnboardingStepId.HELP_REQUEST_MAP, + route = Routes.HelpRequestMap.route, + title = "Help Request Map", + eyebrow = "See needs nearby", + description = "Use this page to view help requests on the map and orient yourself before moving toward support locations.", + completionMessage = "You reviewed the map for nearby needs." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_NEARBY_USERS_MENU, + route = Routes.HelpRequestMap.route, + nextPageName = "Nearby Users" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_NEARBY_USERS, + route = Routes.HelpRequestMap.route, + target = Routes.NearbyUsers, + title = "Nearby Users", + description = "Nearby Users helps you understand who else is visible around you for coordination.", + completionMessage = "You opened Nearby Users." + ), + pageStep( + id = MobileOnboardingStepId.NEARBY_USERS, + route = Routes.NearbyUsers.route, + title = "Nearby Users", + eyebrow = "Coordinate locally", + description = "This page shows visible nearby people when location and account settings allow it.", + completionMessage = "You reviewed nearby-user coordination." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_GATHERING_AREAS_MENU, + route = Routes.NearbyUsers.route, + nextPageName = "Gathering Areas" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_GATHERING_AREAS, + route = Routes.NearbyUsers.route, + target = Routes.GatheringAreas, + title = "Gathering Areas", + description = "Gathering Areas helps you find safer public places and coordination points.", + completionMessage = "You opened Gathering Areas." + ), + pageStep( + id = MobileOnboardingStepId.GATHERING_AREAS, + route = Routes.GatheringAreas.route, + title = "Gathering Areas", + eyebrow = "Find safe places", + description = "Use this page to explore nearby gathering areas and shelter-like places on the map.", + completionMessage = "You reviewed gathering areas." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_SAFETY_CIRCLES_MENU, + route = Routes.GatheringAreas.route, + nextPageName = "Safety Circles" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_SAFETY_CIRCLES, + route = Routes.GatheringAreas.route, + target = Routes.SafetyCircles, + title = "Safety Circles", + description = "Safety Circles are private groups for checking in with trusted people.", + completionMessage = "You opened Safety Circles." + ), + pageStep( + id = MobileOnboardingStepId.SAFETY_CIRCLES, + route = Routes.SafetyCircles.route, + title = "Safety Circles", + eyebrow = "Stay connected", + description = "Use this page to create circles, invite trusted people, and coordinate check-ins.", + completionMessage = "You reviewed safety circles." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_NOTIFICATIONS_MENU, + route = Routes.SafetyCircles.route, + nextPageName = "Notifications" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_NOTIFICATIONS, + route = Routes.SafetyCircles.route, + target = Routes.Notifications, + title = "Notifications", + description = "Notifications collect alerts, assignment updates, and coordination messages.", + completionMessage = "You opened Notifications." + ), + pageStep( + id = MobileOnboardingStepId.NOTIFICATIONS, + route = Routes.Notifications.route, + title = "Notifications", + eyebrow = "Review app alerts", + description = "Use this page to catch up on unread alerts and recent emergency coordination updates.", + completionMessage = "You reviewed notifications." + ), + menuOpenStep( + id = MobileOnboardingStepId.OPEN_SETTINGS_MENU, + route = Routes.Notifications.route, + nextPageName = "Settings" + ), + menuSelectStep( + id = MobileOnboardingStepId.SELECT_SETTINGS, + route = Routes.Notifications.route, + target = Routes.Settings, + title = "Settings", + description = "Settings is where you can restart this guide, manage privacy and security, or sign out.", + completionMessage = "You opened Settings." + ), + pageStep( + id = MobileOnboardingStepId.SETTINGS, + route = Routes.Settings.route, + title = "Settings", + eyebrow = "Manage your account", + description = "The guide is complete. You can restart it later from Settings whenever you want to practice the core flow again.", + actionLabel = "Finish guide", + targetHint = "Click Finish to close the guide and return Home.", + panelPlacement = MobileOnboardingPanelPlacement.CENTER + ) + ) + + fun availableSteps(isAuthenticated: Boolean): List { + return steps.filter { !it.authenticatedOnly || isAuthenticated } + } + + fun firstStep(isAuthenticated: Boolean): MobileOnboardingStep { + return availableSteps(isAuthenticated).first() + } + + fun stepFor(id: MobileOnboardingStepId, isAuthenticated: Boolean): MobileOnboardingStep? { + return availableSteps(isAuthenticated).firstOrNull { it.id == id } + } + + fun nextStep(currentId: MobileOnboardingStepId, isAuthenticated: Boolean): MobileOnboardingStep? { + val available = availableSteps(isAuthenticated) + val currentIndex = available.indexOfFirst { it.id == currentId } + if (currentIndex == -1) return available.firstOrNull() + return available.getOrNull(currentIndex + 1) + } + + fun previousStep(currentId: MobileOnboardingStepId, isAuthenticated: Boolean): MobileOnboardingStep? { + val available = availableSteps(isAuthenticated) + val currentIndex = available.indexOfFirst { it.id == currentId } + if (currentIndex <= 0) return null + return available[currentIndex - 1] + } + + fun progressFor(currentId: MobileOnboardingStepId, isAuthenticated: Boolean): Pair { + val available = availableSteps(isAuthenticated) + val currentIndex = available.indexOfFirst { it.id == currentId }.takeIf { it >= 0 } ?: 0 + val totalCountedSteps = available.count { !it.opensMenu }.coerceAtLeast(1) + val currentStep = available.getOrNull(currentIndex) + val countedStepNumber = if (currentStep?.opensMenu == true) { + available.take(currentIndex).count { !it.opensMenu } + } else { + available.take(currentIndex + 1).count { !it.opensMenu } + }.coerceIn(1, totalCountedSteps) + + return countedStepNumber to totalCountedSteps + } + + private fun menuOpenStep( + id: MobileOnboardingStepId, + route: String, + nextPageName: String, + panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.CENTER + ): MobileOnboardingStep { + return MobileOnboardingStep( + id = id, + route = route, + title = "Open the Menu", + eyebrow = "Move to the next section", + description = "Open the bottom Menu to continue to $nextPageName.", + actionLabel = "Tap Menu", + targetHint = "Open the menu to find $nextPageName.", + completionMessage = "You opened the app menu.", + usesExistingTarget = true, + panelPlacement = panelPlacement, + authenticatedOnly = true, + opensMenu = true + ) + } + + private fun menuSelectStep( + id: MobileOnboardingStepId, + route: String, + target: Routes, + title: String, + description: String, + completionMessage: String + ): MobileOnboardingStep { + return MobileOnboardingStep( + id = id, + route = route, + title = title, + eyebrow = "Choose it from the menu", + description = description, + actionLabel = "Tap ${target.drawerLabel ?: title}", + targetHint = "Open ${target.drawerLabel ?: title} from the menu.", + completionMessage = completionMessage, + usesExistingTarget = true, + panelPlacement = MobileOnboardingPanelPlacement.TOP, + authenticatedOnly = true, + menuTargetRoute = target.route + ) + } + + private fun pageStep( + id: MobileOnboardingStepId, + route: String, + title: String, + eyebrow: String, + description: String, + actionLabel: String = "Continue", + targetHint: String = "", + completionMessage: String? = null, + panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.CENTER + ): MobileOnboardingStep { + return MobileOnboardingStep( + id = id, + route = route, + title = title, + eyebrow = eyebrow, + description = description, + actionLabel = actionLabel, + targetHint = targetHint, + completionMessage = completionMessage, + panelPlacement = panelPlacement, + authenticatedOnly = true + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingPracticeHelpRequest.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingPracticeHelpRequest.kt new file mode 100644 index 00000000..63c2aae7 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingPracticeHelpRequest.kt @@ -0,0 +1,11 @@ +package com.neph.features.onboarding.data + +data class MobileOnboardingPracticeHelpRequest( + val helpTypes: List, + val riskFlags: List, + val description: String, + val locationLabel: String, + val contactName: String?, + val contactPhone: String?, + val createdAtLabel: String +) diff --git a/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt new file mode 100644 index 00000000..d3dce1fc --- /dev/null +++ b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt @@ -0,0 +1,138 @@ +package com.neph.features.onboarding.data + +import android.content.Context +import android.content.SharedPreferences +import com.neph.BuildConfig +import com.neph.features.auth.data.AuthSessionStore +import com.neph.features.profile.data.ProfileRepository + +object MobileOnboardingStore { + private const val PrefsName = "neph_mobile_onboarding" + internal const val PendingPrefix = "mobile_onboarding_pending" + internal const val SeenPrefix = "mobile_onboarding_seen" + internal const val CurrentStepPrefix = "mobile_onboarding_current_step" + + private lateinit var prefs: SharedPreferences + + fun initialize(context: Context) { + if (!::prefs.isInitialized) { + prefs = context.applicationContext.getSharedPreferences(PrefsName, Context.MODE_PRIVATE) + } + } + + fun markPendingForCurrentUser() { + val userKey = currentUserKey() ?: return + val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true) + prefs.edit() + .putBoolean(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey), true) + .putString(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey), firstStep.id.name) + .apply() + } + + fun restartForCurrentUser() { + val userKey = currentUserKey() ?: return + val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true) + prefs.edit() + .putBoolean(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey), true) + .remove(MobileOnboardingKeys.scopedKey(SeenPrefix, userKey)) + .putString(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey), firstStep.id.name) + .apply() + } + + fun shouldShowForCurrentUser(): Boolean { + val userKey = currentUserKey() ?: return false + val pendingKey = MobileOnboardingKeys.scopedKey(PendingPrefix, userKey) + val seenKey = MobileOnboardingKeys.scopedKey(SeenPrefix, userKey) + val pending = prefs.getBoolean(pendingKey, false) + val seen = prefs.getBoolean(seenKey, false) + + if (pending && seen) { + prefs.edit().remove(pendingKey).apply() + } + + return MobileOnboardingKeys.shouldShow(pending = pending, seen = seen) + } + + fun currentStepForCurrentUser(isAuthenticated: Boolean): MobileOnboardingStepId? { + val userKey = currentUserKey() ?: return null + val stored = prefs.getString(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey), null) + val parsed = stored?.let { runCatching { MobileOnboardingStepId.valueOf(it) }.getOrNull() } + val valid = parsed?.takeIf { MobileOnboardingJourney.stepFor(it, isAuthenticated) != null } + if (valid != null) return valid + + val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated) + setCurrentStepForCurrentUser(firstStep.id) + return firstStep.id + } + + fun setCurrentStepForCurrentUser(stepId: MobileOnboardingStepId) { + val userKey = currentUserKey() ?: return + prefs.edit() + .putString(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey), stepId.name) + .apply() + } + + fun markSeenForCurrentUser() { + val userKey = currentUserKey() ?: return + prefs.edit() + .putBoolean(MobileOnboardingKeys.scopedKey(SeenPrefix, userKey), true) + .remove(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey)) + .remove(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey)) + .apply() + } + + fun clearPendingForCurrentUser() { + val userKey = currentUserKey() ?: return + prefs.edit() + .remove(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey)) + .remove(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey)) + .apply() + } + + fun resetForTesting() { + requireDebugBuildForTestingReset() + if (::prefs.isInitialized) { + prefs.edit().clear().commit() + } + } + + private fun currentUserKey(): String? { + ensureInitialized() + if (AuthSessionStore.getAccessToken().isNullOrBlank()) { + return null + } + + AuthSessionStore.getCurrentUserId() + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { return "user:$it" } + + val email = runCatching { ProfileRepository.getProfile().email } + .getOrNull() + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotBlank() } + return email?.let { "email:$it" } + } + + private fun ensureInitialized() { + check(::prefs.isInitialized) { + "MobileOnboardingStore must be initialized before use." + } + } + + private fun requireDebugBuildForTestingReset() { + check(BuildConfig.DEBUG) { + "MobileOnboardingStore.resetForTesting() is only available in debug/e2e test builds." + } + } +} + +internal object MobileOnboardingKeys { + fun scopedKey(prefix: String, userKey: String): String { + val sanitizedUserKey = userKey.trim().lowercase().replace(Regex("[^a-z0-9:_@.+-]"), "_") + return "${prefix}_${sanitizedUserKey}" + } + + fun shouldShow(pending: Boolean, seen: Boolean): Boolean = pending && !seen +} diff --git a/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingPulse.kt b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingPulse.kt new file mode 100644 index 00000000..d7609ca9 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingPulse.kt @@ -0,0 +1,46 @@ +package com.neph.features.onboarding.presentation + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun Modifier.mobileOnboardingPulse(enabled: Boolean): Modifier { + if (!enabled) return this + + val infiniteTransition = rememberInfiniteTransition(label = "mobile-onboarding-existing-control-pulse") + val scale = infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1300 + 1f at 0 + 1.06f at 180 + 0.98f at 360 + 1.04f at 540 + 1f at 760 + 1f at 1300 + }, + repeatMode = RepeatMode.Restart + ), + label = "mobile-onboarding-heart-bump" + ).value + + return this + .scale(scale) + .border( + width = 3.dp, + color = Color(0xFFFF5A7A), + shape = RoundedCornerShape(28.dp) + ) +} diff --git a/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt new file mode 100644 index 00000000..7ff4353b --- /dev/null +++ b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt @@ -0,0 +1,256 @@ +package com.neph.features.onboarding.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.neph.features.onboarding.data.MobileOnboardingPanelPlacement +import com.neph.features.onboarding.data.MobileOnboardingStep +import com.neph.ui.theme.LocalNephSpacing +import kotlin.math.roundToInt + +@Composable +fun MobileOnboardingGuide( + step: MobileOnboardingStep, + stepNumber: Int, + totalSteps: Int, + isOnTargetRoute: Boolean, + feedbackMessage: String?, + onNavigateToStep: () -> Unit, + onContinue: () -> Unit, + onBack: () -> Unit, + onSkip: () -> Unit, + onFinish: () -> Unit +) { + val spacing = LocalNephSpacing.current + val density = LocalDensity.current + val isFirstStep = stepNumber <= 1 + val isLastStep = stepNumber >= totalSteps + val progress = stepNumber.toFloat() / totalSteps.toFloat() + var dragOffsetPx by remember(step.id) { mutableStateOf(0f) } + var panelHeightPx by remember(step.id) { mutableStateOf(0f) } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val containerHeightPx = with(density) { maxHeight.toPx() } + val topInsetPx = with(density) { 16.dp.toPx() } + val reservedBottomPx = with(density) { 140.dp.toPx() } + val dragBounds = calculateGuideDragBounds( + placement = step.panelPlacement, + containerHeightPx = containerHeightPx, + panelHeightPx = panelHeightPx, + topInsetPx = topInsetPx, + reservedBottomPx = reservedBottomPx + ) + + LaunchedEffect(dragBounds.start, dragBounds.endInclusive) { + dragOffsetPx = dragOffsetPx.coerceIn(dragBounds.start, dragBounds.endInclusive) + } + + Surface( + modifier = Modifier + .align(step.panelPlacement.panelAlignment()) + .offset { IntOffset(x = 0, y = dragOffsetPx.roundToInt()) } + .fillMaxWidth() + .padding(horizontal = 28.dp, vertical = 12.dp) + .navigationBarsPadding() + .onGloballyPositioned { coordinates -> + panelHeightPx = coordinates.size.height.toFloat() + } + .pointerInput(step.id, dragBounds.start, dragBounds.endInclusive) { + detectVerticalDragGestures { change, dragAmount -> + change.consume() + dragOffsetPx = (dragOffsetPx + dragAmount) + .coerceIn(dragBounds.start, dragBounds.endInclusive) + } + } + .testTag("mobile_onboarding_dialog"), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "NEPH Guide", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = step.title, + modifier = Modifier.testTag("mobile_onboarding_title"), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = step.eyebrow, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + feedbackMessage?.takeIf { it.isNotBlank() }?.let { message -> + Surface( + modifier = Modifier + .fillMaxWidth() + .testTag("mobile_onboarding_feedback"), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = message, + modifier = Modifier.padding(spacing.sm), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + Text( + text = step.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val instructionText = when { + isOnTargetRoute && step.usesExistingTarget -> step.actionLabel + !isOnTargetRoute -> "Go to ${step.title}" + else -> step.targetHint + } + if (instructionText.isNotBlank()) { + Text( + text = instructionText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("mobile_onboarding_instruction") + ) + } + + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + Text( + text = "Step $stepNumber of $totalSteps", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onSkip, + modifier = Modifier.testTag("mobile_onboarding_skip") + ) { + Text("Skip tour") + } + TextButton( + onClick = onBack, + enabled = !isFirstStep, + modifier = Modifier.testTag("mobile_onboarding_back") + ) { + Text("Back") + } + Spacer(modifier = Modifier.weight(1f)) + if (!isOnTargetRoute || !step.usesExistingTarget) { + Button( + onClick = when { + !isOnTargetRoute -> onNavigateToStep + isLastStep -> onFinish + else -> onContinue + }, + modifier = Modifier.testTag( + if (!isOnTargetRoute) { + "mobile_onboarding_take_me_there" + } else if (isLastStep) { + "mobile_onboarding_finish" + } else { + "mobile_onboarding_continue" + } + ) + ) { + Text( + when { + !isOnTargetRoute -> "Go there" + isLastStep -> "Finish" + else -> "Continue" + } + ) + } + } + } + } + } + } +} + +private fun MobileOnboardingPanelPlacement.panelAlignment(): Alignment { + return when (this) { + MobileOnboardingPanelPlacement.TOP -> Alignment.TopCenter + MobileOnboardingPanelPlacement.CENTER -> Alignment.Center + MobileOnboardingPanelPlacement.BOTTOM -> Alignment.BottomCenter + } +} + +private fun calculateGuideDragBounds( + placement: MobileOnboardingPanelPlacement, + containerHeightPx: Float, + panelHeightPx: Float, + topInsetPx: Float, + reservedBottomPx: Float +): ClosedFloatingPointRange { + if (containerHeightPx <= 0f || panelHeightPx <= 0f) { + return 0f..0f + } + + val baseY = when (placement) { + MobileOnboardingPanelPlacement.TOP -> topInsetPx + MobileOnboardingPanelPlacement.CENTER -> (containerHeightPx - panelHeightPx) / 2f + MobileOnboardingPanelPlacement.BOTTOM -> containerHeightPx - panelHeightPx - reservedBottomPx + }.coerceAtLeast(topInsetPx) + + val minOffset = topInsetPx - baseY + val maxOffset = (containerHeightPx - reservedBottomPx - panelHeightPx) - baseY + + return if (maxOffset < minOffset) { + 0f..0f + } else { + minOffset..maxOffset + } +} diff --git a/android/app/src/main/java/com/neph/features/privacysecurity/presentation/PrivacySecurityScreen.kt b/android/app/src/main/java/com/neph/features/privacysecurity/presentation/PrivacySecurityScreen.kt index e9d63afe..78c8dc44 100644 --- a/android/app/src/main/java/com/neph/features/privacysecurity/presentation/PrivacySecurityScreen.kt +++ b/android/app/src/main/java/com/neph/features/privacysecurity/presentation/PrivacySecurityScreen.kt @@ -207,7 +207,7 @@ fun PrivacySecurityScreen( verticalArrangement = Arrangement.spacedBy(spacing.md) ) { Text( - text = "Choose who can see profile, health, and location details.", + text = "Choose who can see profile and location details.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -220,14 +220,6 @@ fun PrivacySecurityScreen( vertical = true ) - AppRadioGroup( - value = healthInfoVisibility, - onValueChange = { healthInfoVisibility = it }, - label = "Health information visibility", - options = visibilityOptions, - vertical = true - ) - AppRadioGroup( value = locationVisibility, onValueChange = { locationVisibility = it }, @@ -240,7 +232,7 @@ fun PrivacySecurityScreen( checked = shareLocation, onCheckedChange = { shareLocation = it }, label = "Share Current Location", - description = "Allow emergency coordination flows to use your saved current-location status." + description = "Used to make your current or saved location available for emergency coordination features, such as nearby visibility and location-aware support. This is a sharing preference, not where you edit your saved address." ) } } diff --git a/android/app/src/main/java/com/neph/features/profile/presentation/ProfileScreen.kt b/android/app/src/main/java/com/neph/features/profile/presentation/ProfileScreen.kt index e94df522..85df91b5 100644 --- a/android/app/src/main/java/com/neph/features/profile/presentation/ProfileScreen.kt +++ b/android/app/src/main/java/com/neph/features/profile/presentation/ProfileScreen.kt @@ -155,10 +155,6 @@ fun ProfileScreen( ProfileFieldRow(label = "District", value = profile.district) ProfileFieldRow(label = "Neighborhood", value = profile.neighborhood) ProfileFieldRow(label = "Extra address", value = profile.extraAddress) - ProfileFieldRow( - label = "Share current location", - value = profile.shareLocation?.let { if (it) "Enabled" else "Disabled" } - ) } } } diff --git a/android/app/src/main/java/com/neph/features/requesthelp/data/RequestHelpRepository.kt b/android/app/src/main/java/com/neph/features/requesthelp/data/RequestHelpRepository.kt index 469382f8..c58b1844 100644 --- a/android/app/src/main/java/com/neph/features/requesthelp/data/RequestHelpRepository.kt +++ b/android/app/src/main/java/com/neph/features/requesthelp/data/RequestHelpRepository.kt @@ -74,7 +74,7 @@ data class RequestHelpSubmission( val description: String, val riskFlags: List, val vulnerableGroups: List, - val bloodType: String, + val shareProfileHealthInfoWithVolunteer: Boolean, val location: RequestHelpLocationSubmission, val contact: RequestHelpContactSubmission, val consentGiven: Boolean @@ -94,6 +94,15 @@ data class CreateHelpRequestResult( val recordedLocally: Boolean = true ) +data class MarkSafeHelpRequestCancellationResult( + val confirmedCount: Int = 0, + val pendingCount: Int = 0, + val failedCount: Int = 0 +) { + val canMarkSafe: Boolean + get() = pendingCount == 0 && failedCount == 0 +} + data class GuestTrackedHelpRequest( val requestId: String, val guestAccessToken: String @@ -142,6 +151,63 @@ object RequestHelpRepository { return database.helpRequestDao().countActiveByOwner(ownerTypeForToken(token)) > 0 } + suspend fun cancelActiveAuthenticatedHelpRequestsForMarkSafe( + token: String + ): MarkSafeHelpRequestCancellationResult { + ensureInitialized() + if (token.isBlank()) return MarkSafeHelpRequestCancellationResult() + + try { + refreshAuthenticatedHelpRequests(token) + } catch (error: ApiException) { + if (error.status == 401) { + throw error + } + } catch (_: Exception) { + // Fall back to local active requests so marking safe can still proceed offline. + } + + val activeRequests = database.helpRequestDao() + .getByOwner(LocalOwnerType.AUTHENTICATED) + .filter { it.isActiveHelpRequestForStatusUpdate() } + + if (activeRequests.isEmpty()) return MarkSafeHelpRequestCancellationResult() + + var confirmedCount = 0 + var pendingCount = 0 + var failedCount = 0 + activeRequests.forEach { request -> + val remoteId = resolveRemoteRequestIdForSync(request) + if (remoteId.isNullOrBlank()) { + pendingCount += 1 + return@forEach + } + + val response = JsonHttpClient.request( + path = "/help-requests/$remoteId/status", + method = "PATCH", + token = token, + body = JSONObject().put("status", "CANCELLED") + ) + val remoteRequest = response.optJSONObject("request") + if (remoteRequest?.optString("status")?.trim()?.uppercase(Locale.ROOT) == "CANCELLED") { + upsertRemoteHelpRequest( + ownerType = LocalOwnerType.AUTHENTICATED, + request = remoteRequest, + guestAccessToken = null + ) + confirmedCount += 1 + } else { + failedCount += 1 + } + } + return MarkSafeHelpRequestCancellationResult( + confirmedCount = confirmedCount, + pendingCount = pendingCount, + failedCount = failedCount + ) + } + suspend fun createHelpRequest( token: String?, submission: RequestHelpSubmission @@ -597,6 +663,12 @@ object RequestHelpRepository { ?.let { mapOf("x-help-request-access-token" to it) } ?: emptyMap() } + + private fun HelpRequestEntity.isActiveHelpRequestForStatusUpdate(): Boolean { + return status.trim().uppercase(Locale.ROOT) !in setOf("RESOLVED", "CANCELLED") && + syncStatus != SyncStatus.CONFLICTED && + !isDeleted + } } private fun JSONObject.optTrimmedString(key: String): String? { @@ -616,7 +688,7 @@ internal fun RequestHelpSubmission.toJson(): JSONObject { put("description", description) put("riskFlags", JSONArray(riskFlags)) put("vulnerableGroups", JSONArray(vulnerableGroups)) - put("bloodType", bloodType) + put("shareProfileHealthInfoWithVolunteer", shareProfileHealthInfoWithVolunteer) put( "location", JSONObject().apply { @@ -699,13 +771,13 @@ internal fun buildEmergencyDraftSubmission( val extraAddress = reverseLocation.extraAddress?.trim()?.takeIf { it.isNotBlank() } ?: "" return RequestHelpSubmission( - helpTypes = listOf("other"), - otherHelpText = "Emergency assistance requested from mobile app", + helpTypes = listOf("search_rescue"), + otherHelpText = "", affectedPeopleCount = 1, description = "Emergency assistance requested from mobile app. Details pending.", riskFlags = emptyList(), vulnerableGroups = emptyList(), - bloodType = profile.bloodType.orEmpty(), + shareProfileHealthInfoWithVolunteer = false, location = RequestHelpLocationSubmission( country = country, city = city, @@ -744,7 +816,8 @@ internal fun RequestHelpSubmission.toEntity( riskFlagsJson = JSONArray(riskFlags).toString(), vulnerableGroupsJson = JSONArray(vulnerableGroups).toString(), description = description, - bloodType = bloodType, + bloodType = "", + shareProfileHealthInfoWithVolunteer = shareProfileHealthInfoWithVolunteer, country = location.country, city = location.city, district = location.district, @@ -809,6 +882,7 @@ internal fun JSONObject.toHelpRequestEntity( vulnerableGroupsJson = optJSONArray("vulnerableGroups").orEmptyJsonArrayString(), description = optString("description"), bloodType = optString("bloodType"), + shareProfileHealthInfoWithVolunteer = optBoolean("shareProfileHealthInfoWithVolunteer", false), country = location.optString("country"), city = location.optString("city"), district = location.optString("district"), diff --git a/android/app/src/main/java/com/neph/features/requesthelp/data/RequestLifecycleFormatting.kt b/android/app/src/main/java/com/neph/features/requesthelp/data/RequestLifecycleFormatting.kt index cb95a5a5..195d7b3a 100644 --- a/android/app/src/main/java/com/neph/features/requesthelp/data/RequestLifecycleFormatting.kt +++ b/android/app/src/main/java/com/neph/features/requesthelp/data/RequestLifecycleFormatting.kt @@ -1,10 +1,8 @@ package com.neph.features.requesthelp.data +import com.neph.core.format.formatTimestampWithRelativeDay +import com.neph.core.format.parseTimestampToInstant import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneId -import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.Locale @@ -19,34 +17,32 @@ internal fun formatOperationalLevel(value: String?): String? { } } -internal fun formatLifecycleTimestamp(raw: String?): String? { +internal fun formatLifecycleTimestamp( + raw: String?, + nowInstant: Instant = Instant.now() +): String? { val value = raw ?.trim() ?.takeIf { it.isNotBlank() } ?: return null - val instant = parseTimestampToInstant(value) - ?: return value + return formatTimestampWithRelativeDay( + raw = value, + fallbackFormatter = LifecycleDisplayFormatter, + timeFormatter = LifecycleTimeFormatter, + nowInstant = nowInstant, + relativeSeparator = " " + ) ?: value .replace('T', ' ') .substringBefore('.') .substringBefore('Z') - - return LifecycleDisplayFormatter - .withZone(ZoneId.systemDefault()) - .format(instant) } private val LifecycleDisplayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US) -private fun parseTimestampToInstant(raw: String): Instant? { - val value = raw.trim().takeIf { it.isNotBlank() } ?: return null - - return runCatching { Instant.parse(value) } - .recoverCatching { OffsetDateTime.parse(value).toInstant() } - .recoverCatching { LocalDateTime.parse(value.replace(' ', 'T')).toInstant(ZoneOffset.UTC) } - .getOrNull() -} +private val LifecycleTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("HH:mm:ss", Locale.US) internal fun buildDurationLabel( openedAtRaw: String?, diff --git a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt index ab120770..5927637b 100644 --- a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt +++ b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt @@ -1,6 +1,7 @@ package com.neph.features.requesthelp.presentation import androidx.compose.foundation.background +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions @@ -9,6 +10,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error @@ -30,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.neph.core.network.ApiException @@ -50,11 +54,13 @@ import com.neph.features.profile.data.LocationTreeRepository import com.neph.features.profile.data.ProfileData import com.neph.features.profile.data.ProfileRepository import com.neph.features.profile.data.PhoneParts -import com.neph.features.profile.data.bloodTypeOptions import com.neph.features.profile.data.locationData -import com.neph.features.profile.data.normalizeBloodType import com.neph.features.profile.data.normalizePhoneParts import com.neph.features.operationallocation.data.OperationalLocationRepository +import com.neph.features.onboarding.data.MobileOnboardingJourney +import com.neph.features.onboarding.data.MobileOnboardingPracticeHelpRequest +import com.neph.features.onboarding.data.MobileOnboardingStepId +import com.neph.features.onboarding.presentation.mobileOnboardingPulse import com.neph.features.requesthelp.data.RequestHelpContactSubmission import com.neph.features.requesthelp.data.RequestHelpLocationSubmission import com.neph.features.profile.presentation.components.LocationSelector @@ -62,6 +68,7 @@ import com.neph.features.requesthelp.data.RequestHelpReverseLocation import com.neph.features.requesthelp.data.RequestHelpRepository import com.neph.features.requesthelp.data.RequestHelpSubmission import com.neph.features.requesthelp.data.jsonArrayToStringList +import com.neph.features.safetystatus.data.SafetyStatusRepository import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton import com.neph.ui.components.buttons.TextActionButton @@ -83,6 +90,7 @@ import com.neph.ui.map.resolveMapPickerLocationUpdate import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephTheme import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -91,13 +99,9 @@ import java.util.TimeZone private val helpTypeOptions = listOf( "First Aid", - "Search & Rescue", - "Fire Brigade", - "Evacuation / Transport", "Food & Water", "Shelter", - "Security Support", - "Other" + "Search & Rescue" ) private val riskFlagOptions = listOf( @@ -122,12 +126,11 @@ private const val RequestHelpMapCoordinateSource = "map_selection" private data class RequestHelpFormState( val helpTypes: List = emptyList(), - val otherHelpType: String = "", val affectedPeopleCount: String = "", val riskFlags: List = emptyList(), val vulnerableGroups: List = emptyList(), val situationDescription: String = "", - val bloodType: String = "", + val shareProfileHealthInfoWithVolunteer: Boolean = false, val country: String = "", val city: String = "", val district: String = "", @@ -164,12 +167,8 @@ private data class RequestHelpFieldErrors( private val helpTypeApiValues = mapOf( "First Aid" to "first_aid", "Search & Rescue" to "search_rescue", - "Fire Brigade" to "fire_brigade", - "Evacuation / Transport" to "evacuation_transport", "Food & Water" to "food_water", - "Shelter" to "shelter", - "Security Support" to "security_support", - "Other" to "other" + "Shelter" to "shelter" ) private val helpTypeLabelsByApiValue = helpTypeApiValues.entries.associate { (label, value) -> value to label } @@ -189,10 +188,8 @@ private fun parseBackendPhoneNumber(countryCode: String, phone: String): Long? { private fun buildPrefilledForm(profile: ProfileData): RequestHelpFormState { val phoneParts: PhoneParts = normalizePhoneParts(profile.phone) - val normalizedBloodType = normalizeBloodType(profile.bloodType).orEmpty() return RequestHelpFormState( - bloodType = normalizedBloodType, country = profile.country.orEmpty(), city = profile.city.orEmpty(), district = profile.district.orEmpty(), @@ -204,16 +201,34 @@ private fun buildPrefilledForm(profile: ProfileData): RequestHelpFormState { ) } +private fun RequestHelpFormState.toMobileOnboardingPracticeHelpRequest(): MobileOnboardingPracticeHelpRequest { + val locationParts = listOf(country, city, district, neighborhood, shortAddress) + .map { it.trim() } + .filter { it.isNotBlank() } + val phone = "$countryCode $phoneNumber".trim().takeIf { it.isNotBlank() } + + return MobileOnboardingPracticeHelpRequest( + helpTypes = helpTypes.ifEmpty { listOf("Help Request") }, + riskFlags = riskFlags, + description = situationDescription + .trim() + .ifBlank { "Practice request created during the app guide." }, + locationLabel = locationParts.joinToString(" / ").ifBlank { "Practice location" }, + contactName = fullName.trim().takeIf { it.isNotBlank() }, + contactPhone = phone, + createdAtLabel = "Just now" + ) +} + private fun HelpRequestEntity.toFormState(): RequestHelpFormState { val phoneParts = normalizePhoneParts(contactPhone) return RequestHelpFormState( helpTypes = helpTypesJson.jsonArrayToStringList().mapNotNull { helpTypeLabelsByApiValue[it] }, - otherHelpType = otherHelpText, affectedPeopleCount = affectedPeopleCount.toString(), riskFlags = riskFlagsJson.jsonArrayToStringList(), vulnerableGroups = vulnerableGroupsJson.jsonArrayToStringList(), situationDescription = description, - bloodType = bloodType, + shareProfileHealthInfoWithVolunteer = shareProfileHealthInfoWithVolunteer, country = country, city = city, district = district, @@ -356,12 +371,12 @@ private fun buildSubmission( return RequestHelpSubmission( helpTypes = state.helpTypes.mapNotNull { helpTypeApiValues[it] }, - otherHelpText = state.otherHelpType.trim(), + otherHelpText = "", affectedPeopleCount = state.affectedPeopleCount.toIntOrNull() ?: 1, description = state.situationDescription.trim(), riskFlags = state.riskFlags.map { it.trim() }, vulnerableGroups = state.vulnerableGroups.map { it.trim() }, - bloodType = state.bloodType.trim(), + shareProfileHealthInfoWithVolunteer = state.shareProfileHealthInfoWithVolunteer, location = RequestHelpLocationSubmission( country = findCountryLabel(state.country, locations).ifBlank { state.country.trim() }, city = findCityLabel(state.country, state.city, locations).ifBlank { state.city.trim() }, @@ -498,12 +513,35 @@ private fun resolveEventLocationAutofillSelection( ) } +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun rememberMobileOnboardingTargetModifier( + active: Boolean, + testTag: String +): Modifier { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(active) { + if (active) { + delay(250) + bringIntoViewRequester.bringIntoView() + } + } + + return Modifier + .bringIntoViewRequester(bringIntoViewRequester) + .mobileOnboardingPulse(active) + .then(if (active) Modifier.testTag(testTag) else Modifier) +} + @Composable fun RequestHelpScreen( draftLocalId: String? = null, onNavigateBack: () -> Unit, onNavigateToLogin: () -> Unit, - onNavigateToMyHelpRequests: () -> Unit + onNavigateToMyHelpRequests: () -> Unit, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, + onMobileOnboardingPracticeHelpRequestChanged: (MobileOnboardingPracticeHelpRequest?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val scope = rememberCoroutineScope() @@ -533,6 +571,22 @@ fun RequestHelpScreen( var mapPickerSelection by remember { mutableStateOf(null) } var checkingActiveRequest by remember { mutableStateOf(isLoggedIn) } var currentLocationLoading by remember { mutableStateOf(false) } + val requestHelpOnboardingSteps = setOf( + MobileOnboardingStepId.REQUEST_HELP_TYPE, + MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE, + MobileOnboardingStepId.REQUEST_HELP_CONFIRM, + MobileOnboardingStepId.REQUEST_HELP_SEND + ) + val isMobileOnboardingActive = mobileOnboardingStepId in requestHelpOnboardingSteps + val isHelpTypeOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_TYPE + val isFireRiskOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE + val isConfirmationOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_CONFIRM + val isSendOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_SEND + + fun completeRequestHelpOnboardingStep(stepId: MobileOnboardingStepId) { + val message = MobileOnboardingJourney.stepFor(stepId, isAuthenticated = isLoggedIn)?.completionMessage + onMobileOnboardingStepCompleted(message) + } fun applyPendingCoordinateSnapshot( baseState: RequestHelpFormState, @@ -814,10 +868,12 @@ fun RequestHelpScreen( } try { - val hasActiveRequest = RequestHelpRepository.hasActiveHelpRequest(sessionToken) - if (hasActiveRequest) { - onNavigateToMyHelpRequests() - return@LaunchedEffect + if (!isMobileOnboardingActive) { + val hasActiveRequest = RequestHelpRepository.hasActiveHelpRequest(sessionToken) + if (hasActiveRequest) { + onNavigateToMyHelpRequests() + return@LaunchedEffect + } } val profile = ProfileRepository.fetchAndCacheRemoteProfile() @@ -855,6 +911,16 @@ fun RequestHelpScreen( } fun handleSubmit() { + if (isSendOnboardingTarget) { + fieldErrors = RequestHelpFieldErrors() + errorMessage = "" + infoMessage = "Practice complete. No real help request was saved." + mapActionMessage = "" + onMobileOnboardingPracticeHelpRequestChanged(formState.toMobileOnboardingPracticeHelpRequest()) + completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_SEND) + return + } + val nextFieldErrors = validateForm(formState) fieldErrors = nextFieldErrors errorMessage = "" @@ -874,6 +940,11 @@ fun RequestHelpScreen( errorMessage = "You can only have one active help request at a time." return@launch } + runCatching { + SafetyStatusRepository.clearSafeStatusForRequestHelp(sessionToken) + }.onFailure { error -> + if (error is CancellationException) throw error + } } val submission = buildSubmission(formState, availableLocationData) @@ -892,6 +963,9 @@ fun RequestHelpScreen( } activeDraftLocalId = result.requestId infoMessage = "Help request saved on this device and queued for sync." + if (isSendOnboardingTarget) { + completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_SEND) + } onNavigateToMyHelpRequests() } catch (error: ApiException) { if (error.status == 401) { @@ -944,21 +1018,24 @@ fun RequestHelpScreen( selectedOptions = formState.helpTypes, onOptionToggle = { formState = formState.copy( - helpTypes = toggleSelection(formState.helpTypes, it), - otherHelpType = if (it == "Other" && "Other" in formState.helpTypes) "" else formState.otherHelpType + helpTypes = toggleSelection(formState.helpTypes, it) + ) + if (isHelpTypeOnboardingTarget && it == "Search & Rescue") { + completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_TYPE) + } + }, + optionModifier = { option -> + rememberMobileOnboardingTargetModifier( + active = isHelpTypeOnboardingTarget && option == "Search & Rescue", + testTag = "mobile_onboarding_target_search_rescue" ) }, + optionEnabled = { option -> + !isMobileOnboardingActive || + (isHelpTypeOnboardingTarget && option == "Search & Rescue") + }, error = fieldErrors.helpTypes ) - - if ("Other" in formState.helpTypes) { - AppTextField( - value = formState.otherHelpType, - onValueChange = { formState = formState.copy(otherHelpType = it) }, - label = "Other Help Type Details", - placeholder = "Add a short detail if needed" - ) - } } } @@ -982,18 +1059,31 @@ fun RequestHelpScreen( ) AppMultiSelectChipGroup( - label = "Risk Flags", + label = "Risk Flags (optional)", options = riskFlagOptions, selectedOptions = formState.riskFlags, onOptionToggle = { formState = formState.copy( riskFlags = toggleSelection(formState.riskFlags, it) ) + if (isFireRiskOnboardingTarget && it == "Fire") { + completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE) + } + }, + optionModifier = { option -> + rememberMobileOnboardingTargetModifier( + active = isFireRiskOnboardingTarget && option == "Fire", + testTag = "mobile_onboarding_target_fire_risk" + ) + }, + optionEnabled = { option -> + !isMobileOnboardingActive || + (isFireRiskOnboardingTarget && option == "Fire") } ) AppMultiSelectChipGroup( - label = "Vulnerable Groups", + label = "Vulnerable Groups (optional)", options = vulnerableGroupOptions, selectedOptions = formState.vulnerableGroups, onOptionToggle = { @@ -1011,13 +1101,12 @@ fun RequestHelpScreen( error = fieldErrors.situationDescription ) - AppDropdown( - value = formState.bloodType, - onValueChange = { formState = formState.copy(bloodType = it) }, - label = "Blood Type", - options = bloodTypeOptions, - placeholder = "Select blood type", - selectedTextMapper = { it.label } + AppCheckbox( + checked = formState.shareProfileHealthInfoWithVolunteer, + onCheckedChange = { + formState = formState.copy(shareProfileHealthInfoWithVolunteer = it) + }, + label = "I agree to share my profile health information with the volunteer assigned to this request." ) } } @@ -1193,8 +1282,17 @@ fun RequestHelpScreen( checked = formState.confirmationAccepted, onCheckedChange = { formState = formState.copy(confirmationAccepted = it) + if (isConfirmationOnboardingTarget && it) { + completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_CONFIRM) + } }, + modifier = rememberMobileOnboardingTargetModifier( + active = isConfirmationOnboardingTarget, + testTag = "mobile_onboarding_target_confirmation" + ), label = "I confirm this information can be shared for emergency coordination.", + enabled = !isMobileOnboardingActive || isConfirmationOnboardingTarget, + testTag = "mobile_onboarding_confirmation_checkbox", error = fieldErrors.confirmationAccepted ) } @@ -1215,13 +1313,21 @@ fun RequestHelpScreen( PrimaryButton( text = "Send Help Request", onClick = ::handleSubmit, + modifier = Modifier + .then( + rememberMobileOnboardingTargetModifier( + active = isSendOnboardingTarget, + testTag = "mobile_onboarding_target_send_help_request" + ) + ), + enabled = !isMobileOnboardingActive || isSendOnboardingTarget, loading = loading ) SecondaryButton( text = "Cancel", onClick = onNavigateBack, - enabled = !loading + enabled = !loading && !isMobileOnboardingActive ) } } @@ -1245,7 +1351,10 @@ private fun RequestHelpStickyTopBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - TextActionButton(text = "Back", onClick = onNavigateBack) + TextActionButton( + text = "Back", + onClick = onNavigateBack + ) Text( text = "Request Help", style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/java/com/neph/features/safetycircles/presentation/SafetyCirclesScreen.kt b/android/app/src/main/java/com/neph/features/safetycircles/presentation/SafetyCirclesScreen.kt index 42fc053f..6f17bdcc 100644 --- a/android/app/src/main/java/com/neph/features/safetycircles/presentation/SafetyCirclesScreen.kt +++ b/android/app/src/main/java/com/neph/features/safetycircles/presentation/SafetyCirclesScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.neph.core.format.formatTimestampWithRelativeDay import com.neph.core.network.ApiException import com.neph.features.auth.data.AuthRepository import com.neph.features.auth.data.AuthSessionStore @@ -35,6 +36,7 @@ import com.neph.features.safetycircles.data.SafetyCircleInvite import com.neph.features.safetycircles.data.SafetyCircleMember import com.neph.features.safetycircles.data.SafetyCircleSummary import com.neph.features.safetycircles.data.SafetyCirclesRepository +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.buttons.SecondaryButton @@ -44,6 +46,9 @@ import com.neph.ui.layout.AppDrawerScaffold import com.neph.ui.theme.LocalNephSpacing import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter @Composable fun SafetyCirclesScreen( @@ -52,7 +57,9 @@ fun SafetyCirclesScreen( onProfileClick: () -> Unit, onNavigateToLogin: () -> Unit, profileBadgeText: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val scope = rememberCoroutineScope() @@ -282,7 +289,9 @@ fun SafetyCirclesScreen( profileBadgeText = profileBadgeText, profileLabel = "Profile", contentMaxWidth = 560.dp, - contentAlignment = Alignment.TopCenter + contentAlignment = Alignment.TopCenter, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( modifier = Modifier.fillMaxWidth(), @@ -553,7 +562,7 @@ private fun MemberRow( text = listOf( formatSafetyStatus(member.status), member.emergencyContactPhone?.let { "Contact: $it" }, - member.lastCheckedInAt?.let { "Last checked in: $it" }, + formatLastCheckedInLabel(member.lastCheckedInAt), if (member.hasSharedLocation) "Location shared" else null ).filterNotNull().joinToString(" · "), style = MaterialTheme.typography.bodySmall, @@ -584,3 +593,18 @@ private fun formatSafetyStatus(status: String): String { else -> "No response" } } + +internal fun formatLastCheckedInLabel( + rawTimestamp: String?, + zoneId: ZoneId = ZoneId.systemDefault(), + nowInstant: Instant = Instant.now() +): String? { + val formatted = formatTimestampWithRelativeDay( + raw = rawTimestamp, + fallbackFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy, HH:mm"), + timeFormatter = DateTimeFormatter.ofPattern("HH:mm"), + zoneId = zoneId, + nowInstant = nowInstant + ) ?: return null + return "Last checked in: $formatted" +} diff --git a/android/app/src/main/java/com/neph/features/safetystatus/data/SafetyStatusRepository.kt b/android/app/src/main/java/com/neph/features/safetystatus/data/SafetyStatusRepository.kt index 82aca899..ab5e5558 100644 --- a/android/app/src/main/java/com/neph/features/safetystatus/data/SafetyStatusRepository.kt +++ b/android/app/src/main/java/com/neph/features/safetystatus/data/SafetyStatusRepository.kt @@ -102,6 +102,30 @@ object SafetyStatusRepository { return getSafetyStatusState() } + suspend fun clearSafeStatusForRequestHelp(token: String?): SafetyStatusState { + if (token.isNullOrBlank()) return getSafetyStatusState() + + val current = database.safetyStatusDao().get() + if (current?.status?.lowercase() != "safe") { + return current?.toState() ?: SafetyStatusState() + } + + val body = buildRequestHelpSafetyStatusPayload() + val now = System.currentTimeMillis() + val entity = buildPendingSafetyStatusEntity( + payload = body, + checkedAtEpochMillis = now, + existing = current + ) + + database.safetyStatusDao().upsert(entity) + upsertSafetyStatusOperation(payload = body, now = now) + OfflineSyncScheduler.enqueueSync(NephAppContext.get(), reason = "request-help-clears-safe-status") + + syncPendingSafetyStatusNow(token) + return getSafetyStatusState() + } + internal fun buildMarkSafePayload( note: String? = null, location: CurrentDeviceLocation? = null, @@ -133,6 +157,13 @@ object SafetyStatusRepository { return body } + internal fun buildRequestHelpSafetyStatusPayload(): JSONObject { + return JSONObject() + .put("status", "unknown") + .put("shareLocationConsent", false) + .put("location", JSONObject.NULL) + } + suspend fun syncPendingSafetyStatusNow(token: String?) { val operation = database.syncOperationDao().getLatestPendingOperation( entityType = SyncEntityType.SAFETY_STATUS, diff --git a/android/app/src/main/java/com/neph/features/settings/presentation/SettingsScreen.kt b/android/app/src/main/java/com/neph/features/settings/presentation/SettingsScreen.kt index b88bc0e1..1b85cf08 100644 --- a/android/app/src/main/java/com/neph/features/settings/presentation/SettingsScreen.kt +++ b/android/app/src/main/java/com/neph/features/settings/presentation/SettingsScreen.kt @@ -10,10 +10,12 @@ import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.WbSunny import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -25,10 +27,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.size import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.neph.core.theme.ThemePreferenceStore import com.neph.features.auth.data.AuthRepository +import com.neph.features.onboarding.data.MobileOnboardingStepId import com.neph.navigation.Routes import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.display.IconListRow @@ -47,7 +51,10 @@ fun SettingsScreen( profileBadgeText: String, onNavigateToPrivacySecurity: () -> Unit, onLogout: () -> Unit, - onAccountDeleted: () -> Unit + onAccountDeleted: () -> Unit, + onRestartMobileOnboarding: () -> Unit, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {} ) { val spacing = LocalNephSpacing.current val coroutineScope = rememberCoroutineScope() @@ -114,7 +121,9 @@ fun SettingsScreen( drawerItems = Routes.authenticatedDrawerItems, onProfileClick = onProfileClick, profileBadgeText = profileBadgeText, - profileLabel = "Profile" + profileLabel = "Profile", + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) { Column( modifier = Modifier.fillMaxWidth(), @@ -138,9 +147,13 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(spacing.md) ) { androidx.compose.material3.Icon( - imageVector = Icons.Filled.DarkMode, + imageVector = if (darkThemeEnabled) Icons.Filled.DarkMode else Icons.Filled.WbSunny, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = if (darkThemeEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primary + } ) Column(modifier = Modifier.weight(1f)) { Text( @@ -156,11 +169,44 @@ fun SettingsScreen( } Switch( checked = darkThemeEnabled, - onCheckedChange = { ThemePreferenceStore.setDarkThemeEnabled(it) } + onCheckedChange = { ThemePreferenceStore.setDarkThemeEnabled(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + checkedBorderColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onPrimary, + uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + uncheckedBorderColor = MaterialTheme.colorScheme.outline + ), + thumbContent = { + androidx.compose.material3.Icon( + imageVector = if (darkThemeEnabled) Icons.Filled.DarkMode else Icons.Filled.WbSunny, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + tint = if (darkThemeEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + } + ) + } ) } } + SectionCard { + SectionHeader( + title = "App guide", + subtitle = "Replay the guided tour of NEPH's core concepts." + ) + + PrimaryButton( + text = "Restart App Guide", + onClick = onRestartMobileOnboarding, + modifier = Modifier.fillMaxWidth() + ) + } + SectionCard { SectionHeader( title = "Account", @@ -229,7 +275,8 @@ private fun SettingsScreenPreview() { profileBadgeText = "PP", onNavigateToPrivacySecurity = {}, onLogout = {}, - onAccountDeleted = {} + onAccountDeleted = {}, + onRestartMobileOnboarding = {} ) } } diff --git a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt index bedcfb23..9681625d 100644 --- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt @@ -38,11 +38,19 @@ import com.neph.features.profile.presentation.ProfileScreen import com.neph.features.requesthelp.presentation.RequestHelpScreen import com.neph.features.safetycircles.presentation.SafetyCirclesScreen import com.neph.features.settings.presentation.SettingsScreen +import com.neph.features.onboarding.data.MobileOnboardingPracticeHelpRequest +import com.neph.features.onboarding.data.MobileOnboardingStepId @Composable fun AppNavGraph( navController: NavHostController, - startDestination: String = Routes.Welcome.route + startDestination: String = Routes.Welcome.route, + onRestartMobileOnboarding: () -> Unit = {}, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, + onMobileOnboardingFeedbackChanged: (String?) -> Unit = {}, + mobileOnboardingPracticeHelpRequest: MobileOnboardingPracticeHelpRequest? = null, + onMobileOnboardingPracticeHelpRequestChanged: (MobileOnboardingPracticeHelpRequest?) -> Unit = {} ) { val verifyEmailRouteWithToken = "${Routes.VerifyEmail.route}?token={token}" val accessToken by AuthSessionStore.accessTokenFlow.collectAsState() @@ -140,7 +148,9 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -191,7 +201,10 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted, + mobileOnboardingPracticeHelpRequest = mobileOnboardingPracticeHelpRequest ) } @@ -216,7 +229,10 @@ fun AppNavGraph( profileBadgeText = profileBadgeText, onNavigateToLogin = { navigateToLogin() - } + }, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted, + onMobileOnboardingFeedbackChanged = onMobileOnboardingFeedbackChanged ) } @@ -276,7 +292,9 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -299,7 +317,9 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -322,7 +342,9 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -347,7 +369,9 @@ fun AppNavGraph( onNavigateToLogin = { navigateToLogin() }, - profileBadgeText = profileBadgeText + profileBadgeText = profileBadgeText, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -370,7 +394,9 @@ fun AppNavGraph( } }, profileBadgeText = profileBadgeText, - isAuthenticated = authenticated + isAuthenticated = authenticated, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -395,7 +421,9 @@ fun AppNavGraph( onNavigateToLogin = { navigateToLogin() }, - profileBadgeText = profileBadgeText + profileBadgeText = profileBadgeText, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -430,7 +458,10 @@ fun AppNavGraph( popUpTo(navController.graph.id) { inclusive = true } launchSingleTop = true } - } + }, + onRestartMobileOnboarding = onRestartMobileOnboarding, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted ) } @@ -471,8 +502,14 @@ fun AppNavGraph( navigateToLogin() }, onNavigateToMyHelpRequests = { - navigateToDrawerRoute(Routes.MyHelpRequests.route) - } + val popped = navController.popBackStack(Routes.MyHelpRequests.route, inclusive = false) + if (!popped) { + navigateToDrawerRoute(Routes.MyHelpRequests.route) + } + }, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted, + onMobileOnboardingPracticeHelpRequestChanged = onMobileOnboardingPracticeHelpRequestChanged ) } diff --git a/android/app/src/main/java/com/neph/ui/components/selection/AppCheckbox.kt b/android/app/src/main/java/com/neph/ui/components/selection/AppCheckbox.kt index d0c535d4..7e29557b 100644 --- a/android/app/src/main/java/com/neph/ui/components/selection/AppCheckbox.kt +++ b/android/app/src/main/java/com/neph/ui/components/selection/AppCheckbox.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import com.neph.ui.theme.LocalNephSpacing @Composable @@ -20,6 +21,7 @@ fun AppCheckbox( label: String, modifier: Modifier = Modifier, enabled: Boolean = true, + testTag: String? = null, error: String? = null ) { val spacing = LocalNephSpacing.current @@ -36,6 +38,7 @@ fun AppCheckbox( Checkbox( checked = checked, onCheckedChange = onCheckedChange, + modifier = if (testTag.isNullOrBlank()) Modifier else Modifier.testTag(testTag), enabled = enabled, colors = CheckboxDefaults.colors( checkedColor = MaterialTheme.colorScheme.primary, @@ -55,10 +58,10 @@ fun AppCheckbox( if (isError) { Text( - text = error ?: "", + text = error.orEmpty(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/neph/ui/components/selection/AppMultiSelectChipGroup.kt b/android/app/src/main/java/com/neph/ui/components/selection/AppMultiSelectChipGroup.kt index 936609c2..5963977d 100644 --- a/android/app/src/main/java/com/neph/ui/components/selection/AppMultiSelectChipGroup.kt +++ b/android/app/src/main/java/com/neph/ui/components/selection/AppMultiSelectChipGroup.kt @@ -20,6 +20,8 @@ fun AppMultiSelectChipGroup( selectedOptions: List, onOptionToggle: (String) -> Unit, modifier: Modifier = Modifier, + optionModifier: @Composable (String) -> Modifier = { Modifier }, + optionEnabled: (String) -> Boolean = { true }, error: String? = null ) { val spacing = LocalNephSpacing.current @@ -46,6 +48,8 @@ fun AppMultiSelectChipGroup( FilterChip( selected = selected, onClick = { onOptionToggle(option) }, + modifier = optionModifier(option), + enabled = optionEnabled(option), label = { Text( text = option, diff --git a/android/app/src/main/java/com/neph/ui/layout/AppDrawerScaffold.kt b/android/app/src/main/java/com/neph/ui/layout/AppDrawerScaffold.kt index 60b338ef..4204524c 100644 --- a/android/app/src/main/java/com/neph/ui/layout/AppDrawerScaffold.kt +++ b/android/app/src/main/java/com/neph/ui/layout/AppDrawerScaffold.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -66,10 +67,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.runtime.collectAsState import com.neph.features.notifications.data.NotificationsBadge +import com.neph.features.onboarding.data.MobileOnboardingJourney +import com.neph.features.onboarding.data.MobileOnboardingStepId +import com.neph.features.onboarding.presentation.mobileOnboardingPulse import com.neph.navigation.Routes import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephSpacing @@ -97,6 +102,8 @@ fun AppDrawerScaffold( contentScrollable: Boolean = !contentFillMaxSize, contentAlignment: Alignment = Alignment.TopCenter, alertCount: Int = 0, + mobileOnboardingStepId: MobileOnboardingStepId? = null, + onMobileOnboardingStepCompleted: (String?) -> Unit = {}, topBarActions: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit ) { @@ -108,6 +115,16 @@ fun AppDrawerScaffold( val effectiveAlertCount = maxOf(alertCount, badgeUnread) var menuOpen by remember { mutableStateOf(false) } + val activeMobileOnboardingStep = mobileOnboardingStepId?.let { stepId -> + MobileOnboardingJourney.stepFor(stepId, isAuthenticated = true) + } + val isOpenMenuOnboardingTarget = activeMobileOnboardingStep?.opensMenu == true + val isMobileOnboardingActive = mobileOnboardingStepId != null + + fun completeMobileOnboardingStep(stepId: MobileOnboardingStepId) { + val message = MobileOnboardingJourney.stepFor(stepId, isAuthenticated = true)?.completionMessage + onMobileOnboardingStepCompleted(message) + } Box(modifier = Modifier.fillMaxSize()) { Scaffold( @@ -150,7 +167,19 @@ fun AppDrawerScaffold( // Hamburger menu — opens the slide-up panel NavigationBarItem( selected = menuOpen, - onClick = { menuOpen = !menuOpen }, + onClick = { + if (isMobileOnboardingActive && !isOpenMenuOnboardingTarget) { + return@NavigationBarItem + } + + menuOpen = !menuOpen + if (isOpenMenuOnboardingTarget) { + completeMobileOnboardingStep(mobileOnboardingStepId) + } + }, + modifier = Modifier + .testTag("mobile_onboarding_target_menu") + .mobileOnboardingPulse(isOpenMenuOnboardingTarget), icon = { Icon( imageVector = Icons.Filled.Menu, @@ -176,6 +205,7 @@ fun AppDrawerScaffold( currentRoute = currentRoute, effectiveAlertCount = effectiveAlertCount, itemColors = itemColors, + mobileOnboardingActive = isMobileOnboardingActive, onNavigateToRoute = onNavigateToRoute ) } @@ -185,6 +215,7 @@ fun AppDrawerScaffold( currentRoute = currentRoute, effectiveAlertCount = effectiveAlertCount, itemColors = itemColors, + mobileOnboardingActive = isMobileOnboardingActive, onNavigateToRoute = onNavigateToRoute ) @@ -194,6 +225,7 @@ fun AppDrawerScaffold( currentRoute = currentRoute, effectiveAlertCount = effectiveAlertCount, itemColors = itemColors, + mobileOnboardingActive = isMobileOnboardingActive, onNavigateToRoute = onNavigateToRoute ) } @@ -237,7 +269,11 @@ fun AppDrawerScaffold( // Translucent scrim + small slide-up menu anchored bottom-left MenuOverlay( visible = menuOpen, - onDismiss = { menuOpen = false }, + onDismiss = { + if (!isMobileOnboardingActive) { + menuOpen = false + } + }, spacing = spacing, drawerItems = safeDrawerItems, currentRoute = currentRoute, @@ -260,7 +296,9 @@ fun AppDrawerScaffold( menuOpen = false it() } - } + }, + mobileOnboardingStepId = mobileOnboardingStepId, + onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep ) } } @@ -271,6 +309,7 @@ private fun RowScope.BottomNavRouteItem( currentRoute: String, effectiveAlertCount: Int, itemColors: NavigationBarItemColors, + mobileOnboardingActive: Boolean, onNavigateToRoute: (String) -> Unit ) { val selected = currentRoute == item.route @@ -278,6 +317,10 @@ private fun RowScope.BottomNavRouteItem( NavigationBarItem( selected = selected, onClick = { + if (mobileOnboardingActive) { + return@NavigationBarItem + } + if (currentRoute != item.route) { onNavigateToRoute(item.route) } @@ -327,8 +370,16 @@ private fun BoxScope.MenuOverlay( onProfileClick: (() -> Unit)?, profileBadgeText: String, profileLabel: String, - onOpenSettings: (() -> Unit)? + onOpenSettings: (() -> Unit)?, + mobileOnboardingStepId: MobileOnboardingStepId?, + onMobileOnboardingStepCompleted: (MobileOnboardingStepId) -> Unit ) { + val activeMobileOnboardingStep = mobileOnboardingStepId?.let { stepId -> + MobileOnboardingJourney.stepFor(stepId, isAuthenticated = true) + } + val mobileOnboardingMenuTargetRoute = activeMobileOnboardingStep?.menuTargetRoute + val isMobileOnboardingActive = mobileOnboardingStepId != null + // Translucent scrim — taps outside dismiss AnimatedVisibility( visible = visible, @@ -361,6 +412,8 @@ private fun BoxScope.MenuOverlay( Column( modifier = Modifier .width(260.dp) + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()) .background( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), shape = RoundedCornerShape(20.dp) @@ -371,7 +424,7 @@ private fun BoxScope.MenuOverlay( Row( modifier = Modifier .fillMaxWidth() - .clickable { onProfileClick() } + .clickable(enabled = !isMobileOnboardingActive) { onProfileClick() } .padding(horizontal = spacing.md, vertical = spacing.sm), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.md) @@ -413,11 +466,27 @@ private fun BoxScope.MenuOverlay( } drawerItems.forEach { item -> + val isMobileOnboardingMenuTarget = item.route == mobileOnboardingMenuTargetRoute MenuRow( icon = bottomNavIconFor(item), label = item.drawerLabel.orEmpty(), selected = currentRoute == item.route, - onClick = { onSelect(item.route) }, + onClick = { + if (isMobileOnboardingMenuTarget) { + onMobileOnboardingStepCompleted(mobileOnboardingStepId) + } + onSelect(item.route) + }, + enabled = !isMobileOnboardingActive || isMobileOnboardingMenuTarget, + modifier = Modifier + .then( + if (isMobileOnboardingMenuTarget) { + Modifier.testTag(mobileOnboardingMenuTargetTag(item.route)) + } else { + Modifier + } + ) + .mobileOnboardingPulse(isMobileOnboardingMenuTarget), spacing = spacing ) } @@ -427,11 +496,27 @@ private fun BoxScope.MenuOverlay( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), modifier = Modifier.padding(horizontal = spacing.md) ) + val isSettingsOnboardingTarget = mobileOnboardingMenuTargetRoute == Routes.Settings.route MenuRow( icon = Icons.Filled.Settings, label = "Settings", - selected = false, - onClick = onOpenSettings, + selected = currentRoute == Routes.Settings.route, + onClick = { + if (isSettingsOnboardingTarget) { + onMobileOnboardingStepCompleted(mobileOnboardingStepId) + } + onOpenSettings() + }, + enabled = !isMobileOnboardingActive || isSettingsOnboardingTarget, + modifier = Modifier + .then( + if (isSettingsOnboardingTarget) { + Modifier.testTag(mobileOnboardingMenuTargetTag(Routes.Settings.route)) + } else { + Modifier + } + ) + .mobileOnboardingPulse(isSettingsOnboardingTarget), spacing = spacing ) } @@ -445,17 +530,21 @@ private fun MenuRow( label: String, selected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, spacing: NephSpacing ) { val contentColor = if (selected) { MaterialTheme.colorScheme.primary + } else if (!enabled) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.42f) } else { MaterialTheme.colorScheme.onSurface } Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } .padding(horizontal = spacing.md, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.md) @@ -485,6 +574,13 @@ private fun MenuRow( } } +private fun mobileOnboardingMenuTargetTag(route: String): String { + if (route == Routes.AssignedRequest.route) { + return "mobile_onboarding_target_assigned_request_menu" + } + return "mobile_onboarding_target_menu_${route.replace('-', '_')}" +} + private fun bottomNavIconFor(item: Routes): ImageVector { return when (item) { Routes.Home -> Icons.Filled.Home diff --git a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt index 45a6dadf..af0a514a 100644 --- a/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt +++ b/android/app/src/main/java/com/neph/ui/map/LeafletMapWebView.kt @@ -16,6 +16,7 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember @@ -323,7 +324,7 @@ fun LeafletMarkerMap( mapHeightCssPx = mapHeightCssPx ) } - val bridge = remember { + val bridge = remember(mapInstanceId) { LeafletMarkerMapBridge( currentMapInstanceId = { latestCurrentMapInstanceId() }, onMarkerSelected = { instanceId, markerId -> latestOnMarkerSelected(instanceId, markerId) }, @@ -335,6 +336,10 @@ fun LeafletMarkerMap( ) } + DisposableEffect(bridge) { + onDispose { bridge.dispose() } + } + LeafletMapWebView( mapInstanceId = mapInstanceId, html = html, @@ -744,11 +749,14 @@ private class LeafletMarkerMapBridge( private val onViewportChanged: (String, LeafletMapViewport) -> Unit ) { private val mainHandler = Handler(Looper.getMainLooper()) + @Volatile + private var disposed = false private var readyDeliveredInstanceId: String? = null private val aliveDeliveredSignals = mutableSetOf() @JavascriptInterface fun onMarkerSelected(instanceId: String?, id: String?) { + if (disposed) return val incomingInstanceId = instanceId.orEmpty() val currentInstanceId = currentMapInstanceId() if (incomingInstanceId != currentInstanceId) { @@ -760,6 +768,7 @@ private class LeafletMarkerMapBridge( val trimmed = id?.trim().orEmpty() if (trimmed.isBlank()) return mainHandler.post { + if (disposed) return@post if (incomingInstanceId != currentMapInstanceId()) { logMapDebug( "native onMarkerSelected ignored stale instance=$incomingInstanceId current=${currentMapInstanceId()}" @@ -775,6 +784,7 @@ private class LeafletMarkerMapBridge( @JavascriptInterface fun onMapReady(instanceId: String?) { + if (disposed) return val incomingInstanceId = instanceId.orEmpty() val currentInstanceId = currentMapInstanceId() logMapDebug( @@ -796,6 +806,7 @@ private class LeafletMarkerMapBridge( readyDeliveredInstanceId = incomingInstanceId } mainHandler.post { + if (disposed) return@post val postedCurrentInstanceId = currentMapInstanceId() if (incomingInstanceId != postedCurrentInstanceId) { logMapDebug( @@ -812,6 +823,7 @@ private class LeafletMarkerMapBridge( @JavascriptInterface fun onMapAlive(instanceId: String?, source: String?) { + if (disposed) return val incomingInstanceId = instanceId.orEmpty() val currentInstanceId = currentMapInstanceId() val readySource = source?.trim().orEmpty().ifBlank { "alive" } @@ -828,12 +840,14 @@ private class LeafletMarkerMapBridge( return } mainHandler.post { + if (disposed) return@post dispatchMapAliveFromMain(incomingInstanceId, readySource) } } @JavascriptInterface fun onMapError(instanceId: String?, message: String?) { + if (disposed) return val incomingInstanceId = instanceId.orEmpty() val currentInstanceId = currentMapInstanceId() if (incomingInstanceId != currentInstanceId) { @@ -844,6 +858,7 @@ private class LeafletMarkerMapBridge( } val trimmed = message?.trim().orEmpty() mainHandler.post { + if (disposed) return@post if (incomingInstanceId != currentMapInstanceId()) { logMapDebug( "native onMapError ignored stale instance=$incomingInstanceId current=${currentMapInstanceId()}" @@ -868,6 +883,7 @@ private class LeafletMarkerMapBridge( heightKm: Double, widestVisibleDimensionKm: Double ) { + if (disposed) return val incomingInstanceId = instanceId.orEmpty() val currentInstanceId = currentMapInstanceId() if (incomingInstanceId != currentInstanceId) { @@ -889,6 +905,7 @@ private class LeafletMarkerMapBridge( widestVisibleDimensionKm = widestVisibleDimensionKm ) mainHandler.post { + if (disposed) return@post if (incomingInstanceId != currentMapInstanceId()) { logMapDebug( "native onViewportChanged ignored stale instance=$incomingInstanceId current=${currentMapInstanceId()}" @@ -899,7 +916,13 @@ private class LeafletMarkerMapBridge( } } + fun dispose() { + disposed = true + mainHandler.removeCallbacksAndMessages(null) + } + private fun dispatchMapAliveFromMain(instanceId: String, source: String) { + if (disposed) return val currentInstanceId = currentMapInstanceId() if (instanceId != currentInstanceId) { logMapDebug( @@ -914,6 +937,7 @@ private class LeafletMarkerMapBridge( } private fun markAliveSignalForDelivery(instanceId: String, source: String): Boolean { + if (disposed) return false return synchronized(this) { aliveDeliveredSignals.add("$instanceId:$source") } diff --git a/android/app/src/main/res/drawable/ic_notification.png b/android/app/src/main/res/drawable/ic_notification.png new file mode 100644 index 00000000..21471c87 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_notification.png differ diff --git a/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..9fb7533c Binary files /dev/null and b/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..9fb7533c Binary files /dev/null and b/android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..21471c87 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..21471c87 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/test/java/com/neph/features/assignedrequest/data/AssignedRequestRepositoryMappingTest.kt b/android/app/src/test/java/com/neph/features/assignedrequest/data/AssignedRequestRepositoryMappingTest.kt index b7cfd950..48ec9442 100644 --- a/android/app/src/test/java/com/neph/features/assignedrequest/data/AssignedRequestRepositoryMappingTest.kt +++ b/android/app/src/test/java/com/neph/features/assignedrequest/data/AssignedRequestRepositoryMappingTest.kt @@ -27,8 +27,8 @@ class AssignedRequestRepositoryMappingTest { val assignment = JSONObject().apply { put("assignment_id", "asg_1") put("request_id", "req_1") - put("need_type", "food_water") - put("help_types", JSONArray(listOf("food_water"))) + put("need_type", "search_rescue") + put("help_types", JSONArray(listOf("search_rescue"))) put("description", "Need bottled water") put("request_status", "ASSIGNED") put("urgency_level", "HIGH") @@ -49,6 +49,9 @@ class AssignedRequestRepositoryMappingTest { assertEquals("High", model.priorityLabel) assertEquals("2026-04-26 10:00:00", model.openedAtLabel) assertEquals("Assigned to you", model.statusLabel) + assertEquals("Search and Rescue", model.helpType) + assertEquals(listOf("Search and Rescue"), model.helpTypes) + assertEquals("Search and Rescue", model.helpTypeSummary) assertEquals(41.043, model.latitude ?: 0.0, 0.0) assertEquals(29.009, model.longitude ?: 0.0, 0.0) } diff --git a/android/app/src/test/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepositoryTest.kt b/android/app/src/test/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepositoryTest.kt index ad91836c..b895a421 100644 --- a/android/app/src/test/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepositoryTest.kt +++ b/android/app/src/test/java/com/neph/features/helprequestmap/data/ActiveHelpRequestsRepositoryTest.kt @@ -4,6 +4,8 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Test +import java.time.Instant +import java.util.TimeZone class ActiveHelpRequestsRepositoryTest { @Test @@ -32,7 +34,7 @@ class ActiveHelpRequestsRepositoryTest { .put( JSONObject() .put("requestId", "req-assigned") - .put("type", "search_and_rescue") + .put("type", "search_rescue") .put("status", "PENDING") .put("urgencyLevel", "HIGH") .put("createdAt", "2026-05-01T10:05:00.000Z") @@ -196,8 +198,41 @@ class ActiveHelpRequestsRepositoryTest { assertEquals(CrisisRequestType.FOOD_WATER, ActiveHelpRequestsRepository.normalizeRequestType("food")) assertEquals(CrisisRequestType.FOOD_WATER, ActiveHelpRequestsRepository.normalizeRequestType("water")) assertEquals(CrisisRequestType.FOOD_WATER, ActiveHelpRequestsRepository.normalizeRequestType("food_water")) + assertEquals(CrisisRequestType.SEARCH_AND_RESCUE, ActiveHelpRequestsRepository.normalizeRequestType("search_rescue")) assertEquals(CrisisRequestType.SEARCH_AND_RESCUE, ActiveHelpRequestsRepository.normalizeRequestType("fire_brigade")) assertEquals(CrisisRequestType.SEARCH_AND_RESCUE, ActiveHelpRequestsRepository.normalizeRequestType("search_and_rescue")) + assertEquals(CrisisRequestType.SEARCH_AND_RESCUE, ActiveHelpRequestsRepository.normalizeRequestType("sar")) + assertEquals(CrisisRequestType.SEARCH_AND_RESCUE, ActiveHelpRequestsRepository.normalizeRequestType("rescue")) assertEquals(CrisisRequestType.OTHER, ActiveHelpRequestsRepository.normalizeRequestType("unknown")) } + + @Test + fun formatOpenedAtUsesRelativeDayLabels() { + withDefaultTimeZone("Europe/Istanbul") { + assertEquals( + "Today, 13:15", + ActiveHelpRequestsRepository.formatOpenedAt( + createdAt = "2026-05-11T10:15:00.000Z", + nowInstant = Instant.parse("2026-05-11T18:00:00.000Z") + ) + ) + assertEquals( + "Yesterday, 23:30", + ActiveHelpRequestsRepository.formatOpenedAt( + createdAt = "2026-05-10T20:30:00.000Z", + nowInstant = Instant.parse("2026-05-11T10:00:00.000Z") + ) + ) + } + } + + private fun withDefaultTimeZone(id: String, block: () -> Unit) { + val previous = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone(id)) + block() + } finally { + TimeZone.setDefault(previous) + } + } } diff --git a/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt b/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt new file mode 100644 index 00000000..3513b8ba --- /dev/null +++ b/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt @@ -0,0 +1,119 @@ +package com.neph.features.onboarding.data + +import com.neph.navigation.Routes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileOnboardingKeysTest { + @Test + fun shouldShowOnlyWhenPendingAndNotSeen() { + assertTrue(MobileOnboardingKeys.shouldShow(pending = true, seen = false)) + assertFalse(MobileOnboardingKeys.shouldShow(pending = true, seen = true)) + assertFalse(MobileOnboardingKeys.shouldShow(pending = false, seen = false)) + assertFalse(MobileOnboardingKeys.shouldShow(pending = false, seen = true)) + } + + @Test + fun scopedKeyKeepsStatePerUserAndSanitizesUnsafeCharacters() { + assertEquals( + "mobile_onboarding_seen_user:user-1", + MobileOnboardingKeys.scopedKey("mobile_onboarding_seen", "user:User-1") + ) + assertEquals( + "mobile_onboarding_pending_email:person+test@example.com", + MobileOnboardingKeys.scopedKey("mobile_onboarding_pending", "email:Person+Test@Example.com") + ) + assertEquals( + "mobile_onboarding_seen_user:abc_123", + MobileOnboardingKeys.scopedKey("mobile_onboarding_seen", "user:abc 123") + ) + } + + @Test + fun journeySkipsAuthenticatedOnlyStepsForGuests() { + val guestSteps = MobileOnboardingJourney.availableSteps(isAuthenticated = false) + + assertEquals(MobileOnboardingStepId.HOME_DASHBOARD, guestSteps.first().id) + assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU }) + assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST }) + assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.ASSIGNED_REQUESTS }) + assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.SETTINGS }) + } + + @Test + fun journeyKeepsCoreRoutesInGuidedOrderForAuthenticatedUsers() { + val authenticatedSteps = MobileOnboardingJourney.availableSteps(isAuthenticated = true) + + assertEquals( + listOf( + MobileOnboardingStepId.HOME_DASHBOARD, + MobileOnboardingStepId.REQUEST_HELP_TYPE, + MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE, + MobileOnboardingStepId.REQUEST_HELP_CONFIRM, + MobileOnboardingStepId.REQUEST_HELP_SEND, + MobileOnboardingStepId.MY_HELP_REQUESTS, + MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU, + MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST, + MobileOnboardingStepId.ASSIGNED_REQUESTS, + MobileOnboardingStepId.OPEN_EMERGENCY_NUMBERS_MENU, + MobileOnboardingStepId.SELECT_EMERGENCY_NUMBERS, + MobileOnboardingStepId.EMERGENCY_NUMBERS, + MobileOnboardingStepId.OPEN_HELP_REQUEST_MAP_MENU, + MobileOnboardingStepId.SELECT_HELP_REQUEST_MAP, + MobileOnboardingStepId.HELP_REQUEST_MAP, + MobileOnboardingStepId.OPEN_NEARBY_USERS_MENU, + MobileOnboardingStepId.SELECT_NEARBY_USERS, + MobileOnboardingStepId.NEARBY_USERS, + MobileOnboardingStepId.OPEN_GATHERING_AREAS_MENU, + MobileOnboardingStepId.SELECT_GATHERING_AREAS, + MobileOnboardingStepId.GATHERING_AREAS, + MobileOnboardingStepId.OPEN_SAFETY_CIRCLES_MENU, + MobileOnboardingStepId.SELECT_SAFETY_CIRCLES, + MobileOnboardingStepId.SAFETY_CIRCLES, + MobileOnboardingStepId.OPEN_NOTIFICATIONS_MENU, + MobileOnboardingStepId.SELECT_NOTIFICATIONS, + MobileOnboardingStepId.NOTIFICATIONS, + MobileOnboardingStepId.OPEN_SETTINGS_MENU, + MobileOnboardingStepId.SELECT_SETTINGS, + MobileOnboardingStepId.SETTINGS + ), + authenticatedSteps.map { it.id } + ) + assertEquals(Routes.RequestHelp.route, authenticatedSteps[1].route) + assertEquals(Routes.MyHelpRequests.route, authenticatedSteps[5].route) + assertEquals(Routes.Settings.route, authenticatedSteps.last().route) + assertEquals(Routes.AssignedRequest.route, authenticatedSteps[8].route) + } + + @Test + fun journeyProgressDoesNotCountMenuOpenSteps() { + assertEquals( + 22, + MobileOnboardingJourney.progressFor(MobileOnboardingStepId.SETTINGS, isAuthenticated = true).second + ) + assertEquals( + MobileOnboardingJourney.progressFor(MobileOnboardingStepId.ASSIGNED_REQUESTS, isAuthenticated = true), + MobileOnboardingJourney.progressFor(MobileOnboardingStepId.OPEN_EMERGENCY_NUMBERS_MENU, isAuthenticated = true) + ) + assertEquals( + 9, + MobileOnboardingJourney.progressFor(MobileOnboardingStepId.SELECT_EMERGENCY_NUMBERS, isAuthenticated = true).first + ) + } + + @Test + fun journeyCanMoveForwardAndBackward() { + assertEquals( + MobileOnboardingStepId.REQUEST_HELP_TYPE, + MobileOnboardingJourney.nextStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true)?.id + ) + assertEquals( + MobileOnboardingStepId.HOME_DASHBOARD, + MobileOnboardingJourney.previousStep(MobileOnboardingStepId.REQUEST_HELP_TYPE, isAuthenticated = true)?.id + ) + assertNull(MobileOnboardingJourney.previousStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true)) + } +} diff --git a/android/app/src/test/java/com/neph/features/requesthelp/data/RequestHelpOfflineMappingTest.kt b/android/app/src/test/java/com/neph/features/requesthelp/data/RequestHelpOfflineMappingTest.kt index deb96b10..9b8c38c3 100644 --- a/android/app/src/test/java/com/neph/features/requesthelp/data/RequestHelpOfflineMappingTest.kt +++ b/android/app/src/test/java/com/neph/features/requesthelp/data/RequestHelpOfflineMappingTest.kt @@ -30,6 +30,7 @@ class RequestHelpOfflineMappingTest { assertEquals("PENDING_SYNC", entity.status) assertEquals("Need water and medication", entity.description) assertEquals(listOf("food_water", "first_aid"), entity.helpTypesJson.jsonArrayToStringList()) + assertTrue(entity.shareProfileHealthInfoWithVolunteer) assertFalse(entity.isDeleted) } @@ -42,6 +43,7 @@ class RequestHelpOfflineMappingTest { assertEquals(3, json.getInt("affectedPeopleCount")) assertEquals("Kadikoy", json.getJSONObject("location").getString("district")) assertEquals(5551234567L, json.getJSONObject("contact").getLong("phone")) + assertTrue(json.getBoolean("shareProfileHealthInfoWithVolunteer")) assertTrue(json.getBoolean("consentGiven")) } @@ -158,7 +160,7 @@ class RequestHelpOfflineMappingTest { put("riskFlags", JSONArray(listOf("fire"))) put("vulnerableGroups", JSONArray(listOf("elderly"))) put("description", "Need support") - put("bloodType", "A+") + put("shareProfileHealthInfoWithVolunteer", true) put("status", "RESOLVED") put("urgencyLevel", "MEDIUM") put("priorityLevel", "MEDIUM") @@ -184,6 +186,7 @@ class RequestHelpOfflineMappingTest { assertEquals("2026-04-26T11:30:00.000Z", entity.resolvedAt) assertNull(entity.cancelledAt) assertEquals("2026-04-26T10:00:00.000Z", entity.serverCreatedAt) + assertTrue(entity.shareProfileHealthInfoWithVolunteer) } @Test @@ -264,6 +267,8 @@ class RequestHelpOfflineMappingTest { assertEquals(29.009, submission.location.longitude ?: 0.0, 0.0) assertEquals("gps", submission.location.coordinateSource) assertEquals(12.0, submission.location.coordinateAccuracyMeters ?: 0.0, 0.0) + assertEquals(listOf("search_rescue"), submission.helpTypes) + assertFalse(submission.shareProfileHealthInfoWithVolunteer) assertTrue(submission.consentGiven) } @@ -275,7 +280,7 @@ class RequestHelpOfflineMappingTest { description = "Need water and medication", riskFlags = listOf("Flooding"), vulnerableGroups = listOf("Elderly"), - bloodType = "A+", + shareProfileHealthInfoWithVolunteer = true, location = RequestHelpLocationSubmission( country = "Turkey", city = "Istanbul", diff --git a/android/app/src/test/java/com/neph/features/requesthelp/data/RequestLifecycleFormattingTest.kt b/android/app/src/test/java/com/neph/features/requesthelp/data/RequestLifecycleFormattingTest.kt index 70761c60..eecd61f1 100644 --- a/android/app/src/test/java/com/neph/features/requesthelp/data/RequestLifecycleFormattingTest.kt +++ b/android/app/src/test/java/com/neph/features/requesthelp/data/RequestLifecycleFormattingTest.kt @@ -2,6 +2,7 @@ package com.neph.features.requesthelp.data import org.junit.Assert.assertEquals import org.junit.Test +import java.time.Instant import java.util.TimeZone class RequestLifecycleFormattingTest { @@ -25,6 +26,32 @@ class RequestLifecycleFormattingTest { } } + @Test + fun formatLifecycleTimestampUsesTodayForCurrentLocalDate() { + withDefaultTimeZone("Europe/Istanbul") { + assertEquals( + "Today 13:00:00", + formatLifecycleTimestamp( + raw = "2026-05-11T10:00:00.000Z", + nowInstant = Instant.parse("2026-05-11T18:00:00.000Z") + ) + ) + } + } + + @Test + fun formatLifecycleTimestampUsesYesterdayForPreviousLocalDate() { + withDefaultTimeZone("Europe/Istanbul") { + assertEquals( + "Yesterday 23:30:00", + formatLifecycleTimestamp( + raw = "2026-05-10T20:30:00.000Z", + nowInstant = Instant.parse("2026-05-11T10:00:00.000Z") + ) + ) + } + } + private fun withDefaultTimeZone(id: String, block: () -> Unit) { val previous = TimeZone.getDefault() try { diff --git a/android/app/src/test/java/com/neph/features/safetycircles/presentation/SafetyCirclesFormattingTest.kt b/android/app/src/test/java/com/neph/features/safetycircles/presentation/SafetyCirclesFormattingTest.kt new file mode 100644 index 00000000..5451efaf --- /dev/null +++ b/android/app/src/test/java/com/neph/features/safetycircles/presentation/SafetyCirclesFormattingTest.kt @@ -0,0 +1,26 @@ +package com.neph.features.safetycircles.presentation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant +import java.time.ZoneId + +class SafetyCirclesFormattingTest { + @Test + fun formatLastCheckedInLabelUsesRelativeLocalTimeForToday() { + assertEquals( + "Last checked in: Today, 16:52", + formatLastCheckedInLabel( + rawTimestamp = "2026-05-11T13:52:27.755Z", + zoneId = ZoneId.of("Europe/Istanbul"), + nowInstant = Instant.parse("2026-05-11T18:00:00.000Z") + ) + ) + } + + @Test + fun formatLastCheckedInLabelIgnoresInvalidTimestamps() { + assertNull(formatLastCheckedInLabel("not-a-timestamp")) + } +} diff --git a/backend/demo-migrations/20260507_153000__seed_demo_ready_neph_data.sql b/backend/demo-migrations/20260507_153000__seed_demo_ready_neph_data.sql index 420a59c9..57e37a97 100644 --- a/backend/demo-migrations/20260507_153000__seed_demo_ready_neph_data.sql +++ b/backend/demo-migrations/20260507_153000__seed_demo_ready_neph_data.sql @@ -483,7 +483,7 @@ INSERT INTO expertise ( VALUES ('demo_expertise_volunteer_1', 'demo_profile_volunteer_1', 'Paramedic volunteer', '["first_aid","medical_support"]', TRUE), ('demo_expertise_volunteer_2', 'demo_profile_volunteer_2', 'Logistics volunteer', '["food","water","mobility_support"]', TRUE), - ('demo_expertise_volunteer_3', 'demo_profile_volunteer_3', 'Search and rescue volunteer', '["search_and_rescue","structural_assessment"]', TRUE), + ('demo_expertise_volunteer_3', 'demo_profile_volunteer_3', 'Search and rescue volunteer', '["search_rescue","structural_assessment"]', TRUE), ('demo_expertise_volunteer_4', 'demo_profile_volunteer_4', 'Shelter coordinator', '["shelter","logistics","translation"]', TRUE), ('demo_expertise_resident_volunteer_2', 'demo_profile_resident_2', 'Neighborhood runner', '["food","water","delivery"]', TRUE), ('demo_expertise_resident_volunteer_4', 'demo_profile_resident_4', 'Building safety volunteer', '["structural_assessment","fire_safety"]', TRUE), @@ -513,10 +513,10 @@ INSERT INTO volunteers ( VALUES ('demo_volunteer_elif', 'demo_user_volunteer_1', TRUE, ARRAY['first_aid','medical_support'], ARRAY['medical','mobility'], 41.06390, 29.00690, CURRENT_TIMESTAMP - INTERVAL '12 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '12 minutes', 18, 'DEMO_DEVICE_GPS'), ('demo_volunteer_can', 'demo_user_volunteer_2', TRUE, ARRAY['supplies','mobility_support'], ARRAY['food','water','mobility'], 40.98612, 29.02562, CURRENT_TIMESTAMP - INTERVAL '8 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '8 minutes', 18, 'DEMO_DEVICE_GPS'), - ('demo_volunteer_sarp', 'demo_user_volunteer_3', TRUE, ARRAY['search_and_rescue','structural_assessment'], ARRAY['search_and_rescue'], 41.05880, 28.98190, CURRENT_TIMESTAMP - INTERVAL '10 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '10 minutes', 16, 'DEMO_DEVICE_GPS'), + ('demo_volunteer_sarp', 'demo_user_volunteer_3', TRUE, ARRAY['search_rescue','structural_assessment'], ARRAY['search_rescue'], 41.05880, 28.98190, CURRENT_TIMESTAMP - INTERVAL '10 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '10 minutes', 16, 'DEMO_DEVICE_GPS'), ('demo_volunteer_zeynep', 'demo_user_volunteer_4', TRUE, ARRAY['shelter','logistics','translation'], ARRAY['shelter','food','water'], 41.03630, 29.03020, CURRENT_TIMESTAMP - INTERVAL '6 minutes', CURRENT_TIMESTAMP + INTERVAL '6 hours', CURRENT_TIMESTAMP - INTERVAL '6 minutes', 16, 'DEMO_DEVICE_GPS'), ('demo_volunteer_resident_emre', 'demo_user_resident_2', TRUE, ARRAY['delivery','food','water'], ARRAY['food','water'], 40.99790, 29.03070, CURRENT_TIMESTAMP - INTERVAL '11 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '11 minutes', 26, 'DEMO_DEVICE_GPS'), - ('demo_volunteer_resident_baris', 'demo_user_resident_4', TRUE, ARRAY['structural_assessment','fire_safety'], ARRAY['search_and_rescue','other'], 41.05890, 28.98130, CURRENT_TIMESTAMP - INTERVAL '18 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '18 minutes', 24, 'DEMO_DEVICE_GPS'), + ('demo_volunteer_resident_baris', 'demo_user_resident_4', TRUE, ARRAY['structural_assessment','fire_safety'], ARRAY['search_rescue','other'], 41.05890, 28.98130, CURRENT_TIMESTAMP - INTERVAL '18 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '18 minutes', 24, 'DEMO_DEVICE_GPS'), ('demo_volunteer_resident_kerem', 'demo_user_resident_6', TRUE, ARRAY['transport','mobility_support'], ARRAY['mobility','medical'], 41.04980, 29.02660, CURRENT_TIMESTAMP - INTERVAL '14 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '14 minutes', 30, 'DEMO_DEVICE_GPS'), ('demo_volunteer_resident_tolga', 'demo_user_resident_8', TRUE, ARRAY['first_aid','elderly_support'], ARRAY['medical','shelter'], 41.01590, 29.01820, CURRENT_TIMESTAMP - INTERVAL '16 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '16 minutes', 26, 'DEMO_DEVICE_GPS'), ('demo_volunteer_resident_kaan', 'demo_user_resident_10', TRUE, ARRAY['communications','translation','logistics'], ARRAY['other','shelter'], 40.99490, 29.05610, CURRENT_TIMESTAMP - INTERVAL '13 minutes', CURRENT_TIMESTAMP + INTERVAL '5 hours', CURRENT_TIMESTAMP - INTERVAL '13 minutes', 30, 'DEMO_DEVICE_GPS') @@ -665,7 +665,7 @@ INSERT INTO help_requests ( VALUES ('demo_request_active_medical', 'demo_user_requester_1', ARRAY['medical'], 1, ARRAY['elderly','urgent_medication'], ARRAY['elderly'], 'medical', '[DEMO] Elderly resident needs urgent blood pressure medication and a basic health check.', 'A+', 'Ayse Kara', 5332223344, TRUE, 'PENDING'::request_status, 'HIGH', 'HIGH', CURRENT_TIMESTAMP - INTERVAL '45 minutes', NULL, NULL), ('demo_request_assigned_food_water', 'demo_user_requester_2', ARRAY['food','water'], 4, ARRAY['children'], ARRAY['children'], 'food/water', '[DEMO] Family of four needs drinking water and ready-to-eat food supplies.', NULL, 'Mert Demir', 5343334455, TRUE, 'ASSIGNED'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '1 hour', NULL, NULL), - ('demo_request_active_search_rescue', 'demo_user_requester_4', ARRAY['search_and_rescue'], 2, ARRAY['injury','structural_damage'], ARRAY[]::TEXT[], 'search_and_rescue', '[DEMO] Residents report a damaged stairwell and possible trapped neighbor near an apartment entrance.', NULL, 'Orhan Yildiz', 5327778899, TRUE, 'PENDING'::request_status, 'HIGH', 'HIGH', CURRENT_TIMESTAMP - INTERVAL '30 minutes', NULL, NULL), + ('demo_request_active_search_rescue', 'demo_user_requester_4', ARRAY['search_rescue'], 2, ARRAY['injury','structural_damage'], ARRAY[]::TEXT[], 'search_rescue', '[DEMO] Residents report a damaged stairwell and possible trapped neighbor near an apartment entrance.', NULL, 'Orhan Yildiz', 5327778899, TRUE, 'PENDING'::request_status, 'HIGH', 'HIGH', CURRENT_TIMESTAMP - INTERVAL '30 minutes', NULL, NULL), ('demo_request_active_shelter', 'demo_user_requester_3', ARRAY['shelter'], 2, ARRAY['elderly','cold_exposure'], ARRAY['elderly'], 'shelter', '[DEMO] Two residents need a temporary indoor shelter and warm blankets near Kuzguncuk.', NULL, 'Fatma Celik', 5376667788, TRUE, 'PENDING'::request_status, 'MEDIUM', 'MEDIUM', CURRENT_TIMESTAMP - INTERVAL '25 minutes', NULL, NULL), ('demo_request_resolved_mobility', 'demo_user_requester_1', ARRAY['mobility'], 1, ARRAY['mobility_impairment'], ARRAY['disabled'], 'mobility', '[DEMO] Wheelchair user needed help reaching a temporary gathering area.', NULL, 'Ayse Kara', 5332223344, TRUE, 'RESOLVED'::request_status, 'LOW', 'LOW', CURRENT_TIMESTAMP - INTERVAL '3 hours', CURRENT_TIMESTAMP - INTERVAL '2 hours', NULL), ('demo_request_cancelled_checkin', 'demo_user_requester_2', ARRAY['other'], 1, ARRAY['duplicate_report'], ARRAY[]::TEXT[], 'other', '[DEMO] Duplicate neighborhood check-in request cancelled after phone confirmation.', NULL, 'Mert Demir', 5343334455, TRUE, 'CANCELLED'::request_status, 'LOW', 'LOW', CURRENT_TIMESTAMP - INTERVAL '5 hours', NULL, CURRENT_TIMESTAMP - INTERVAL '4 hours 30 minutes'), diff --git a/backend/demo-migrations/20260511_141500__normalize_demo_help_request_types.sql b/backend/demo-migrations/20260511_141500__normalize_demo_help_request_types.sql new file mode 100644 index 00000000..9c9df106 --- /dev/null +++ b/backend/demo-migrations/20260511_141500__normalize_demo_help_request_types.sql @@ -0,0 +1,151 @@ +-- Normalize already-seeded demo help request type values after the product +-- transition to canonical request help types. + +WITH normalized_demo_help_requests AS ( + SELECT + request_id, + ARRAY( + SELECT DISTINCT mapped_type + FROM unnest(help_types) AS raw_type + CROSS JOIN LATERAL ( + SELECT CASE lower(trim(raw_type)) + WHEN 'search_rescue' THEN 'search_rescue' + WHEN 'search_and_rescue' THEN 'search_rescue' + WHEN 'sar' THEN 'search_rescue' + WHEN 'fire_brigade' THEN 'search_rescue' + WHEN 'rescue' THEN 'search_rescue' + WHEN 'evacuation_transport' THEN 'shelter' + WHEN 'security_support' THEN 'shelter' + WHEN 'other' THEN 'shelter' + WHEN 'medical' THEN 'first_aid' + WHEN 'mobility' THEN 'shelter' + WHEN 'food' THEN 'food_water' + WHEN 'water' THEN 'food_water' + WHEN 'food/water' THEN 'food_water' + WHEN 'supplies' THEN 'food_water' + WHEN 'basic_supplies' THEN 'food_water' + ELSE lower(trim(raw_type)) + END AS mapped_type + ) mapped + WHERE mapped_type IN ('first_aid', 'food_water', 'shelter', 'search_rescue') + ORDER BY mapped_type + ) AS normalized_help_types, + CASE lower(trim(need_type)) + WHEN 'search_rescue' THEN 'search_rescue' + WHEN 'search_and_rescue' THEN 'search_rescue' + WHEN 'sar' THEN 'search_rescue' + WHEN 'fire_brigade' THEN 'search_rescue' + WHEN 'rescue' THEN 'search_rescue' + WHEN 'evacuation_transport' THEN 'shelter' + WHEN 'security_support' THEN 'shelter' + WHEN 'other' THEN 'shelter' + WHEN 'medical' THEN 'first_aid' + WHEN 'mobility' THEN 'shelter' + WHEN 'food' THEN 'food_water' + WHEN 'water' THEN 'food_water' + WHEN 'food/water' THEN 'food_water' + WHEN 'supplies' THEN 'food_water' + WHEN 'basic_supplies' THEN 'food_water' + ELSE lower(trim(need_type)) + END AS normalized_need_type + FROM help_requests + WHERE request_id LIKE 'demo_%' + OR description ILIKE '[DEMO]%' +) +UPDATE help_requests hr +SET + help_types = CASE + WHEN cardinality(nd.normalized_help_types) > 0 THEN nd.normalized_help_types + ELSE ARRAY['shelter']::text[] + END, + need_type = CASE + WHEN nd.normalized_need_type IN ('first_aid', 'food_water', 'shelter', 'search_rescue') + THEN nd.normalized_need_type + WHEN cardinality(nd.normalized_help_types) > 0 + THEN nd.normalized_help_types[1] + ELSE 'shelter' + END +FROM normalized_demo_help_requests nd +WHERE hr.request_id = nd.request_id; + +UPDATE volunteers +SET + skills = ARRAY( + SELECT DISTINCT mapped_skill + FROM unnest(skills) AS raw_skill + CROSS JOIN LATERAL ( + SELECT CASE lower(trim(raw_skill)) + WHEN 'search_and_rescue' THEN 'search_rescue' + WHEN 'sar' THEN 'search_rescue' + WHEN 'fire_brigade' THEN 'search_rescue' + WHEN 'rescue' THEN 'search_rescue' + ELSE lower(trim(raw_skill)) + END AS mapped_skill + ) mapped + ORDER BY mapped_skill + ), + need_types = ARRAY( + SELECT DISTINCT mapped_need_type + FROM unnest(need_types) AS raw_need_type + CROSS JOIN LATERAL ( + SELECT CASE lower(trim(raw_need_type)) + WHEN 'search_and_rescue' THEN 'search_rescue' + WHEN 'sar' THEN 'search_rescue' + WHEN 'fire_brigade' THEN 'search_rescue' + WHEN 'rescue' THEN 'search_rescue' + WHEN 'evacuation_transport' THEN 'shelter' + WHEN 'security_support' THEN 'shelter' + WHEN 'other' THEN 'shelter' + WHEN 'medical' THEN 'first_aid' + WHEN 'mobility' THEN 'shelter' + WHEN 'food' THEN 'food_water' + WHEN 'water' THEN 'food_water' + WHEN 'food/water' THEN 'food_water' + WHEN 'supplies' THEN 'food_water' + WHEN 'basic_supplies' THEN 'food_water' + ELSE lower(trim(raw_need_type)) + END AS mapped_need_type + ) mapped + WHERE mapped_need_type IN ('first_aid', 'food_water', 'shelter', 'search_rescue') + ORDER BY mapped_need_type + ) +WHERE volunteer_id LIKE 'demo_%' + AND (skills && ARRAY['search_and_rescue', 'sar', 'fire_brigade', 'rescue'] + OR need_types && ARRAY[ + 'search_and_rescue', + 'sar', + 'fire_brigade', + 'rescue', + 'evacuation_transport', + 'security_support', + 'other', + 'medical', + 'mobility', + 'food', + 'water', + 'food/water', + 'supplies', + 'basic_supplies' + ]); + +UPDATE expertise +SET expertise_area = replace( + replace( + replace( + replace(expertise_area, 'search_and_rescue', 'search_rescue'), + 'fire_brigade', + 'search_rescue' + ), + 'evacuation_transport', + 'shelter' + ), + 'security_support', + 'shelter' +) +WHERE expertise_id LIKE 'demo_%' + AND ( + expertise_area LIKE '%search_and_rescue%' OR + expertise_area LIKE '%fire_brigade%' OR + expertise_area LIKE '%evacuation_transport%' OR + expertise_area LIKE '%security_support%' + ); diff --git a/backend/migrations/20260510_120000__add_help_request_health_sharing_consent.sql b/backend/migrations/20260510_120000__add_help_request_health_sharing_consent.sql new file mode 100644 index 00000000..2f7998b6 --- /dev/null +++ b/backend/migrations/20260510_120000__add_help_request_health_sharing_consent.sql @@ -0,0 +1,2 @@ +ALTER TABLE help_requests + ADD COLUMN IF NOT EXISTS share_profile_health_info_with_volunteer BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/modules/availability/repository.js b/backend/src/modules/availability/repository.js index 5a1099ed..caf7780a 100644 --- a/backend/src/modules/availability/repository.js +++ b/backend/src/modules/availability/repository.js @@ -12,8 +12,8 @@ function runQuery(executor, text, params = []) { const DEFAULT_MAX_MATCH_DISTANCE_METERS = 1000; const FIRST_AID_HELP_TYPES = new Set(['first_aid', 'medical']); -const SEARCH_AND_RESCUE_HELP_TYPES = new Set(['search_and_rescue', 'sar', 'fire_brigade', 'rescue']); -const SUPPLIES_HELP_TYPES = new Set(['food', 'water', 'basic_supplies', 'supplies']); +const SEARCH_AND_RESCUE_HELP_TYPES = new Set(['search_rescue', 'search_and_rescue', 'sar', 'fire_brigade', 'rescue']); +const SUPPLIES_HELP_TYPES = new Set(['food_water', 'food', 'water', 'basic_supplies', 'supplies']); const SHELTER_HELP_TYPES = new Set(['shelter']); const FIRST_AID_EXPERTISE_MARKERS = new Set(['first_aid', 'medical']); const FIRST_AID_ONLY_GENERAL_FALLBACK_CAP = 1; @@ -773,7 +773,12 @@ async function getAssignmentByVolunteerId(volunteerId, executor = null) { const sql = ` SELECT a.*, hr.need_type, hr.description, hr.status as request_status, hr.help_types, hr.other_help_text, hr.affected_people_count, - hr.risk_flags, hr.vulnerable_groups, hr.blood_type, + hr.risk_flags, hr.vulnerable_groups, + hr.share_profile_health_info_with_volunteer, + CASE WHEN hr.share_profile_health_info_with_volunteer THEN hi.blood_type ELSE NULL END AS blood_type, + CASE WHEN hr.share_profile_health_info_with_volunteer THEN COALESCE(hi.medical_conditions, ARRAY[]::TEXT[]) ELSE ARRAY[]::TEXT[] END AS medical_conditions, + CASE WHEN hr.share_profile_health_info_with_volunteer THEN COALESCE(hi.chronic_diseases, ARRAY[]::TEXT[]) ELSE ARRAY[]::TEXT[] END AS chronic_diseases, + CASE WHEN hr.share_profile_health_info_with_volunteer THEN COALESCE(hi.allergies, ARRAY[]::TEXT[]) ELSE ARRAY[]::TEXT[] END AS allergies, hr.urgency_level, hr.priority_level, hr.created_at AS opened_at, hr.contact_full_name, hr.contact_phone, hr.contact_alternative_phone, rl.latitude, rl.longitude, @@ -787,6 +792,7 @@ async function getAssignmentByVolunteerId(volunteerId, executor = null) { LEFT JOIN request_locations rl ON hr.request_id = rl.request_id LEFT JOIN users u ON hr.user_id = u.user_id LEFT JOIN user_profiles up ON u.user_id = up.user_id + LEFT JOIN health_info hi ON hi.profile_id = up.profile_id WHERE a.volunteer_id = $1 AND a.is_cancelled = FALSE AND hr.status != 'RESOLVED' AND hr.status != 'CANCELLED' LIMIT 1; `; diff --git a/backend/src/modules/help-requests/repository.js b/backend/src/modules/help-requests/repository.js index 19d5c491..49a7ec7d 100644 --- a/backend/src/modules/help-requests/repository.js +++ b/backend/src/modules/help-requests/repository.js @@ -144,6 +144,7 @@ function mapHelpRequest(row) { vulnerableGroups: row.vulnerable_groups || [], description: row.description, bloodType: row.blood_type || '', + shareProfileHealthInfoWithVolunteer: row.share_profile_health_info_with_volunteer || false, location: mapLocation(row), contact: mapContact(row), consentGiven: row.consent_given, @@ -183,6 +184,7 @@ function buildSelectQuery() { hr.need_type, hr.description, hr.blood_type, + hr.share_profile_health_info_with_volunteer, hr.contact_full_name, hr.contact_phone, hr.contact_alternative_phone, @@ -272,6 +274,7 @@ async function createHelpRequest(input) { need_type, description, blood_type, + share_profile_health_info_with_volunteer, contact_full_name, contact_phone, contact_alternative_phone, @@ -280,7 +283,7 @@ async function createHelpRequest(input) { priority_level, is_saved_locally ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING request_id, user_id, @@ -292,6 +295,7 @@ async function createHelpRequest(input) { need_type, description, blood_type, + share_profile_health_info_with_volunteer, contact_full_name, contact_phone, contact_alternative_phone, @@ -320,7 +324,7 @@ async function createHelpRequest(input) { input.vulnerableGroups, input.needType, input.description || null, - input.bloodType || null, + input.shareProfileHealthInfoWithVolunteer || false, input.contact.fullName || null, input.contact.phone, input.contact.alternativePhone ?? null, @@ -428,7 +432,8 @@ async function updateHelpRequestByRequestId(requestId, input, executor = null) { vulnerable_groups = $6, need_type = $7, description = $8, - blood_type = $9, + blood_type = NULL, + share_profile_health_info_with_volunteer = $9, contact_full_name = $10, contact_phone = $11, contact_alternative_phone = $12, @@ -449,6 +454,7 @@ async function updateHelpRequestByRequestId(requestId, input, executor = null) { need_type, description, blood_type, + share_profile_health_info_with_volunteer, contact_full_name, contact_phone, contact_alternative_phone, @@ -476,7 +482,7 @@ async function updateHelpRequestByRequestId(requestId, input, executor = null) { input.vulnerableGroups, input.needType, input.description || null, - input.bloodType || null, + input.shareProfileHealthInfoWithVolunteer || false, input.contact.fullName || null, input.contact.phone, input.contact.alternativePhone ?? null, diff --git a/backend/src/modules/help-requests/validators.js b/backend/src/modules/help-requests/validators.js index 040d5d48..80ece8d6 100644 --- a/backend/src/modules/help-requests/validators.js +++ b/backend/src/modules/help-requests/validators.js @@ -76,6 +76,19 @@ function validateStringArray(fieldName, value, errors, { required = false } = {} return normalized; } +const allowedHelpTypes = new Set(['first_aid', 'food_water', 'shelter', 'search_rescue']); + +function validateHelpTypes(value, errors) { + const helpTypes = validateStringArray('helpTypes', value, errors, { required: true }); + const invalidHelpTypes = helpTypes.filter((entry) => !allowedHelpTypes.has(entry)); + + if (invalidHelpTypes.length > 0) { + errors.push('`helpTypes` can only include: first_aid, food_water, shelter, search_rescue.'); + } + + return helpTypes; +} + function validateRequiredString(fieldName, value, errors, { maxLength = 255 } = {}) { const normalized = normalizeOptionalString(value); @@ -225,7 +238,7 @@ function validateCreateHelpRequest(payload) { }; } - const helpTypes = validateStringArray('helpTypes', payload.helpTypes, errors, { required: true }); + const helpTypes = validateHelpTypes(payload.helpTypes, errors); const otherHelpText = validateOptionalString('otherHelpText', payload.otherHelpText, errors, { maxLength: 500, }); @@ -244,9 +257,14 @@ function validateCreateHelpRequest(payload) { const description = validateOptionalString('description', payload.description, errors, { maxLength: 2000, }); - const bloodType = validateOptionalString('bloodType', payload.bloodType, errors, { - maxLength: 10, - }); + let shareProfileHealthInfoWithVolunteer = false; + if (payload.shareProfileHealthInfoWithVolunteer != null) { + if (!isBoolean(payload.shareProfileHealthInfoWithVolunteer)) { + errors.push('`shareProfileHealthInfoWithVolunteer` must be a boolean.'); + } else { + shareProfileHealthInfoWithVolunteer = payload.shareProfileHealthInfoWithVolunteer; + } + } let consentGiven = false; if (!isBoolean(payload.consentGiven)) { @@ -376,7 +394,8 @@ function validateCreateHelpRequest(payload) { riskFlags, vulnerableGroups, description, - bloodType, + bloodType: '', + shareProfileHealthInfoWithVolunteer, location, contact, consentGiven, diff --git a/backend/src/modules/notifications/service.js b/backend/src/modules/notifications/service.js index 8d0cab0f..a5a5bad1 100644 --- a/backend/src/modules/notifications/service.js +++ b/backend/src/modules/notifications/service.js @@ -74,6 +74,32 @@ function decodeCursor(cursor) { } } +const DATE_TIME_WITHOUT_OFFSET_PATTERN = /^\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}/; +const TIMEZONE_OFFSET_PATTERN = /(?:[zZ]|[+-]\d{2}:?\d{2})$/; + +function toClientTimestamp(value) { + if (value === null || value === undefined) { + return null; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + const normalizedValue = String(value).trim(); + if (!normalizedValue) { + return null; + } + + const parseValue = + DATE_TIME_WITHOUT_OFFSET_PATTERN.test(normalizedValue) && !TIMEZONE_OFFSET_PATTERN.test(normalizedValue) + ? `${normalizedValue.replace(' ', 'T')}Z` + : normalizedValue; + const date = new Date(parseValue); + + return Number.isNaN(date.getTime()) ? normalizedValue : date.toISOString(); +} + function mapNotificationForClient(notification) { return { id: notification.id, @@ -81,8 +107,8 @@ function mapNotificationForClient(notification) { title: notification.title, body: notification.body, isRead: notification.isRead, - readAt: notification.readAt, - createdAt: notification.createdAt, + readAt: toClientTimestamp(notification.readAt), + createdAt: toClientTimestamp(notification.createdAt), actorUserId: notification.actorUserId, entity: notification.entity, data: notification.data, diff --git a/backend/tests/integration/modules/help-requests/help-requests.integration.test.js b/backend/tests/integration/modules/help-requests/help-requests.integration.test.js index aba3a358..edcd68c3 100644 --- a/backend/tests/integration/modules/help-requests/help-requests.integration.test.js +++ b/backend/tests/integration/modules/help-requests/help-requests.integration.test.js @@ -39,13 +39,13 @@ function minutesFromNow(minutes) { function buildCreatePayload(overrides = {}) { return { - helpTypes: ['first_aid', 'fire_brigade'], + helpTypes: ['first_aid', 'food_water'], otherHelpText: '', affectedPeopleCount: 3, riskFlags: ['fire', 'electric_hazard'], vulnerableGroups: ['children', 'pregnant'], description: 'Apartment entrance blocked, one person bleeding.', - bloodType: 'A+', + shareProfileHealthInfoWithVolunteer: true, location: { country: 'turkiye', city: 'istanbul', @@ -200,7 +200,7 @@ describe('help-requests integration', () => { expect(response.status).toBe(201); expect(response.body.guestAccessToken).toEqual(expect.any(String)); expect(response.body.request.userId).toBeNull(); - expect(response.body.request.helpTypes).toEqual(['first_aid', 'fire_brigade']); + expect(response.body.request.helpTypes).toEqual(['first_aid', 'food_water']); expect(response.body.request.contact.fullName).toBe('Ayse Yilmaz'); expect(response.body.request.status).toBe('SYNCED'); expect(response.body.request.urgencyLevel).toBe('HIGH'); @@ -259,7 +259,8 @@ describe('help-requests integration', () => { expect(response.body.request.affectedPeopleCount).toBe(payload.affectedPeopleCount); expect(response.body.request.riskFlags).toEqual(payload.riskFlags); expect(response.body.request.vulnerableGroups).toEqual(payload.vulnerableGroups); - expect(response.body.request.bloodType).toBe(payload.bloodType); + expect(response.body.request.bloodType).toBe(''); + expect(response.body.request.shareProfileHealthInfoWithVolunteer).toBe(true); expect(response.body.request.location).toEqual(payload.location); expect(response.body.request.location).not.toHaveProperty('coordinate'); expect(response.body.request.location).not.toHaveProperty('latitude'); @@ -409,7 +410,6 @@ describe('help-requests integration', () => { const payload = buildCreatePayload({ otherHelpText: ' generator needed ', - bloodType: ' A+ ', location: { country: 'turkiye', city: 'istanbul', @@ -431,7 +431,7 @@ describe('help-requests integration', () => { expect(response.status).toBe(201); expect(response.body.request.otherHelpText).toBe('generator needed'); - expect(response.body.request.bloodType).toBe('A+'); + expect(response.body.request.bloodType).toBe(''); expect(response.body.request.location.extraAddress).toBe('Need entry from the back'); expect(response.body.request.contact.phone).toBe(5052318546); expect(response.body.request.contact.alternativePhone).toBe(5321234567); @@ -562,12 +562,12 @@ describe('help-requests integration', () => { await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token1}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token1}`) - .send(buildCreatePayload({ helpTypes: ['water'] })); + .send(buildCreatePayload({ helpTypes: ['shelter'] })); await request(app) .post('/api/help-requests') @@ -598,12 +598,12 @@ describe('help-requests integration', () => { const activeCreate = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['food'], description: 'active request' })); + .send(buildCreatePayload({ helpTypes: ['food_water'], description: 'active request' })); const resolvedCreate = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['water'], description: 'resolved request' })); + .send(buildCreatePayload({ helpTypes: ['shelter'], description: 'resolved request' })); const cancelledCreate = await request(app) .post('/api/help-requests') @@ -670,7 +670,7 @@ describe('help-requests integration', () => { expect(response.status).toBe(200); expect(response.body.request.id).toBe(requestId); expect(response.body.request.description).toBe('broken leg'); - expect(response.body.request.helpTypes).toEqual(['first_aid', 'fire_brigade']); + expect(response.body.request.helpTypes).toEqual(['first_aid', 'food_water']); }); test('GET /api/help-requests/:requestId returns 401 without auth and guest token', async () => { @@ -847,7 +847,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token1}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); const requestId = createResponse.body.request.id; @@ -981,7 +981,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); const requestId = createResponse.body.request.id; expect(createResponse.body.request.status).toBe('SYNCED'); @@ -1005,7 +1005,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); const requestId = createResponse.body.request.id; @@ -1105,7 +1105,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); const requestId = createResponse.body.request.id; @@ -1132,7 +1132,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['food'] })); + .send(buildCreatePayload({ helpTypes: ['food_water'] })); const requestId = createResponse.body.request.id; @@ -1212,7 +1212,7 @@ describe('help-requests integration', () => { const createResponse = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) - .send(buildCreatePayload({ helpTypes: ['water'] })); + .send(buildCreatePayload({ helpTypes: ['shelter'] })); const requestId = createResponse.body.request.id; @@ -1263,7 +1263,7 @@ describe('help-requests integration', () => { expect(assignmentRes.status).toBe(200); const asg = assignmentRes.body.assignment; - expect(asg.help_types).toEqual(['first_aid', 'fire_brigade']); + expect(asg.help_types).toEqual(['first_aid', 'food_water']); expect(asg.affected_people_count).toBe(3); expect(asg.risk_flags).toEqual(['fire', 'electric_hazard']); expect(asg.vulnerable_groups).toEqual(['children', 'pregnant']); @@ -1418,7 +1418,7 @@ describe('help-requests integration', () => { const createRes = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${requesterToken}`) - .send(buildCreatePayload({ helpTypes: ['fire_brigade'], needType: 'fire_brigade' })); + .send(buildCreatePayload({ helpTypes: ['food_water'], needType: 'food_water' })); const requestId = createRes.body.request.id; @@ -1488,7 +1488,7 @@ describe('help-requests integration', () => { const createRes = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${requesterToken}`) - .send(buildCreatePayload({ helpTypes: ['food'], needType: 'food' })); + .send(buildCreatePayload({ helpTypes: ['food_water'], needType: 'food_water' })); const requestId = createRes.body.request.id; @@ -1704,7 +1704,7 @@ describe('help-requests integration', () => { const response = await request(app) .post('/api/help-requests') .set('Authorization', `Bearer ${buildAuthToken(requesterId)}`) - .send(buildCreatePayload({ helpTypes: ['first_aid', 'fire_brigade'] })); + .send(buildCreatePayload({ helpTypes: ['first_aid', 'search_rescue'] })); expect(response.status).toBe(201); expect(response.body.request.status).toBe('MATCHED'); @@ -1741,7 +1741,7 @@ describe('help-requests integration', () => { .post('/api/help-requests') .set('Authorization', `Bearer ${buildAuthToken(requesterId)}`) .send(buildCreatePayload({ - helpTypes: ['first_aid', 'fire_brigade'], + helpTypes: ['first_aid', 'search_rescue'], location: { country: 'turkiye', city: 'istanbul', @@ -1927,7 +1927,7 @@ describe('help-requests integration', () => { .post('/api/help-requests') .set('Authorization', `Bearer ${token}`) .send(buildCreatePayload({ - helpTypes: ['food'], + helpTypes: ['food_water'], location: { country: 'turkiye', city: 'ankara', @@ -2085,7 +2085,7 @@ describe('help-requests integration', () => { .post('/api/help-requests') .set('Authorization', `Bearer ${requesterToken}`) .send(buildCreatePayload({ - helpTypes: ['food'], + helpTypes: ['food_water'], location: { country: 'turkiye', city: 'istanbul', diff --git a/backend/tests/unit/modules/help-requests/service.test.js b/backend/tests/unit/modules/help-requests/service.test.js index 40a1daba..7b82a379 100644 --- a/backend/tests/unit/modules/help-requests/service.test.js +++ b/backend/tests/unit/modules/help-requests/service.test.js @@ -42,7 +42,7 @@ describe('help-requests service', () => { describe('createMyHelpRequest', () => { test('delegates to repository with userId merged into input', async () => { const input = { - helpTypes: ['first_aid', 'fire_brigade'], + helpTypes: ['first_aid', 'food_water'], otherHelpText: '', affectedPeopleCount: 3, riskFlags: ['fire'], @@ -63,7 +63,7 @@ describe('help-requests service', () => { }, consentGiven: true, }; - const expected = { id: 'req_1', userId: 'u1', helpTypes: ['first_aid', 'fire_brigade'] }; + const expected = { id: 'req_1', userId: 'u1', helpTypes: ['first_aid', 'food_water'] }; repository.createHelpRequest.mockResolvedValueOnce(expected); repository.findHelpRequestById.mockResolvedValueOnce(expected); availabilityService.tryToAssignRequest.mockResolvedValueOnce(false); @@ -71,7 +71,7 @@ describe('help-requests service', () => { const result = await createMyHelpRequest('u1', input); expect(repository.createHelpRequest).toHaveBeenCalledWith({ - helpTypes: ['first_aid', 'fire_brigade'], + helpTypes: ['first_aid', 'food_water'], otherHelpText: '', affectedPeopleCount: 3, riskFlags: ['fire'], diff --git a/backend/tests/unit/modules/help-requests/validators.test.js b/backend/tests/unit/modules/help-requests/validators.test.js index a614f6fc..998adf4d 100644 --- a/backend/tests/unit/modules/help-requests/validators.test.js +++ b/backend/tests/unit/modules/help-requests/validators.test.js @@ -71,13 +71,13 @@ describe('help-requests validators', () => { describe('validateCreateHelpRequest', () => { function buildPayload() { return { - helpTypes: ['first_aid', 'fire_brigade'], + helpTypes: ['first_aid', 'food_water'], otherHelpText: '', affectedPeopleCount: 3, riskFlags: ['fire', 'electric_hazard'], vulnerableGroups: ['children', 'pregnant'], description: 'Apartment entrance blocked, one person bleeding.', - bloodType: 'A+', + shareProfileHealthInfoWithVolunteer: true, location: { country: 'turkiye', city: 'istanbul', @@ -99,11 +99,13 @@ describe('help-requests validators', () => { expect(errors).toHaveLength(0); expect(warnings).toEqual([]); - expect(value.helpTypes).toEqual(['first_aid', 'fire_brigade']); + expect(value.helpTypes).toEqual(['first_aid', 'food_water']); expect(value.needType).toBe('first_aid'); expect(value.affectedPeopleCount).toBe(3); expect(value.location.city).toBe('istanbul'); expect(value.contact.fullName).toBe('Ayse Yilmaz'); + expect(value.bloodType).toBe(''); + expect(value.shareProfileHealthInfoWithVolunteer).toBe(true); expect(value.consentGiven).toBe(true); expect(value.isSavedLocally).toBe(false); }); @@ -146,6 +148,16 @@ describe('help-requests validators', () => { expect(value.description).toBe(''); expect(value.location.neighborhood).toBe(''); expect(value.contact.fullName).toBe(''); + expect(value.shareProfileHealthInfoWithVolunteer).toBe(false); + }); + + test('rejects removed help types', () => { + const payload = buildPayload(); + payload.helpTypes = ['fire_brigade', 'evacuation_transport', 'security_support', 'other']; + + const { errors } = validateCreateHelpRequest(payload); + + expect(errors).toContain('`helpTypes` can only include: first_aid, food_water, shelter, search_rescue.'); }); test('rejects invalid provided affectedPeopleCount instead of defaulting it', () => { @@ -187,13 +199,12 @@ describe('help-requests validators', () => { test('trims optional strings while preserving numeric phones', () => { const payload = buildPayload(); payload.otherHelpText = ' extra detail '; - payload.bloodType = ' A+ '; payload.location.extraAddress = ' back door '; const { value } = validateCreateHelpRequest(payload); expect(value.otherHelpText).toBe('extra detail'); - expect(value.bloodType).toBe('A+'); + expect(value.bloodType).toBe(''); expect(value.location.extraAddress).toBe('back door'); expect(value.contact.alternativePhone).toBe(5321234567); }); diff --git a/backend/tests/unit/modules/notifications/service.test.js b/backend/tests/unit/modules/notifications/service.test.js index 16b5efcc..dfa562d3 100644 --- a/backend/tests/unit/modules/notifications/service.test.js +++ b/backend/tests/unit/modules/notifications/service.test.js @@ -95,6 +95,8 @@ describe('notifications service', () => { title: 'Title', body: 'Body', isRead: false, + readAt: null, + createdAt: '2026-04-22T20:00:00.000Z', }); expect(repository.insertNotificationDelivery).toHaveBeenCalled(); }); @@ -136,10 +138,37 @@ describe('notifications service', () => { cursorNotificationId: null, }); expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ + id: 'notif_2', + createdAt: '2026-04-22T20:00:00.000Z', + }); expect(result.unreadCount).toBe(3); expect(typeof result.nextCursor).toBe('string'); }); + test('listMyNotifications normalizes offsetless notification timestamps as UTC ISO strings', async () => { + const rows = [ + createStoredNotification({ + id: 'notif_1', + createdAt: '2026-04-22 20:00:00', + readAt: '2026-04-22T20:05:00', + }), + ]; + repository.listNotificationsByRecipient.mockResolvedValue(rows); + repository.countUnreadNotifications.mockResolvedValue(1); + + const result = await listMyNotifications('user_1', { + limit: 20, + unreadOnly: false, + cursor: null, + }); + + expect(result.items[0]).toMatchObject({ + createdAt: '2026-04-22T20:00:00.000Z', + readAt: '2026-04-22T20:05:00.000Z', + }); + }); + test('listMyNotifications rejects malformed cursor', async () => { await expect( listMyNotifications('user_1', { diff --git a/infra/docker/postgres/init.sql b/infra/docker/postgres/init.sql index 5d769446..8f0ce636 100644 --- a/infra/docker/postgres/init.sql +++ b/infra/docker/postgres/init.sql @@ -216,6 +216,7 @@ CREATE TABLE help_requests ( need_type VARCHAR(200) NOT NULL, description TEXT, blood_type VARCHAR(10), + share_profile_health_info_with_volunteer BOOLEAN NOT NULL DEFAULT FALSE, contact_full_name VARCHAR(200) NOT NULL, contact_phone BIGINT NOT NULL, contact_alternative_phone BIGINT, diff --git a/web/e2e/help-request-map.spec.js b/web/e2e/help-request-map.spec.js index 873c7d44..ad0f4f44 100644 --- a/web/e2e/help-request-map.spec.js +++ b/web/e2e/help-request-map.spec.js @@ -70,7 +70,7 @@ test('guest can view waiting help requests on the map without operational status }, { requestId: 'map_req_assigned', - type: 'search_and_rescue', + type: 'search_rescue', status: 'PENDING', urgencyLevel: 'HIGH', createdAt: '2026-05-01T09:55:00.000Z', @@ -191,30 +191,47 @@ test('supports multi-select request type filters and clears selected details whe assignmentState: 'UNASSIGNED', location: { latitude: 41.066, longitude: 28.993, city: 'istanbul', district: 'sisli' }, }, + { + requestId: 'map_req_search_rescue', + type: 'search_rescue', + status: 'PENDING', + urgencyLevel: 'HIGH', + createdAt: '2026-05-01T11:00:00.000Z', + assignmentState: 'UNASSIGNED', + location: { latitude: 41.05, longitude: 29.01, city: 'istanbul', district: 'kadikoy' }, + }, { requestId: 'map_req_food', type: 'food_water', status: 'PENDING', urgencyLevel: 'LOW', - createdAt: '2026-05-01T11:00:00.000Z', + createdAt: '2026-05-01T11:05:00.000Z', assignmentState: 'UNASSIGNED', - location: { latitude: 41.05, longitude: 29.01, city: 'istanbul', district: 'kadikoy' }, + location: { latitude: 41.052, longitude: 29.012, city: 'istanbul', district: 'kadikoy' }, }, ], - total: 3, + total: 4, pagination: { limit: 300, offset: 0 }, }), }); }); await page.goto('/crisis-map'); - await expect(page.locator('.crisis-pin')).toHaveCount(3); + await expect(page.locator('.crisis-pin')).toHaveCount(4); await expect(page.getByText('Select a request marker to view details.')).toBeVisible(); + await expect(list.getByRole('button', { name: /Search and Rescue/i })).toBeVisible(); + await expect(list.getByRole('button', { name: /Other \/ Unknown/i })).toHaveCount(0); await list.getByRole('button', { name: /First Aid/i }).click(); await expect(page.locator('.crisis-pin.is-selected')).toHaveCount(1); await expect(page.locator('.gathering-areas-selected-card')).toContainText('First Aid'); + await filters.getByRole('button', { name: 'Search and Rescue', exact: true }).click(); + await expect(page.locator('.crisis-pin')).toHaveCount(1); + await expect(list.getByRole('button', { name: /Search and Rescue/i })).toBeVisible(); + await expect(list.getByRole('button', { name: /First Aid/i })).toHaveCount(0); + + await filters.getByRole('button', { name: 'Search and Rescue', exact: true }).click(); await filters.getByRole('button', { name: 'Shelter', exact: true }).click(); await filters.getByRole('button', { name: 'Food / Water Supplies', exact: true }).click(); await expect(page.locator('.crisis-pin')).toHaveCount(2); @@ -227,7 +244,7 @@ test('supports multi-select request type filters and clears selected details whe await filters.getByRole('button', { name: 'Shelter', exact: true }).click(); await filters.getByRole('button', { name: 'Food / Water Supplies', exact: true }).click(); - await expect(page.locator('.crisis-pin')).toHaveCount(3); + await expect(page.locator('.crisis-pin')).toHaveCount(4); await expect(list.getByRole('button', { name: /First Aid/i })).toBeVisible(); await filters.getByRole('button', { name: 'Other / Unknown', exact: true }).click(); @@ -235,5 +252,5 @@ test('supports multi-select request type filters and clears selected details whe await expect(page.getByText('Select a request marker to view details.')).toBeVisible(); await filters.getByRole('button', { name: 'Clear', exact: true }).click(); - await expect(page.locator('.crisis-pin')).toHaveCount(3); + await expect(page.locator('.crisis-pin')).toHaveCount(4); }); diff --git a/web/e2e/profile-and-privacy.spec.js b/web/e2e/profile-and-privacy.spec.js index 9e83bdb4..81ea6dd9 100644 --- a/web/e2e/profile-and-privacy.spec.js +++ b/web/e2e/profile-and-privacy.spec.js @@ -1,5 +1,5 @@ const { test, expect } = require('@playwright/test'); -const { createCompletedUser, fetchMyProfile } = require('./helpers/api'); +const { createCompletedUser, createVerifiedUser, fetchMyProfile } = require('./helpers/api'); const { resetDatabase } = require('./helpers/db'); const { getStoredAccessToken, loginThroughUi } = require('./helpers/ui'); @@ -68,54 +68,78 @@ async function mockGeolocationError(page, { code, message, permissionState = 'pr }, { code, message, permissionState }); } +async function expectNoWrittenCurrentLocationButton(page) { + await expect(page.locator('button', { hasText: 'Use Current Location' })).toHaveCount(0); +} + test.beforeEach(async () => { await resetDatabase(); }); -test('privacy page blocks first-time share enable until profile captures current location', async ({ page }) => { - const email = `privacy-block-${Date.now()}@example.com`; +test('privacy page owns location sharing and hides health visibility', async ({ page }) => { + const email = `privacy-location-${Date.now()}@example.com`; const password = 'Passw0rd!'; await createCompletedUser({ email, password }); await loginToProtectedRoute(page, '/privacy-security', { email, password }); + await expect(page.getByText('Health information visibility')).toHaveCount(0); + await expect(page.getByText(/Used to make your current or saved location available/i)).toBeVisible(); + const locationToggle = page.getByRole('button', { name: 'Share Current Location' }); await expect(locationToggle).toHaveAttribute('aria-pressed', 'false'); await locationToggle.click(); await page.getByRole('button', { name: 'Save Privacy Settings' }).click(); - - await expect(page.getByText('To enable Share Current Location, go to Profile, click Use Current Location, and save there first.')).toBeVisible(); + await expect(locationToggle).toHaveAttribute('aria-pressed', 'true'); const accessToken = await getStoredAccessToken(page); await expect.poll(async () => { const profile = await fetchMyProfile(accessToken); return profile.privacySettings.locationSharingEnabled; - }).toBe(false); + }).toBe(true); }); -test('profile save blocks first-time share enable until Use Current Location is used', async ({ page }) => { - const email = `profile-block-${Date.now()}@example.com`; +test('profile edit no longer exposes Share Current Location control', async ({ page }) => { + const email = `profile-no-share-toggle-${Date.now()}@example.com`; const password = 'Passw0rd!'; await createCompletedUser({ email, password }); await loginToProtectedRoute(page, '/profile', { email, password }); - const locationToggle = page.getByRole('button', { name: 'Share Current Location' }); - await locationToggle.click(); - await expect.poll(async () => locationToggle.getAttribute('aria-pressed')).toBe('true'); + await expect(page.getByRole('button', { name: 'Share Current Location' })).toHaveCount(0); + await expectNoWrittenCurrentLocationButton(page); await page.locator('#height').fill('180'); await page.locator('#extraAddress').fill('Updated Address 42'); - await page.getByRole('button', { name: 'Save Changes' }).click(); - await expect( - page.getByText(/To enable Share Current Location, click Use Current Location first/i) - ).toBeVisible(); + const accessToken = await getStoredAccessToken(page); + await expect.poll(async () => { + const profile = await fetchMyProfile(accessToken); + return profile.privacySettings.locationSharingEnabled; + }).toBe(false); }); -test('persists real current-device metadata when sharing is enabled after fresh capture', async ({ page }) => { +test('complete profile location picker initializes from current location without written button', async ({ page }) => { + const email = `complete-profile-map-${Date.now()}@example.com`; + const password = 'Passw0rd!'; + + await createVerifiedUser({ email, password }); + await mockGeolocationSuccess(page, { + latitude: 41.0136, + longitude: 28.955, + accuracy: 7, + }); + + await loginToProtectedRoute(page, '/complete-profile', { email, password }); + + await expectNoWrittenCurrentLocationButton(page); + await expect(page.getByRole('button', { name: 'Use Current Location' })).toBeVisible(); + await expect(page.getByText('Selected:')).toBeVisible(); +}); + +test('privacy page enables sharing after profile saves real current-device metadata', async ({ page }) => { const email = `profile-${Date.now()}@example.com`; const password = 'Passw0rd!'; const captureTimestamp = Date.now(); @@ -130,12 +154,10 @@ test('persists real current-device metadata when sharing is enabled after fresh await loginToProtectedRoute(page, '/profile', { email, password }); + await expectNoWrittenCurrentLocationButton(page); await page.getByRole('button', { name: 'Use Current Location' }).click(); await expect(page.getByText('Selected:')).toBeVisible(); - const locationToggle = page.getByRole('button', { name: 'Share Current Location' }); - await locationToggle.click(); - const heightInput = page.locator('#height'); await heightInput.fill(''); await heightInput.fill('180'); @@ -144,8 +166,6 @@ test('persists real current-device metadata when sharing is enabled after fresh const accessToken = await getStoredAccessToken(page); - // Success banner text can be flaky in CI timing; assert the persisted backend - // state instead, which is the real contract this scenario verifies. await expect .poll(async () => { const profile = await fetchMyProfile(accessToken); @@ -159,13 +179,24 @@ test('persists real current-device metadata when sharing is enabled after fresh }, { timeout: 20_000 }) .toMatchObject({ height: 180, - locationSharingEnabled: true, + locationSharingEnabled: false, source: 'current_device', accuracyMeters: 7, }); + await page.goto('/privacy-security'); + await expect(page.getByText(/Used to make your current or saved location available/i)).toBeVisible(); + + const locationToggle = page.getByRole('button', { name: 'Share Current Location' }); + await locationToggle.click(); + await page.getByRole('button', { name: 'Save Privacy Settings' }).click(); await expect(locationToggle).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(async () => { + const profile = await fetchMyProfile(accessToken); + return profile.privacySettings.locationSharingEnabled; + }).toBe(true); + const profile = await fetchMyProfile(accessToken); expect((profile.locationProfile.address || '').trim().length).toBeGreaterThan(0); expect(profile.locationProfile.coordinate?.capturedAt).toBeTruthy(); @@ -193,6 +224,7 @@ test('shows denied geolocation error on current location action', async ({ page }); await loginToProtectedRoute(page, '/profile', { email, password }); + await expectNoWrittenCurrentLocationButton(page); await page.getByRole('button', { name: 'Use Current Location' }).click(); await expect(page.getByText('Location permission is denied. Enable location access in your browser settings.')).toBeVisible(); @@ -210,6 +242,7 @@ test('shows position unavailable geolocation error on current location action', }); await loginToProtectedRoute(page, '/profile', { email, password }); + await expectNoWrittenCurrentLocationButton(page); await page.getByRole('button', { name: 'Use Current Location' }).click(); await expect(page.getByText('Current location is unavailable right now. Please try again or select from map.')).toBeVisible(); @@ -227,6 +260,7 @@ test('shows timeout geolocation error on current location action', async ({ page }); await loginToProtectedRoute(page, '/profile', { email, password }); + await expectNoWrittenCurrentLocationButton(page); await page.getByRole('button', { name: 'Use Current Location' }).click(); await expect(page.getByText('Location request timed out. Please try again.')).toBeVisible(); diff --git a/web/public/dark_neph_logo.png b/web/public/dark_neph_logo.png new file mode 100644 index 00000000..86d86566 Binary files /dev/null and b/web/public/dark_neph_logo.png differ diff --git a/web/public/dark_neph_logo_only.png b/web/public/dark_neph_logo_only.png new file mode 100644 index 00000000..9fb7533c Binary files /dev/null and b/web/public/dark_neph_logo_only.png differ diff --git a/web/public/neph_logo.png b/web/public/neph_logo.png index 6fbe6e5e..494e0bb0 100644 Binary files a/web/public/neph_logo.png and b/web/public/neph_logo.png differ diff --git a/web/public/neph_logo_only.png b/web/public/neph_logo_only.png index 21471c87..cfef1597 100644 Binary files a/web/public/neph_logo_only.png and b/web/public/neph_logo_only.png differ diff --git a/web/src/app/crisis-map/page.tsx b/web/src/app/crisis-map/page.tsx index 6d4c800f..622934b8 100644 --- a/web/src/app/crisis-map/page.tsx +++ b/web/src/app/crisis-map/page.tsx @@ -4,24 +4,26 @@ import * as React from "react"; import { AppShell } from "@/components/layout/AppShell"; import { SectionCard } from "@/components/ui/display/SectionCard"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; +import { SecondaryButton } from "@/components/ui/buttons/SecondaryButton"; import { CrisisMap } from "@/components/feature/location/CrisisMap"; import type { CrisisMapFeature, CrisisRequestType } from "@/components/feature/location/LeafletCrisisMap"; import { fetchActiveHelpRequests } from "@/lib/crisisMap"; import { getAccessToken } from "@/lib/auth"; -import { openDirections } from "@/lib/mapDirections"; +import { openDirections, openMapLocation } from "@/lib/mapDirections"; import type { MapBounds } from "@/components/feature/location/LeafletMapCanvas"; import { effectiveViewportKey, isViewportDiscoverable, viewportBoundsToBbox, } from "@/lib/viewportDiscovery"; +import { formatTimestampDateTime } from "@/lib/formatters"; const DEFAULT_CENTER = { latitude: 39.0, longitude: 35.0, }; const DEFAULT_ZOOM = 5; -const CURRENT_LOCATION_ZOOM = 13; +const CURRENT_LOCATION_ZOOM = 15; const FETCH_LIMIT = 300; type FetchState = "idle" | "loading" | "success" | "empty" | "error"; @@ -54,7 +56,13 @@ function normalizeType(type: string): CrisisRequestType { if (value === "first_aid") { return "FIRST_AID"; } - if (value === "fire_brigade" || value === "search_and_rescue") { + if ( + value === "search_rescue" || + value === "search_and_rescue" || + value === "sar" || + value === "fire_brigade" || + value === "rescue" + ) { return "SEARCH_AND_RESCUE"; } if (value === "food" || value === "water" || value === "food_water") { @@ -79,14 +87,7 @@ function typeLabel(type: CrisisRequestType) { } function formatRelative(createdAt: string) { - const date = new Date(createdAt); - if (Number.isNaN(date.getTime())) { - return createdAt; - } - return new Intl.DateTimeFormat("en-US", { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + return formatTimestampDateTime(createdAt); } function formatPriority(priority: CrisisMapFeature["priorityLevel"]) { @@ -125,6 +126,7 @@ function toFeature(item: Awaited>["re export default function CrisisMapPage() { const [center, setCenter] = React.useState(DEFAULT_CENTER); const [mapZoom, setMapZoom] = React.useState(DEFAULT_ZOOM); + const [mapViewResetToken, setMapViewResetToken] = React.useState(0); const [requests, setRequests] = React.useState([]); const [selectedRequestId, setSelectedRequestId] = React.useState(null); const [isDetailsOpen, setIsDetailsOpen] = React.useState(true); @@ -266,6 +268,7 @@ export default function CrisisMapPage() { }; setCenter(nextCenter); setMapZoom(CURRENT_LOCATION_ZOOM); + setMapViewResetToken((token) => token + 1); setInfoMessage("Showing requests around your current location."); }, () => { @@ -331,6 +334,13 @@ export default function CrisisMapPage() { ); }, []); + const handleOpenInMap = React.useCallback((request: CrisisMapFeature) => { + const opened = openMapLocation(request.latitude, request.longitude, request.typeLabel); + setDirectionsMessage( + opened ? "" : "Map application is unavailable for this request location." + ); + }, []); + return (
@@ -340,6 +350,7 @@ export default function CrisisMapPage() { Opened: {formatRelative(selectedRequest.createdAt)}

- handleGetDirections(selectedRequest)} - > - Get Directions - +
+ handleGetDirections(selectedRequest)} + > + Get Directions + + handleOpenInMap(selectedRequest)} + > + Open in Map + +
{directionsMessage ? (

{directionsMessage}

) : null} diff --git a/web/src/app/gathering-areas/page.tsx b/web/src/app/gathering-areas/page.tsx index 1df10a52..3a925d23 100644 --- a/web/src/app/gathering-areas/page.tsx +++ b/web/src/app/gathering-areas/page.tsx @@ -22,7 +22,7 @@ const DEFAULT_CENTER = { longitude: 35.0, }; const DEFAULT_ZOOM = 5; -const CURRENT_LOCATION_ZOOM = 13; +const CURRENT_LOCATION_ZOOM = 15; const DEFAULT_LIMIT = 20; const ADDRESS_UNAVAILABLE = "Address unavailable"; type FetchState = "idle" | "loading" | "success" | "empty" | "error"; @@ -32,6 +32,7 @@ const ResourceLoadingMessage = "Loading resources in this area..."; const ResourceEmptyMessage = "No resources were found in this visible area."; const ResourceErrorMessage = "Resources could not be loaded for this area. Please try again."; const ResourceProviderUnavailableMessage = "Gathering areas provider is temporarily unavailable. Please retry."; +const CurrentLocationResolvingMessage = "Resolving your current location..."; type CategoryOption = { key: string; @@ -213,6 +214,7 @@ function mapFeatures(features: GatheringAreaFeature[]) { export default function GatheringAreasPage() { const [center, setCenter] = React.useState(DEFAULT_CENTER); const [mapZoom, setMapZoom] = React.useState(DEFAULT_ZOOM); + const [mapViewResetToken, setMapViewResetToken] = React.useState(0); const [hasUserLocation, setHasUserLocation] = React.useState(false); const [areas, setAreas] = React.useState([]); const [categoryOptions, setCategoryOptions] = React.useState([]); @@ -234,6 +236,7 @@ export default function GatheringAreasPage() { const reverseAddressCacheRef = React.useRef>(new Map()); const hasRequestedInitialLocationRef = React.useRef(false); const hasInitializedCategoryFiltersRef = React.useRef(false); + const geolocationDeniedRef = React.useRef(false); const filteredAreas = React.useMemo(() => { if (!selectedCategoryKeys.length) { @@ -433,7 +436,7 @@ export default function GatheringAreasPage() { return; } - setInfoMessage("Resolving your current location..."); + setInfoMessage(CurrentLocationResolvingMessage); navigator.geolocation.getCurrentPosition( (position) => { @@ -442,13 +445,16 @@ export default function GatheringAreasPage() { longitude: position.coords.longitude, }; + geolocationDeniedRef.current = false; setCenter(nextCenter); setMapZoom(CURRENT_LOCATION_ZOOM); + setMapViewResetToken((token) => token + 1); setHasUserLocation(true); setLocationNote("Showing gathering areas around your current location."); setInfoMessage("Showing resources around your current location."); }, () => { + geolocationDeniedRef.current = true; setHasUserLocation(false); setInfoMessage( "Location permission was denied or unavailable. Continue by moving the map manually." @@ -478,7 +484,9 @@ export default function GatheringAreasPage() { setLastFetchedViewportKey(null); setError(""); setFetchState("idle"); - setInfoMessage(ResourceZoomedOutMessage); + setInfoMessage((current) => + geolocationDeniedRef.current ? current : ResourceZoomedOutMessage + ); return; } @@ -505,7 +513,11 @@ export default function GatheringAreasPage() { setLastFetchedViewportKey(null); setError(""); setFetchState("idle"); - setInfoMessage(ResourceZoomedOutMessage); + setInfoMessage((current) => + current === CurrentLocationResolvingMessage || geolocationDeniedRef.current + ? current + : ResourceZoomedOutMessage + ); }, []); React.useEffect(() => { @@ -526,7 +538,12 @@ export default function GatheringAreasPage() { const isError = fetchState === "error"; const isEmpty = fetchState === "empty" && isDiscoverable && Boolean(lastFetchedViewportKey); const isFilterEmpty = !isLoading && !isError && isDiscoverable && areas.length > 0 && filteredAreas.length === 0; - const searchContextLine = isDiscoverable + const searchContextLine = !isInitialState && !isDiscoverable && ( + infoMessage === CurrentLocationResolvingMessage || + infoMessage === "Location permission was denied or unavailable. Continue by moving the map manually." + ) + ? infoMessage + : isDiscoverable ? "Showing resources in the visible map area." : ResourceZoomedOutMessage; @@ -567,6 +584,7 @@ export default function GatheringAreasPage() { ) : (

- {infoMessage || ResourceInitialMessage} + {ResourceInitialMessage}

)}
@@ -794,7 +812,7 @@ export default function GatheringAreasPage() { {isInitialState ? (
-

{ResourceInitialMessage}

+

{infoMessage || ResourceInitialMessage}

) : null} diff --git a/web/src/app/home/page.tsx b/web/src/app/home/page.tsx index b38588fd..abec92a7 100644 --- a/web/src/app/home/page.tsx +++ b/web/src/app/home/page.tsx @@ -18,6 +18,7 @@ import { readCachedAnnouncements, type NewsItem, } from "@/lib/news"; +import { formatTimestampDateTime } from "@/lib/formatters"; type HeroSlide = { title: string; @@ -75,15 +76,7 @@ function describeNewsPreviewFailure(err: unknown) { } function formatLastUpdated(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return date.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); + return formatTimestampDateTime(value); } export default function HomePage() { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 03f97b9a..0280eb14 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,7 +2,7 @@ import "../styles/globals.css"; import type { Metadata } from "next"; import { SiteFooter } from "@/components/layout/SiteFooter"; import { ThemeProvider } from "@/components/theme/ThemeProvider"; -import { ThemeToggle } from "@/components/theme/ThemeToggle"; +import { GuestThemeToggle } from "@/components/theme/GuestThemeToggle"; import { getThemeInitScript } from "@/lib/theme"; import { GoogleOAuthProvider } from "@react-oauth/google"; @@ -44,7 +44,7 @@ export default function RootLayout({ - +
{children}
diff --git a/web/src/app/news/page.tsx b/web/src/app/news/page.tsx index 33d48740..59eea65e 100644 --- a/web/src/app/news/page.tsx +++ b/web/src/app/news/page.tsx @@ -14,17 +14,10 @@ import { readCachedAnnouncements, type NewsItem, } from "@/lib/news"; +import { formatTimestampDateTime } from "@/lib/formatters"; function formatLastUpdated(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return date.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); + return formatTimestampDateTime(value); } function buildFailureMessage(err: unknown, sourceLabel: string) { diff --git a/web/src/app/notifications/page.tsx b/web/src/app/notifications/page.tsx index 9c82ceda..7f72a3fe 100644 --- a/web/src/app/notifications/page.tsx +++ b/web/src/app/notifications/page.tsx @@ -15,18 +15,7 @@ import { updateNotificationPreferences, type NotificationItem, } from "@/lib/notifications"; - -function formatDateTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return date.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); -} +import { formatTimestampDateTime } from "@/lib/formatters"; function formatTypeLabel(value: string) { const normalized = (value || "").trim().toLowerCase(); @@ -240,7 +229,7 @@ export default function NotificationsPage() { {formatTypeLabel(item.type)} - {formatDateTime(item.createdAt)} + {formatTimestampDateTime(item.createdAt)}

@@ -255,7 +244,7 @@ export default function NotificationsPage() {

Status: {item.isRead ? "Read" : "Unread"} - {item.readAt ? ` - Read at ${formatDateTime(item.readAt)}` : ""} + {item.readAt ? ` - Read at ${formatTimestampDateTime(item.readAt)}` : ""}

diff --git a/web/src/components/feature/admin/AdminEmergencyHistoryView.tsx b/web/src/components/feature/admin/AdminEmergencyHistoryView.tsx index 7a86ed64..51632fe0 100644 --- a/web/src/components/feature/admin/AdminEmergencyHistoryView.tsx +++ b/web/src/components/feature/admin/AdminEmergencyHistoryView.tsx @@ -8,7 +8,7 @@ import { fetchAdminEmergencyHistory, type EmergencyHistoryItem, } from "@/lib/admin"; -import { formatOperationalLabel } from "@/lib/formatters"; +import { formatOperationalLabel, formatTimestampDateTime } from "@/lib/formatters"; import { SectionCard } from "@/components/ui/display/SectionCard"; import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; @@ -39,7 +39,7 @@ function formatDateTime(value: string | null) { return value; } - return date.toLocaleString(); + return formatTimestampDateTime(value); } export default function AdminEmergencyHistoryView() { diff --git a/web/src/components/feature/admin/AdminEmergencyInsightsView.tsx b/web/src/components/feature/admin/AdminEmergencyInsightsView.tsx index 1598b14e..68cbcb8e 100644 --- a/web/src/components/feature/admin/AdminEmergencyInsightsView.tsx +++ b/web/src/components/feature/admin/AdminEmergencyInsightsView.tsx @@ -9,7 +9,7 @@ import { type EmergencyAnalytics, type EmergencyAnalyticsComparisonMetric, } from "@/lib/admin"; -import { formatOperationalLabel } from "@/lib/formatters"; +import { formatOperationalLabel, formatTimestampDate } from "@/lib/formatters"; import { SectionCard } from "@/components/ui/display/SectionCard"; import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; @@ -466,7 +466,7 @@ export default function AdminEmergencyInsightsView() { {dailyTrend.map((row) => ( - {row.date} + {formatTimestampDate(row.date)} {row.created} {row.resolved} {row.cancelled} diff --git a/web/src/components/feature/admin/AdminEmergencyOverviewView.tsx b/web/src/components/feature/admin/AdminEmergencyOverviewView.tsx index fb935aa4..5482c7c4 100644 --- a/web/src/components/feature/admin/AdminEmergencyOverviewView.tsx +++ b/web/src/components/feature/admin/AdminEmergencyOverviewView.tsx @@ -5,7 +5,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ApiError } from "@/lib/api"; import { getAccessToken, clearAccessToken } from "@/lib/auth"; import { fetchAdminEmergencyOverview, type EmergencyOverview } from "@/lib/admin"; -import { formatOperationalLabel } from "@/lib/formatters"; +import { formatOperationalLabel, formatTimestampDateTime } from "@/lib/formatters"; import { SectionCard } from "@/components/ui/display/SectionCard"; import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; @@ -38,7 +38,7 @@ function formatDateTime(value: string | null) { return value; } - return date.toLocaleString(); + return formatTimestampDateTime(value); } export default function AdminEmergencyOverviewView() { diff --git a/web/src/components/feature/admin/AdminUsersView.tsx b/web/src/components/feature/admin/AdminUsersView.tsx index ce7802bf..121b4a59 100644 --- a/web/src/components/feature/admin/AdminUsersView.tsx +++ b/web/src/components/feature/admin/AdminUsersView.tsx @@ -16,6 +16,7 @@ import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { SecondaryButton } from "@/components/ui/buttons/SecondaryButton"; import { TextArea } from "@/components/ui/inputs/TextArea"; +import { formatTimestampDateTime } from "@/lib/formatters"; type VerifiedFilter = "ALL" | "VERIFIED" | "UNVERIFIED"; type BannedFilter = "ALL" | "BANNED" | "ACTIVE"; @@ -62,7 +63,7 @@ function formatDateTime(value: string | null) { if (Number.isNaN(date.getTime())) { return value; } - return date.toLocaleString(); + return formatTimestampDateTime(value); } function StatusBadge({ diff --git a/web/src/components/feature/auth/AuthShowcase.tsx b/web/src/components/feature/auth/AuthShowcase.tsx index 5bd8fb10..65f24661 100644 --- a/web/src/components/feature/auth/AuthShowcase.tsx +++ b/web/src/components/feature/auth/AuthShowcase.tsx @@ -112,7 +112,7 @@ export function AuthShowcase() { className="absolute -left-16 bottom-0 h-40 w-40 rounded-full opacity-50" style={{ backgroundColor: "var(--hero-from)" }} /> -
+
); diff --git a/web/src/components/feature/auth/CompleteProfileForm.tsx b/web/src/components/feature/auth/CompleteProfileForm.tsx index 20563910..a09f665e 100644 --- a/web/src/components/feature/auth/CompleteProfileForm.tsx +++ b/web/src/components/feature/auth/CompleteProfileForm.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { TextInput } from "@/components/ui/inputs/TextInput"; import { SelectInput } from "@/components/ui/inputs/SelectInput"; import { TextArea } from "@/components/ui/inputs/TextArea"; -import { ToggleSwitch } from "@/components/ui/selection/ToggleSwitch"; import { Checkbox } from "@/components/ui/selection/Checkbox"; import { ProfileInfoRow } from "../../ui/display/ProfileInfoRow"; import { SaveActionBar } from "../../ui/display/SaveActionBar"; @@ -35,7 +34,6 @@ import { patchMyHealth, patchMyLocation, patchMyPhysical, - patchMyPrivacy, patchMyProfile, putMyExpertiseAreas, validateExpertiseAreas, @@ -60,7 +58,6 @@ type ProfileForm = { district: string; neighborhood: string; extraAddress: string; - shareLocation: boolean; }; const initialForm: ProfileForm = { @@ -82,28 +79,8 @@ const initialForm: ProfileForm = { district: "", neighborhood: "", extraAddress: "", - shareLocation: false, }; -const FRESH_DEVICE_CAPTURE_MAX_AGE_MS = 5 * 60 * 1000; - -function isFreshCurrentDeviceSelection(value: LocationPickerValue | null) { - if (!value || value.source !== "current_device") { - return false; - } - - if (!value.capturedAt) { - return false; - } - - const capturedAtMs = Date.parse(value.capturedAt); - if (Number.isNaN(capturedAtMs)) { - return false; - } - - return Date.now() - capturedAtMs <= FRESH_DEVICE_CAPTURE_MAX_AGE_MS; -} - function toPickerValueFromSearchItem(item: { placeId: string; displayName: string; @@ -448,13 +425,6 @@ export default function CompleteProfileForm() { return; } - if (form.shareLocation && !isFreshCurrentDeviceSelection(locationPickerValue)) { - setError( - "To enable Share Current Location, click Use Current Location first so we can save a fresh device location." - ); - return; - } - const token = getAccessToken(); if (!token) { @@ -524,10 +494,6 @@ export default function CompleteProfileForm() { : undefined, }); - await patchMyPrivacy(token, { - locationSharingEnabled: form.shareLocation, - }); - await putMyExpertiseAreas(token, { expertiseAreas, }); @@ -730,7 +696,7 @@ export default function CompleteProfileForm() { -
- Share Current Location - - - setForm({ ...form, shareLocation: value }) - } - /> -
- {error ? {error} : null} diff --git a/web/src/components/feature/location/LeafletCrisisMap.tsx b/web/src/components/feature/location/LeafletCrisisMap.tsx index 46325991..4cab2974 100644 --- a/web/src/components/feature/location/LeafletCrisisMap.tsx +++ b/web/src/components/feature/location/LeafletCrisisMap.tsx @@ -33,6 +33,7 @@ type LeafletCrisisMapProps = { onViewportChange?: (bounds: MapBounds) => void; heightClassName?: string; zoom?: number; + viewResetToken?: number; }; const TYPE_STYLES: Record = { @@ -97,6 +98,7 @@ export function LeafletCrisisMap({ onViewportChange, heightClassName = "h-[380px] md:h-[500px]", zoom = 11, + viewResetToken = 0, }: LeafletCrisisMapProps) { const mapRef = React.useRef(null); const markerLayerRef = React.useRef(null); @@ -185,6 +187,7 @@ export function LeafletCrisisMap({ void; heightClassName?: string; zoom?: number; + viewResetToken?: number; }; function formatCategoryLabel(category: string) { @@ -127,6 +128,7 @@ export function LeafletGatheringAreasMap({ onViewportChange, heightClassName = "h-[380px] md:h-[500px]", zoom = 14, + viewResetToken = 0, }: LeafletGatheringAreasMapProps) { const mapRef = React.useRef(null); const centerMarkerRef = React.useRef(null); @@ -230,6 +232,7 @@ export function LeafletGatheringAreasMap({ void; + onUseCurrentLocation?: () => void; + currentLocationDisabled?: boolean; }; function toAssetUrl(asset: string | { src: string }) { @@ -34,10 +37,13 @@ const locationMarkerIcon = L.icon({ export function LeafletLocationMap({ center, zoom = 12, + viewResetToken = 0, selectedPosition, heightClassName = "h-72", interactionMode = "selectable", onSelectPosition, + onUseCurrentLocation, + currentLocationDisabled = false, }: LeafletLocationMapProps) { const mapRef = React.useRef(null); const markerRef = React.useRef(null); @@ -117,26 +123,57 @@ export function LeafletLocationMap({ }, [selectedPosition, interactionMode, mapReadyVersion]); return ( - { - mapRef.current = map; - setMapReadyVersion((version) => version + 1); - }} - onMapClick={ - interactionMode === "selectable" - ? (position) => { - onSelectPositionRef.current?.(position); - } - : undefined - } - /> +
+ { + mapRef.current = map; + setMapReadyVersion((version) => version + 1); + }} + onMapClick={ + interactionMode === "selectable" + ? (position) => { + onSelectPositionRef.current?.(position); + } + : undefined + } + /> + + {interactionMode === "selectable" && onUseCurrentLocation ? ( + + ) : null} +
); } diff --git a/web/src/components/feature/location/LeafletMapCanvas.tsx b/web/src/components/feature/location/LeafletMapCanvas.tsx index 977744e4..ae14b8e1 100644 --- a/web/src/components/feature/location/LeafletMapCanvas.tsx +++ b/web/src/components/feature/location/LeafletMapCanvas.tsx @@ -19,6 +19,7 @@ export type LatLng = { type LeafletMapCanvasProps = { center: LatLng; zoom?: number; + viewResetToken?: number; heightClassName?: string; ariaLabel?: string; onMapReady?: (map: L.Map) => void; @@ -29,6 +30,7 @@ type LeafletMapCanvasProps = { export function LeafletMapCanvas({ center, zoom = 12, + viewResetToken = 0, heightClassName = "h-72", ariaLabel = "Map", onMapReady, @@ -105,21 +107,11 @@ export function LeafletMapCanvas({ return; } - if (map.getZoom() !== zoom) { - map.setZoom(zoom); - } - }, [zoom]); - - React.useEffect(() => { - const map = mapRef.current; - if (!map) { - return; - } - - map.setView([center.latitude, center.longitude], map.getZoom(), { + map.setView([center.latitude, center.longitude], zoom, { animate: true, }); - }, [center.latitude, center.longitude]); + map.invalidateSize(); + }, [center.latitude, center.longitude, zoom, viewResetToken]); return (
([]); const [error, setError] = React.useState(""); - const skipNextSearchRef = React.useRef(false); - const searchRequestIdRef = React.useRef(0); + const [mapViewResetToken, setMapViewResetToken] = React.useState(0); const reverseRequestIdRef = React.useRef(0); + const hasAttemptedInitialLocationRef = React.useRef(false); + const userSelectionVersionRef = React.useRef(0); + const latestValueRef = React.useRef(value); + + React.useEffect(() => { + latestValueRef.current = value; + }, [value]); const center = value ? { latitude: value.latitude, longitude: value.longitude } : DEFAULT_CENTER; - const handleSearch = React.useCallback(async () => { - if (query.trim().length < 2) { - setResults([]); - return; - } - - if (skipNextSearchRef.current) { - skipNextSearchRef.current = false; - return; - } - - const currentSearchRequestId = ++searchRequestIdRef.current; - - try { - setSearching(true); - setError(""); - - const response = await searchLocations({ - q: query.trim(), - countryCode, - limit: 10, - }); - - if (currentSearchRequestId !== searchRequestIdRef.current) { - return; - } - - setResults(response.items); - } catch (err) { - if (currentSearchRequestId !== searchRequestIdRef.current) { - return; - } - - setError(err instanceof Error ? err.message : "Could not search locations."); - } finally { - if (currentSearchRequestId === searchRequestIdRef.current) { - setSearching(false); - } - } - }, [countryCode, query]); + const markUserSelection = React.useCallback(() => { + userSelectionVersionRef.current += 1; + }, []); const handleResolveCoordinates = React.useCallback( async ( @@ -142,6 +106,9 @@ export function LocationPicker({ source?: string | null; accuracyMeters?: number | null; capturedAt?: string | null; + }, + options?: { + shouldApplyResult?: () => boolean; } ) => { const currentReverseRequestId = ++reverseRequestIdRef.current; @@ -155,6 +122,9 @@ export function LocationPicker({ if (currentReverseRequestId !== reverseRequestIdRef.current) { return; } + if (options?.shouldApplyResult && !options.shouldApplyResult()) { + return; + } onChange({ ...toPickerValue(response.item), @@ -166,6 +136,9 @@ export function LocationPicker({ if (currentReverseRequestId !== reverseRequestIdRef.current) { return; } + if (options?.shouldApplyResult && !options.shouldApplyResult()) { + return; + } setError(err instanceof Error ? err.message : "Could not resolve selected location."); onChange({ @@ -183,7 +156,14 @@ export function LocationPicker({ [onChange] ); - const handleUseCurrentLocation = React.useCallback(() => { + const requestCurrentLocation = React.useCallback((options?: { initial?: boolean }) => { + const isInitial = options?.initial === true; + const initialSelectionVersion = userSelectionVersionRef.current; + + if (isInitial && latestValueRef.current) { + return; + } + if (!navigator.geolocation) { setError("Geolocation is not supported in this browser."); return; @@ -195,30 +175,58 @@ export function LocationPicker({ navigator.geolocation.getCurrentPosition( (position) => { - onChange({ - ...toManualPickerValue( - position.coords.latitude, - position.coords.longitude - ), + if ( + isInitial && + ( + userSelectionVersionRef.current !== initialSelectionVersion || + ( + latestValueRef.current !== null && + latestValueRef.current.source !== "current_device" + ) + ) + ) { + setResolving(false); + return; + } + + const metadata = { source: "current_device", accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : null, capturedAt: new Date(position.timestamp).toISOString(), + }; + + onChange({ + ...toManualPickerValue( + position.coords.latitude, + position.coords.longitude + ), + ...metadata, }); + setMapViewResetToken((token) => token + 1); void handleResolveCoordinates( position.coords.latitude, position.coords.longitude, - { - source: "current_device", - accuracyMeters: - typeof position.coords.accuracy === "number" - ? position.coords.accuracy - : null, - capturedAt: new Date(position.timestamp).toISOString(), - } + metadata, + isInitial + ? { + shouldApplyResult: () => { + const current = latestValueRef.current; + return userSelectionVersionRef.current === initialSelectionVersion && + ( + current === null || + ( + current.source === "current_device" && + Math.abs(current.latitude - position.coords.latitude) < 0.000001 && + Math.abs(current.longitude - position.coords.longitude) < 0.000001 + ) + ); + }, + } + : undefined ); }, (geoError) => { @@ -252,65 +260,30 @@ export function LocationPicker({ .catch(() => { requestLocation(); }); - }, [handleResolveCoordinates]); + }, [handleResolveCoordinates, onChange]); + + const handleUseCurrentLocation = React.useCallback(() => { + markUserSelection(); + requestCurrentLocation(); + }, [markUserSelection, requestCurrentLocation]); React.useEffect(() => { - const timeout = setTimeout(() => { - void handleSearch(); - }, 350); + if (hasAttemptedInitialLocationRef.current || value) { + return; + } - return () => clearTimeout(timeout); - }, [handleSearch]); + hasAttemptedInitialLocationRef.current = true; + requestCurrentLocation({ initial: true }); + }, [requestCurrentLocation, value]); return (
{label} -
- setQuery(event.target.value)} - /> - - - Use Current Location - -
- - {results.length > 0 ? ( -
- {results.map((item) => ( - - ))} -
- ) : null} - { + markUserSelection(); onChange({ ...toManualPickerValue(position.latitude, position.longitude), source: "map_pin", accuracyMeters: null, capturedAt: new Date().toISOString(), }); + setMapViewResetToken((token) => token + 1); void handleResolveCoordinates(position.latitude, position.longitude, { source: "map_pin", accuracyMeters: null, }); }} + onUseCurrentLocation={handleUseCurrentLocation} + currentLocationDisabled={resolving} /> - {searching ? Searching locations... : null} {resolving ? Resolving selected coordinates... : null} {value ? ( diff --git a/web/src/components/feature/profile/ProfileView.tsx b/web/src/components/feature/profile/ProfileView.tsx index 6f69400c..836a4fa2 100644 --- a/web/src/components/feature/profile/ProfileView.tsx +++ b/web/src/components/feature/profile/ProfileView.tsx @@ -8,7 +8,6 @@ import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { TextInput } from "@/components/ui/inputs/TextInput"; import { SelectInput } from "@/components/ui/inputs/SelectInput"; import { TextArea } from "@/components/ui/inputs/TextArea"; -import { ToggleSwitch } from "@/components/ui/selection/ToggleSwitch"; import { Checkbox } from "@/components/ui/selection/Checkbox"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { SecondaryButton } from "@/components/ui/buttons/SecondaryButton"; @@ -50,32 +49,12 @@ import { patchMyHealth, patchMyLocation, patchMyPhysical, - patchMyPrivacy, validateExpertiseAreas, putMyExpertiseAreas, } from "@/lib/profile"; type EmptyStateAction = "login" | "complete-profile" | null; type ProfileData = EditableProfileData; -const FRESH_DEVICE_CAPTURE_MAX_AGE_MS = 5 * 60 * 1000; - -function isFreshCurrentDeviceSelection(value: LocationPickerValue | null) { - if (!value || value.source !== "current_device") { - return false; - } - - if (!value.capturedAt) { - return false; - } - - const capturedAtMs = Date.parse(value.capturedAt); - if (Number.isNaN(capturedAtMs)) { - return false; - } - - return Date.now() - capturedAtMs <= FRESH_DEVICE_CAPTURE_MAX_AGE_MS; -} - function toPickerValueFromSearchItem(item: { placeId: string; displayName: string; @@ -136,8 +115,6 @@ export default function ProfileView() { const [deletingAccount, setDeletingAccount] = React.useState(false); const [error, setError] = React.useState(""); const [info, setInfo] = React.useState(""); - const [initialShareLocation, setInitialShareLocation] = - React.useState(false); const [emptyStateAction, setEmptyStateAction] = React.useState(null); const dropdownSyncRequestIdRef = React.useRef(0); @@ -149,10 +126,6 @@ export default function ProfileView() { fetchMyProfile(token), ]); - setInitialShareLocation( - backendProfile.privacySettings.locationSharingEnabled - ); - setProfile((currentProfile) => { const refreshedProfile = toProfileData( backendProfile, @@ -234,9 +207,6 @@ export default function ProfileView() { ); setProfile(mappedProfile); - setInitialShareLocation( - backendProfile.privacySettings.locationSharingEnabled - ); if ( backendProfile.locationProfile.latitude !== null && backendProfile.locationProfile.longitude !== null @@ -508,17 +478,6 @@ export default function ProfileView() { } } - if ( - !initialShareLocation && - profile.shareLocation && - !isFreshCurrentDeviceSelection(locationPickerValue) - ) { - setError( - "To enable Share Current Location, click Use Current Location first so we can save a fresh device location." - ); - return; - } - const token = getAccessToken(); if (!token) { @@ -640,10 +599,6 @@ export default function ProfileView() { : undefined, }); - await patchMyPrivacy(token, { - locationSharingEnabled: profile.shareLocation, - }); - await putMyExpertiseAreas(token, { expertiseAreas, }); @@ -1079,7 +1034,7 @@ export default function ProfileView() {
@@ -1195,20 +1150,6 @@ export default function ProfileView() {
-
- Share Current Location - - setProfile((currentProfile) => - currentProfile - ? { ...currentProfile, shareLocation: value } - : currentProfile - ) - } - /> -
{error ? {error} : null} diff --git a/web/src/components/feature/settings/PrivacySecurityView.tsx b/web/src/components/feature/settings/PrivacySecurityView.tsx index b5314cf9..d601fc23 100644 --- a/web/src/components/feature/settings/PrivacySecurityView.tsx +++ b/web/src/components/feature/settings/PrivacySecurityView.tsx @@ -31,7 +31,6 @@ export default function PrivacySecurityView() { const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [profileVisibility, setProfileVisibility] = React.useState("PRIVATE"); - const [healthInfoVisibility, setHealthInfoVisibility] = React.useState("PRIVATE"); const [locationVisibility, setLocationVisibility] = React.useState("PRIVATE"); const [shareLocation, setShareLocation] = React.useState(false); const [initialShareLocation, setInitialShareLocation] = React.useState(false); @@ -60,7 +59,6 @@ export default function PrivacySecurityView() { try { const profile = await fetchMyProfile(token); setProfileVisibility(profile.privacySettings.profileVisibility || "PRIVATE"); - setHealthInfoVisibility(profile.privacySettings.healthInfoVisibility || "PRIVATE"); setLocationVisibility(profile.privacySettings.locationVisibility || "PRIVATE"); setShareLocation(profile.privacySettings.locationSharingEnabled); setInitialShareLocation(profile.privacySettings.locationSharingEnabled); @@ -108,16 +106,15 @@ export default function PrivacySecurityView() { setError(""); setInfo(""); - if (!initialShareLocation && shareLocation) { + if (!initialShareLocation && shareLocation && !locationPreview) { setError( - "To enable Share Current Location, go to Profile, click Use Current Location, and save there first." + "To enable Share Current Location, save your profile location first." ); return; } await patchMyPrivacy(token, { profileVisibility, - healthInfoVisibility, locationVisibility, locationSharingEnabled: shareLocation, }); @@ -154,7 +151,7 @@ export default function PrivacySecurityView() {
@@ -167,15 +164,6 @@ export default function PrivacySecurityView() { direction="column" /> - -

- Allow your profile to expose live location-sharing status for - emergency coordination. + Used to make your current or saved location available for + emergency coordination features, such as nearby visibility and + location-aware support. This is a sharing preference, not the + place where you edit your saved address.

diff --git a/web/src/components/layout/AuthLayout.tsx b/web/src/components/layout/AuthLayout.tsx index cb381045..26b2fd8e 100644 --- a/web/src/components/layout/AuthLayout.tsx +++ b/web/src/components/layout/AuthLayout.tsx @@ -1,5 +1,7 @@ +"use client"; import * as React from "react"; import Image from "next/image"; +import { useTheme } from "@/components/theme/ThemeProvider"; import { AuthCard } from "@/components/ui/display/AuthCard"; import { PageContainer } from "@/components/layout/PageContainer"; import { AuthShowcase } from "@/components/feature/auth/AuthShowcase"; @@ -11,6 +13,8 @@ type AuthLayoutProps = { }; export function AuthLayout({ title, subtitle, children }: AuthLayoutProps) { + const { isDarkTheme } = useTheme(); + return (
@@ -23,7 +27,7 @@ export function AuthLayout({ title, subtitle, children }: AuthLayoutProps) {
NEPH logo NEPH NEPH icon
+ {isAuthenticated ? : null} ; +} diff --git a/web/src/components/theme/ThemeToggle.tsx b/web/src/components/theme/ThemeToggle.tsx index df236cce..6f4bfc6d 100644 --- a/web/src/components/theme/ThemeToggle.tsx +++ b/web/src/components/theme/ThemeToggle.tsx @@ -3,18 +3,23 @@ import { useTheme } from "@/components/theme/ThemeProvider"; import { cn } from "@/lib/cn"; +type ThemeToggleVariant = "corner" | "navbar"; + type ThemeToggleProps = { className?: string; + variant?: ThemeToggleVariant; }; -export function ThemeToggle({ className }: ThemeToggleProps) { +export function ThemeToggle({ className, variant = "corner" }: ThemeToggleProps) { const { isDarkTheme, setTheme } = useTheme(); const nextTheme = isDarkTheme ? "light" : "dark"; + const baseClass = variant === "navbar" ? "theme-toggle-navbar" : "theme-toggle-corner"; + return (