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

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