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
@@ -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
+ {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 (