From 30c38f145a132633586938b340213f5b8811fdb2 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sat, 9 May 2026 22:43:14 +0300
Subject: [PATCH 01/41] feat: implement mobile onboarding tutorial
---
.../java/com/neph/e2e/AndroidE2ETest.kt | 11 +-
.../e2e/AuthenticatedSessionAndroidE2ETest.kt | 9 +-
.../e2e/MobileOnboardingAndroidE2ETest.kt | 108 ++++++++++++
.../neph/e2e/NephE2ETestEnvironmentRule.kt | 16 +-
.../src/main/java/com/neph/MainActivity.kt | 44 +++++
.../presentation/CompleteProfileScreen.kt | 2 +
.../presentation/HelpRequestMapScreen.kt | 59 +++++--
.../onboarding/data/MobileOnboardingStore.kt | 104 ++++++++++++
.../presentation/MobileOnboardingTutorial.kt | 155 ++++++++++++++++++
.../java/com/neph/ui/map/LeafletMapWebView.kt | 26 ++-
.../data/MobileOnboardingKeysTest.kt | 32 ++++
11 files changed, 541 insertions(+), 25 deletions(-)
create mode 100644 android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
create mode 100644 android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt
create mode 100644 android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt
create mode 100644 android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt
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 50d0059e..6047bb84 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/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
new file mode 100644
index 00000000..5ac584ab
--- /dev/null
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -0,0 +1,108 @@
+package com.neph.e2e
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+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 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_canFinishMobileOnboarding_once() {
+ waitForText("I need help now")
+ waitForTag("mobile_onboarding_dialog")
+ composeRule.onNodeWithText("Home Overview").assertIsDisplayed()
+ composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled()
+
+ repeat(5) {
+ composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
+ }
+
+ composeRule.onNodeWithText("Your Profile and Privacy").assertIsDisplayed()
+ composeRule.onNodeWithTag("mobile_onboarding_finish").performClick()
+ waitUntilTagGone("mobile_onboarding_dialog")
+
+ composeRule.activityRule.scenario.recreate()
+ waitForText("I need help now")
+ waitUntilTagGone("mobile_onboarding_dialog")
+ }
+
+ private fun waitForText(text: String, timeoutMillis: Long = 15_000) {
+ composeRule.waitUntil(timeoutMillis) {
+ runCatching {
+ composeRule.onAllNodesWithText(text).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/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index b52ad0e7..651e5ce4 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -15,6 +15,7 @@ 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
@@ -39,6 +40,8 @@ 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.MobileOnboardingStore
+import com.neph.features.onboarding.presentation.MobileOnboardingTutorial
import com.neph.features.requesthelp.data.RequestHelpRepository
import com.neph.features.safetystatus.data.SafetyStatusRepository
import com.neph.navigation.AppNavGraph
@@ -61,6 +64,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)
@@ -163,12 +167,18 @@ fun NephApp() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
+ val accessToken by AuthSessionStore.accessTokenFlow.collectAsState()
+ var showMobileOnboarding by remember { mutableStateOf(false) }
var showExitDialog by remember { mutableStateOf(false) }
val canPopBackStack = navController.previousBackStackEntry != null
val shouldConfirmExit = !canPopBackStack && (
currentRoute == Routes.Home.route || currentRoute == Routes.Welcome.route
)
+ LaunchedEffect(accessToken, currentRoute) {
+ showMobileOnboarding = shouldShowMobileOnboardingForRoute(currentRoute)
+ }
+
BackHandler(enabled = canPopBackStack || shouldConfirmExit) {
if (canPopBackStack) {
navController.popBackStack()
@@ -210,7 +220,41 @@ fun NephApp() {
else -> Routes.Welcome.route
}
)
+
+ if (showMobileOnboarding) {
+ MobileOnboardingTutorial(
+ onDismissCompleted = {
+ MobileOnboardingStore.markSeenForCurrentUser()
+ showMobileOnboarding = false
+ }
+ )
+ }
+ }
+}
+
+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/features/auth/presentation/CompleteProfileScreen.kt b/android/app/src/main/java/com/neph/features/auth/presentation/CompleteProfileScreen.kt
index b2e76a6c..fd59243b 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/helprequestmap/presentation/HelpRequestMapScreen.kt b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt
index 7c5aa7da..16e68e74 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
@@ -31,11 +31,14 @@ 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.navigation.Routes
@@ -193,6 +196,20 @@ fun HelpRequestMapScreen(
var viewportRefreshNonce by remember { mutableStateOf(0) }
var viewportRequestSerial 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)) {
@@ -232,6 +249,31 @@ fun HelpRequestMapScreen(
}
}
+ 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(effectiveLeafletViewportKey(pendingViewport), viewportRefreshNonce) {
val viewport = pendingViewport ?: return@LaunchedEffect
if (!isLeafletViewportDiscoverable(viewport)) return@LaunchedEffect
@@ -251,17 +293,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.requests.isEmpty() -> ResourceEmptyMessage
- result.skippedCount > 0 ->
- "${result.skippedCount} inactive or malformed request entries were hidden."
- else -> ""
- }
+ applyRequestResult(result, viewportKey = viewportKey)
}
} catch (cancellationException: CancellationException) {
throw cancellationException
@@ -710,7 +742,7 @@ private fun CrisisRequestMapPanel(
val tileLoadedInstanceIdState = remember { mutableStateOf(null) }
val errorInstanceIdState = remember { mutableStateOf(null) }
var mapError by remember { mutableStateOf("") }
- val mapInstanceKey = HelpRequestMapInstanceKey(TurkeyOverviewLatitude, TurkeyOverviewLongitude)
+ val mapInstanceKey = helpRequestMapInstanceKey(requests)
val mapInstanceId = remember(mapInstanceKey) {
newLeafletMapInstanceId()
}
@@ -788,7 +820,7 @@ private fun CrisisRequestMapPanel(
mapHeightCssPx = HelpRequestMapHeightCssPx,
zoom = if (markers.isEmpty()) TurkeyOverviewZoom else 13,
showCenterMarker = false,
- fitBoundsToMarkers = false,
+ fitBoundsToMarkers = markers.isNotEmpty(),
onMarkerSelected = { markerInstanceId, markerId ->
if (markerInstanceId == currentMapInstanceIdState.value) {
onSelectRequest(markerId)
@@ -849,6 +881,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/onboarding/data/MobileOnboardingStore.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt
new file mode 100644
index 00000000..8f9be70b
--- /dev/null
+++ b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingStore.kt
@@ -0,0 +1,104 @@
+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"
+
+ 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
+ prefs.edit()
+ .putBoolean(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey), true)
+ .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 markSeenForCurrentUser() {
+ val userKey = currentUserKey() ?: return
+ prefs.edit()
+ .putBoolean(MobileOnboardingKeys.scopedKey(SeenPrefix, userKey), true)
+ .remove(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey))
+ .apply()
+ }
+
+ fun clearPendingForCurrentUser() {
+ val userKey = currentUserKey() ?: return
+ prefs.edit()
+ .remove(MobileOnboardingKeys.scopedKey(PendingPrefix, 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/MobileOnboardingTutorial.kt b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt
new file mode 100644
index 00000000..fdf32022
--- /dev/null
+++ b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt
@@ -0,0 +1,155 @@
+package com.neph.features.onboarding.presentation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import com.neph.ui.theme.LocalNephSpacing
+
+private data class MobileOnboardingStep(
+ val title: String,
+ val subtitle: String,
+ val description: String
+)
+
+private val mobileOnboardingSteps = listOf(
+ MobileOnboardingStep(
+ title = "Home Overview",
+ subtitle = "Start here for the fastest snapshot.",
+ description = "Use Home to see key updates, request help, mark yourself safe, and manage volunteer availability."
+ ),
+ MobileOnboardingStep(
+ title = "News and Announcements",
+ subtitle = "Follow verified updates.",
+ description = "Track preparedness notes and community communication in one feed."
+ ),
+ MobileOnboardingStep(
+ title = "Emergency Tools",
+ subtitle = "Map and contacts in one flow.",
+ description = "Use Emergency Numbers and the urgent help action when you need fast support."
+ ),
+ MobileOnboardingStep(
+ title = "Help Request Map",
+ subtitle = "See active help context on the map.",
+ description = "View active emergency requests and understand where support may be needed."
+ ),
+ MobileOnboardingStep(
+ title = "Gathering Areas",
+ subtitle = "Find nearby safe assembly points.",
+ description = "Review assembly and support points around your area."
+ ),
+ MobileOnboardingStep(
+ title = "Your Profile and Privacy",
+ subtitle = "Keep coordination data accurate.",
+ description = "Update Profile and review Privacy & Security settings to control what is shared."
+ )
+)
+
+@Composable
+fun MobileOnboardingTutorial(
+ onDismissCompleted: () -> Unit
+) {
+ val spacing = LocalNephSpacing.current
+ var stepIndex by remember { mutableIntStateOf(0) }
+ val step = mobileOnboardingSteps[stepIndex]
+ val isFirstStep = stepIndex == 0
+ val isLastStep = stepIndex == mobileOnboardingSteps.lastIndex
+ val progress = (stepIndex + 1).toFloat() / mobileOnboardingSteps.size.toFloat()
+
+ AlertDialog(
+ modifier = Modifier.testTag("mobile_onboarding_dialog"),
+ onDismissRequest = onDismissCompleted,
+ title = {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
+ Text(
+ text = "Welcome to NEPH",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = step.title,
+ modifier = Modifier.testTag("mobile_onboarding_title"),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) {
+ Text(
+ text = step.subtitle,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = step.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ Text(
+ text = "Step ${stepIndex + 1} of ${mobileOnboardingSteps.size}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismissCompleted,
+ modifier = Modifier.testTag("mobile_onboarding_skip")
+ ) {
+ Text("Skip")
+ }
+ },
+ confirmButton = {
+ Row(
+ modifier = Modifier.padding(start = spacing.sm),
+ horizontalArrangement = Arrangement.spacedBy(spacing.xs)
+ ) {
+ TextButton(
+ onClick = { stepIndex = (stepIndex - 1).coerceAtLeast(0) },
+ enabled = !isFirstStep,
+ modifier = Modifier.testTag("mobile_onboarding_back")
+ ) {
+ Text("Back")
+ }
+
+ if (isLastStep) {
+ Button(
+ onClick = onDismissCompleted,
+ modifier = Modifier.testTag("mobile_onboarding_finish")
+ ) {
+ Text("Finish")
+ }
+ } else {
+ Button(
+ onClick = { stepIndex = (stepIndex + 1).coerceAtMost(mobileOnboardingSteps.lastIndex) },
+ modifier = Modifier.testTag("mobile_onboarding_next")
+ ) {
+ Text("Next")
+ }
+ }
+ }
+ }
+ )
+}
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 f4b5b8ff..db38eb10 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
@@ -15,6 +15,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
@@ -298,7 +299,7 @@ fun LeafletMarkerMap(
mapHeightCssPx = mapHeightCssPx
)
}
- val bridge = remember {
+ val bridge = remember(mapInstanceId) {
LeafletMarkerMapBridge(
currentMapInstanceId = { latestCurrentMapInstanceId() },
onMarkerSelected = { instanceId, markerId -> latestOnMarkerSelected(instanceId, markerId) },
@@ -310,6 +311,10 @@ fun LeafletMarkerMap(
)
}
+ DisposableEffect(bridge) {
+ onDispose { bridge.dispose() }
+ }
+
LeafletMapWebView(
mapInstanceId = mapInstanceId,
html = html,
@@ -711,11 +716,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) {
@@ -727,6 +735,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()}"
@@ -742,6 +751,7 @@ private class LeafletMarkerMapBridge(
@JavascriptInterface
fun onMapReady(instanceId: String?) {
+ if (disposed) return
val incomingInstanceId = instanceId.orEmpty()
val currentInstanceId = currentMapInstanceId()
logMapDebug(
@@ -763,6 +773,7 @@ private class LeafletMarkerMapBridge(
readyDeliveredInstanceId = incomingInstanceId
}
mainHandler.post {
+ if (disposed) return@post
val postedCurrentInstanceId = currentMapInstanceId()
if (incomingInstanceId != postedCurrentInstanceId) {
logMapDebug(
@@ -779,6 +790,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" }
@@ -795,12 +807,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) {
@@ -811,6 +825,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()}"
@@ -835,6 +850,7 @@ private class LeafletMarkerMapBridge(
heightKm: Double,
widestVisibleDimensionKm: Double
) {
+ if (disposed) return
val incomingInstanceId = instanceId.orEmpty()
val currentInstanceId = currentMapInstanceId()
if (incomingInstanceId != currentInstanceId) {
@@ -856,6 +872,7 @@ private class LeafletMarkerMapBridge(
widestVisibleDimensionKm = widestVisibleDimensionKm
)
mainHandler.post {
+ if (disposed) return@post
if (incomingInstanceId != currentMapInstanceId()) {
logMapDebug(
"native onViewportChanged ignored stale instance=$incomingInstanceId current=${currentMapInstanceId()}"
@@ -866,7 +883,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(
@@ -881,6 +904,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/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..f6ab939d
--- /dev/null
+++ b/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt
@@ -0,0 +1,32 @@
+package com.neph.features.onboarding.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+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")
+ )
+ }
+}
From 1db307cff3b80d589fb96b70acd1c169a7e12c6a Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sat, 9 May 2026 23:24:15 +0300
Subject: [PATCH 02/41] feat: implement structured onboarding with step-by-step
navigation
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 34 ++-
.../src/main/java/com/neph/MainActivity.kt | 73 +++++-
.../data/MobileOnboardingJourney.kt | 146 ++++++++++++
.../onboarding/data/MobileOnboardingStore.kt | 34 +++
.../presentation/MobileOnboardingTutorial.kt | 208 +++++++++---------
.../settings/presentation/SettingsScreen.kt | 19 +-
.../java/com/neph/navigation/AppNavGraph.kt | 6 +-
.../data/MobileOnboardingKeysTest.kt | 47 ++++
8 files changed, 445 insertions(+), 122 deletions(-)
create mode 100644 android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index 5ac584ab..58b4f2a3 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -2,6 +2,7 @@ package com.neph.e2e
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
+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
@@ -61,29 +62,44 @@ class MobileOnboardingAndroidE2ETest {
.around(composeRule)
@Test
- fun pendingAuthenticatedUser_canFinishMobileOnboarding_once() {
+ fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() {
waitForText("I need help now")
waitForTag("mobile_onboarding_dialog")
- composeRule.onNodeWithText("Home Overview").assertIsDisplayed()
+ composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals("Home Dashboard")
composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled()
- repeat(5) {
- composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
- }
+ composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
+ waitForGuideTitle("Request Help")
+ waitForText("Create a help request")
+
+ composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
+ waitForGuideTitle("My Help Requests")
+ waitForText("Follow request status")
+
+ composeRule.activityRule.scenario.recreate()
+ waitForGuideTitle("My Help Requests")
- composeRule.onNodeWithText("Your Profile and Privacy").assertIsDisplayed()
- composeRule.onNodeWithTag("mobile_onboarding_finish").performClick()
+ composeRule.onNodeWithTag("mobile_onboarding_skip").performClick()
waitUntilTagGone("mobile_onboarding_dialog")
composeRule.activityRule.scenario.recreate()
- waitForText("I need help now")
+ waitForText("My Help Requests")
waitUntilTagGone("mobile_onboarding_dialog")
}
+ 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).fetchSemanticsNodes().isNotEmpty()
+ composeRule.onAllNodesWithText(text, substring = true).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
}
}
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index 651e5ce4..c7862d39 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -40,8 +40,10 @@ 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.MobileOnboardingStepId
import com.neph.features.onboarding.data.MobileOnboardingStore
-import com.neph.features.onboarding.presentation.MobileOnboardingTutorial
+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
@@ -168,15 +170,46 @@ fun NephApp() {
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 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 ->
+ navigateForMobileOnboarding(step.route)
+ }
+ }
+
+ fun restartMobileOnboarding() {
+ MobileOnboardingStore.restartForCurrentUser()
+ val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true)
+ activeMobileOnboardingStepId = firstStep.id
+ showMobileOnboarding = true
+ navigateForMobileOnboarding(firstStep.route)
+ }
+
LaunchedEffect(accessToken, currentRoute) {
- showMobileOnboarding = shouldShowMobileOnboardingForRoute(currentRoute)
+ val shouldShow = shouldShowMobileOnboardingForRoute(currentRoute)
+ showMobileOnboarding = shouldShow
+ activeMobileOnboardingStepId = if (shouldShow) {
+ MobileOnboardingStore.currentStepForCurrentUser(isAuthenticated)
+ } else {
+ null
+ }
}
BackHandler(enabled = canPopBackStack || shouldConfirmExit) {
@@ -218,14 +251,42 @@ fun NephApp() {
}
AuthSessionStore.isGuestMode() -> Routes.Home.route
else -> Routes.Welcome.route
- }
+ },
+ onRestartMobileOnboarding = ::restartMobileOnboarding
)
- if (showMobileOnboarding) {
- MobileOnboardingTutorial(
- onDismissCompleted = {
+ val activeStepId = activeMobileOnboardingStepId
+ val activeStep = activeStepId?.let { MobileOnboardingJourney.stepFor(it, isAuthenticated) }
+ if (showMobileOnboarding && activeStepId != null && activeStep != null) {
+ val (stepNumber, totalSteps) = MobileOnboardingJourney.progressFor(activeStepId, isAuthenticated)
+ val routeBase = currentRoute?.substringBefore('?')
+ MobileOnboardingGuide(
+ step = activeStep,
+ stepNumber = stepNumber,
+ totalSteps = totalSteps,
+ isOnTargetRoute = routeBase == activeStep.route,
+ onNavigateToStep = {
+ navigateForMobileOnboarding(activeStep.route)
+ },
+ onBack = {
+ MobileOnboardingJourney.previousStep(activeStepId, isAuthenticated)?.let { previousStep ->
+ setMobileOnboardingStep(previousStep.id)
+ }
+ },
+ onNext = {
+ MobileOnboardingJourney.nextStep(activeStepId, isAuthenticated)?.let { nextStep ->
+ setMobileOnboardingStep(nextStep.id)
+ }
+ },
+ onSkip = {
+ MobileOnboardingStore.markSeenForCurrentUser()
+ showMobileOnboarding = false
+ activeMobileOnboardingStepId = null
+ },
+ onFinish = {
MobileOnboardingStore.markSeenForCurrentUser()
showMobileOnboarding = false
+ activeMobileOnboardingStepId = null
}
)
}
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..ddc25918
--- /dev/null
+++ b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt
@@ -0,0 +1,146 @@
+package com.neph.features.onboarding.data
+
+import com.neph.navigation.Routes
+
+enum class MobileOnboardingStepId {
+ HOME_DASHBOARD,
+ REQUEST_HELP,
+ MY_HELP_REQUESTS,
+ HELP_REQUEST_MAP,
+ GATHERING_AREAS,
+ EMERGENCY_NUMBERS,
+ NEWS,
+ VOLUNTEER_AVAILABILITY,
+ ASSIGNED_REQUESTS,
+ PROFILE_PRIVACY
+}
+
+data class MobileOnboardingStep(
+ val id: MobileOnboardingStepId,
+ val route: String,
+ val title: String,
+ val eyebrow: String,
+ val description: String,
+ val actionLabel: String,
+ val authenticatedOnly: Boolean = false
+)
+
+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 = "Go to Home"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.REQUEST_HELP,
+ 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.",
+ actionLabel = "Open Request Help"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.MY_HELP_REQUESTS,
+ route = Routes.MyHelpRequests.route,
+ title = "My Help Requests",
+ eyebrow = "Track your requests",
+ description = "Follow request status, local/offline saves, and updates after you submit or edit a request.",
+ actionLabel = "Open My Requests"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.HELP_REQUEST_MAP,
+ route = Routes.HelpRequestMap.route,
+ title = "Help Request Map",
+ eyebrow = "See community needs",
+ description = "Use the map to understand active waiting requests around the community and where support is needed.",
+ actionLabel = "Open Help Map"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.GATHERING_AREAS,
+ route = Routes.GatheringAreas.route,
+ title = "Gathering Areas",
+ eyebrow = "Find safe assembly points",
+ description = "Review nearby gathering and support areas before or during an emergency.",
+ actionLabel = "Open Gathering Areas"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.EMERGENCY_NUMBERS,
+ route = Routes.EmergencyInfo.route,
+ title = "Emergency Numbers",
+ eyebrow = "Fast emergency contacts",
+ description = "Keep important emergency phone numbers close when you need direct help outside the app.",
+ actionLabel = "Open Emergency Numbers"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.NEWS,
+ route = Routes.News.route,
+ title = "News & Announcements",
+ eyebrow = "Verified preparedness updates",
+ description = "Read preparedness notes and community announcements without leaving NEPH.",
+ actionLabel = "Open News"
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.VOLUNTEER_AVAILABILITY,
+ route = Routes.Home.route,
+ title = "Volunteer Availability",
+ eyebrow = "Tell NEPH when you can help",
+ description = "When you are available, NEPH can use your profile and location context for volunteer coordination.",
+ actionLabel = "Return to Home",
+ authenticatedOnly = true
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.ASSIGNED_REQUESTS,
+ route = Routes.AssignedRequest.route,
+ title = "Assigned Requests",
+ eyebrow = "Follow work assigned to you",
+ description = "If you volunteer, this is where assigned support requests and next actions appear.",
+ actionLabel = "Open Assigned Requests",
+ authenticatedOnly = true
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.PROFILE_PRIVACY,
+ route = Routes.Profile.route,
+ title = "Profile & Privacy",
+ eyebrow = "Keep coordination data accurate",
+ description = "Update your profile and use Privacy & Security settings to control what others can see.",
+ actionLabel = "Open Profile",
+ authenticatedOnly = true
+ )
+ )
+
+ 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
+ return currentIndex + 1 to available.size
+ }
+}
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
index 8f9be70b..d3dce1fc 100644
--- 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
@@ -10,6 +10,7 @@ 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
@@ -21,8 +22,20 @@ object MobileOnboardingStore {
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()
}
@@ -40,11 +53,31 @@ object MobileOnboardingStore {
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()
}
@@ -52,6 +85,7 @@ object MobileOnboardingStore {
val userKey = currentUserKey() ?: return
prefs.edit()
.remove(MobileOnboardingKeys.scopedKey(PendingPrefix, userKey))
+ .remove(MobileOnboardingKeys.scopedKey(CurrentStepPrefix, userKey))
.apply()
}
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
index fdf32022..ef717429 100644
--- 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
@@ -1,105 +1,96 @@
package com.neph.features.onboarding.presentation
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.AlertDialog
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.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import com.neph.features.onboarding.data.MobileOnboardingStep
import com.neph.ui.theme.LocalNephSpacing
-private data class MobileOnboardingStep(
- val title: String,
- val subtitle: String,
- val description: String
-)
-
-private val mobileOnboardingSteps = listOf(
- MobileOnboardingStep(
- title = "Home Overview",
- subtitle = "Start here for the fastest snapshot.",
- description = "Use Home to see key updates, request help, mark yourself safe, and manage volunteer availability."
- ),
- MobileOnboardingStep(
- title = "News and Announcements",
- subtitle = "Follow verified updates.",
- description = "Track preparedness notes and community communication in one feed."
- ),
- MobileOnboardingStep(
- title = "Emergency Tools",
- subtitle = "Map and contacts in one flow.",
- description = "Use Emergency Numbers and the urgent help action when you need fast support."
- ),
- MobileOnboardingStep(
- title = "Help Request Map",
- subtitle = "See active help context on the map.",
- description = "View active emergency requests and understand where support may be needed."
- ),
- MobileOnboardingStep(
- title = "Gathering Areas",
- subtitle = "Find nearby safe assembly points.",
- description = "Review assembly and support points around your area."
- ),
- MobileOnboardingStep(
- title = "Your Profile and Privacy",
- subtitle = "Keep coordination data accurate.",
- description = "Update Profile and review Privacy & Security settings to control what is shared."
- )
-)
-
@Composable
-fun MobileOnboardingTutorial(
- onDismissCompleted: () -> Unit
+fun MobileOnboardingGuide(
+ step: MobileOnboardingStep,
+ stepNumber: Int,
+ totalSteps: Int,
+ isOnTargetRoute: Boolean,
+ onNavigateToStep: () -> Unit,
+ onBack: () -> Unit,
+ onNext: () -> Unit,
+ onSkip: () -> Unit,
+ onFinish: () -> Unit
) {
val spacing = LocalNephSpacing.current
- var stepIndex by remember { mutableIntStateOf(0) }
- val step = mobileOnboardingSteps[stepIndex]
- val isFirstStep = stepIndex == 0
- val isLastStep = stepIndex == mobileOnboardingSteps.lastIndex
- val progress = (stepIndex + 1).toFloat() / mobileOnboardingSteps.size.toFloat()
+ val isFirstStep = stepNumber <= 1
+ val isLastStep = stepNumber >= totalSteps
+ val progress = stepNumber.toFloat() / totalSteps.toFloat()
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .navigationBarsPadding()
+ .testTag("mobile_onboarding_dialog"),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 8.dp,
+ shadowElevation = 8.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(spacing.lg),
+ verticalArrangement = Arrangement.spacedBy(spacing.md)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
+ 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.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = step.eyebrow,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
- AlertDialog(
- modifier = Modifier.testTag("mobile_onboarding_dialog"),
- onDismissRequest = onDismissCompleted,
- title = {
- Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
- Text(
- text = "Welcome to NEPH",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.primary
- )
- Text(
- text = step.title,
- modifier = Modifier.testTag("mobile_onboarding_title"),
- style = MaterialTheme.typography.headlineSmall,
- color = MaterialTheme.colorScheme.onSurface
- )
- }
- },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(spacing.md)) {
- Text(
- text = step.subtitle,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
Text(
text = step.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
+
+ if (!isOnTargetRoute) {
+ Text(
+ text = "This concept lives on another screen. Tap ${step.actionLabel} to continue the guided path.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
@@ -107,49 +98,60 @@ fun MobileOnboardingTutorial(
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Text(
- text = "Step ${stepIndex + 1} of ${mobileOnboardingSteps.size}",
+ text = "Step $stepNumber of $totalSteps",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- }
- },
- dismissButton = {
- TextButton(
- onClick = onDismissCompleted,
- modifier = Modifier.testTag("mobile_onboarding_skip")
- ) {
- Text("Skip")
- }
- },
- confirmButton = {
- Row(
- modifier = Modifier.padding(start = spacing.sm),
- horizontalArrangement = Arrangement.spacedBy(spacing.xs)
- ) {
- TextButton(
- onClick = { stepIndex = (stepIndex - 1).coerceAtLeast(0) },
- enabled = !isFirstStep,
- modifier = Modifier.testTag("mobile_onboarding_back")
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.xs),
+ verticalAlignment = Alignment.CenterVertically
) {
- Text("Back")
+ 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")
+ }
}
- if (isLastStep) {
+ if (!isOnTargetRoute) {
+ Button(
+ onClick = onNavigateToStep,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("mobile_onboarding_take_me_there")
+ ) {
+ Text(step.actionLabel)
+ }
+ } else if (isLastStep) {
Button(
- onClick = onDismissCompleted,
- modifier = Modifier.testTag("mobile_onboarding_finish")
+ onClick = onFinish,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("mobile_onboarding_finish")
) {
Text("Finish")
}
} else {
Button(
- onClick = { stepIndex = (stepIndex + 1).coerceAtMost(mobileOnboardingSteps.lastIndex) },
- modifier = Modifier.testTag("mobile_onboarding_next")
+ onClick = onNext,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("mobile_onboarding_next")
) {
Text("Next")
}
}
}
}
- )
+ }
}
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..4903813a 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
@@ -47,7 +47,8 @@ fun SettingsScreen(
profileBadgeText: String,
onNavigateToPrivacySecurity: () -> Unit,
onLogout: () -> Unit,
- onAccountDeleted: () -> Unit
+ onAccountDeleted: () -> Unit,
+ onRestartMobileOnboarding: () -> Unit
) {
val spacing = LocalNephSpacing.current
val coroutineScope = rememberCoroutineScope()
@@ -161,6 +162,19 @@ fun SettingsScreen(
}
}
+ 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 +243,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 73d96547..4e56a6d5 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -42,7 +42,8 @@ import com.neph.features.settings.presentation.SettingsScreen
@Composable
fun AppNavGraph(
navController: NavHostController,
- startDestination: String = Routes.Welcome.route
+ startDestination: String = Routes.Welcome.route,
+ onRestartMobileOnboarding: () -> Unit = {}
) {
val verifyEmailRouteWithToken = "${Routes.VerifyEmail.route}?token={token}"
val accessToken by AuthSessionStore.accessTokenFlow.collectAsState()
@@ -430,7 +431,8 @@ fun AppNavGraph(
popUpTo(navController.graph.id) { inclusive = true }
launchSingleTop = true
}
- }
+ },
+ onRestartMobileOnboarding = onRestartMobileOnboarding
)
}
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
index f6ab939d..459db18a 100644
--- 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
@@ -1,7 +1,9 @@
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
@@ -29,4 +31,49 @@ class MobileOnboardingKeysTest {
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.ASSIGNED_REQUESTS })
+ assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.PROFILE_PRIVACY })
+ }
+
+ @Test
+ fun journeyKeepsCoreRoutesInGuidedOrderForAuthenticatedUsers() {
+ val authenticatedSteps = MobileOnboardingJourney.availableSteps(isAuthenticated = true)
+
+ assertEquals(
+ listOf(
+ MobileOnboardingStepId.HOME_DASHBOARD,
+ MobileOnboardingStepId.REQUEST_HELP,
+ MobileOnboardingStepId.MY_HELP_REQUESTS,
+ MobileOnboardingStepId.HELP_REQUEST_MAP,
+ MobileOnboardingStepId.GATHERING_AREAS,
+ MobileOnboardingStepId.EMERGENCY_NUMBERS,
+ MobileOnboardingStepId.NEWS,
+ MobileOnboardingStepId.VOLUNTEER_AVAILABILITY,
+ MobileOnboardingStepId.ASSIGNED_REQUESTS,
+ MobileOnboardingStepId.PROFILE_PRIVACY
+ ),
+ authenticatedSteps.map { it.id }
+ )
+ assertEquals(Routes.RequestHelp.route, authenticatedSteps[1].route)
+ assertEquals(Routes.Profile.route, authenticatedSteps.last().route)
+ }
+
+ @Test
+ fun journeyCanMoveForwardAndBackward() {
+ assertEquals(
+ MobileOnboardingStepId.REQUEST_HELP,
+ MobileOnboardingJourney.nextStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true)?.id
+ )
+ assertEquals(
+ MobileOnboardingStepId.HOME_DASHBOARD,
+ MobileOnboardingJourney.previousStep(MobileOnboardingStepId.REQUEST_HELP, isAuthenticated = true)?.id
+ )
+ assertNull(MobileOnboardingJourney.previousStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true))
+ }
}
From e92cdc308ed1346330e6acee0a35dd590d8e1db7 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 00:04:31 +0300
Subject: [PATCH 03/41] feat: enhance onboarding with target hints, custom
panel placement, and interactive overlay transitions
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 2 +
.../data/MobileOnboardingJourney.kt | 48 +++-
.../presentation/MobileOnboardingTutorial.kt | 208 ++++++++++++++----
3 files changed, 211 insertions(+), 47 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index 58b4f2a3..73b621ee 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -65,6 +65,8 @@ class MobileOnboardingAndroidE2ETest {
fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() {
waitForText("I need help now")
waitForTag("mobile_onboarding_dialog")
+ waitForTag("mobile_onboarding_blocker")
+ waitForTag("mobile_onboarding_target_action")
composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals("Home Dashboard")
composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled()
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
index ddc25918..10eb6b75 100644
--- 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
@@ -15,6 +15,12 @@ enum class MobileOnboardingStepId {
PROFILE_PRIVACY
}
+enum class MobileOnboardingPanelPlacement {
+ TOP,
+ CENTER,
+ BOTTOM
+}
+
data class MobileOnboardingStep(
val id: MobileOnboardingStepId,
val route: String,
@@ -22,6 +28,8 @@ data class MobileOnboardingStep(
val eyebrow: String,
val description: String,
val actionLabel: String,
+ val targetHint: String,
+ val panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.BOTTOM,
val authenticatedOnly: Boolean = false
)
@@ -33,7 +41,9 @@ object MobileOnboardingJourney {
title = "Home Dashboard",
eyebrow = "Your command center",
description = "Start here to request help, mark yourself safe, review updates, and manage your volunteer status.",
- actionLabel = "Go to Home"
+ actionLabel = "Tap I need help now",
+ targetHint = "This is the fastest emergency entry point on the home screen.",
+ panelPlacement = MobileOnboardingPanelPlacement.TOP
),
MobileOnboardingStep(
id = MobileOnboardingStepId.REQUEST_HELP,
@@ -41,7 +51,9 @@ object MobileOnboardingJourney {
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.",
- actionLabel = "Open Request Help"
+ actionLabel = "Tap My Help Requests",
+ targetHint = "After creating or saving a request, this is where you follow its status.",
+ panelPlacement = MobileOnboardingPanelPlacement.TOP
),
MobileOnboardingStep(
id = MobileOnboardingStepId.MY_HELP_REQUESTS,
@@ -49,7 +61,9 @@ object MobileOnboardingJourney {
title = "My Help Requests",
eyebrow = "Track your requests",
description = "Follow request status, local/offline saves, and updates after you submit or edit a request.",
- actionLabel = "Open My Requests"
+ actionLabel = "Tap Help Request Map",
+ targetHint = "Use the map next to understand needs around you.",
+ panelPlacement = MobileOnboardingPanelPlacement.TOP
),
MobileOnboardingStep(
id = MobileOnboardingStepId.HELP_REQUEST_MAP,
@@ -57,7 +71,9 @@ object MobileOnboardingJourney {
title = "Help Request Map",
eyebrow = "See community needs",
description = "Use the map to understand active waiting requests around the community and where support is needed.",
- actionLabel = "Open Help Map"
+ actionLabel = "Tap Gathering Areas",
+ targetHint = "Switch from active requests to safe assembly/support points.",
+ panelPlacement = MobileOnboardingPanelPlacement.TOP
),
MobileOnboardingStep(
id = MobileOnboardingStepId.GATHERING_AREAS,
@@ -65,7 +81,9 @@ object MobileOnboardingJourney {
title = "Gathering Areas",
eyebrow = "Find safe assembly points",
description = "Review nearby gathering and support areas before or during an emergency.",
- actionLabel = "Open Gathering Areas"
+ actionLabel = "Tap Emergency Numbers",
+ targetHint = "Keep direct emergency contacts close when app coordination is not enough.",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER
),
MobileOnboardingStep(
id = MobileOnboardingStepId.EMERGENCY_NUMBERS,
@@ -73,7 +91,9 @@ object MobileOnboardingJourney {
title = "Emergency Numbers",
eyebrow = "Fast emergency contacts",
description = "Keep important emergency phone numbers close when you need direct help outside the app.",
- actionLabel = "Open Emergency Numbers"
+ actionLabel = "Tap News & Announcements",
+ targetHint = "Move to verified community updates after reviewing direct contacts.",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER
),
MobileOnboardingStep(
id = MobileOnboardingStepId.NEWS,
@@ -81,7 +101,9 @@ object MobileOnboardingJourney {
title = "News & Announcements",
eyebrow = "Verified preparedness updates",
description = "Read preparedness notes and community announcements without leaving NEPH.",
- actionLabel = "Open News"
+ actionLabel = "Tap Volunteer Availability",
+ targetHint = "Authenticated users can also tell NEPH when they are ready to help.",
+ panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
),
MobileOnboardingStep(
id = MobileOnboardingStepId.VOLUNTEER_AVAILABILITY,
@@ -89,7 +111,9 @@ object MobileOnboardingJourney {
title = "Volunteer Availability",
eyebrow = "Tell NEPH when you can help",
description = "When you are available, NEPH can use your profile and location context for volunteer coordination.",
- actionLabel = "Return to Home",
+ actionLabel = "Tap Assigned Requests",
+ targetHint = "Once available, assigned work appears in the assigned requests area.",
+ panelPlacement = MobileOnboardingPanelPlacement.TOP,
authenticatedOnly = true
),
MobileOnboardingStep(
@@ -98,7 +122,9 @@ object MobileOnboardingJourney {
title = "Assigned Requests",
eyebrow = "Follow work assigned to you",
description = "If you volunteer, this is where assigned support requests and next actions appear.",
- actionLabel = "Open Assigned Requests",
+ actionLabel = "Tap Profile",
+ targetHint = "Your profile and privacy settings control the coordination information others can use.",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER,
authenticatedOnly = true
),
MobileOnboardingStep(
@@ -107,7 +133,9 @@ object MobileOnboardingJourney {
title = "Profile & Privacy",
eyebrow = "Keep coordination data accurate",
description = "Update your profile and use Privacy & Security settings to control what others can see.",
- actionLabel = "Open Profile",
+ actionLabel = "Finish guide",
+ targetHint = "You have reached the end of the guided path.",
+ panelPlacement = MobileOnboardingPanelPlacement.BOTTOM,
authenticatedOnly = true
)
)
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
index ef717429..c56c67af 100644
--- 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
@@ -1,13 +1,26 @@
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.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -15,10 +28,15 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import com.neph.features.onboarding.data.MobileOnboardingPanelPlacement
import com.neph.features.onboarding.data.MobileOnboardingStep
import com.neph.ui.theme.LocalNephSpacing
@@ -38,13 +56,49 @@ fun MobileOnboardingGuide(
val isFirstStep = stepNumber <= 1
val isLastStep = stepNumber >= totalSteps
val progress = stepNumber.toFloat() / totalSteps.toFloat()
+ val panelAlignment = step.panelPlacement.panelAlignment()
+ val targetAlignment = step.panelPlacement.targetAlignment()
+ val blockerInteractionSource = remember { MutableInteractionSource() }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.44f))
+ .clickable(
+ interactionSource = blockerInteractionSource,
+ indication = null,
+ onClick = {}
+ )
+ .testTag("mobile_onboarding_blocker")
+ )
+
+ PulsingTargetButton(
+ label = when {
+ !isOnTargetRoute -> "Go to ${step.title}"
+ isLastStep -> step.actionLabel
+ else -> step.actionLabel
+ },
+ hint = if (isOnTargetRoute) step.targetHint else "First move to this screen, then follow the highlighted action.",
+ buttonTestTag = when {
+ !isOnTargetRoute -> "mobile_onboarding_take_me_there"
+ isLastStep -> "mobile_onboarding_finish"
+ else -> "mobile_onboarding_next"
+ },
+ modifier = Modifier
+ .align(targetAlignment)
+ .padding(24.dp)
+ .testTag("mobile_onboarding_target_action"),
+ onClick = when {
+ !isOnTargetRoute -> onNavigateToStep
+ isLastStep -> onFinish
+ else -> onNext
+ }
+ )
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.BottomCenter
- ) {
Surface(
modifier = Modifier
+ .align(panelAlignment)
.fillMaxWidth()
.padding(16.dp)
.navigationBarsPadding()
@@ -83,13 +137,15 @@ fun MobileOnboardingGuide(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- if (!isOnTargetRoute) {
- Text(
- text = "This concept lives on another screen. Tap ${step.actionLabel} to continue the guided path.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary
- )
- }
+ Text(
+ text = if (isOnTargetRoute) {
+ "Tap the pulsing highlighted action to continue. Other screen actions are temporarily disabled during the guide."
+ } else {
+ "Tap the pulsing highlighted action to move to the right screen first."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
LinearProgressIndicator(
progress = { progress },
@@ -121,37 +177,115 @@ fun MobileOnboardingGuide(
) {
Text("Back")
}
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = "Use the pulse",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
+ }
+ }
+ }
+}
- if (!isOnTargetRoute) {
- Button(
- onClick = onNavigateToStep,
- modifier = Modifier
- .fillMaxWidth()
- .testTag("mobile_onboarding_take_me_there")
- ) {
- Text(step.actionLabel)
- }
- } else if (isLastStep) {
- Button(
- onClick = onFinish,
- modifier = Modifier
- .fillMaxWidth()
- .testTag("mobile_onboarding_finish")
- ) {
- Text("Finish")
- }
- } else {
- Button(
- onClick = onNext,
- modifier = Modifier
- .fillMaxWidth()
- .testTag("mobile_onboarding_next")
- ) {
- Text("Next")
+@Composable
+private fun PulsingTargetButton(
+ label: String,
+ hint: String,
+ buttonTestTag: String,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ val spacing = LocalNephSpacing.current
+ val infiniteTransition = rememberInfiniteTransition(label = "mobile-onboarding-pulse")
+ val scale = infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = keyframes {
+ durationMillis = 1300
+ 1f at 0
+ 1.08f at 180
+ 0.98f at 360
+ 1.05f at 540
+ 1f at 760
+ 1f at 1300
+ },
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "mobile-onboarding-heart-bump"
+ ).value
+
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .scale(scale),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ tonalElevation = 10.dp,
+ shadowElevation = 12.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(spacing.md),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(spacing.sm)
+ ) {
+ Surface(
+ modifier = Modifier.size(36.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ androidx.compose.material3.Icon(
+ imageVector = Icons.Filled.Favorite,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(18.dp)
+ )
}
}
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = hint,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
+ )
+ }
+ }
+ Button(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(buttonTestTag)
+ ) {
+ Text(label)
}
}
}
}
+
+private fun MobileOnboardingPanelPlacement.panelAlignment(): Alignment {
+ return when (this) {
+ MobileOnboardingPanelPlacement.TOP -> Alignment.TopCenter
+ MobileOnboardingPanelPlacement.CENTER -> Alignment.Center
+ MobileOnboardingPanelPlacement.BOTTOM -> Alignment.BottomCenter
+ }
+}
+
+private fun MobileOnboardingPanelPlacement.targetAlignment(): Alignment {
+ return when (this) {
+ MobileOnboardingPanelPlacement.TOP -> Alignment.BottomCenter
+ MobileOnboardingPanelPlacement.CENTER -> Alignment.BottomCenter
+ MobileOnboardingPanelPlacement.BOTTOM -> Alignment.TopCenter
+ }
+}
From ebd4c1b98d23685b46b8d65e2e451bf91601c8a7 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 00:34:31 +0300
Subject: [PATCH 04/41] feat: implement interactive onboarding pulse animation
and integrate guided walkthrough steps into RequestHelpScreen
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 29 ++-
.../src/main/java/com/neph/MainActivity.kt | 30 ++-
.../features/home/presentation/HomeScreen.kt | 35 +++-
.../data/MobileOnboardingJourney.kt | 81 ++++++-
.../presentation/MobileOnboardingPulse.kt | 46 ++++
.../presentation/MobileOnboardingTutorial.kt | 197 ++++--------------
.../presentation/RequestHelpScreen.kt | 123 ++++++++++-
.../java/com/neph/navigation/AppNavGraph.kt | 13 +-
.../selection/AppMultiSelectChipGroup.kt | 4 +
.../data/MobileOnboardingKeysTest.kt | 13 +-
10 files changed, 377 insertions(+), 194 deletions(-)
create mode 100644 android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingPulse.kt
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index 73b621ee..a54c00b7 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -9,6 +9,7 @@ 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
@@ -65,27 +66,39 @@ class MobileOnboardingAndroidE2ETest {
fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() {
waitForText("I need help now")
waitForTag("mobile_onboarding_dialog")
- waitForTag("mobile_onboarding_blocker")
- waitForTag("mobile_onboarding_target_action")
composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals("Home Dashboard")
composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled()
- composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
+ composeRule.onNodeWithTag("home_request_help_action").performClick()
+ waitForGuideTitle("Request Help Page")
+ waitForTag("mobile_onboarding_target_request_help_back")
+
+ composeRule.onNodeWithTag("mobile_onboarding_target_request_help_back").performClick()
+ waitForGuideTitle("Start Request Creation")
+ waitForText("You returned from the request form")
+
+ composeRule.onNodeWithTag("home_request_help_action").performClick()
waitForGuideTitle("Request Help")
waitForText("Create a help request")
+ waitForTag("mobile_onboarding_target_fire_brigade")
+
+ composeRule.onNodeWithTag("mobile_onboarding_target_fire_brigade").performClick()
+ waitForGuideTitle("Risk Flags")
+ waitForText("You selected Fire Brigade")
+ waitForTag("mobile_onboarding_target_fire_risk")
- composeRule.onNodeWithTag("mobile_onboarding_next").performClick()
- waitForGuideTitle("My Help Requests")
- waitForText("Follow request status")
+ composeRule.onNodeWithTag("mobile_onboarding_target_fire_risk").performScrollTo().performClick()
+ waitForGuideTitle("Confirmation")
+ waitForText("You marked fire")
composeRule.activityRule.scenario.recreate()
- waitForGuideTitle("My Help Requests")
+ waitForGuideTitle("Confirmation")
composeRule.onNodeWithTag("mobile_onboarding_skip").performClick()
waitUntilTagGone("mobile_onboarding_dialog")
composeRule.activityRule.scenario.recreate()
- waitForText("My Help Requests")
+ waitForText("Request Help")
waitUntilTagGone("mobile_onboarding_dialog")
}
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index c7862d39..761c918a 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -173,6 +173,7 @@ fun NephApp() {
val isAuthenticated = !accessToken.isNullOrBlank()
var showMobileOnboarding by remember { mutableStateOf(false) }
var activeMobileOnboardingStepId by remember { mutableStateOf(null) }
+ var mobileOnboardingFeedback by remember { mutableStateOf(null) }
var showExitDialog by remember { mutableStateOf(false) }
val canPopBackStack = navController.previousBackStackEntry != null
val shouldConfirmExit = !canPopBackStack && (
@@ -198,16 +199,32 @@ fun NephApp() {
MobileOnboardingStore.restartForCurrentUser()
val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true)
activeMobileOnboardingStepId = firstStep.id
+ mobileOnboardingFeedback = null
showMobileOnboarding = true
navigateForMobileOnboarding(firstStep.route)
}
+ fun completeMobileOnboardingStep(message: String?) {
+ val currentStepId = activeMobileOnboardingStepId ?: return
+ mobileOnboardingFeedback = message
+ val nextStep = MobileOnboardingJourney.nextStep(currentStepId, isAuthenticated)
+ if (nextStep == null) {
+ MobileOnboardingStore.markSeenForCurrentUser()
+ showMobileOnboarding = false
+ activeMobileOnboardingStepId = null
+ mobileOnboardingFeedback = null
+ } else {
+ setMobileOnboardingStep(nextStep.id)
+ }
+ }
+
LaunchedEffect(accessToken, currentRoute) {
val shouldShow = shouldShowMobileOnboardingForRoute(currentRoute)
showMobileOnboarding = shouldShow
activeMobileOnboardingStepId = if (shouldShow) {
MobileOnboardingStore.currentStepForCurrentUser(isAuthenticated)
} else {
+ mobileOnboardingFeedback = null
null
}
}
@@ -252,7 +269,9 @@ fun NephApp() {
AuthSessionStore.isGuestMode() -> Routes.Home.route
else -> Routes.Welcome.route
},
- onRestartMobileOnboarding = ::restartMobileOnboarding
+ onRestartMobileOnboarding = ::restartMobileOnboarding,
+ mobileOnboardingStepId = activeMobileOnboardingStepId.takeIf { showMobileOnboarding },
+ onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep
)
val activeStepId = activeMobileOnboardingStepId
@@ -265,28 +284,27 @@ fun NephApp() {
stepNumber = stepNumber,
totalSteps = totalSteps,
isOnTargetRoute = routeBase == activeStep.route,
+ feedbackMessage = mobileOnboardingFeedback,
onNavigateToStep = {
navigateForMobileOnboarding(activeStep.route)
},
onBack = {
MobileOnboardingJourney.previousStep(activeStepId, isAuthenticated)?.let { previousStep ->
setMobileOnboardingStep(previousStep.id)
- }
- },
- onNext = {
- MobileOnboardingJourney.nextStep(activeStepId, isAuthenticated)?.let { nextStep ->
- setMobileOnboardingStep(nextStep.id)
+ mobileOnboardingFeedback = null
}
},
onSkip = {
MobileOnboardingStore.markSeenForCurrentUser()
showMobileOnboarding = false
activeMobileOnboardingStepId = null
+ mobileOnboardingFeedback = null
},
onFinish = {
MobileOnboardingStore.markSeenForCurrentUser()
showMobileOnboarding = false
activeMobileOnboardingStepId = null
+ mobileOnboardingFeedback = null
}
)
}
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..53fb7928 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
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
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,6 +53,9 @@ 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
@@ -82,6 +86,8 @@ fun HomeScreen(
onProfileClick: () -> Unit,
profileBadgeText: String,
isAuthenticated: Boolean,
+ mobileOnboardingStepId: MobileOnboardingStepId? = null,
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {},
modifier: Modifier = Modifier
) {
val spacing = LocalNephSpacing.current
@@ -135,6 +141,9 @@ fun HomeScreen(
}
val lifecycleOwner = LocalLifecycleOwner.current
+ val isMobileOnboardingActive = mobileOnboardingStepId != null
+ val isRequestHelpOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.HOME_DASHBOARD ||
+ mobileOnboardingStepId == MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN
DisposableEffect(lifecycleOwner, context) {
val observer = LifecycleEventObserver { _, event ->
@@ -533,13 +542,26 @@ fun HomeScreen(
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 +604,8 @@ fun HomeScreen(
else -> ""
},
syncIndicator = availabilitySyncIndicator,
- onRefreshLocationAndBecomeAvailable = { handleAvailabilityChange(true) },
- onAvailabilityChange = ::handleAvailabilityChange
+ onRefreshLocationAndBecomeAvailable = { if (!isMobileOnboardingActive) handleAvailabilityChange(true) },
+ onAvailabilityChange = { if (!isMobileOnboardingActive) handleAvailabilityChange(it) }
)
}
@@ -688,6 +710,7 @@ private fun HomeGreetingHero(
private fun EmergencyHelpAction(
loading: Boolean,
enabled: Boolean,
+ modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val spacing = LocalNephSpacing.current
@@ -700,6 +723,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))
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
index 10eb6b75..afcc7bf3 100644
--- 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
@@ -4,7 +4,12 @@ import com.neph.navigation.Routes
enum class MobileOnboardingStepId {
HOME_DASHBOARD,
- REQUEST_HELP,
+ REQUEST_HELP_BACK,
+ HOME_REQUEST_HELP_REOPEN,
+ REQUEST_HELP_TYPE,
+ REQUEST_HELP_RISK_FIRE,
+ REQUEST_HELP_CONFIRM,
+ REQUEST_HELP_SEND,
MY_HELP_REQUESTS,
HELP_REQUEST_MAP,
GATHERING_AREAS,
@@ -29,6 +34,8 @@ data class MobileOnboardingStep(
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
)
@@ -43,16 +50,80 @@ object MobileOnboardingJourney {
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.TOP
),
MobileOnboardingStep(
- id = MobileOnboardingStepId.REQUEST_HELP,
+ id = MobileOnboardingStepId.REQUEST_HELP_BACK,
+ route = Routes.RequestHelp.route,
+ title = "Request Help Page",
+ eyebrow = "Know how to leave safely",
+ description = "This is the request creation form. Before filling it, try the real Back action so you know how to leave without submitting.",
+ actionLabel = "Tap Back",
+ targetHint = "Back returns you to the previous page without sending a request.",
+ completionMessage = "You returned from the request form without sending anything.",
+ usesExistingTarget = true,
+ panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
+ ),
+ MobileOnboardingStep(
+ id = MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
+ route = Routes.Home.route,
+ title = "Start Request Creation",
+ eyebrow = "Continue the request flow",
+ description = "Now reopen the request form and walk through the important creation actions.",
+ actionLabel = "Tap I need help now",
+ targetHint = "This brings you back to the request form.",
+ completionMessage = "You reopened the request form.",
+ 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.",
- actionLabel = "Tap My Help Requests",
- targetHint = "After creating or saving a request, this is where you follow its status.",
+ 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 Fire Brigade",
+ targetHint = "Use this chip when the emergency needs fire-related support.",
+ completionMessage = "You selected Fire Brigade 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 = "Submit the request",
+ description = "The final action saves the request locally and queues it for sync.",
+ actionLabel = "Tap Send Help Request",
+ targetHint = "This sends the help request using the information in the form.",
+ completionMessage = "You sent a help request and moved to your request history.",
+ usesExistingTarget = true,
panelPlacement = MobileOnboardingPanelPlacement.TOP
),
MobileOnboardingStep(
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
index c56c67af..a51c4ad6 100644
--- 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
@@ -1,13 +1,5 @@
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.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,10 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -28,13 +16,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.neph.features.onboarding.data.MobileOnboardingPanelPlacement
import com.neph.features.onboarding.data.MobileOnboardingStep
@@ -46,59 +30,20 @@ fun MobileOnboardingGuide(
stepNumber: Int,
totalSteps: Int,
isOnTargetRoute: Boolean,
+ feedbackMessage: String?,
onNavigateToStep: () -> Unit,
onBack: () -> Unit,
- onNext: () -> Unit,
onSkip: () -> Unit,
onFinish: () -> Unit
) {
val spacing = LocalNephSpacing.current
val isFirstStep = stepNumber <= 1
- val isLastStep = stepNumber >= totalSteps
val progress = stepNumber.toFloat() / totalSteps.toFloat()
- val panelAlignment = step.panelPlacement.panelAlignment()
- val targetAlignment = step.panelPlacement.targetAlignment()
- val blockerInteractionSource = remember { MutableInteractionSource() }
Box(modifier = Modifier.fillMaxSize()) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(alpha = 0.44f))
- .clickable(
- interactionSource = blockerInteractionSource,
- indication = null,
- onClick = {}
- )
- .testTag("mobile_onboarding_blocker")
- )
-
- PulsingTargetButton(
- label = when {
- !isOnTargetRoute -> "Go to ${step.title}"
- isLastStep -> step.actionLabel
- else -> step.actionLabel
- },
- hint = if (isOnTargetRoute) step.targetHint else "First move to this screen, then follow the highlighted action.",
- buttonTestTag = when {
- !isOnTargetRoute -> "mobile_onboarding_take_me_there"
- isLastStep -> "mobile_onboarding_finish"
- else -> "mobile_onboarding_next"
- },
- modifier = Modifier
- .align(targetAlignment)
- .padding(24.dp)
- .testTag("mobile_onboarding_target_action"),
- onClick = when {
- !isOnTargetRoute -> onNavigateToStep
- isLastStep -> onFinish
- else -> onNext
- }
- )
-
Surface(
modifier = Modifier
- .align(panelAlignment)
+ .align(step.panelPlacement.panelAlignment())
.fillMaxWidth()
.padding(16.dp)
.navigationBarsPadding()
@@ -131,6 +76,23 @@ fun MobileOnboardingGuide(
)
}
+ 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.md),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+
Text(
text = step.description,
style = MaterialTheme.typography.bodyMedium,
@@ -138,13 +100,16 @@ fun MobileOnboardingGuide(
)
Text(
- text = if (isOnTargetRoute) {
- "Tap the pulsing highlighted action to continue. Other screen actions are temporarily disabled during the guide."
+ text = if (isOnTargetRoute && step.usesExistingTarget) {
+ step.actionLabel
+ } else if (!isOnTargetRoute) {
+ "Go to ${step.title}"
} else {
- "Tap the pulsing highlighted action to move to the right screen first."
+ step.targetHint
},
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.testTag("mobile_onboarding_instruction")
)
LinearProgressIndicator(
@@ -178,97 +143,21 @@ fun MobileOnboardingGuide(
Text("Back")
}
Spacer(modifier = Modifier.weight(1f))
- Text(
- text = "Use the pulse",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun PulsingTargetButton(
- label: String,
- hint: String,
- buttonTestTag: String,
- modifier: Modifier = Modifier,
- onClick: () -> Unit
-) {
- val spacing = LocalNephSpacing.current
- val infiniteTransition = rememberInfiniteTransition(label = "mobile-onboarding-pulse")
- val scale = infiniteTransition.animateFloat(
- initialValue = 1f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = keyframes {
- durationMillis = 1300
- 1f at 0
- 1.08f at 180
- 0.98f at 360
- 1.05f at 540
- 1f at 760
- 1f at 1300
- },
- repeatMode = RepeatMode.Restart
- ),
- label = "mobile-onboarding-heart-bump"
- ).value
-
- Surface(
- modifier = modifier
- .fillMaxWidth()
- .scale(scale),
- shape = MaterialTheme.shapes.extraLarge,
- color = MaterialTheme.colorScheme.primaryContainer,
- tonalElevation = 10.dp,
- shadowElevation = 12.dp
- ) {
- Column(
- modifier = Modifier.padding(spacing.md),
- verticalArrangement = Arrangement.spacedBy(spacing.sm)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(spacing.sm)
- ) {
- Surface(
- modifier = Modifier.size(36.dp),
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary
- ) {
- Box(contentAlignment = Alignment.Center) {
- androidx.compose.material3.Icon(
- imageVector = Icons.Filled.Favorite,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(18.dp)
- )
+ if (!isOnTargetRoute || !step.usesExistingTarget) {
+ Button(
+ onClick = if (!isOnTargetRoute) onNavigateToStep else onFinish,
+ modifier = Modifier.testTag(
+ if (!isOnTargetRoute) {
+ "mobile_onboarding_take_me_there"
+ } else {
+ "mobile_onboarding_finish"
+ }
+ )
+ ) {
+ Text(if (!isOnTargetRoute) "Go there" else "Finish")
+ }
}
}
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = label,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = hint,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
- )
- }
- }
- Button(
- onClick = onClick,
- modifier = Modifier
- .fillMaxWidth()
- .testTag(buttonTestTag)
- ) {
- Text(label)
}
}
}
@@ -281,11 +170,3 @@ private fun MobileOnboardingPanelPlacement.panelAlignment(): Alignment {
MobileOnboardingPanelPlacement.BOTTOM -> Alignment.BottomCenter
}
}
-
-private fun MobileOnboardingPanelPlacement.targetAlignment(): Alignment {
- return when (this) {
- MobileOnboardingPanelPlacement.TOP -> Alignment.BottomCenter
- MobileOnboardingPanelPlacement.CENTER -> Alignment.BottomCenter
- MobileOnboardingPanelPlacement.BOTTOM -> Alignment.TopCenter
- }
-}
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..dbbe1eaf 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
@@ -55,6 +59,9 @@ 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.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
@@ -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
@@ -498,12 +506,34 @@ 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 = {}
) {
val spacing = LocalNephSpacing.current
val scope = rememberCoroutineScope()
@@ -533,6 +563,23 @@ 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_BACK,
+ 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,
@@ -892,6 +939,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) {
@@ -915,7 +965,10 @@ fun RequestHelpScreen(
topBar = {
RequestHelpStickyTopBar(
draft = observedDraft,
- onNavigateBack = onNavigateBack
+ onNavigateBack = onNavigateBack,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted,
+ isLoggedIn = isLoggedIn
)
}
) {
@@ -947,6 +1000,19 @@ fun RequestHelpScreen(
helpTypes = toggleSelection(formState.helpTypes, it),
otherHelpType = if (it == "Other" && "Other" in formState.helpTypes) "" else formState.otherHelpType
)
+ if (isHelpTypeOnboardingTarget && it == "Fire Brigade") {
+ completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_TYPE)
+ }
+ },
+ optionModifier = { option ->
+ rememberMobileOnboardingTargetModifier(
+ active = isHelpTypeOnboardingTarget && option == "Fire Brigade",
+ testTag = "mobile_onboarding_target_fire_brigade"
+ )
+ },
+ optionEnabled = { option ->
+ !isMobileOnboardingActive ||
+ (isHelpTypeOnboardingTarget && option == "Fire Brigade")
},
error = fieldErrors.helpTypes
)
@@ -989,6 +1055,19 @@ fun RequestHelpScreen(
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")
}
)
@@ -1193,8 +1272,16 @@ 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,
error = fieldErrors.confirmationAccepted
)
}
@@ -1215,13 +1302,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
)
}
}
@@ -1230,9 +1325,13 @@ fun RequestHelpScreen(
@Composable
private fun RequestHelpStickyTopBar(
draft: HelpRequestEntity?,
- onNavigateBack: () -> Unit
+ onNavigateBack: () -> Unit,
+ mobileOnboardingStepId: MobileOnboardingStepId? = null,
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {},
+ isLoggedIn: Boolean = false
) {
val spacing = LocalNephSpacing.current
+ val isBackOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_BACK
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
@@ -1245,7 +1344,21 @@ private fun RequestHelpStickyTopBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
- TextActionButton(text = "Back", onClick = onNavigateBack)
+ TextActionButton(
+ text = "Back",
+ onClick = {
+ onNavigateBack()
+ if (isBackOnboardingTarget) {
+ val message = MobileOnboardingJourney
+ .stepFor(MobileOnboardingStepId.REQUEST_HELP_BACK, isAuthenticated = isLoggedIn)
+ ?.completionMessage
+ onMobileOnboardingStepCompleted(message)
+ }
+ },
+ modifier = Modifier
+ .mobileOnboardingPulse(isBackOnboardingTarget)
+ .testTag("mobile_onboarding_target_request_help_back")
+ )
Text(
text = "Request Help",
style = MaterialTheme.typography.titleMedium,
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 4e56a6d5..8ffa590e 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -38,12 +38,15 @@ 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.MobileOnboardingStepId
@Composable
fun AppNavGraph(
navController: NavHostController,
startDestination: String = Routes.Welcome.route,
- onRestartMobileOnboarding: () -> Unit = {}
+ onRestartMobileOnboarding: () -> Unit = {},
+ mobileOnboardingStepId: MobileOnboardingStepId? = null,
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {}
) {
val verifyEmailRouteWithToken = "${Routes.VerifyEmail.route}?token={token}"
val accessToken by AuthSessionStore.accessTokenFlow.collectAsState()
@@ -141,7 +144,9 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -474,7 +479,9 @@ fun AppNavGraph(
},
onNavigateToMyHelpRequests = {
navigateToDrawerRoute(Routes.MyHelpRequests.route)
- }
+ },
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
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/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt b/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt
index 459db18a..bc7bb4ba 100644
--- 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
@@ -48,7 +48,12 @@ class MobileOnboardingKeysTest {
assertEquals(
listOf(
MobileOnboardingStepId.HOME_DASHBOARD,
- MobileOnboardingStepId.REQUEST_HELP,
+ MobileOnboardingStepId.REQUEST_HELP_BACK,
+ MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
+ MobileOnboardingStepId.REQUEST_HELP_TYPE,
+ MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE,
+ MobileOnboardingStepId.REQUEST_HELP_CONFIRM,
+ MobileOnboardingStepId.REQUEST_HELP_SEND,
MobileOnboardingStepId.MY_HELP_REQUESTS,
MobileOnboardingStepId.HELP_REQUEST_MAP,
MobileOnboardingStepId.GATHERING_AREAS,
@@ -67,12 +72,12 @@ class MobileOnboardingKeysTest {
@Test
fun journeyCanMoveForwardAndBackward() {
assertEquals(
- MobileOnboardingStepId.REQUEST_HELP,
+ MobileOnboardingStepId.REQUEST_HELP_BACK,
MobileOnboardingJourney.nextStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true)?.id
)
assertEquals(
- MobileOnboardingStepId.HOME_DASHBOARD,
- MobileOnboardingJourney.previousStep(MobileOnboardingStepId.REQUEST_HELP, isAuthenticated = true)?.id
+ MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
+ MobileOnboardingJourney.previousStep(MobileOnboardingStepId.REQUEST_HELP_TYPE, isAuthenticated = true)?.id
)
assertNull(MobileOnboardingJourney.previousStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true))
}
From 492e93d19d8cc563c5eacf30cc05b7f6b4bcd1a9 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 00:41:05 +0300
Subject: [PATCH 05/41] refactor: remove onboarding steps related to the back
navigation action in RequestHelpScreen
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 8 ------
.../features/home/presentation/HomeScreen.kt | 3 +--
.../data/MobileOnboardingJourney.kt | 26 -------------------
.../presentation/RequestHelpScreen.kt | 25 +++---------------
.../data/MobileOnboardingKeysTest.kt | 6 ++---
5 files changed, 6 insertions(+), 62 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index a54c00b7..dfbe36cf 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -69,14 +69,6 @@ class MobileOnboardingAndroidE2ETest {
composeRule.onNodeWithTag("mobile_onboarding_title").assertTextEquals("Home Dashboard")
composeRule.onNodeWithTag("mobile_onboarding_back").assertIsNotEnabled()
- composeRule.onNodeWithTag("home_request_help_action").performClick()
- waitForGuideTitle("Request Help Page")
- waitForTag("mobile_onboarding_target_request_help_back")
-
- composeRule.onNodeWithTag("mobile_onboarding_target_request_help_back").performClick()
- waitForGuideTitle("Start Request Creation")
- waitForText("You returned from the request form")
-
composeRule.onNodeWithTag("home_request_help_action").performClick()
waitForGuideTitle("Request Help")
waitForText("Create a help request")
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 53fb7928..e2785068 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
@@ -142,8 +142,7 @@ fun HomeScreen(
val lifecycleOwner = LocalLifecycleOwner.current
val isMobileOnboardingActive = mobileOnboardingStepId != null
- val isRequestHelpOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.HOME_DASHBOARD ||
- mobileOnboardingStepId == MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN
+ val isRequestHelpOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.HOME_DASHBOARD
DisposableEffect(lifecycleOwner, context) {
val observer = LifecycleEventObserver { _, event ->
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
index afcc7bf3..02a6f39f 100644
--- 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
@@ -4,8 +4,6 @@ import com.neph.navigation.Routes
enum class MobileOnboardingStepId {
HOME_DASHBOARD,
- REQUEST_HELP_BACK,
- HOME_REQUEST_HELP_REOPEN,
REQUEST_HELP_TYPE,
REQUEST_HELP_RISK_FIRE,
REQUEST_HELP_CONFIRM,
@@ -52,30 +50,6 @@ object MobileOnboardingJourney {
targetHint = "This is the fastest emergency entry point on the home screen.",
completionMessage = "You opened the emergency help request flow.",
usesExistingTarget = true,
- panelPlacement = MobileOnboardingPanelPlacement.TOP
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.REQUEST_HELP_BACK,
- route = Routes.RequestHelp.route,
- title = "Request Help Page",
- eyebrow = "Know how to leave safely",
- description = "This is the request creation form. Before filling it, try the real Back action so you know how to leave without submitting.",
- actionLabel = "Tap Back",
- targetHint = "Back returns you to the previous page without sending a request.",
- completionMessage = "You returned from the request form without sending anything.",
- usesExistingTarget = true,
- panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
- route = Routes.Home.route,
- title = "Start Request Creation",
- eyebrow = "Continue the request flow",
- description = "Now reopen the request form and walk through the important creation actions.",
- actionLabel = "Tap I need help now",
- targetHint = "This brings you back to the request form.",
- completionMessage = "You reopened the request form.",
- usesExistingTarget = true,
panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
),
MobileOnboardingStep(
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 dbbe1eaf..f506ec5c 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
@@ -564,7 +564,6 @@ fun RequestHelpScreen(
var checkingActiveRequest by remember { mutableStateOf(isLoggedIn) }
var currentLocationLoading by remember { mutableStateOf(false) }
val requestHelpOnboardingSteps = setOf(
- MobileOnboardingStepId.REQUEST_HELP_BACK,
MobileOnboardingStepId.REQUEST_HELP_TYPE,
MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE,
MobileOnboardingStepId.REQUEST_HELP_CONFIRM,
@@ -965,10 +964,7 @@ fun RequestHelpScreen(
topBar = {
RequestHelpStickyTopBar(
draft = observedDraft,
- onNavigateBack = onNavigateBack,
- mobileOnboardingStepId = mobileOnboardingStepId,
- onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted,
- isLoggedIn = isLoggedIn
+ onNavigateBack = onNavigateBack
)
}
) {
@@ -1325,13 +1321,9 @@ fun RequestHelpScreen(
@Composable
private fun RequestHelpStickyTopBar(
draft: HelpRequestEntity?,
- onNavigateBack: () -> Unit,
- mobileOnboardingStepId: MobileOnboardingStepId? = null,
- onMobileOnboardingStepCompleted: (String?) -> Unit = {},
- isLoggedIn: Boolean = false
+ onNavigateBack: () -> Unit
) {
val spacing = LocalNephSpacing.current
- val isBackOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.REQUEST_HELP_BACK
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
@@ -1346,18 +1338,7 @@ private fun RequestHelpStickyTopBar(
) {
TextActionButton(
text = "Back",
- onClick = {
- onNavigateBack()
- if (isBackOnboardingTarget) {
- val message = MobileOnboardingJourney
- .stepFor(MobileOnboardingStepId.REQUEST_HELP_BACK, isAuthenticated = isLoggedIn)
- ?.completionMessage
- onMobileOnboardingStepCompleted(message)
- }
- },
- modifier = Modifier
- .mobileOnboardingPulse(isBackOnboardingTarget)
- .testTag("mobile_onboarding_target_request_help_back")
+ onClick = onNavigateBack
)
Text(
text = "Request Help",
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
index bc7bb4ba..33014dcb 100644
--- 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
@@ -48,8 +48,6 @@ class MobileOnboardingKeysTest {
assertEquals(
listOf(
MobileOnboardingStepId.HOME_DASHBOARD,
- MobileOnboardingStepId.REQUEST_HELP_BACK,
- MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
MobileOnboardingStepId.REQUEST_HELP_TYPE,
MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE,
MobileOnboardingStepId.REQUEST_HELP_CONFIRM,
@@ -72,11 +70,11 @@ class MobileOnboardingKeysTest {
@Test
fun journeyCanMoveForwardAndBackward() {
assertEquals(
- MobileOnboardingStepId.REQUEST_HELP_BACK,
+ MobileOnboardingStepId.REQUEST_HELP_TYPE,
MobileOnboardingJourney.nextStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true)?.id
)
assertEquals(
- MobileOnboardingStepId.HOME_REQUEST_HELP_REOPEN,
+ MobileOnboardingStepId.HOME_DASHBOARD,
MobileOnboardingJourney.previousStep(MobileOnboardingStepId.REQUEST_HELP_TYPE, isAuthenticated = true)?.id
)
assertNull(MobileOnboardingJourney.previousStep(MobileOnboardingStepId.HOME_DASHBOARD, isAuthenticated = true))
From 3923614a9609c228c921153a59231a5feb41ce32 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 12:08:34 +0300
Subject: [PATCH 06/41] feat: update E2E onboarding tests with refined
navigation logic
---
.../com/neph/e2e/MobileOnboardingAndroidE2ETest.kt | 8 ++++++--
android/app/src/main/java/com/neph/MainActivity.kt | 10 ++++++----
.../requesthelp/presentation/RequestHelpScreen.kt | 1 +
.../com/neph/ui/components/selection/AppCheckbox.kt | 7 +++++--
4 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index dfbe36cf..a515a758 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -2,6 +2,7 @@ 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
@@ -82,9 +83,12 @@ class MobileOnboardingAndroidE2ETest {
composeRule.onNodeWithTag("mobile_onboarding_target_fire_risk").performScrollTo().performClick()
waitForGuideTitle("Confirmation")
waitForText("You marked fire")
+ waitForTag("mobile_onboarding_confirmation_checkbox")
- composeRule.activityRule.scenario.recreate()
- waitForGuideTitle("Confirmation")
+ 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_skip").performClick()
waitUntilTagGone("mobile_onboarding_dialog")
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index 761c918a..ab7072e8 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
@@ -21,7 +21,6 @@ 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
@@ -165,7 +164,7 @@ 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
@@ -191,7 +190,10 @@ fun NephApp() {
activeMobileOnboardingStepId = stepId
MobileOnboardingStore.setCurrentStepForCurrentUser(stepId)
MobileOnboardingJourney.stepFor(stepId, isAuthenticated)?.let { step ->
- navigateForMobileOnboarding(step.route)
+ val routeBase = currentRoute?.substringBefore('?')
+ if (routeBase != step.route) {
+ navigateForMobileOnboarding(step.route)
+ }
}
}
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 f506ec5c..33ffa64d 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
@@ -1278,6 +1278,7 @@ fun RequestHelpScreen(
),
label = "I confirm this information can be shared for emergency coordination.",
enabled = !isMobileOnboardingActive || isConfirmationOnboardingTarget,
+ testTag = "mobile_onboarding_confirmation_checkbox",
error = fieldErrors.confirmationAccepted
)
}
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
+}
From 7b58ccb4a434e91e8f4d75e051cb4474ce583978 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 12:42:45 +0300
Subject: [PATCH 07/41] feat: integrate mobile onboarding flow into app
navigation and drawer UI components
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 20 +++-
.../src/main/java/com/neph/MainActivity.kt | 28 ++++-
.../presentation/AssignedRequestScreen.kt | 22 +++-
.../features/home/presentation/HomeScreen.kt | 60 ++++++++++-
.../data/MobileOnboardingJourney.kt | 100 +++++-------------
.../presentation/RequestHelpScreen.kt | 19 +++-
.../java/com/neph/navigation/AppNavGraph.kt | 8 +-
.../com/neph/ui/layout/AppDrawerScaffold.kt | 80 ++++++++++++--
.../data/MobileOnboardingKeysTest.kt | 15 ++-
9 files changed, 249 insertions(+), 103 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index a515a758..45df28cf 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -67,6 +67,7 @@ class MobileOnboardingAndroidE2ETest {
fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() {
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()
@@ -90,11 +91,26 @@ class MobileOnboardingAndroidE2ETest {
composeRule.onNodeWithTag("mobile_onboarding_confirmation_checkbox").assertIsOn()
waitForTag("mobile_onboarding_target_send_help_request")
- composeRule.onNodeWithTag("mobile_onboarding_skip").performClick()
+ composeRule.onNodeWithTag("mobile_onboarding_target_send_help_request").performScrollTo().performClick()
+ waitForGuideTitle("Open the Menu")
+ waitForText("No real help request was saved")
+ 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.")
+
+ composeRule.onNodeWithTag("mobile_onboarding_finish").performClick()
waitUntilTagGone("mobile_onboarding_dialog")
composeRule.activityRule.scenario.recreate()
- waitForText("Request Help")
+ waitForText("Assigned Request")
waitUntilTagGone("mobile_onboarding_dialog")
}
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index ab7072e8..1e707aa3 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -220,6 +220,12 @@ fun NephApp() {
}
}
+ fun updateMobileOnboardingFeedback(message: String?) {
+ if (showMobileOnboarding) {
+ mobileOnboardingFeedback = message
+ }
+ }
+
LaunchedEffect(accessToken, currentRoute) {
val shouldShow = shouldShowMobileOnboardingForRoute(currentRoute)
showMobileOnboarding = shouldShow
@@ -231,6 +237,14 @@ fun NephApp() {
}
}
+ 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()
@@ -273,19 +287,25 @@ fun NephApp() {
},
onRestartMobileOnboarding = ::restartMobileOnboarding,
mobileOnboardingStepId = activeMobileOnboardingStepId.takeIf { showMobileOnboarding },
- onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep
+ onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep,
+ onMobileOnboardingFeedbackChanged = ::updateMobileOnboardingFeedback
)
val activeStepId = activeMobileOnboardingStepId
val activeStep = activeStepId?.let { MobileOnboardingJourney.stepFor(it, isAuthenticated) }
- if (showMobileOnboarding && activeStepId != null && activeStep != null) {
+ val activeRouteBase = currentRoute?.substringBefore('?')
+ if (
+ showMobileOnboarding &&
+ activeStepId != null &&
+ activeStep != null &&
+ activeRouteBase == activeStep.route
+ ) {
val (stepNumber, totalSteps) = MobileOnboardingJourney.progressFor(activeStepId, isAuthenticated)
- val routeBase = currentRoute?.substringBefore('?')
MobileOnboardingGuide(
step = activeStep,
stepNumber = stepNumber,
totalSteps = totalSteps,
- isOnTargetRoute = routeBase == activeStep.route,
+ isOnTargetRoute = true,
feedbackMessage = mobileOnboardingFeedback,
onNavigateToStep = {
navigateForMobileOnboarding(activeStep.route)
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..bc553f28 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 -> {
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 e2785068..17bf32db 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,6 +36,7 @@ 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
@@ -527,7 +529,9 @@ 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
) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -539,6 +543,10 @@ fun HomeScreen(
safetyStatus = safetyStatusState.status
)
+ if (isRequestHelpOnboardingTarget) {
+ MobileOnboardingWelcomeMessage()
+ }
+
EmergencyHelpAction(
loading = requestHelpLoading,
enabled = !availabilityLoading &&
@@ -774,6 +782,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/onboarding/data/MobileOnboardingJourney.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt
index 02a6f39f..06bef4f2 100644
--- 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
@@ -8,6 +8,8 @@ enum class MobileOnboardingStepId {
REQUEST_HELP_RISK_FIRE,
REQUEST_HELP_CONFIRM,
REQUEST_HELP_SEND,
+ OPEN_ASSIGNED_REQUESTS_MENU,
+ SELECT_ASSIGNED_REQUEST,
MY_HELP_REQUESTS,
HELP_REQUEST_MAP,
GATHERING_AREAS,
@@ -92,72 +94,37 @@ object MobileOnboardingJourney {
id = MobileOnboardingStepId.REQUEST_HELP_SEND,
route = Routes.RequestHelp.route,
title = "Send Help Request",
- eyebrow = "Submit the request",
- description = "The final action saves the request locally and queues it for sync.",
+ 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 = "This sends the help request using the information in the form.",
- completionMessage = "You sent a help request and moved to your request history.",
+ 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 requests",
- description = "Follow request status, local/offline saves, and updates after you submit or edit a request.",
- actionLabel = "Tap Help Request Map",
- targetHint = "Use the map next to understand needs around you.",
- panelPlacement = MobileOnboardingPanelPlacement.TOP
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.HELP_REQUEST_MAP,
- route = Routes.HelpRequestMap.route,
- title = "Help Request Map",
- eyebrow = "See community needs",
- description = "Use the map to understand active waiting requests around the community and where support is needed.",
- actionLabel = "Tap Gathering Areas",
- targetHint = "Switch from active requests to safe assembly/support points.",
- panelPlacement = MobileOnboardingPanelPlacement.TOP
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.GATHERING_AREAS,
- route = Routes.GatheringAreas.route,
- title = "Gathering Areas",
- eyebrow = "Find safe assembly points",
- description = "Review nearby gathering and support areas before or during an emergency.",
- actionLabel = "Tap Emergency Numbers",
- targetHint = "Keep direct emergency contacts close when app coordination is not enough.",
- panelPlacement = MobileOnboardingPanelPlacement.CENTER
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.EMERGENCY_NUMBERS,
- route = Routes.EmergencyInfo.route,
- title = "Emergency Numbers",
- eyebrow = "Fast emergency contacts",
- description = "Keep important emergency phone numbers close when you need direct help outside the app.",
- actionLabel = "Tap News & Announcements",
- targetHint = "Move to verified community updates after reviewing direct contacts.",
- panelPlacement = MobileOnboardingPanelPlacement.CENTER
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.NEWS,
- route = Routes.News.route,
- title = "News & Announcements",
- eyebrow = "Verified preparedness updates",
- description = "Read preparedness notes and community announcements without leaving NEPH.",
- actionLabel = "Tap Volunteer Availability",
- targetHint = "Authenticated users can also tell NEPH when they are ready to help.",
- panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
+ id = MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU,
+ route = Routes.Home.route,
+ title = "Open the Menu",
+ eyebrow = "Find more app areas",
+ description = "The bottom Menu opens the rest of the app sections you can use during coordination.",
+ actionLabel = "Tap Menu",
+ targetHint = "Open the menu to find Assigned Request.",
+ completionMessage = "You opened the app menu.",
+ usesExistingTarget = true,
+ panelPlacement = MobileOnboardingPanelPlacement.TOP,
+ authenticatedOnly = true
),
MobileOnboardingStep(
- id = MobileOnboardingStepId.VOLUNTEER_AVAILABILITY,
+ id = MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST,
route = Routes.Home.route,
- title = "Volunteer Availability",
- eyebrow = "Tell NEPH when you can help",
- description = "When you are available, NEPH can use your profile and location context for volunteer coordination.",
- actionLabel = "Tap Assigned Requests",
- targetHint = "Once available, assigned work appears in the assigned requests area.",
+ title = "Assigned Request",
+ eyebrow = "Check assigned work",
+ description = "Assigned Request shows the emergency support request currently assigned to you, if there is one.",
+ actionLabel = "Tap Assigned Request",
+ targetHint = "Open this page to see whether NEPH currently assigned you a request.",
+ completionMessage = "You opened Assigned Request.",
+ usesExistingTarget = true,
panelPlacement = MobileOnboardingPanelPlacement.TOP,
authenticatedOnly = true
),
@@ -166,21 +133,10 @@ object MobileOnboardingJourney {
route = Routes.AssignedRequest.route,
title = "Assigned Requests",
eyebrow = "Follow work assigned to you",
- description = "If you volunteer, this is where assigned support requests and next actions appear.",
- actionLabel = "Tap Profile",
- targetHint = "Your profile and privacy settings control the coordination information others can use.",
- panelPlacement = MobileOnboardingPanelPlacement.CENTER,
- authenticatedOnly = true
- ),
- MobileOnboardingStep(
- id = MobileOnboardingStepId.PROFILE_PRIVACY,
- route = Routes.Profile.route,
- title = "Profile & Privacy",
- eyebrow = "Keep coordination data accurate",
- description = "Update your profile and use Privacy & Security settings to control what others can see.",
+ description = "This is where assigned support requests and next actions appear. The guide ends here.",
actionLabel = "Finish guide",
- targetHint = "You have reached the end of the guided path.",
- panelPlacement = MobileOnboardingPanelPlacement.BOTTOM,
+ targetHint = "Click Finish to close the guide and continue using NEPH.",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER,
authenticatedOnly = true
)
)
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 33ffa64d..cd9f5926 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
@@ -860,10 +860,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()
@@ -901,6 +903,15 @@ fun RequestHelpScreen(
}
fun handleSubmit() {
+ if (isSendOnboardingTarget) {
+ fieldErrors = RequestHelpFieldErrors()
+ errorMessage = ""
+ infoMessage = "Practice complete. No real help request was saved."
+ mapActionMessage = ""
+ completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_SEND)
+ return
+ }
+
val nextFieldErrors = validateForm(formState)
fieldErrors = nextFieldErrors
errorMessage = ""
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 8ffa590e..01f5f967 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -46,7 +46,8 @@ fun AppNavGraph(
startDestination: String = Routes.Welcome.route,
onRestartMobileOnboarding: () -> Unit = {},
mobileOnboardingStepId: MobileOnboardingStepId? = null,
- onMobileOnboardingStepCompleted: (String?) -> Unit = {}
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {},
+ onMobileOnboardingFeedbackChanged: (String?) -> Unit = {}
) {
val verifyEmailRouteWithToken = "${Routes.VerifyEmail.route}?token={token}"
val accessToken by AuthSessionStore.accessTokenFlow.collectAsState()
@@ -222,7 +223,10 @@ fun AppNavGraph(
profileBadgeText = profileBadgeText,
onNavigateToLogin = {
navigateToLogin()
- }
+ },
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted,
+ onMobileOnboardingFeedbackChanged = onMobileOnboardingFeedbackChanged
)
}
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..c583a109 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
@@ -66,10 +66,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 +101,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 +114,14 @@ fun AppDrawerScaffold(
val effectiveAlertCount = maxOf(alertCount, badgeUnread)
var menuOpen by remember { mutableStateOf(false) }
+ val isOpenMenuOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU
+ val isSelectAssignedOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST
+ 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 +164,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.OPEN_ASSIGNED_REQUESTS_MENU)
+ }
+ },
+ modifier = Modifier
+ .testTag("mobile_onboarding_target_menu")
+ .mobileOnboardingPulse(isOpenMenuOnboardingTarget),
icon = {
Icon(
imageVector = Icons.Filled.Menu,
@@ -176,6 +202,7 @@ fun AppDrawerScaffold(
currentRoute = currentRoute,
effectiveAlertCount = effectiveAlertCount,
itemColors = itemColors,
+ mobileOnboardingActive = isMobileOnboardingActive,
onNavigateToRoute = onNavigateToRoute
)
}
@@ -185,6 +212,7 @@ fun AppDrawerScaffold(
currentRoute = currentRoute,
effectiveAlertCount = effectiveAlertCount,
itemColors = itemColors,
+ mobileOnboardingActive = isMobileOnboardingActive,
onNavigateToRoute = onNavigateToRoute
)
@@ -194,6 +222,7 @@ fun AppDrawerScaffold(
currentRoute = currentRoute,
effectiveAlertCount = effectiveAlertCount,
itemColors = itemColors,
+ mobileOnboardingActive = isMobileOnboardingActive,
onNavigateToRoute = onNavigateToRoute
)
}
@@ -237,7 +266,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 +293,9 @@ fun AppDrawerScaffold(
menuOpen = false
it()
}
- }
+ },
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep
)
}
}
@@ -271,6 +306,7 @@ private fun RowScope.BottomNavRouteItem(
currentRoute: String,
effectiveAlertCount: Int,
itemColors: NavigationBarItemColors,
+ mobileOnboardingActive: Boolean,
onNavigateToRoute: (String) -> Unit
) {
val selected = currentRoute == item.route
@@ -278,6 +314,10 @@ private fun RowScope.BottomNavRouteItem(
NavigationBarItem(
selected = selected,
onClick = {
+ if (mobileOnboardingActive) {
+ return@NavigationBarItem
+ }
+
if (currentRoute != item.route) {
onNavigateToRoute(item.route)
}
@@ -327,8 +367,13 @@ private fun BoxScope.MenuOverlay(
onProfileClick: (() -> Unit)?,
profileBadgeText: String,
profileLabel: String,
- onOpenSettings: (() -> Unit)?
+ onOpenSettings: (() -> Unit)?,
+ mobileOnboardingStepId: MobileOnboardingStepId?,
+ onMobileOnboardingStepCompleted: (MobileOnboardingStepId) -> Unit
) {
+ val isSelectAssignedOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST
+ val isMobileOnboardingActive = mobileOnboardingStepId != null
+
// Translucent scrim — taps outside dismiss
AnimatedVisibility(
visible = visible,
@@ -417,7 +462,23 @@ private fun BoxScope.MenuOverlay(
icon = bottomNavIconFor(item),
label = item.drawerLabel.orEmpty(),
selected = currentRoute == item.route,
- onClick = { onSelect(item.route) },
+ onClick = {
+ if (isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest) {
+ onMobileOnboardingStepCompleted(MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST)
+ }
+ onSelect(item.route)
+ },
+ enabled = !isMobileOnboardingActive ||
+ (isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest),
+ modifier = Modifier
+ .then(
+ if (item == Routes.AssignedRequest) {
+ Modifier.testTag("mobile_onboarding_target_assigned_request_menu")
+ } else {
+ Modifier
+ }
+ )
+ .mobileOnboardingPulse(isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest),
spacing = spacing
)
}
@@ -432,6 +493,7 @@ private fun BoxScope.MenuOverlay(
label = "Settings",
selected = false,
onClick = onOpenSettings,
+ enabled = !isMobileOnboardingActive,
spacing = spacing
)
}
@@ -445,17 +507,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)
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
index 33014dcb..2d2b05d0 100644
--- 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
@@ -37,6 +37,8 @@ class MobileOnboardingKeysTest {
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.PROFILE_PRIVACY })
}
@@ -52,19 +54,14 @@ class MobileOnboardingKeysTest {
MobileOnboardingStepId.REQUEST_HELP_RISK_FIRE,
MobileOnboardingStepId.REQUEST_HELP_CONFIRM,
MobileOnboardingStepId.REQUEST_HELP_SEND,
- MobileOnboardingStepId.MY_HELP_REQUESTS,
- MobileOnboardingStepId.HELP_REQUEST_MAP,
- MobileOnboardingStepId.GATHERING_AREAS,
- MobileOnboardingStepId.EMERGENCY_NUMBERS,
- MobileOnboardingStepId.NEWS,
- MobileOnboardingStepId.VOLUNTEER_AVAILABILITY,
- MobileOnboardingStepId.ASSIGNED_REQUESTS,
- MobileOnboardingStepId.PROFILE_PRIVACY
+ MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU,
+ MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST,
+ MobileOnboardingStepId.ASSIGNED_REQUESTS
),
authenticatedSteps.map { it.id }
)
assertEquals(Routes.RequestHelp.route, authenticatedSteps[1].route)
- assertEquals(Routes.Profile.route, authenticatedSteps.last().route)
+ assertEquals(Routes.AssignedRequest.route, authenticatedSteps.last().route)
}
@Test
From 6e6673c3a295f406238258191369cec3abc17cbc Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 13:21:14 +0300
Subject: [PATCH 08/41] feat: add practice help request support to onboarding
flow and display in help requests screen
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 10 ++-
.../src/main/java/com/neph/MainActivity.kt | 25 ++++++-
.../data/MyHelpRequestsRepository.kt | 3 +-
.../presentation/MyHelpRequestsScreen.kt | 69 ++++++++++++++++---
.../data/MobileOnboardingJourney.kt | 17 ++++-
.../MobileOnboardingPracticeHelpRequest.kt | 11 +++
.../presentation/MobileOnboardingTutorial.kt | 20 +++++-
.../presentation/RequestHelpScreen.kt | 24 ++++++-
.../java/com/neph/navigation/AppNavGraph.kt | 13 +++-
.../data/MobileOnboardingKeysTest.kt | 2 +
10 files changed, 172 insertions(+), 22 deletions(-)
create mode 100644 android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingPracticeHelpRequest.kt
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index 45df28cf..e4617b7f 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -92,8 +92,13 @@ class MobileOnboardingAndroidE2ETest {
waitForTag("mobile_onboarding_target_send_help_request")
composeRule.onNodeWithTag("mobile_onboarding_target_send_help_request").performScrollTo().performClick()
- waitForGuideTitle("Open the Menu")
+ waitForGuideTitle("My Help Requests")
waitForText("No real help request was saved")
+ waitForText("Guide preview only")
+ waitForText("Fire Brigade")
+
+ composeRule.onNodeWithTag("mobile_onboarding_continue").performClick()
+ waitForGuideTitle("Open the Menu")
waitForTag("mobile_onboarding_target_menu")
composeRule.onNodeWithTag("mobile_onboarding_target_menu").performClick()
@@ -108,9 +113,10 @@ class MobileOnboardingAndroidE2ETest {
composeRule.onNodeWithTag("mobile_onboarding_finish").performClick()
waitUntilTagGone("mobile_onboarding_dialog")
+ waitForText("I need help now")
composeRule.activityRule.scenario.recreate()
- waitForText("Assigned Request")
+ waitForText("I need help now")
waitUntilTagGone("mobile_onboarding_dialog")
}
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index 1e707aa3..21f29b2b 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -40,6 +40,7 @@ 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
@@ -173,6 +174,9 @@ fun NephApp() {
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 && (
@@ -202,6 +206,7 @@ fun NephApp() {
val firstStep = MobileOnboardingJourney.firstStep(isAuthenticated = true)
activeMobileOnboardingStepId = firstStep.id
mobileOnboardingFeedback = null
+ mobileOnboardingPracticeHelpRequest = null
showMobileOnboarding = true
navigateForMobileOnboarding(firstStep.route)
}
@@ -215,6 +220,7 @@ fun NephApp() {
showMobileOnboarding = false
activeMobileOnboardingStepId = null
mobileOnboardingFeedback = null
+ mobileOnboardingPracticeHelpRequest = null
} else {
setMobileOnboardingStep(nextStep.id)
}
@@ -226,6 +232,12 @@ fun NephApp() {
}
}
+ fun updateMobileOnboardingPracticeHelpRequest(request: MobileOnboardingPracticeHelpRequest?) {
+ if (showMobileOnboarding) {
+ mobileOnboardingPracticeHelpRequest = request
+ }
+ }
+
LaunchedEffect(accessToken, currentRoute) {
val shouldShow = shouldShowMobileOnboardingForRoute(currentRoute)
showMobileOnboarding = shouldShow
@@ -288,7 +300,9 @@ fun NephApp() {
onRestartMobileOnboarding = ::restartMobileOnboarding,
mobileOnboardingStepId = activeMobileOnboardingStepId.takeIf { showMobileOnboarding },
onMobileOnboardingStepCompleted = ::completeMobileOnboardingStep,
- onMobileOnboardingFeedbackChanged = ::updateMobileOnboardingFeedback
+ onMobileOnboardingFeedbackChanged = ::updateMobileOnboardingFeedback,
+ mobileOnboardingPracticeHelpRequest = mobileOnboardingPracticeHelpRequest,
+ onMobileOnboardingPracticeHelpRequestChanged = ::updateMobileOnboardingPracticeHelpRequest
)
val activeStepId = activeMobileOnboardingStepId
@@ -310,6 +324,9 @@ fun NephApp() {
onNavigateToStep = {
navigateForMobileOnboarding(activeStep.route)
},
+ onContinue = {
+ completeMobileOnboardingStep(activeStep.completionMessage)
+ },
onBack = {
MobileOnboardingJourney.previousStep(activeStepId, isAuthenticated)?.let { previousStep ->
setMobileOnboardingStep(previousStep.id)
@@ -321,12 +338,18 @@ fun NephApp() {
showMobileOnboarding = false
activeMobileOnboardingStepId = null
mobileOnboardingFeedback = null
+ mobileOnboardingPracticeHelpRequest = null
},
onFinish = {
MobileOnboardingStore.markSeenForCurrentUser()
showMobileOnboarding = false
activeMobileOnboardingStepId = null
mobileOnboardingFeedback = null
+ mobileOnboardingPracticeHelpRequest = null
+ navController.navigate(Routes.Home.route) {
+ popUpTo(navController.graph.id) { inclusive = true }
+ launchSingleTop = true
+ }
}
)
}
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..c41fe732 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
@@ -51,7 +51,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
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/onboarding/data/MobileOnboardingJourney.kt b/android/app/src/main/java/com/neph/features/onboarding/data/MobileOnboardingJourney.kt
index 06bef4f2..f2b03a76 100644
--- 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
@@ -8,9 +8,9 @@ enum class MobileOnboardingStepId {
REQUEST_HELP_RISK_FIRE,
REQUEST_HELP_CONFIRM,
REQUEST_HELP_SEND,
+ MY_HELP_REQUESTS,
OPEN_ASSIGNED_REQUESTS_MENU,
SELECT_ASSIGNED_REQUEST,
- MY_HELP_REQUESTS,
HELP_REQUEST_MAP,
GATHERING_AREAS,
EMERGENCY_NUMBERS,
@@ -102,9 +102,20 @@ object MobileOnboardingJourney {
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.TOP
+ ),
MobileOnboardingStep(
id = MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU,
- route = Routes.Home.route,
+ route = Routes.MyHelpRequests.route,
title = "Open the Menu",
eyebrow = "Find more app areas",
description = "The bottom Menu opens the rest of the app sections you can use during coordination.",
@@ -117,7 +128,7 @@ object MobileOnboardingJourney {
),
MobileOnboardingStep(
id = MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST,
- route = Routes.Home.route,
+ route = Routes.MyHelpRequests.route,
title = "Assigned Request",
eyebrow = "Check assigned work",
description = "Assigned Request shows the emergency support request currently assigned to you, if there is one.",
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/presentation/MobileOnboardingTutorial.kt b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt
index a51c4ad6..fb20799e 100644
--- 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
@@ -32,12 +32,14 @@ fun MobileOnboardingGuide(
isOnTargetRoute: Boolean,
feedbackMessage: String?,
onNavigateToStep: () -> Unit,
+ onContinue: () -> Unit,
onBack: () -> Unit,
onSkip: () -> Unit,
onFinish: () -> Unit
) {
val spacing = LocalNephSpacing.current
val isFirstStep = stepNumber <= 1
+ val isLastStep = stepNumber >= totalSteps
val progress = stepNumber.toFloat() / totalSteps.toFloat()
Box(modifier = Modifier.fillMaxSize()) {
@@ -145,16 +147,28 @@ fun MobileOnboardingGuide(
Spacer(modifier = Modifier.weight(1f))
if (!isOnTargetRoute || !step.usesExistingTarget) {
Button(
- onClick = if (!isOnTargetRoute) onNavigateToStep else onFinish,
+ onClick = when {
+ !isOnTargetRoute -> onNavigateToStep
+ isLastStep -> onFinish
+ else -> onContinue
+ },
modifier = Modifier.testTag(
if (!isOnTargetRoute) {
"mobile_onboarding_take_me_there"
- } else {
+ } else if (isLastStep) {
"mobile_onboarding_finish"
+ } else {
+ "mobile_onboarding_continue"
}
)
) {
- Text(if (!isOnTargetRoute) "Go there" else "Finish")
+ Text(
+ when {
+ !isOnTargetRoute -> "Go there"
+ isLastStep -> "Finish"
+ else -> "Continue"
+ }
+ )
}
}
}
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 cd9f5926..fd67b8c7 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
@@ -60,6 +60,7 @@ 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
@@ -212,6 +213,25 @@ 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(
@@ -533,7 +553,8 @@ fun RequestHelpScreen(
onNavigateToLogin: () -> Unit,
onNavigateToMyHelpRequests: () -> Unit,
mobileOnboardingStepId: MobileOnboardingStepId? = null,
- onMobileOnboardingStepCompleted: (String?) -> Unit = {}
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {},
+ onMobileOnboardingPracticeHelpRequestChanged: (MobileOnboardingPracticeHelpRequest?) -> Unit = {}
) {
val spacing = LocalNephSpacing.current
val scope = rememberCoroutineScope()
@@ -908,6 +929,7 @@ fun RequestHelpScreen(
errorMessage = ""
infoMessage = "Practice complete. No real help request was saved."
mapActionMessage = ""
+ onMobileOnboardingPracticeHelpRequestChanged(formState.toMobileOnboardingPracticeHelpRequest())
completeRequestHelpOnboardingStep(MobileOnboardingStepId.REQUEST_HELP_SEND)
return
}
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 01f5f967..0a45405e 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -38,6 +38,7 @@ 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
@@ -47,7 +48,9 @@ fun AppNavGraph(
onRestartMobileOnboarding: () -> Unit = {},
mobileOnboardingStepId: MobileOnboardingStepId? = null,
onMobileOnboardingStepCompleted: (String?) -> Unit = {},
- onMobileOnboardingFeedbackChanged: (String?) -> Unit = {}
+ onMobileOnboardingFeedbackChanged: (String?) -> Unit = {},
+ mobileOnboardingPracticeHelpRequest: MobileOnboardingPracticeHelpRequest? = null,
+ onMobileOnboardingPracticeHelpRequestChanged: (MobileOnboardingPracticeHelpRequest?) -> Unit = {}
) {
val verifyEmailRouteWithToken = "${Routes.VerifyEmail.route}?token={token}"
val accessToken by AuthSessionStore.accessTokenFlow.collectAsState()
@@ -198,7 +201,10 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted,
+ mobileOnboardingPracticeHelpRequest = mobileOnboardingPracticeHelpRequest
)
}
@@ -485,7 +491,8 @@ fun AppNavGraph(
navigateToDrawerRoute(Routes.MyHelpRequests.route)
},
mobileOnboardingStepId = mobileOnboardingStepId,
- onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted,
+ onMobileOnboardingPracticeHelpRequestChanged = onMobileOnboardingPracticeHelpRequestChanged
)
}
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
index 2d2b05d0..95acdf08 100644
--- 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
@@ -54,6 +54,7 @@ class MobileOnboardingKeysTest {
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
@@ -61,6 +62,7 @@ class MobileOnboardingKeysTest {
authenticatedSteps.map { it.id }
)
assertEquals(Routes.RequestHelp.route, authenticatedSteps[1].route)
+ assertEquals(Routes.MyHelpRequests.route, authenticatedSteps[5].route)
assertEquals(Routes.AssignedRequest.route, authenticatedSteps.last().route)
}
From d2d2fa4e9866b66480c5a95521cb772fe878aa7e Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 13:39:46 +0300
Subject: [PATCH 09/41] feat: extend mobile onboarding tutorial to cover all
app navigation screens
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 63 ++++
.../presentation/EmergencyInfoScreen.kt | 9 +-
.../presentation/GatheringAreasScreen.kt | 9 +-
.../presentation/HelpRequestMapScreen.kt | 9 +-
.../presentation/NearbyVisibleUsersScreen.kt | 9 +-
.../presentation/NotificationsScreen.kt | 9 +-
.../data/MobileOnboardingJourney.kt | 282 ++++++++++++++++--
.../presentation/MobileOnboardingTutorial.kt | 71 ++++-
.../presentation/SafetyCirclesScreen.kt | 9 +-
.../settings/presentation/SettingsScreen.kt | 9 +-
.../java/com/neph/navigation/AppNavGraph.kt | 28 +-
.../com/neph/ui/layout/AppDrawerScaffold.kt | 60 +++-
.../data/MobileOnboardingKeysTest.kt | 28 +-
13 files changed, 525 insertions(+), 70 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index e4617b7f..0033e7b6 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -111,6 +111,49 @@ class MobileOnboardingAndroidE2ETest {
waitForText("There is no assigned request for you right now.")
waitForText("No assigned request right now.")
+ 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")
@@ -120,6 +163,26 @@ class MobileOnboardingAndroidE2ETest {
waitUntilTagGone("mobile_onboarding_dialog")
}
+
+ 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 {
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 31710727..c116ff44 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
@@ -32,6 +32,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
@@ -119,7 +120,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()
@@ -358,7 +361,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/presentation/HelpRequestMapScreen.kt b/android/app/src/main/java/com/neph/features/helprequestmap/presentation/HelpRequestMapScreen.kt
index 16e68e74..e6b4c059 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
@@ -41,6 +41,7 @@ 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.navigation.Routes
import com.neph.ui.components.buttons.SecondaryButton
import com.neph.ui.components.buttons.TextActionButton
@@ -179,7 +180,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
@@ -372,7 +375,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)
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..0e40fcae 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
@@ -34,6 +34,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
@@ -58,7 +59,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 +231,9 @@ fun NearbyVisibleUsersScreen(
profileBadgeText = profileBadgeText,
profileLabel = "Profile",
contentMaxWidth = 560.dp,
- contentAlignment = Alignment.TopCenter
+ contentAlignment = Alignment.TopCenter,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
) {
Column(
modifier = Modifier.fillMaxWidth(),
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 53241ebf..8de83fc0 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
@@ -29,6 +29,7 @@ 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
@@ -46,7 +47,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()
@@ -100,7 +103,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(),
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
index f2b03a76..615926f3 100644
--- 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
@@ -11,13 +11,28 @@ enum class MobileOnboardingStepId {
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,
- EMERGENCY_NUMBERS,
- NEWS,
- VOLUNTEER_AVAILABILITY,
- ASSIGNED_REQUESTS,
- PROFILE_PRIVACY
+ OPEN_SAFETY_CIRCLES_MENU,
+ SELECT_SAFETY_CIRCLES,
+ SAFETY_CIRCLES,
+ OPEN_NOTIFICATIONS_MENU,
+ SELECT_NOTIFICATIONS,
+ NOTIFICATIONS,
+ OPEN_SETTINGS_MENU,
+ SELECT_SETTINGS,
+ SETTINGS
}
enum class MobileOnboardingPanelPlacement {
@@ -37,7 +52,9 @@ data class MobileOnboardingStep(
val completionMessage: String? = null,
val usesExistingTarget: Boolean = false,
val panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.BOTTOM,
- val authenticatedOnly: Boolean = false
+ val authenticatedOnly: Boolean = false,
+ val opensMenu: Boolean = false,
+ val menuTargetRoute: String? = null
)
object MobileOnboardingJourney {
@@ -111,44 +128,178 @@ object MobileOnboardingJourney {
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.TOP
+ panelPlacement = MobileOnboardingPanelPlacement.BOTTOM
),
- MobileOnboardingStep(
+ menuOpenStep(
id = MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU,
route = Routes.MyHelpRequests.route,
- title = "Open the Menu",
- eyebrow = "Find more app areas",
- description = "The bottom Menu opens the rest of the app sections you can use during coordination.",
- actionLabel = "Tap Menu",
- targetHint = "Open the menu to find Assigned Request.",
- completionMessage = "You opened the app menu.",
- usesExistingTarget = true,
- panelPlacement = MobileOnboardingPanelPlacement.TOP,
- authenticatedOnly = true
+ nextPageName = "Assigned Request",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER
),
- MobileOnboardingStep(
+ menuSelectStep(
id = MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST,
route = Routes.MyHelpRequests.route,
+ target = Routes.AssignedRequest,
title = "Assigned Request",
- eyebrow = "Check assigned work",
description = "Assigned Request shows the emergency support request currently assigned to you, if there is one.",
- actionLabel = "Tap Assigned Request",
- targetHint = "Open this page to see whether NEPH currently assigned you a request.",
- completionMessage = "You opened Assigned Request.",
- usesExistingTarget = true,
- panelPlacement = MobileOnboardingPanelPlacement.TOP,
- authenticatedOnly = true
+ completionMessage = "You opened Assigned Request."
),
- MobileOnboardingStep(
+ 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. The guide ends here.",
+ 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 continue using NEPH.",
- panelPlacement = MobileOnboardingPanelPlacement.CENTER,
- authenticatedOnly = true
+ targetHint = "Click Finish to close the guide and return Home.",
+ panelPlacement = MobileOnboardingPanelPlacement.CENTER
)
)
@@ -183,4 +334,75 @@ object MobileOnboardingJourney {
val currentIndex = available.indexOfFirst { it.id == currentId }.takeIf { it >= 0 } ?: 0
return currentIndex + 1 to available.size
}
+
+ 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 = "Review this page, then continue to the next section.",
+ 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/presentation/MobileOnboardingTutorial.kt b/android/app/src/main/java/com/neph/features/onboarding/presentation/MobileOnboardingTutorial.kt
index fb20799e..bf1bf3b9 100644
--- 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
@@ -1,13 +1,16 @@
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
@@ -16,13 +19,23 @@ 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(
@@ -38,17 +51,46 @@ fun MobileOnboardingGuide(
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)
+ }
- Box(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier
.align(step.panelPlacement.panelAlignment())
+ .offset { IntOffset(x = 0, y = dragOffsetPx.roundToInt()) }
.fillMaxWidth()
.padding(16.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,
@@ -184,3 +226,30 @@ private fun MobileOnboardingPanelPlacement.panelAlignment(): Alignment {
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/safetycircles/presentation/SafetyCirclesScreen.kt b/android/app/src/main/java/com/neph/features/safetycircles/presentation/SafetyCirclesScreen.kt
index 42fc053f..65cfc402 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
@@ -35,6 +35,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
@@ -52,7 +53,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 +285,9 @@ fun SafetyCirclesScreen(
profileBadgeText = profileBadgeText,
profileLabel = "Profile",
contentMaxWidth = 560.dp,
- contentAlignment = Alignment.TopCenter
+ contentAlignment = Alignment.TopCenter,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
) {
Column(
modifier = Modifier.fillMaxWidth(),
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 4903813a..97a6e71d 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
@@ -29,6 +29,7 @@ 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
@@ -48,7 +49,9 @@ fun SettingsScreen(
onNavigateToPrivacySecurity: () -> Unit,
onLogout: () -> Unit,
onAccountDeleted: () -> Unit,
- onRestartMobileOnboarding: () -> Unit
+ onRestartMobileOnboarding: () -> Unit,
+ mobileOnboardingStepId: MobileOnboardingStepId? = null,
+ onMobileOnboardingStepCompleted: (String?) -> Unit = {}
) {
val spacing = LocalNephSpacing.current
val coroutineScope = rememberCoroutineScope()
@@ -115,7 +118,9 @@ fun SettingsScreen(
drawerItems = Routes.authenticatedDrawerItems,
onProfileClick = onProfileClick,
profileBadgeText = profileBadgeText,
- profileLabel = "Profile"
+ profileLabel = "Profile",
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
) {
Column(
modifier = Modifier.fillMaxWidth(),
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 0a45405e..2b605505 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -292,7 +292,9 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -315,7 +317,9 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -338,7 +342,9 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -363,7 +369,9 @@ fun AppNavGraph(
onNavigateToLogin = {
navigateToLogin()
},
- profileBadgeText = profileBadgeText
+ profileBadgeText = profileBadgeText,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -386,7 +394,9 @@ fun AppNavGraph(
}
},
profileBadgeText = profileBadgeText,
- isAuthenticated = authenticated
+ isAuthenticated = authenticated,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -411,7 +421,9 @@ fun AppNavGraph(
onNavigateToLogin = {
navigateToLogin()
},
- profileBadgeText = profileBadgeText
+ profileBadgeText = profileBadgeText,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
@@ -447,7 +459,9 @@ fun AppNavGraph(
launchSingleTop = true
}
},
- onRestartMobileOnboarding = onRestartMobileOnboarding
+ onRestartMobileOnboarding = onRestartMobileOnboarding,
+ mobileOnboardingStepId = mobileOnboardingStepId,
+ onMobileOnboardingStepCompleted = onMobileOnboardingStepCompleted
)
}
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 c583a109..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
@@ -114,8 +115,10 @@ fun AppDrawerScaffold(
val effectiveAlertCount = maxOf(alertCount, badgeUnread)
var menuOpen by remember { mutableStateOf(false) }
- val isOpenMenuOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU
- val isSelectAssignedOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST
+ val activeMobileOnboardingStep = mobileOnboardingStepId?.let { stepId ->
+ MobileOnboardingJourney.stepFor(stepId, isAuthenticated = true)
+ }
+ val isOpenMenuOnboardingTarget = activeMobileOnboardingStep?.opensMenu == true
val isMobileOnboardingActive = mobileOnboardingStepId != null
fun completeMobileOnboardingStep(stepId: MobileOnboardingStepId) {
@@ -171,7 +174,7 @@ fun AppDrawerScaffold(
menuOpen = !menuOpen
if (isOpenMenuOnboardingTarget) {
- completeMobileOnboardingStep(MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU)
+ completeMobileOnboardingStep(mobileOnboardingStepId)
}
},
modifier = Modifier
@@ -371,7 +374,10 @@ private fun BoxScope.MenuOverlay(
mobileOnboardingStepId: MobileOnboardingStepId?,
onMobileOnboardingStepCompleted: (MobileOnboardingStepId) -> Unit
) {
- val isSelectAssignedOnboardingTarget = mobileOnboardingStepId == MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST
+ val activeMobileOnboardingStep = mobileOnboardingStepId?.let { stepId ->
+ MobileOnboardingJourney.stepFor(stepId, isAuthenticated = true)
+ }
+ val mobileOnboardingMenuTargetRoute = activeMobileOnboardingStep?.menuTargetRoute
val isMobileOnboardingActive = mobileOnboardingStepId != null
// Translucent scrim — taps outside dismiss
@@ -406,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)
@@ -416,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)
@@ -458,27 +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 = {
- if (isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest) {
- onMobileOnboardingStepCompleted(MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST)
+ if (isMobileOnboardingMenuTarget) {
+ onMobileOnboardingStepCompleted(mobileOnboardingStepId)
}
onSelect(item.route)
},
- enabled = !isMobileOnboardingActive ||
- (isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest),
+ enabled = !isMobileOnboardingActive || isMobileOnboardingMenuTarget,
modifier = Modifier
.then(
- if (item == Routes.AssignedRequest) {
- Modifier.testTag("mobile_onboarding_target_assigned_request_menu")
+ if (isMobileOnboardingMenuTarget) {
+ Modifier.testTag(mobileOnboardingMenuTargetTag(item.route))
} else {
Modifier
}
)
- .mobileOnboardingPulse(isSelectAssignedOnboardingTarget && item == Routes.AssignedRequest),
+ .mobileOnboardingPulse(isMobileOnboardingMenuTarget),
spacing = spacing
)
}
@@ -488,12 +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,
- enabled = !isMobileOnboardingActive,
+ 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
)
}
@@ -551,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/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt b/android/app/src/test/java/com/neph/features/onboarding/data/MobileOnboardingKeysTest.kt
index 95acdf08..45ce8454 100644
--- 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
@@ -40,7 +40,7 @@ class MobileOnboardingKeysTest {
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.PROFILE_PRIVACY })
+ assertFalse(guestSteps.any { it.id == MobileOnboardingStepId.SETTINGS })
}
@Test
@@ -57,13 +57,35 @@ class MobileOnboardingKeysTest {
MobileOnboardingStepId.MY_HELP_REQUESTS,
MobileOnboardingStepId.OPEN_ASSIGNED_REQUESTS_MENU,
MobileOnboardingStepId.SELECT_ASSIGNED_REQUEST,
- MobileOnboardingStepId.ASSIGNED_REQUESTS
+ 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.AssignedRequest.route, authenticatedSteps.last().route)
+ assertEquals(Routes.Settings.route, authenticatedSteps.last().route)
+ assertEquals(Routes.AssignedRequest.route, authenticatedSteps[8].route)
}
@Test
From 101395a6f6d4dc199fda3ffa3d38e2a809167fa4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 13:51:16 +0300
Subject: [PATCH 10/41] feat: update request help health sharing consent
---
.../com/neph/core/database/NephDatabase.kt | 14 ++++-
.../com/neph/core/database/OfflineEntities.kt | 5 ++
.../data/AssignedRequestRepository.kt | 12 ++++
.../presentation/AssignedRequestScreen.kt | 24 +++++++-
.../requesthelp/data/RequestHelpRepository.kt | 14 +++--
.../presentation/RequestHelpScreen.kt | 55 +++++--------------
.../data/RequestHelpOfflineMappingTest.kt | 9 ++-
...dd_help_request_health_sharing_consent.sql | 2 +
.../src/modules/availability/repository.js | 8 ++-
.../src/modules/help-requests/repository.js | 14 +++--
.../src/modules/help-requests/validators.js | 29 ++++++++--
.../help-requests.integration.test.js | 22 ++++----
.../modules/help-requests/service.test.js | 6 +-
.../modules/help-requests/validators.test.js | 21 +++++--
infra/docker/postgres/init.sql | 1 +
15 files changed, 155 insertions(+), 81 deletions(-)
create mode 100644 backend/migrations/20260510_120000__add_help_request_health_sharing_consent.sql
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/features/assignedrequest/data/AssignedRequestRepository.kt b/android/app/src/main/java/com/neph/features/assignedrequest/data/AssignedRequestRepository.kt
index 7857b600..82b1be0c 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,
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..b7501767 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
@@ -340,8 +340,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/requesthelp/data/RequestHelpRepository.kt b/android/app/src/main/java/com/neph/features/requesthelp/data/RequestHelpRepository.kt
index 469382f8..e8af220b 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
@@ -616,7 +616,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 +699,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 +744,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 +810,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/presentation/RequestHelpScreen.kt b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt
index ab120770..91355538 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
@@ -50,9 +50,7 @@ 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.requesthelp.data.RequestHelpContactSubmission
@@ -91,13 +89,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 +116,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 +157,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 +178,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(),
@@ -208,12 +195,11 @@ 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 +342,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() },
@@ -944,21 +930,11 @@ 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)
)
},
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,7 +958,7 @@ fun RequestHelpScreen(
)
AppMultiSelectChipGroup(
- label = "Risk Flags",
+ label = "Risk Flags (optional)",
options = riskFlagOptions,
selectedOptions = formState.riskFlags,
onOptionToggle = {
@@ -993,7 +969,7 @@ fun RequestHelpScreen(
)
AppMultiSelectChipGroup(
- label = "Vulnerable Groups",
+ label = "Vulnerable Groups (optional)",
options = vulnerableGroupOptions,
selectedOptions = formState.vulnerableGroups,
onOptionToggle = {
@@ -1011,13 +987,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."
)
}
}
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/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..46f06db7 100644
--- a/backend/src/modules/availability/repository.js
+++ b/backend/src/modules/availability/repository.js
@@ -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/tests/integration/modules/help-requests/help-requests.integration.test.js b/backend/tests/integration/modules/help-requests/help-requests.integration.test.js
index aba3a358..ea5c5ea4 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);
@@ -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 () => {
@@ -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;
@@ -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', 'food_water'] }));
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', '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/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,
From aba162865ead7aaded73a0a68393768ecf82898f Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 14:01:42 +0300
Subject: [PATCH 11/41] fix: streamline onboarding completion, improve progress
calculation, and add skipping logic to E2E tests
---
.../e2e/MobileOnboardingAndroidE2ETest.kt | 110 +++++++++++-------
.../src/main/java/com/neph/MainActivity.kt | 38 +++---
.../data/MobileOnboardingJourney.kt | 12 +-
.../presentation/MobileOnboardingTutorial.kt | 43 +++----
.../data/MobileOnboardingKeysTest.kt | 16 +++
5 files changed, 128 insertions(+), 91 deletions(-)
diff --git a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
index 0033e7b6..19b5cf0c 100644
--- a/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
+++ b/android/app/src/androidTest/java/com/neph/e2e/MobileOnboardingAndroidE2ETest.kt
@@ -65,51 +65,7 @@ class MobileOnboardingAndroidE2ETest {
@Test
fun pendingAuthenticatedUser_canFollowGuidedCoreConcepts_once() {
- 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_fire_brigade")
-
- composeRule.onNodeWithTag("mobile_onboarding_target_fire_brigade").performClick()
- waitForGuideTitle("Risk Flags")
- waitForText("You selected Fire Brigade")
- 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("Fire Brigade")
-
- 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.")
+ reachAssignedRequestsPage()
continueThroughDrawerPage(
pageTitle = "Emergency Numbers",
@@ -163,6 +119,70 @@ class MobileOnboardingAndroidE2ETest {
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_fire_brigade")
+
+ composeRule.onNodeWithTag("mobile_onboarding_target_fire_brigade").performClick()
+ waitForGuideTitle("Risk Flags")
+ waitForText("You selected Fire Brigade")
+ 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("Fire Brigade")
+
+ 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,
diff --git a/android/app/src/main/java/com/neph/MainActivity.kt b/android/app/src/main/java/com/neph/MainActivity.kt
index 21f29b2b..d6731262 100644
--- a/android/app/src/main/java/com/neph/MainActivity.kt
+++ b/android/app/src/main/java/com/neph/MainActivity.kt
@@ -211,16 +211,24 @@ fun NephApp() {
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) {
- MobileOnboardingStore.markSeenForCurrentUser()
- showMobileOnboarding = false
- activeMobileOnboardingStepId = null
- mobileOnboardingFeedback = null
- mobileOnboardingPracticeHelpRequest = null
+ closeMobileOnboardingAndReturnHome()
} else {
setMobileOnboardingStep(nextStep.id)
}
@@ -333,24 +341,8 @@ fun NephApp() {
mobileOnboardingFeedback = null
}
},
- onSkip = {
- MobileOnboardingStore.markSeenForCurrentUser()
- showMobileOnboarding = false
- activeMobileOnboardingStepId = null
- mobileOnboardingFeedback = null
- mobileOnboardingPracticeHelpRequest = null
- },
- onFinish = {
- MobileOnboardingStore.markSeenForCurrentUser()
- showMobileOnboarding = false
- activeMobileOnboardingStepId = null
- mobileOnboardingFeedback = null
- mobileOnboardingPracticeHelpRequest = null
- navController.navigate(Routes.Home.route) {
- popUpTo(navController.graph.id) { inclusive = true }
- launchSingleTop = true
- }
- }
+ onSkip = ::closeMobileOnboardingAndReturnHome,
+ onFinish = ::closeMobileOnboardingAndReturnHome
)
}
}
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
index 615926f3..2b3f5eb6 100644
--- 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
@@ -332,7 +332,15 @@ object MobileOnboardingJourney {
fun progressFor(currentId: MobileOnboardingStepId, isAuthenticated: Boolean): Pair {
val available = availableSteps(isAuthenticated)
val currentIndex = available.indexOfFirst { it.id == currentId }.takeIf { it >= 0 } ?: 0
- return currentIndex + 1 to available.size
+ 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(
@@ -388,7 +396,7 @@ object MobileOnboardingJourney {
eyebrow: String,
description: String,
actionLabel: String = "Continue",
- targetHint: String = "Review this page, then continue to the next section.",
+ targetHint: String = "",
completionMessage: String? = null,
panelPlacement: MobileOnboardingPanelPlacement = MobileOnboardingPanelPlacement.CENTER
): MobileOnboardingStep {
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
index bf1bf3b9..7ff4353b 100644
--- 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
@@ -79,7 +79,7 @@ fun MobileOnboardingGuide(
.align(step.panelPlacement.panelAlignment())
.offset { IntOffset(x = 0, y = dragOffsetPx.roundToInt()) }
.fillMaxWidth()
- .padding(16.dp)
+ .padding(horizontal = 28.dp, vertical = 12.dp)
.navigationBarsPadding()
.onGloballyPositioned { coordinates ->
panelHeightPx = coordinates.size.height.toFloat()
@@ -98,10 +98,10 @@ fun MobileOnboardingGuide(
shadowElevation = 8.dp
) {
Column(
- modifier = Modifier.padding(spacing.lg),
- verticalArrangement = Arrangement.spacedBy(spacing.md)
+ modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm),
+ verticalArrangement = Arrangement.spacedBy(spacing.sm)
) {
- Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "NEPH Guide",
style = MaterialTheme.typography.labelLarge,
@@ -110,12 +110,12 @@ fun MobileOnboardingGuide(
Text(
text = step.title,
modifier = Modifier.testTag("mobile_onboarding_title"),
- style = MaterialTheme.typography.headlineSmall,
+ style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = step.eyebrow,
- style = MaterialTheme.typography.titleSmall,
+ style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -130,8 +130,8 @@ fun MobileOnboardingGuide(
) {
Text(
text = message,
- modifier = Modifier.padding(spacing.md),
- style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(spacing.sm),
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
@@ -139,22 +139,23 @@ fun MobileOnboardingGuide(
Text(
text = step.description,
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Text(
- text = if (isOnTargetRoute && step.usesExistingTarget) {
- step.actionLabel
- } else if (!isOnTargetRoute) {
- "Go to ${step.title}"
- } else {
- step.targetHint
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.testTag("mobile_onboarding_instruction")
- )
+ 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 },
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
index 45ce8454..3513b8ba 100644
--- 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
@@ -88,6 +88,22 @@ class MobileOnboardingKeysTest {
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(
From 3f6aa52ed218328879699a0277d3cbb141ed7c12 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 14:02:53 +0300
Subject: [PATCH 12/41] test: fix request help and gathering area e2e fixtures
---
.../help-requests.integration.test.js | 30 +++++++++----------
web/src/app/gathering-areas/page.tsx | 7 +++--
2 files changed, 20 insertions(+), 17 deletions(-)
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 ea5c5ea4..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
@@ -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')
@@ -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;
@@ -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', 'food_water'] }));
+ .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', 'food_water'],
+ 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/web/src/app/gathering-areas/page.tsx b/web/src/app/gathering-areas/page.tsx
index 1df10a52..af9ac8d8 100644
--- a/web/src/app/gathering-areas/page.tsx
+++ b/web/src/app/gathering-areas/page.tsx
@@ -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;
@@ -433,7 +434,7 @@ export default function GatheringAreasPage() {
return;
}
- setInfoMessage("Resolving your current location...");
+ setInfoMessage(CurrentLocationResolvingMessage);
navigator.geolocation.getCurrentPosition(
(position) => {
@@ -505,7 +506,9 @@ export default function GatheringAreasPage() {
setLastFetchedViewportKey(null);
setError("");
setFetchState("idle");
- setInfoMessage(ResourceZoomedOutMessage);
+ setInfoMessage((current) =>
+ current === CurrentLocationResolvingMessage ? current : ResourceZoomedOutMessage
+ );
}, []);
React.useEffect(() => {
From 32aa666de0e33f7df19249ba0c50fb24aed8d58b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 14:08:35 +0300
Subject: [PATCH 13/41] fix: recognize search rescue help requests
---
backend/src/modules/availability/repository.js | 2 +-
web/src/app/gathering-areas/page.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/src/modules/availability/repository.js b/backend/src/modules/availability/repository.js
index 46f06db7..e7a9e099 100644
--- a/backend/src/modules/availability/repository.js
+++ b/backend/src/modules/availability/repository.js
@@ -12,7 +12,7 @@ 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 SEARCH_AND_RESCUE_HELP_TYPES = new Set(['search_rescue', 'search_and_rescue', 'sar', 'rescue']);
const SUPPLIES_HELP_TYPES = new Set(['food', 'water', 'basic_supplies', 'supplies']);
const SHELTER_HELP_TYPES = new Set(['shelter']);
const FIRST_AID_EXPERTISE_MARKERS = new Set(['first_aid', 'medical']);
diff --git a/web/src/app/gathering-areas/page.tsx b/web/src/app/gathering-areas/page.tsx
index af9ac8d8..e49b3ee6 100644
--- a/web/src/app/gathering-areas/page.tsx
+++ b/web/src/app/gathering-areas/page.tsx
@@ -797,7 +797,7 @@ export default function GatheringAreasPage() {
{isInitialState ? (
-
{ResourceInitialMessage}
+
{infoMessage || ResourceInitialMessage}
) : null}
From c8ae50fe2fd4bf778ca7bb2a6d97cbc533a3b17a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 14:11:16 +0300
Subject: [PATCH 14/41] fix: preserve legacy availability matching tokens
---
backend/src/modules/availability/repository.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/src/modules/availability/repository.js b/backend/src/modules/availability/repository.js
index e7a9e099..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_rescue', 'search_and_rescue', 'sar', '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;
From c99cbb800bdba3a79eb07585e0e91a961147f317 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 14:15:37 +0300
Subject: [PATCH 15/41] fix: avoid duplicate gathering areas status text
---
web/src/app/gathering-areas/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/app/gathering-areas/page.tsx b/web/src/app/gathering-areas/page.tsx
index e49b3ee6..2a450581 100644
--- a/web/src/app/gathering-areas/page.tsx
+++ b/web/src/app/gathering-areas/page.tsx
@@ -711,7 +711,7 @@ export default function GatheringAreasPage() {
) : (
- {infoMessage || ResourceInitialMessage}
+ {ResourceInitialMessage}
)}
From 7330360d3b6ca75865845bf3933750974068f8e0 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 14:24:31 +0300
Subject: [PATCH 16/41] fix: resolve merge conflicts
---
.../presentation/HelpRequestMapScreen.kt | 357 +++++++++++++-----
1 file changed, 272 insertions(+), 85 deletions(-)
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 e6b4c059..85cead91 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
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -42,6 +43,8 @@ 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
import com.neph.ui.components.buttons.SecondaryButton
import com.neph.ui.components.buttons.TextActionButton
@@ -49,11 +52,13 @@ import com.neph.ui.components.display.HelperText
import com.neph.ui.components.display.SectionCard
import com.neph.ui.components.display.SectionHeader
import com.neph.ui.layout.AppDrawerScaffold
+import com.neph.ui.location.rememberForegroundLocationPermissionRequester
import com.neph.ui.map.LeafletMapInitializationTimeoutMessage
import com.neph.ui.map.LeafletMapInitializationTimeoutMillis
import com.neph.ui.map.LeafletMapMarker
import com.neph.ui.map.LeafletMapViewport
import com.neph.ui.map.LeafletMarkerMap
+import com.neph.ui.map.MapLocationControl
import com.neph.ui.map.NephMapIntegration
import com.neph.ui.map.effectiveLeafletViewportKey
import com.neph.ui.map.isLeafletMapInitializedForInstance
@@ -66,20 +71,25 @@ import com.neph.ui.map.newLeafletMapInstanceId
import com.neph.ui.map.shouldApplyLeafletMapError
import com.neph.ui.map.shouldApplyLeafletMapTimeout
import com.neph.ui.map.shouldClearLeafletMapErrorForSignal
+import com.neph.ui.map.shouldFetchLeafletViewport
import com.neph.ui.theme.LocalNephSpacing
import com.neph.ui.theme.NephTheme
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
private const val HelpRequestMapHeightCssPx = 280
private const val TurkeyOverviewLatitude = 39.0
private const val TurkeyOverviewLongitude = 35.0
private const val TurkeyOverviewZoom = 5
+internal const val HelpRequestMapCurrentLocationZoom = 15
private const val ResourceInitialMessage = "Zoom in or use your device location to see resources in this area."
private const val ResourceZoomedOutMessage = "Zoom in to see resources in this area."
private const val ResourceLoadingMessage = "Loading resources in this area..."
+private const val ResourceUpdatingMessage = "Updating visible area..."
private const val ResourceEmptyMessage = "No resources were found in this visible area."
private const val ResourceErrorMessage = "Resources could not be loaded for this area. Please try again."
+private const val ResourceFilterEmptyMessage = "No help requests match the selected request type filters."
private val RequestTypeFilterOrder = listOf(
CrisisRequestType.FIRST_AID,
@@ -167,6 +177,38 @@ internal fun helpRequestMapInstanceKey(
)
}
+internal fun helpRequestMapEmptyMessage(
+ blockingLoading: Boolean,
+ errorMessage: String,
+ currentViewport: LeafletMapViewport?,
+ isFilterEmpty: Boolean,
+ hasFetchedViewport: Boolean
+): String {
+ return when {
+ blockingLoading -> ResourceLoadingMessage
+ errorMessage.isNotBlank() -> "Request markers are unavailable for this area."
+ currentViewport == null -> ResourceInitialMessage
+ !isLeafletViewportDiscoverable(currentViewport) -> ResourceZoomedOutMessage
+ isFilterEmpty -> "No request markers to display with these filters."
+ hasFetchedViewport -> "No request markers to display in this visible area."
+ else -> ResourceInitialMessage
+ }
+}
+
+internal fun shouldShowPreviousHelpRequestsDuringViewportFetch(
+ requests: List,
+ manualRefresh: Boolean
+): Boolean {
+ 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() &&
@@ -186,8 +228,10 @@ fun HelpRequestMapScreen(
) {
val spacing = LocalNephSpacing.current
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
var loading by remember { mutableStateOf(false) }
+ var backgroundUpdating by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var infoMessage by remember { mutableStateOf("") }
var requests by remember { mutableStateOf(emptyList()) }
@@ -198,6 +242,10 @@ fun HelpRequestMapScreen(
var lastFetchedViewportKey by remember { mutableStateOf(null) }
var viewportRefreshNonce by remember { mutableStateOf(0) }
var viewportRequestSerial by remember { mutableStateOf(0) }
+ var mapCenterLatitude by remember { mutableStateOf(TurkeyOverviewLatitude) }
+ var mapCenterLongitude by remember { mutableStateOf(TurkeyOverviewLongitude) }
+ var mapZoom by remember { mutableStateOf(TurkeyOverviewZoom) }
+ var mapResetNonce by remember { mutableStateOf(0) }
fun applyRequestResult(result: ActiveHelpRequestsResult, viewportKey: String?) {
requests = result.requests
@@ -220,7 +268,8 @@ fun HelpRequestMapScreen(
selectedRequestId = null
errorMessage = ""
loading = false
- infoMessage = ResourceZoomedOutMessage
+ backgroundUpdating = false
+ infoMessage = ""
return
}
errorMessage = ""
@@ -228,6 +277,53 @@ fun HelpRequestMapScreen(
viewportRefreshNonce += 1
}
+ fun requestCurrentLocationAndRefresh() {
+ scope.launch {
+ loading = true
+ backgroundUpdating = false
+ errorMessage = ""
+ infoMessage = ""
+
+ try {
+ val attempt = DeviceLocationProvider.captureCurrentLocationForSharing(
+ context = context,
+ sharingEnabled = true
+ )
+
+ val location = attempt.location
+ if (location != null) {
+ viewportRequestSerial += 1
+ currentViewport = null
+ pendingViewport = null
+ requests = emptyList()
+ mapCenterLatitude = location.latitude
+ mapCenterLongitude = location.longitude
+ mapZoom = HelpRequestMapCurrentLocationZoom
+ mapResetNonce += 1
+ selectedRequestId = null
+ lastFetchedViewportKey = null
+ loading = false
+ backgroundUpdating = false
+ return@launch
+ }
+
+ loading = false
+ infoMessage = when (attempt.warning) {
+ CurrentLocationShareWarning.PERMISSION_DENIED ->
+ "Location permission was denied. Help request map was not recentered."
+
+ CurrentLocationShareWarning.LOCATION_UNAVAILABLE,
+ null -> "Current location is unavailable. Help request map was not recentered."
+ }
+ } catch (cancellationException: CancellationException) {
+ throw cancellationException
+ } catch (_: Exception) {
+ loading = false
+ infoMessage = "Current location is unavailable. Help request map was not recentered."
+ }
+ }
+ }
+
fun openRequestInMap(item: ActiveHelpRequestMapItem) {
val opened = NephMapIntegration.openCoordinates(
context = context,
@@ -281,15 +377,21 @@ fun HelpRequestMapScreen(
val viewport = pendingViewport ?: return@LaunchedEffect
if (!isLeafletViewportDiscoverable(viewport)) return@LaunchedEffect
val viewportKey = effectiveLeafletViewportKey(viewport) ?: return@LaunchedEffect
- if (viewportKey == lastFetchedViewportKey && viewportRefreshNonce == 0) {
+ val manualRefresh = viewportRefreshNonce > 0
+ if (!shouldFetchLeafletViewport(viewportKey, lastFetchedViewportKey, manualRefresh)) {
return@LaunchedEffect
}
delay(450)
val requestSerial = viewportRequestSerial + 1
viewportRequestSerial = requestSerial
- loading = true
+ val blockingLoading = !shouldShowPreviousHelpRequestsDuringViewportFetch(
+ requests = requests,
+ manualRefresh = manualRefresh
+ )
+ loading = blockingLoading
+ backgroundUpdating = !blockingLoading
errorMessage = ""
- infoMessage = ResourceLoadingMessage
+ infoMessage = ""
try {
val result = ActiveHelpRequestsRepository.fetchWaitingHelpRequests(
@@ -303,24 +405,46 @@ fun HelpRequestMapScreen(
} catch (error: ApiException) {
if (requestSerial == viewportRequestSerial) {
errorMessage = error.message.ifBlank { ResourceErrorMessage }
- requests = emptyList()
- selectedRequestId = null
infoMessage = ""
+ if (requests.isEmpty()) {
+ selectedRequestId = null
+ }
}
} catch (_: Exception) {
if (requestSerial == viewportRequestSerial) {
errorMessage = ResourceErrorMessage
- requests = emptyList()
- selectedRequestId = null
infoMessage = ""
+ if (requests.isEmpty()) {
+ selectedRequestId = null
+ }
}
} finally {
if (requestSerial == viewportRequestSerial) {
loading = false
+ backgroundUpdating = false
}
}
}
+ val locationPermissionRequester = rememberForegroundLocationPermissionRequester { result ->
+ if (result.granted) {
+ requestCurrentLocationAndRefresh()
+ } else {
+ errorMessage = ""
+ loading = false
+ backgroundUpdating = false
+ infoMessage = "Location permission was denied. Help request map was not recentered."
+ }
+ }
+
+ fun showCurrentLocationOnMap() {
+ if (locationPermissionRequester.refreshPermissionState()) {
+ requestCurrentLocationAndRefresh()
+ } else {
+ locationPermissionRequester.requestPermission()
+ }
+ }
+
val visibleRequests = filterVisibleRequests(requests, selectedTypes)
LaunchedEffect(visibleRequests, selectedRequestId) {
@@ -329,6 +453,8 @@ fun HelpRequestMapScreen(
fun handleViewportChanged(viewport: LeafletMapViewport) {
currentViewport = viewport
+ mapCenterLatitude = viewport.centerLatitude
+ mapCenterLongitude = viewport.centerLongitude
if (isLeafletViewportDiscoverable(viewport)) {
errorMessage = ""
if (infoMessage == ResourceZoomedOutMessage) {
@@ -342,21 +468,20 @@ fun HelpRequestMapScreen(
lastFetchedViewportKey = null
errorMessage = ""
loading = false
- infoMessage = ResourceZoomedOutMessage
+ backgroundUpdating = false
+ infoMessage = ""
}
}
val selectedRequest = visibleRequests.firstOrNull { it.requestId == selectedRequestId }
val isFilterEmpty = !loading && requests.isNotEmpty() && visibleRequests.isEmpty()
- val mapEmptyMarkersMessage = when {
- loading -> ResourceLoadingMessage
- errorMessage.isNotBlank() -> ResourceErrorMessage
- currentViewport == null -> ResourceInitialMessage
- !isLeafletViewportDiscoverable(currentViewport) -> ResourceZoomedOutMessage
- isFilterEmpty -> "No help requests match the selected request type filters."
- lastFetchedViewportKey != null -> ResourceEmptyMessage
- else -> ResourceInitialMessage
- }
+ val mapEmptyMarkersMessage = helpRequestMapEmptyMessage(
+ blockingLoading = loading,
+ errorMessage = errorMessage,
+ currentViewport = currentViewport,
+ isFilterEmpty = isFilterEmpty,
+ hasFetchedViewport = lastFetchedViewportKey != null
+ )
AppDrawerScaffold(
title = "Help Request Map",
@@ -404,6 +529,13 @@ fun HelpRequestMapScreen(
selectedRequestId = selectedRequest?.requestId,
emptyMarkersMessage = mapEmptyMarkersMessage,
loadingResources = loading,
+ updatingResources = backgroundUpdating,
+ mapCenterLatitude = mapCenterLatitude,
+ mapCenterLongitude = mapCenterLongitude,
+ mapZoom = mapZoom,
+ mapResetToken = mapResetNonce,
+ onShowCurrentLocation = ::showCurrentLocationOnMap,
+ showCurrentLocationEnabled = !loading,
onViewportChanged = ::handleViewportChanged,
onSelectRequest = { selectedRequestId = it }
)
@@ -501,7 +633,7 @@ fun HelpRequestMapScreen(
if (isFilterEmpty) {
SectionCard {
- HelperText(text = "No help requests match the selected request type filters.")
+ HelperText(text = ResourceFilterEmptyMessage)
}
}
@@ -622,6 +754,7 @@ private fun RequestTypeFiltersCard(
}
@Composable
+@OptIn(ExperimentalLayoutApi::class)
private fun RequestDetails(
item: ActiveHelpRequestMapItem,
onOpenMap: () -> Unit,
@@ -659,9 +792,10 @@ private fun RequestDetails(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Row(
+ FlowRow(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End
+ horizontalArrangement = Arrangement.End,
+ verticalArrangement = Arrangement.spacedBy(spacing.xs)
) {
TextActionButton(
text = "Get Directions",
@@ -676,6 +810,7 @@ private fun RequestDetails(
}
@Composable
+@OptIn(ExperimentalLayoutApi::class)
private fun RequestListItem(
item: ActiveHelpRequestMapItem,
selected: Boolean,
@@ -703,31 +838,57 @@ private fun RequestListItem(
verticalArrangement = Arrangement.spacedBy(spacing.xs)
) {
Spacer(modifier = Modifier.height(spacing.sm))
- Row(
+ Column(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(spacing.md),
- verticalAlignment = Alignment.CenterVertically
+ verticalArrangement = Arrangement.spacedBy(spacing.sm)
) {
- PinGlyph(type = item.type)
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = item.typeLabel,
- style = MaterialTheme.typography.titleSmall,
- color = textColor,
- fontWeight = FontWeight.SemiBold
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing.md),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PinGlyph(type = item.type)
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.typeLabel,
+ style = MaterialTheme.typography.titleSmall,
+ color = textColor,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = "Priority: ${ActiveHelpRequestsRepository.formatPriority(item.priorityLevel)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ Text(
+ text = "${item.district}, ${item.city}",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalArrangement = Arrangement.spacedBy(spacing.xs)
+ ) {
+ TextActionButton(
+ text = "Get Directions",
+ onClick = onGetDirections
)
- Text(
- text = "Priority: ${ActiveHelpRequestsRepository.formatPriority(item.priorityLevel)} | ${item.district}",
- style = MaterialTheme.typography.bodySmall,
- color = if (selected) {
- MaterialTheme.colorScheme.onPrimaryContainer
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- }
+ TextActionButton(
+ text = "Open in Map",
+ onClick = onOpenMap
)
}
- TextActionButton(text = "Get Directions", onClick = onGetDirections)
- TextActionButton(text = "Open in Map", onClick = onOpenMap)
}
Spacer(modifier = Modifier.height(spacing.sm))
}
@@ -739,6 +900,13 @@ private fun CrisisRequestMapPanel(
selectedRequestId: String?,
emptyMarkersMessage: String = ResourceEmptyMessage,
loadingResources: Boolean = false,
+ updatingResources: Boolean = false,
+ mapCenterLatitude: Double = TurkeyOverviewLatitude,
+ mapCenterLongitude: Double = TurkeyOverviewLongitude,
+ mapZoom: Int = TurkeyOverviewZoom,
+ mapResetToken: Int = 0,
+ onShowCurrentLocation: (() -> Unit)? = null,
+ showCurrentLocationEnabled: Boolean = true,
onViewportChanged: (LeafletMapViewport) -> Unit,
onSelectRequest: (String) -> Unit
) {
@@ -748,7 +916,11 @@ private fun CrisisRequestMapPanel(
val errorInstanceIdState = remember { mutableStateOf(null) }
var mapError by remember { mutableStateOf("") }
val mapInstanceKey = helpRequestMapInstanceKey(requests)
- val mapInstanceId = remember(mapInstanceKey) {
+ 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) }
@@ -809,53 +981,64 @@ private fun CrisisRequestMapPanel(
SectionHeader(
title = "Live Help Request Map",
subtitle = if (markers.isEmpty()) {
- "No request markers match the selected filters."
+ "Map markers appear after a discoverable area loads."
} else {
"Tap a marker to preview that help request."
}
)
- LeafletMarkerMap(
- mapInstanceId = mapInstanceId,
- currentMapInstanceId = { currentMapInstanceIdState.value },
- centerLatitude = mapInstanceKey.centerLatitude,
- centerLongitude = mapInstanceKey.centerLongitude,
- markers = markers,
- selectedMarkerId = selectedRequestId,
- mapHeightCssPx = HelpRequestMapHeightCssPx,
- zoom = if (markers.isEmpty()) TurkeyOverviewZoom else 13,
- showCenterMarker = false,
- fitBoundsToMarkers = markers.isNotEmpty(),
- onMarkerSelected = { markerInstanceId, markerId ->
- if (markerInstanceId == currentMapInstanceIdState.value) {
- onSelectRequest(markerId)
- }
- },
- onMapReady = { initializedInstanceId, source ->
- markMapAlive(initializedInstanceId, source)
- },
- onMapError = { errorInstanceId, message ->
- if (
- shouldApplyLeafletMapError(
- activeInstanceId = errorInstanceId,
- currentInstanceId = currentMapInstanceIdState.value,
- tileLoadedInstanceId = tileLoadedInstanceIdState.value,
- errorMessage = message
- )
- ) {
- errorInstanceIdState.value = errorInstanceId
- mapError = message.ifBlank { "Map failed to load. Check your connection and try again." }
- }
- },
- onViewportChanged = { viewportInstanceId, viewport ->
- if (viewportInstanceId == currentMapInstanceIdState.value) {
- onViewportChanged(viewport)
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(HelpRequestMapHeightCssPx.dp)
- )
+ Box {
+ LeafletMarkerMap(
+ mapInstanceId = mapInstanceId,
+ currentMapInstanceId = { currentMapInstanceIdState.value },
+ centerLatitude = effectiveCenterLatitude,
+ centerLongitude = effectiveCenterLongitude,
+ markers = markers,
+ selectedMarkerId = selectedRequestId,
+ mapHeightCssPx = HelpRequestMapHeightCssPx,
+ zoom = effectiveZoom,
+ showCenterMarker = false,
+ fitBoundsToMarkers = shouldFitRequestMarkers,
+ onMarkerSelected = { markerInstanceId, markerId ->
+ if (markerInstanceId == currentMapInstanceIdState.value) {
+ onSelectRequest(markerId)
+ }
+ },
+ onMapReady = { initializedInstanceId, source ->
+ markMapAlive(initializedInstanceId, source)
+ },
+ onMapError = { errorInstanceId, message ->
+ if (
+ shouldApplyLeafletMapError(
+ activeInstanceId = errorInstanceId,
+ currentInstanceId = currentMapInstanceIdState.value,
+ tileLoadedInstanceId = tileLoadedInstanceIdState.value,
+ errorMessage = message
+ )
+ ) {
+ errorInstanceIdState.value = errorInstanceId
+ mapError = message.ifBlank { "Map failed to load. Check your connection and try again." }
+ }
+ },
+ onViewportChanged = { viewportInstanceId, viewport ->
+ if (viewportInstanceId == currentMapInstanceIdState.value) {
+ onViewportChanged(viewport)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(HelpRequestMapHeightCssPx.dp)
+ )
+ onShowCurrentLocation?.let { showCurrentLocation ->
+ MapLocationControl(
+ onClick = showCurrentLocation,
+ enabled = showCurrentLocationEnabled,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(spacing.sm)
+ )
+ }
+ }
if (!activeMapInitialized && activeMapError.isBlank()) {
HelperText(text = "Loading map...")
@@ -869,7 +1052,11 @@ private fun CrisisRequestMapPanel(
HelperText(text = ResourceLoadingMessage)
}
- if (markers.isEmpty()) {
+ if (updatingResources && markers.isNotEmpty()) {
+ HelperText(text = ResourceUpdatingMessage)
+ }
+
+ if (markers.isEmpty() && !loadingResources) {
HelperText(text = emptyMarkersMessage)
}
From bdc720692a342b9d66a7d5ce7208041820f4d1b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?G=C3=BClce=20Tahtas=C4=B1z?=
Date: Sun, 10 May 2026 14:48:24 +0300
Subject: [PATCH 17/41] feat:Refactor privacy settings and remove health
visibility control
---
.../presentation/PrivacySecurityScreen.kt | 12 +---
.../profile/presentation/ProfileScreen.kt | 4 --
web/e2e/profile-and-privacy.spec.js | 49 ++++++++-------
.../feature/auth/CompleteProfileForm.tsx | 46 ---------------
.../feature/profile/ProfileView.tsx | 59 -------------------
.../feature/settings/PrivacySecurityView.tsx | 24 +++-----
6 files changed, 37 insertions(+), 157 deletions(-)
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/web/e2e/profile-and-privacy.spec.js b/web/e2e/profile-and-privacy.spec.js
index 9e83bdb4..fbf731e8 100644
--- a/web/e2e/profile-and-privacy.spec.js
+++ b/web/e2e/profile-and-privacy.spec.js
@@ -72,50 +72,51 @@ 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 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('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();
@@ -133,9 +134,6 @@ test('persists real current-device metadata when sharing is enabled after fresh
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 +142,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 +155,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();
diff --git a/web/src/components/feature/auth/CompleteProfileForm.tsx b/web/src/components/feature/auth/CompleteProfileForm.tsx
index 20563910..5bf360cb 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,
});
@@ -821,18 +787,6 @@ export default function CompleteProfileForm() {
) : null}
-
- Share Current Location
-
-
- setForm({ ...form, shareLocation: value })
- }
- />
-
-
{error ? {error} : null}
diff --git a/web/src/components/feature/profile/ProfileView.tsx b/web/src/components/feature/profile/ProfileView.tsx
index 6f69400c..41da3fa5 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,
});
@@ -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.
From ebc7f2ee524a87588b86dadbadd102fc82b56fba Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 15:00:42 +0300
Subject: [PATCH 18/41] fix: prevent duplicate navigation stack entries by
checking for existing route before navigating to MyHelpRequests
---
android/app/src/main/java/com/neph/navigation/AppNavGraph.kt | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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..0ac0bc62 100644
--- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt
@@ -471,7 +471,10 @@ fun AppNavGraph(
navigateToLogin()
},
onNavigateToMyHelpRequests = {
- navigateToDrawerRoute(Routes.MyHelpRequests.route)
+ val popped = navController.popBackStack(Routes.MyHelpRequests.route, inclusive = false)
+ if (!popped) {
+ navigateToDrawerRoute(Routes.MyHelpRequests.route)
+ }
}
)
}
From 604730ef554b3c93384147f63b92c55eeb608c25 Mon Sep 17 00:00:00 2001
From: AlperKartkaya
Date: Sun, 10 May 2026 16:24:20 +0300
Subject: [PATCH 19/41] feat: add theme-aware logo support for web and update
Android app icons and notification configuration
---
android/app/src/main/AndroidManifest.xml | 6 ++++++
.../src/main/res/drawable/ic_notification.png | Bin 0 -> 68615 bytes
.../res/mipmap-night-xxxhdpi/ic_launcher.png | Bin 0 -> 307986 bytes
.../ic_launcher_round.png | Bin 0 -> 307986 bytes
.../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 68615 bytes
.../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 68615 bytes
web/public/dark_neph_logo.png | Bin 0 -> 499677 bytes
web/public/dark_neph_logo_only.png | Bin 0 -> 307986 bytes
web/public/neph_logo.png | Bin 149718 -> 429002 bytes
web/public/neph_logo_only.png | Bin 68615 -> 263815 bytes
.../components/feature/auth/AuthShowcase.tsx | 2 +-
web/src/components/layout/AuthLayout.tsx | 6 +++++-
web/src/components/layout/TopNavbar.tsx | 5 ++++-
13 files changed, 16 insertions(+), 3 deletions(-)
create mode 100644 android/app/src/main/res/drawable/ic_notification.png
create mode 100644 android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher.png
create mode 100644 android/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_round.png
create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
create mode 100644 web/public/dark_neph_logo.png
create mode 100644 web/public/dark_neph_logo_only.png
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/res/drawable/ic_notification.png b/android/app/src/main/res/drawable/ic_notification.png
new file mode 100644
index 0000000000000000000000000000000000000000..21471c877ef8718fd664048056c7d4aa132a6e00
GIT binary patch
literal 68615
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 |