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:
@@ -167,3 +167,8 @@ object FeatureComparisonRoute
|
|||||||
// to personalized task suggestions for a residence).
|
// to personalized task suggestions for a residence).
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TaskSuggestionsRoute(val residenceId: Int)
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user