P2 Stream I: AddTaskWithResidenceScreen

Port iOS AddTaskWithResidenceView. Residence pre-selected via constructor
param, form validation, submit -> APILayer.createTask(residenceId attached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:22:13 -05:00
parent 46db133458
commit edc22c0d2b
4 changed files with 587 additions and 0 deletions

View File

@@ -167,3 +167,8 @@ object FeatureComparisonRoute
// to personalized task suggestions for a residence).
@Serializable
data class TaskSuggestionsRoute(val residenceId: Int)
// Add Task With Residence Route (P2 Stream I — Android port of iOS
// AddTaskWithResidenceView). Residence is pre-selected via residenceId.
@Serializable
data class AddTaskWithResidenceRoute(val residenceId: Int)

View File

@@ -0,0 +1,266 @@
package com.tt.honeyDue.ui.screens.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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.repository.LookupsRepository
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
import com.tt.honeyDue.util.ErrorMessageParser
/**
* Android port of iOS AddTaskWithResidenceView (P2 Stream I).
*
* The residence is pre-selected via [residenceId] so the user doesn't pick
* a property here — callers enter this screen from a residence context
* (e.g. "Add Task" inside a residence detail screen).
*
* On submit, calls [APILayer.createTask] via the ViewModel with the
* residenceId baked into the request. On success, [onCreated] fires so the
* caller can pop + refresh the parent task list. On error, an inline
* message is shown and the user stays on the form.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun AddTaskWithResidenceScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onCreated: () -> Unit,
viewModel: AddTaskWithResidenceViewModel = viewModel {
AddTaskWithResidenceViewModel(residenceId = residenceId)
}
) {
val title by viewModel.title.collectAsState()
val description by viewModel.description.collectAsState()
val priorityId by viewModel.priorityId.collectAsState()
val categoryId by viewModel.categoryId.collectAsState()
val frequencyId by viewModel.frequencyId.collectAsState()
val dueDate by viewModel.dueDate.collectAsState()
val estimatedCost by viewModel.estimatedCost.collectAsState()
val titleError by viewModel.titleError.collectAsState()
val canSubmit by viewModel.canSubmit.collectAsState()
val submitState by viewModel.submitState.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val isSubmitting = submitState is ApiResult.Loading
Scaffold(
topBar = {
TopAppBar(
title = { Text("New Task", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
StandardCard(modifier = Modifier.fillMaxWidth()) {
FormTextField(
value = title,
onValueChange = viewModel::onTitleChange,
label = "Title",
placeholder = "e.g. Flush water heater",
error = titleError,
enabled = !isSubmitting
)
}
StandardCard(modifier = Modifier.fillMaxWidth()) {
FormTextField(
value = description,
onValueChange = viewModel::onDescriptionChange,
label = "Description",
placeholder = "Optional details",
singleLine = false,
maxLines = 4,
enabled = !isSubmitting
)
}
// Priority chips
StandardCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Priority",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm),
modifier = Modifier.padding(top = AppSpacing.sm)
) {
priorities.forEach { p ->
FilterChip(
selected = priorityId == p.id,
onClick = { viewModel.onPriorityIdChange(p.id) },
label = { Text(p.displayName) },
enabled = !isSubmitting,
colors = FilterChipDefaults.filterChipColors()
)
}
}
}
// Category chips
StandardCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Category",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm),
modifier = Modifier.padding(top = AppSpacing.sm)
) {
categories.forEach { c ->
FilterChip(
selected = categoryId == c.id,
onClick = { viewModel.onCategoryIdChange(c.id) },
label = { Text(c.name) },
enabled = !isSubmitting
)
}
}
}
// Frequency chips
StandardCard(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Frequency",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm),
modifier = Modifier.padding(top = AppSpacing.sm)
) {
frequencies.forEach { f ->
FilterChip(
selected = frequencyId == f.id,
onClick = { viewModel.onFrequencyIdChange(f.id) },
label = { Text(f.displayName) },
enabled = !isSubmitting
)
}
}
}
// Due date (optional, yyyy-MM-dd string matches Go API)
StandardCard(modifier = Modifier.fillMaxWidth()) {
FormTextField(
value = dueDate,
onValueChange = viewModel::onDueDateChange,
label = "Due date (optional)",
placeholder = "yyyy-MM-dd",
enabled = !isSubmitting,
helperText = "Leave blank for no due date"
)
}
// Estimated cost (optional)
StandardCard(modifier = Modifier.fillMaxWidth()) {
FormTextField(
value = estimatedCost,
onValueChange = viewModel::onEstimatedCostChange,
label = "Estimated cost (optional)",
placeholder = "0.00",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
enabled = !isSubmitting
)
}
// Inline error (not a navigation pop)
(submitState as? ApiResult.Error)?.let { err ->
Text(
text = ErrorMessageParser.parse(err.message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = AppSpacing.sm)
)
}
Button(
onClick = { viewModel.submit(onSuccess = onCreated) },
enabled = canSubmit && !isSubmitting,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
if (isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(Icons.Default.Save, contentDescription = null)
Text(
text = "Create Task",
modifier = Modifier.padding(start = AppSpacing.sm),
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}

View File

@@ -0,0 +1,140 @@
package com.tt.honeyDue.ui.screens.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* State-logic layer for [AddTaskWithResidenceScreen] (P2 Stream I).
*
* The residence is pre-selected via [residenceId] (Android port of the iOS
* AddTaskWithResidenceView / TaskFormView combo, where a non-null
* residenceId skips the picker). On submit, builds a [TaskCreateRequest]
* with residenceId attached and calls [APILayer.createTask]. On success,
* the screen invokes onCreated so the caller can pop and refresh.
*
* [createTask] is injected for unit-testability.
*/
class AddTaskWithResidenceViewModel(
private val residenceId: Int,
private val createTask: suspend (TaskCreateRequest) -> ApiResult<TaskResponse> = { req ->
APILayer.createTask(req)
}
) : ViewModel() {
// --- Form fields ---
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
private val _description = MutableStateFlow("")
val description: StateFlow<String> = _description.asStateFlow()
private val _priorityId = MutableStateFlow<Int?>(null)
val priorityId: StateFlow<Int?> = _priorityId.asStateFlow()
private val _categoryId = MutableStateFlow<Int?>(null)
val categoryId: StateFlow<Int?> = _categoryId.asStateFlow()
private val _frequencyId = MutableStateFlow<Int?>(null)
val frequencyId: StateFlow<Int?> = _frequencyId.asStateFlow()
/** Optional ISO date string yyyy-MM-dd or blank. */
private val _dueDate = MutableStateFlow("")
val dueDate: StateFlow<String> = _dueDate.asStateFlow()
/** Optional decimal string ("" = no estimate). */
private val _estimatedCost = MutableStateFlow("")
val estimatedCost: StateFlow<String> = _estimatedCost.asStateFlow()
// --- Validation + submit state ---
private val _titleError = MutableStateFlow<String?>(null)
val titleError: StateFlow<String?> = _titleError.asStateFlow()
private val _submitState = MutableStateFlow<ApiResult<TaskResponse>>(ApiResult.Idle)
val submitState: StateFlow<ApiResult<TaskResponse>> = _submitState.asStateFlow()
/**
* True once title is non-blank. The screen disables the submit button on
* false. Implemented as a read-only view over [_title] so the derived
* value is always fresh without relying on a collector coroutine — keeps
* unit tests synchronous (assert on .value immediately after a setter).
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val canSubmit: StateFlow<Boolean> = object : StateFlow<Boolean> {
override val replayCache: List<Boolean>
get() = listOf(value)
override val value: Boolean
get() = _title.value.isNotBlank()
override suspend fun collect(collector: FlowCollector<Boolean>): Nothing {
_title.collect { collector.emit(it.isNotBlank()) }
}
}
// --- Setters ---
fun onTitleChange(value: String) {
_title.value = value
if (value.isNotBlank()) _titleError.value = null
}
fun onDescriptionChange(value: String) { _description.value = value }
fun onPriorityIdChange(value: Int?) { _priorityId.value = value }
fun onCategoryIdChange(value: Int?) { _categoryId.value = value }
fun onFrequencyIdChange(value: Int?) { _frequencyId.value = value }
fun onDueDateChange(value: String) { _dueDate.value = value }
fun onEstimatedCostChange(value: String) { _estimatedCost.value = value }
// --- Submit ---
/**
* Validates the form and submits via [createTask]. On success, fires
* [onSuccess] — the screen uses that to pop and refresh the caller.
* On error, [submitState] is set to [ApiResult.Error] so the screen
* can surface an inline error without popping.
*/
fun submit(onSuccess: () -> Unit) {
val currentTitle = _title.value.trim()
if (currentTitle.isEmpty()) {
_titleError.value = "Title is required"
return
}
val request = TaskCreateRequest(
residenceId = residenceId,
title = currentTitle,
description = _description.value.ifBlank { null },
categoryId = _categoryId.value,
priorityId = _priorityId.value,
frequencyId = _frequencyId.value,
dueDate = _dueDate.value.ifBlank { null },
estimatedCost = _estimatedCost.value.ifBlank { null }?.toDoubleOrNull()
)
viewModelScope.launch {
_submitState.value = ApiResult.Loading
when (val result = createTask(request)) {
is ApiResult.Success -> {
_submitState.value = result
onSuccess()
}
is ApiResult.Error -> _submitState.value = result
ApiResult.Loading -> _submitState.value = ApiResult.Loading
ApiResult.Idle -> _submitState.value = ApiResult.Idle
}
}
}
fun resetSubmitState() {
_submitState.value = ApiResult.Idle
}
}

View File

@@ -0,0 +1,176 @@
package com.tt.honeyDue.ui.screens.task
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskResponse
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.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for [AddTaskWithResidenceViewModel] — the state-logic layer
* behind AddTaskWithResidenceScreen (P2 Stream I). Covers validation,
* submit -> APILayer.createTask wiring, residenceId pre-selection, and
* success/error outcomes.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AddTaskWithResidenceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() { Dispatchers.setMain(dispatcher) }
@AfterTest
fun tearDown() { Dispatchers.resetMain() }
private fun fakeCreatedTask(): TaskResponse = TaskResponse(
id = 1,
residenceId = 42,
createdById = 1,
title = "Created",
description = "",
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z"
)
private fun makeViewModel(
residenceId: Int = 42,
createResult: ApiResult<TaskResponse> = ApiResult.Success(fakeCreatedTask()),
onCreateCall: (TaskCreateRequest) -> Unit = {}
) = AddTaskWithResidenceViewModel(
residenceId = residenceId,
createTask = { request ->
onCreateCall(request)
createResult
}
)
@Test
fun titleEmpty_submitDisabled() {
val vm = makeViewModel()
assertTrue(vm.title.value.isEmpty())
assertFalse(vm.canSubmit.value, "submit should be disabled with empty title")
}
@Test
fun titleValid_submitEnabled() {
val vm = makeViewModel()
vm.onTitleChange("Change water filter")
assertTrue(vm.canSubmit.value, "submit should be enabled when title is non-empty")
}
@Test
fun titleWhitespaceOnly_submitDisabled() {
val vm = makeViewModel()
vm.onTitleChange(" ")
assertFalse(vm.canSubmit.value, "whitespace-only title should not enable submit")
}
@Test
fun submit_buildsTaskCreateRequestWithResidenceId() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 42,
onCreateCall = { captured = it }
)
vm.onTitleChange("Flush water heater")
vm.onDescriptionChange("Annual flush")
vm.onCategoryIdChange(3)
vm.onFrequencyIdChange(7)
vm.onPriorityIdChange(2)
vm.onDueDateChange("2024-06-15")
vm.onEstimatedCostChange("150.50")
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
val req = assertNotNull(captured, "createTask was not called")
assertEquals(42, req.residenceId)
assertEquals("Flush water heater", req.title)
assertEquals("Annual flush", req.description)
assertEquals(3, req.categoryId)
assertEquals(7, req.frequencyId)
assertEquals(2, req.priorityId)
assertEquals("2024-06-15", req.dueDate)
assertEquals(150.50, req.estimatedCost)
}
@Test
fun submit_success_invokesOnCreated() = runTest(dispatcher) {
var createdCalled = 0
val vm = makeViewModel(createResult = ApiResult.Success(fakeCreatedTask()))
vm.onTitleChange("Clean gutters")
vm.submit(onSuccess = { createdCalled++ })
dispatcher.scheduler.advanceUntilIdle()
assertEquals(1, createdCalled, "onCreated should fire exactly once on success")
assertIs<ApiResult.Success<TaskResponse>>(vm.submitState.value)
}
@Test
fun submit_failure_surfacesError() = runTest(dispatcher) {
var createdCalled = 0
val vm = makeViewModel(createResult = ApiResult.Error("Server exploded", 500))
vm.onTitleChange("Mow lawn")
vm.submit(onSuccess = { createdCalled++ })
dispatcher.scheduler.advanceUntilIdle()
val state = vm.submitState.value
assertIs<ApiResult.Error>(state)
assertEquals("Server exploded", state.message)
assertEquals(0, createdCalled, "onCreated must NOT fire on API error")
}
@Test
fun residenceId_passedIntoRequest() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(
residenceId = 999,
onCreateCall = { captured = it }
)
vm.onTitleChange("Replace batteries")
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
assertEquals(999, captured?.residenceId)
}
@Test
fun submit_emptyTitle_doesNotCallCreateTask() = runTest(dispatcher) {
var callCount = 0
val vm = makeViewModel(onCreateCall = { callCount++ })
// Title intentionally left blank.
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
assertEquals(0, callCount, "createTask must not be called when title is blank")
assertEquals("Title is required", vm.titleError.value)
}
@Test
fun submit_omitsOptionalFieldsWhenBlank() = runTest(dispatcher) {
var captured: TaskCreateRequest? = null
val vm = makeViewModel(onCreateCall = { captured = it })
vm.onTitleChange("Just a title")
// Leave description, dueDate, estimatedCost blank.
vm.submit(onSuccess = {})
dispatcher.scheduler.advanceUntilIdle()
val req = assertNotNull(captured)
assertNull(req.description, "blank description should serialize as null")
assertNull(req.dueDate, "blank dueDate should serialize as null")
assertNull(req.estimatedCost, "blank estimatedCost should serialize as null")
}
}