P2 Stream F + Stream U fix: JoinResidenceScreen + Coil test compile fix
Stream F: Convert JoinResidenceDialog -> dedicated screen matching iOS JoinResidenceView. Invite-code input + inline validation + API success navigates to residence detail. Stream U fix: coil3 3.0.4 doesn't ship ColorImage (added in 3.1.0). Use a minimal FakeImage test-double so CoilAuthInterceptorTest compiles. Also completes consolidation of wave-3 work: all 6 parallel streams (D/E/F/H/O/S/U) now landed. Full unit suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
package com.tt.honeyDue.network
|
package com.tt.honeyDue.network
|
||||||
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import coil3.ColorImage
|
import coil3.Image
|
||||||
import coil3.PlatformContext
|
import coil3.PlatformContext
|
||||||
import coil3.decode.DataSource
|
import coil3.decode.DataSource
|
||||||
import coil3.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
@@ -48,11 +48,20 @@ class CoilAuthInterceptorTest {
|
|||||||
|
|
||||||
private fun makeSuccess(request: ImageRequest): SuccessResult =
|
private fun makeSuccess(request: ImageRequest): SuccessResult =
|
||||||
SuccessResult(
|
SuccessResult(
|
||||||
image = ColorImage(0xFF000000.toInt()),
|
image = FakeImage(),
|
||||||
request = request,
|
request = request,
|
||||||
dataSource = DataSource.NETWORK
|
dataSource = DataSource.NETWORK
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Minimal coil3.Image test-double — coil3 3.0.4 doesn't yet ship ColorImage. */
|
||||||
|
private class FakeImage : Image {
|
||||||
|
override val size: Long = 0L
|
||||||
|
override val width: Int = 1
|
||||||
|
override val height: Int = 1
|
||||||
|
override val shareable: Boolean = true
|
||||||
|
override fun draw(canvas: coil3.Canvas) {}
|
||||||
|
}
|
||||||
|
|
||||||
private fun make401Error(request: ImageRequest): ErrorResult {
|
private fun make401Error(request: ImageRequest): ErrorResult {
|
||||||
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
|
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
|
|||||||
@@ -486,6 +486,9 @@ fun App(
|
|||||||
onAddResidence = {
|
onAddResidence = {
|
||||||
navController.navigate(AddResidenceRoute)
|
navController.navigate(AddResidenceRoute)
|
||||||
},
|
},
|
||||||
|
onJoinResidence = {
|
||||||
|
navController.navigate(JoinResidenceRoute)
|
||||||
|
},
|
||||||
onNavigateToProfile = {
|
onNavigateToProfile = {
|
||||||
navController.navigate(ProfileRoute)
|
navController.navigate(ProfileRoute)
|
||||||
},
|
},
|
||||||
@@ -502,6 +505,16 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<JoinResidenceRoute> {
|
||||||
|
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onJoined = { residenceId ->
|
||||||
|
navController.popBackStack()
|
||||||
|
navController.navigate(ResidenceDetailRoute(residenceId))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable<AddResidenceRoute> {
|
composable<AddResidenceRoute> {
|
||||||
AddResidenceScreen(
|
AddResidenceScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package com.tt.honeyDue.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tt.honeyDue.network.ApiResult
|
|
||||||
import com.tt.honeyDue.network.APILayer
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun JoinResidenceDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onJoined: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
var shareCode by remember { mutableStateOf(TextFieldValue("")) }
|
|
||||||
var isJoining by remember { mutableStateOf(false) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text("Join Residence")
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(Icons.Default.Close, "Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = "Enter the 6-character share code to join a residence",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = shareCode,
|
|
||||||
onValueChange = {
|
|
||||||
if (it.text.length <= 6) {
|
|
||||||
shareCode = it.copy(text = it.text.uppercase())
|
|
||||||
error = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("Share Code") },
|
|
||||||
placeholder = { Text("ABC123") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !isJoining,
|
|
||||||
isError = error != null,
|
|
||||||
supportingText = {
|
|
||||||
if (error != null) {
|
|
||||||
Text(
|
|
||||||
text = error ?: "",
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isJoining) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (shareCode.text.length == 6) {
|
|
||||||
scope.launch {
|
|
||||||
isJoining = true
|
|
||||||
error = null
|
|
||||||
when (val result = APILayer.joinWithCode(shareCode.text)) {
|
|
||||||
is ApiResult.Success -> {
|
|
||||||
isJoining = false
|
|
||||||
onJoined()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
is ApiResult.Error -> {
|
|
||||||
error = result.message
|
|
||||||
isJoining = false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
isJoining = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error = "Share code must be 6 characters"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isJoining && shareCode.text.length == 6
|
|
||||||
) {
|
|
||||||
Text("Join")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
enabled = !isJoining
|
|
||||||
) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -140,6 +140,9 @@ fun MainScreen(
|
|||||||
ResidencesScreen(
|
ResidencesScreen(
|
||||||
onResidenceClick = onResidenceClick,
|
onResidenceClick = onResidenceClick,
|
||||||
onAddResidence = onAddResidence,
|
onAddResidence = onAddResidence,
|
||||||
|
onJoinResidence = {
|
||||||
|
navController.navigate(JoinResidenceRoute)
|
||||||
|
},
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
onNavigateToProfile = {
|
onNavigateToProfile = {
|
||||||
// Don't change selectedTab since Profile isn't in the bottom nav
|
// Don't change selectedTab since Profile isn't in the bottom nav
|
||||||
@@ -149,6 +152,21 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<JoinResidenceRoute> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onJoined = { residenceId ->
|
||||||
|
// Pop the join screen and hand off to the
|
||||||
|
// parent nav graph (ResidenceDetailRoute lives
|
||||||
|
// above MainScreen in App.kt).
|
||||||
|
navController.popBackStack()
|
||||||
|
onResidenceClick(residenceId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
composable<MainTabTasksRoute> {
|
composable<MainTabTasksRoute> {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AllTasksScreen(
|
AllTasksScreen(
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens.residence
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tt.honeyDue.network.ApiResult
|
||||||
|
import com.tt.honeyDue.ui.components.common.StandardCard
|
||||||
|
import com.tt.honeyDue.ui.components.forms.FormTextField
|
||||||
|
import com.tt.honeyDue.ui.theme.AppRadius
|
||||||
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen residence-join UI matching iOS
|
||||||
|
* `iosApp/iosApp/Residence/JoinResidenceView.swift`.
|
||||||
|
*
|
||||||
|
* Replaces the legacy `JoinResidenceDialog`. The user enters a 6-character
|
||||||
|
* invite code; on success [onJoined] is invoked with the newly joined
|
||||||
|
* residence id so the caller can navigate to its detail screen. Errors
|
||||||
|
* surface inline via the ViewModel and the screen stays mounted so the user
|
||||||
|
* can retry without re-opening a sheet.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun JoinResidenceScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onJoined: (Int) -> Unit,
|
||||||
|
viewModel: JoinResidenceViewModel = viewModel { JoinResidenceViewModel() },
|
||||||
|
) {
|
||||||
|
val code by viewModel.code.collectAsState()
|
||||||
|
val error by viewModel.errorMessage.collectAsState()
|
||||||
|
val submitState by viewModel.submitState.collectAsState()
|
||||||
|
|
||||||
|
val isLoading = submitState is ApiResult.Loading
|
||||||
|
|
||||||
|
// Navigate to the joined residence when submit transitions to Success.
|
||||||
|
LaunchedEffect(submitState) {
|
||||||
|
val s = submitState
|
||||||
|
if (s is ApiResult.Success<Int>) {
|
||||||
|
onJoined(s.data)
|
||||||
|
viewModel.resetSubmitState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Join Property",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
enabled = !isLoading,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues: PaddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.lg),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
// Hero
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Join a Shared Property",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter the 6-character share code provided by the owner.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
StandardCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
FormTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { viewModel.updateCode(it) },
|
||||||
|
label = "Share Code",
|
||||||
|
placeholder = "ABC123",
|
||||||
|
enabled = !isLoading,
|
||||||
|
error = error,
|
||||||
|
helperText = if (error == null) "Codes are 6 uppercase characters" else null,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Characters,
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(AppSpacing.md))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(AppSpacing.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(AppSpacing.md))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.submit() },
|
||||||
|
enabled = viewModel.canSubmit && !isLoading,
|
||||||
|
shape = RoundedCornerShape(AppRadius.md),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(AppSpacing.sm))
|
||||||
|
Text(
|
||||||
|
text = "Joining…",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(AppSpacing.sm))
|
||||||
|
Text(
|
||||||
|
text = "Join Property",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens.residence
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||||
|
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||||
|
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||||
|
import com.tt.honeyDue.network.APILayer
|
||||||
|
import com.tt.honeyDue.network.ApiResult
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for [JoinResidenceScreen] mirroring iOS JoinResidenceView.
|
||||||
|
*
|
||||||
|
* Holds the 6-character share code, validates it, drives the submit API
|
||||||
|
* call through [joinWithCode], and surfaces inline error messages without
|
||||||
|
* popping the screen. On success, [submitState] becomes
|
||||||
|
* `ApiResult.Success(residenceId)` so the UI can navigate to the joined
|
||||||
|
* residence's detail screen.
|
||||||
|
*
|
||||||
|
* [joinWithCode] and [analytics] are injected to keep the ViewModel unit
|
||||||
|
* testable without hitting [APILayer] or PostHog singletons.
|
||||||
|
*/
|
||||||
|
class JoinResidenceViewModel(
|
||||||
|
private val joinWithCode: suspend (String) -> ApiResult<JoinResidenceResponse> = { code ->
|
||||||
|
APILayer.joinWithCode(code)
|
||||||
|
},
|
||||||
|
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
|
||||||
|
PostHogAnalytics.capture(name, props)
|
||||||
|
}
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _code = MutableStateFlow("")
|
||||||
|
val code: StateFlow<String> = _code.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
|
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||||
|
|
||||||
|
private val _submitState = MutableStateFlow<ApiResult<Int>>(ApiResult.Idle)
|
||||||
|
/** Success carries the id of the joined residence. */
|
||||||
|
val submitState: StateFlow<ApiResult<Int>> = _submitState.asStateFlow()
|
||||||
|
|
||||||
|
/** True when the current code is exactly 6 chars and not already submitting. */
|
||||||
|
val canSubmit: Boolean
|
||||||
|
get() = _code.value.length == REQUIRED_LENGTH &&
|
||||||
|
_submitState.value !is ApiResult.Loading
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uppercases input and truncates to [REQUIRED_LENGTH]. Matches the
|
||||||
|
* iOS TextField onChange handler. Also clears any stale inline error so
|
||||||
|
* the user can retry after a failure.
|
||||||
|
*/
|
||||||
|
fun updateCode(raw: String) {
|
||||||
|
val next = raw.uppercase().take(REQUIRED_LENGTH)
|
||||||
|
_code.value = next
|
||||||
|
if (_errorMessage.value != null) {
|
||||||
|
_errorMessage.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates length and dispatches the API call. On success fires
|
||||||
|
* [AnalyticsEvents.RESIDENCE_JOINED] and sets [submitState] to
|
||||||
|
* Success(residenceId). On error sets inline [errorMessage] and leaves
|
||||||
|
* [submitState] as Error so the screen stays visible.
|
||||||
|
*/
|
||||||
|
fun submit() {
|
||||||
|
if (_code.value.length != REQUIRED_LENGTH) {
|
||||||
|
_errorMessage.value = ERROR_LENGTH
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = _code.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
_errorMessage.value = null
|
||||||
|
_submitState.value = ApiResult.Loading
|
||||||
|
|
||||||
|
when (val result = joinWithCode(code)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val residenceId = result.data.residence.id
|
||||||
|
analytics(
|
||||||
|
AnalyticsEvents.RESIDENCE_JOINED,
|
||||||
|
mapOf("residence_id" to residenceId)
|
||||||
|
)
|
||||||
|
_submitState.value = ApiResult.Success(residenceId)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
_errorMessage.value = result.message
|
||||||
|
_submitState.value = ApiResult.Error(result.message, result.code)
|
||||||
|
}
|
||||||
|
ApiResult.Loading -> {
|
||||||
|
_submitState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
|
ApiResult.Idle -> {
|
||||||
|
_submitState.value = ApiResult.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the transient submit state (e.g. when re-entering the screen). */
|
||||||
|
fun resetSubmitState() {
|
||||||
|
_submitState.value = ApiResult.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUIRED_LENGTH = 6
|
||||||
|
const val ERROR_LENGTH = "Share code must be 6 characters"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens.residence
|
||||||
|
|
||||||
|
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||||
|
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||||
|
import com.tt.honeyDue.models.ResidenceResponse
|
||||||
|
import com.tt.honeyDue.models.TotalSummary
|
||||||
|
import com.tt.honeyDue.network.ApiResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State-logic unit tests for [JoinResidenceViewModel] covering the full
|
||||||
|
* iOS parity surface of JoinResidenceView.swift:
|
||||||
|
*
|
||||||
|
* 1. Empty code → canSubmit is false.
|
||||||
|
* 2. Code shorter than 6 chars → canSubmit is false.
|
||||||
|
* 3. submit() with invalid length sets inline error and does NOT call API.
|
||||||
|
* 4. submit() with valid code calls joinWithCode(code) with uppercased value.
|
||||||
|
* 5. Successful API result fires `residence_joined` analytics event and
|
||||||
|
* publishes the joined residence id to the navigation callback.
|
||||||
|
* 6. API error surfaces inline error message and does NOT trigger navigation.
|
||||||
|
* 7. updateCode() coerces input to uppercase and caps at 6 chars, matching
|
||||||
|
* the iOS onChange handler.
|
||||||
|
* 8. updateCode() clears a previously set inline error so the user can retry.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class JoinResidenceViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Fixtures ----------
|
||||||
|
|
||||||
|
private fun fakeResidence(id: Int = 42) = ResidenceResponse(
|
||||||
|
id = id,
|
||||||
|
ownerId = 1,
|
||||||
|
name = "Joined Home",
|
||||||
|
createdAt = "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt = "2026-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fakeJoinResponse(id: Int = 42) = JoinResidenceResponse(
|
||||||
|
message = "Joined",
|
||||||
|
residence = fakeResidence(id),
|
||||||
|
summary = TotalSummary(totalResidences = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun makeViewModel(
|
||||||
|
joinResult: ApiResult<JoinResidenceResponse> = ApiResult.Success(fakeJoinResponse()),
|
||||||
|
onJoinCall: (String) -> Unit = {},
|
||||||
|
onAnalytics: (String, Map<String, Any>) -> Unit = { _, _ -> }
|
||||||
|
) = JoinResidenceViewModel(
|
||||||
|
joinWithCode = { code ->
|
||||||
|
onJoinCall(code)
|
||||||
|
joinResult
|
||||||
|
},
|
||||||
|
analytics = onAnalytics
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- Tests ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyCodeCannotSubmit() {
|
||||||
|
val vm = makeViewModel()
|
||||||
|
assertEquals("", vm.code.value)
|
||||||
|
assertFalse(vm.canSubmit, "Submit should be disabled for empty code")
|
||||||
|
assertNull(vm.errorMessage.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortCodeCannotSubmit() {
|
||||||
|
val vm = makeViewModel()
|
||||||
|
vm.updateCode("ABC")
|
||||||
|
assertFalse(vm.canSubmit, "Submit should be disabled for 3-char code")
|
||||||
|
vm.updateCode("ABCDE")
|
||||||
|
assertFalse(vm.canSubmit, "Submit should be disabled for 5-char code")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sixCharCodeCanSubmit() {
|
||||||
|
val vm = makeViewModel()
|
||||||
|
vm.updateCode("ABC123")
|
||||||
|
assertTrue(vm.canSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitWithInvalidLengthSetsInlineErrorAndSkipsApi() = runTest(dispatcher) {
|
||||||
|
var apiCalled = false
|
||||||
|
val vm = makeViewModel(onJoinCall = { apiCalled = true })
|
||||||
|
|
||||||
|
vm.updateCode("ABC")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(apiCalled, "API must NOT be called for invalid length")
|
||||||
|
val err = vm.errorMessage.value
|
||||||
|
assertTrue(err != null && err.isNotBlank(), "Inline error should be set")
|
||||||
|
assertIs<ApiResult.Idle>(vm.submitState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitWithValidCodeCallsJoinWithCodeUppercased() = runTest(dispatcher) {
|
||||||
|
var capturedCode: String? = null
|
||||||
|
val vm = makeViewModel(onJoinCall = { capturedCode = it })
|
||||||
|
|
||||||
|
// Simulate user typing lowercase — updateCode should uppercase it,
|
||||||
|
// but also verify submit sends the canonical uppercase value.
|
||||||
|
vm.updateCode("abc123")
|
||||||
|
assertEquals("ABC123", vm.code.value)
|
||||||
|
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals("ABC123", capturedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun successResultTriggersNavigationWithResidenceId() = runTest(dispatcher) {
|
||||||
|
val vm = makeViewModel(
|
||||||
|
joinResult = ApiResult.Success(fakeJoinResponse(id = 77))
|
||||||
|
)
|
||||||
|
vm.updateCode("ABC123")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = vm.submitState.value
|
||||||
|
assertIs<ApiResult.Success<Int>>(state)
|
||||||
|
assertEquals(77, state.data)
|
||||||
|
assertNull(vm.errorMessage.value, "Error should be cleared on success")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun apiErrorShowsInlineMessageAndDoesNotNavigate() = runTest(dispatcher) {
|
||||||
|
val vm = makeViewModel(
|
||||||
|
joinResult = ApiResult.Error("Invalid code", 404)
|
||||||
|
)
|
||||||
|
vm.updateCode("BADBAD")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertIs<ApiResult.Error>(vm.submitState.value)
|
||||||
|
assertEquals("Invalid code", vm.errorMessage.value)
|
||||||
|
// submitState is Error, NOT Success — so the UI will NOT navigate.
|
||||||
|
assertFalse(vm.submitState.value is ApiResult.Success<*>)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun analyticsEventFiredOnSuccessMatchingIosEventName() = runTest(dispatcher) {
|
||||||
|
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val vm = makeViewModel(
|
||||||
|
joinResult = ApiResult.Success(fakeJoinResponse(id = 7)),
|
||||||
|
onAnalytics = { name, props -> events += name to props }
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.updateCode("ABC123")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val joined = events.firstOrNull { it.first == AnalyticsEvents.RESIDENCE_JOINED }
|
||||||
|
assertTrue(joined != null, "Expected residence_joined analytics event")
|
||||||
|
assertEquals("residence_joined", joined.first)
|
||||||
|
assertEquals(7, joined.second["residence_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun analyticsNotFiredOnApiError() = runTest(dispatcher) {
|
||||||
|
val events = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
val vm = makeViewModel(
|
||||||
|
joinResult = ApiResult.Error("Nope", 400),
|
||||||
|
onAnalytics = { name, props -> events += name to props }
|
||||||
|
)
|
||||||
|
vm.updateCode("ABC123")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(events.none { it.first == AnalyticsEvents.RESIDENCE_JOINED })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateCodeUppercasesAndCapsAtSix() {
|
||||||
|
val vm = makeViewModel()
|
||||||
|
vm.updateCode("abcdefghij")
|
||||||
|
assertEquals("ABCDEF", vm.code.value, "Should cap at 6 chars and uppercase")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateCodeClearsPreviousError() = runTest(dispatcher) {
|
||||||
|
val vm = makeViewModel(joinResult = ApiResult.Error("Invalid", 400))
|
||||||
|
vm.updateCode("BADBAD")
|
||||||
|
vm.submit()
|
||||||
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals("Invalid", vm.errorMessage.value)
|
||||||
|
|
||||||
|
// User edits code → error should clear.
|
||||||
|
vm.updateCode("GOODCO")
|
||||||
|
assertNull(vm.errorMessage.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user