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:
Trey T
2026-04-18 13:14:55 -05:00
parent 917c528f67
commit 704c59e5cb
7 changed files with 587 additions and 128 deletions

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.network
import androidx.test.core.app.ApplicationProvider
import coil3.ColorImage
import coil3.Image
import coil3.PlatformContext
import coil3.decode.DataSource
import coil3.intercept.Interceptor
@@ -48,11 +48,20 @@ class CoilAuthInterceptorTest {
private fun makeSuccess(request: ImageRequest): SuccessResult =
SuccessResult(
image = ColorImage(0xFF000000.toInt()),
image = FakeImage(),
request = request,
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 {
val response = NetworkResponse(code = 401, headers = NetworkHeaders.EMPTY)
return ErrorResult(

View File

@@ -486,6 +486,9 @@ fun App(
onAddResidence = {
navController.navigate(AddResidenceRoute)
},
onJoinResidence = {
navController.navigate(JoinResidenceRoute)
},
onNavigateToProfile = {
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> {
AddResidenceScreen(
onNavigateBack = {

View File

@@ -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")
}
}
)
}

View File

@@ -140,6 +140,9 @@ fun MainScreen(
ResidencesScreen(
onResidenceClick = onResidenceClick,
onAddResidence = onAddResidence,
onJoinResidence = {
navController.navigate(JoinResidenceRoute)
},
onLogout = onLogout,
onNavigateToProfile = {
// 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> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(

View File

@@ -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())
}
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}