diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt index fe6b72d..6bcf9bc 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceScreen.kt new file mode 100644 index 0000000..107c79f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModel.kt new file mode 100644 index 0000000..f1e9988 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModel.kt @@ -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 = { req -> + APILayer.createTask(req) + } +) : ViewModel() { + + // --- Form fields --- + + private val _title = MutableStateFlow("") + val title: StateFlow = _title.asStateFlow() + + private val _description = MutableStateFlow("") + val description: StateFlow = _description.asStateFlow() + + private val _priorityId = MutableStateFlow(null) + val priorityId: StateFlow = _priorityId.asStateFlow() + + private val _categoryId = MutableStateFlow(null) + val categoryId: StateFlow = _categoryId.asStateFlow() + + private val _frequencyId = MutableStateFlow(null) + val frequencyId: StateFlow = _frequencyId.asStateFlow() + + /** Optional ISO date string yyyy-MM-dd or blank. */ + private val _dueDate = MutableStateFlow("") + val dueDate: StateFlow = _dueDate.asStateFlow() + + /** Optional decimal string ("" = no estimate). */ + private val _estimatedCost = MutableStateFlow("") + val estimatedCost: StateFlow = _estimatedCost.asStateFlow() + + // --- Validation + submit state --- + + private val _titleError = MutableStateFlow(null) + val titleError: StateFlow = _titleError.asStateFlow() + + private val _submitState = MutableStateFlow>(ApiResult.Idle) + val submitState: StateFlow> = _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 = object : StateFlow { + override val replayCache: List + get() = listOf(value) + override val value: Boolean + get() = _title.value.isNotBlank() + override suspend fun collect(collector: FlowCollector): 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 + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModelTest.kt new file mode 100644 index 0000000..b3adf6f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/AddTaskWithResidenceViewModelTest.kt @@ -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 = 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>(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(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") + } +}