From 7771edd95d225f74c662000b5ea25a673d87952b Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 23:31:52 +0200 Subject: [PATCH 1/2] Let the entry details view model edit the distance Starts from the logged distance, keeps only digits within the allowed length, and saves through the existing record-distance call. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/entry/EntryDetailsViewModel.kt | 49 +++++- .../ui/entry/EntryDetailsEditViewModelTest.kt | 158 ++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditViewModelTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsViewModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsViewModel.kt index 2774171..b3a8500 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsViewModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsViewModel.kt @@ -6,10 +6,14 @@ import com.github.se.pooltrack.model.entry.EntryRepository import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.SubscriptionRepository import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider +import com.github.se.pooltrack.model.swim.MAX_SWIM_DISTANCE_METERS +import com.github.se.pooltrack.model.swim.parseSwimDistance import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -17,7 +21,7 @@ import kotlinx.coroutines.launch * ViewModel for the Entry details screen. Shows the entry picked with [loadEntry], kept up to date * as entries and subscriptions change. * - * @property entryRepository The repository used to read and delete entries. + * @property entryRepository The repository used to read, change and delete entries. * @property subscriptionRepository The repository used to read the subscriptions. */ class EntryDetailsViewModel( @@ -49,4 +53,47 @@ class EntryDetailsViewModel( val entry = details.value?.entry ?: return viewModelScope.launch { entryRepository.deleteEntry(entry) } } + + private val _distanceInput = MutableStateFlow(null) + + /** + * The distance being typed while it is edited, as digits, or `null` when no edit is going on. It + * starts out as the distance logged so far (empty if there is none). + */ + val distanceInput: StateFlow = _distanceInput.asStateFlow() + + /** Whether [distanceInput] is a distance that can be saved, see [parseSwimDistance]. */ + val isDistanceInputValid: StateFlow = + _distanceInput + .map { it?.let(::parseSwimDistance) != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + /** Starts editing the distance of the selected entry. */ + fun onEditDistance() { + val entry = details.value?.entry ?: return + _distanceInput.value = entry.swimDistanceMeters?.toString().orEmpty() + } + + /** Updates the typed distance, dropping anything but digits. */ + fun onDistanceInputChanged(text: String) { + if (_distanceInput.value == null) return + _distanceInput.value = sanitizeDistanceInput(text) + } + + /** Abandons the edit without changing the entry. */ + fun onDistanceEditCancelled() { + _distanceInput.value = null + } + + /** Saves the typed distance on the selected entry. Ignored while it is invalid. */ + fun onDistanceEditSaved() { + val entry = details.value?.entry ?: return + val meters = _distanceInput.value?.let(::parseSwimDistance) ?: return + _distanceInput.value = null + viewModelScope.launch { entryRepository.recordSwimDistance(entry, meters) } + } } + +/** [text] without anything but digits, cut to the longest distance that can be saved. */ +internal fun sanitizeDistanceInput(text: String): String = + text.filter { it in '0'..'9' }.take(MAX_SWIM_DISTANCE_METERS.toString().length) diff --git a/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditViewModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditViewModelTest.kt new file mode 100644 index 0000000..31ea033 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditViewModelTest.kt @@ -0,0 +1,158 @@ +package com.github.se.pooltrack.ui.entry + +import com.github.se.pooltrack.model.entry.Entry +import com.github.se.pooltrack.utils.FakeEntryRepository +import com.github.se.pooltrack.utils.FakeSubscriptionRepository +import com.github.se.pooltrack.utils.MainDispatcherRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class EntryDetailsEditViewModelTest { + + @get:Rule val mainDispatcherRule = MainDispatcherRule() + + private val older = Entry(timestampEpochMilli = 1_000L, subscriptionId = "sub-1") + private val newer = Entry(timestampEpochMilli = 2_000L, subscriptionId = "sub-1") + + private fun viewModel(entries: FakeEntryRepository) = + EntryDetailsViewModel(entries, FakeSubscriptionRepository()) + + @Test + fun onEditDistance_startsFromTheLoggedDistance() { + val logged = older.copy(swimDistanceMeters = 1200) + val viewModel = viewModel(FakeEntryRepository(listOf(logged, newer))) + viewModel.loadEntry(logged.timestampEpochMilli) + + viewModel.onEditDistance() + + assertEquals("1200", viewModel.distanceInput.value) + assertEquals(true, viewModel.isDistanceInputValid.value) + } + + @Test + fun onEditDistance_startsEmpty_whenNoDistanceWasLogged() { + val viewModel = viewModel(FakeEntryRepository(listOf(older, newer))) + viewModel.loadEntry(older.timestampEpochMilli) + + viewModel.onEditDistance() + + assertEquals("", viewModel.distanceInput.value) + assertEquals(false, viewModel.isDistanceInputValid.value) + } + + @Test + fun onEditDistance_doesNothing_whenNoEntryIsLoaded() { + val viewModel = viewModel(FakeEntryRepository(listOf(older, newer))) + + viewModel.onEditDistance() + + assertNull(viewModel.distanceInput.value) + } + + @Test + fun onDistanceInputChanged_keepsOnlyDigits_andTheMaximumLength() { + val viewModel = viewModel(FakeEntryRepository(listOf(older))) + viewModel.loadEntry(older.timestampEpochMilli) + viewModel.onEditDistance() + + viewModel.onDistanceInputChanged("12a3.4 m") + assertEquals("1234", viewModel.distanceInput.value) + + viewModel.onDistanceInputChanged("123456789") + assertEquals("12345", viewModel.distanceInput.value) + } + + @Test + fun onDistanceInputChanged_isIgnored_whenNoEditIsGoingOn() { + val viewModel = viewModel(FakeEntryRepository(listOf(older))) + viewModel.loadEntry(older.timestampEpochMilli) + + viewModel.onDistanceInputChanged("500") + + assertNull(viewModel.distanceInput.value) + } + + @Test + fun isDistanceInputValid_isFalse_forZeroAndTooLongDistances() { + val viewModel = viewModel(FakeEntryRepository(listOf(older))) + viewModel.loadEntry(older.timestampEpochMilli) + viewModel.onEditDistance() + + viewModel.onDistanceInputChanged("0") + assertEquals(false, viewModel.isDistanceInputValid.value) + + viewModel.onDistanceInputChanged("99999") + assertEquals(false, viewModel.isDistanceInputValid.value) + + viewModel.onDistanceInputChanged("50000") + assertEquals(true, viewModel.isDistanceInputValid.value) + } + + @Test + fun onDistanceEditSaved_replacesTheDistanceOfTheLoadedEntryOnly() { + val logged = older.copy(swimDistanceMeters = 1200) + val repository = FakeEntryRepository(listOf(logged, newer)) + val viewModel = viewModel(repository) + viewModel.loadEntry(logged.timestampEpochMilli) + viewModel.onEditDistance() + viewModel.onDistanceInputChanged("1500") + + viewModel.onDistanceEditSaved() + + assertEquals(logged.copy(swimDistanceMeters = 1500), repository.storedEntries.first()) + assertEquals(newer, repository.storedEntries.last()) + assertNull(viewModel.distanceInput.value) + assertEquals(1500, viewModel.details.value?.entry?.swimDistanceMeters) + } + + @Test + fun onDistanceEditSaved_addsADistance_toAnEntryWithoutOne() { + val repository = FakeEntryRepository(listOf(older, newer)) + val viewModel = viewModel(repository) + viewModel.loadEntry(older.timestampEpochMilli) + viewModel.onEditDistance() + viewModel.onDistanceInputChanged("800") + + viewModel.onDistanceEditSaved() + + val saved = + repository.storedEntries.first { it.timestampEpochMilli == older.timestampEpochMilli } + assertEquals(800, saved.swimDistanceMeters) + } + + @Test + fun onDistanceEditSaved_changesNothing_whenTheInputIsInvalid() { + val repository = FakeEntryRepository(listOf(older, newer)) + val viewModel = viewModel(repository) + viewModel.loadEntry(older.timestampEpochMilli) + viewModel.onEditDistance() + viewModel.onDistanceInputChanged("0") + + viewModel.onDistanceEditSaved() + + assertEquals(listOf(older, newer), repository.storedEntries) + assertEquals("0", viewModel.distanceInput.value) + } + + @Test + fun onDistanceEditCancelled_changesNothing() { + val repository = FakeEntryRepository(listOf(older, newer)) + val viewModel = viewModel(repository) + viewModel.loadEntry(older.timestampEpochMilli) + viewModel.onEditDistance() + viewModel.onDistanceInputChanged("700") + + viewModel.onDistanceEditCancelled() + + assertEquals(listOf(older, newer), repository.storedEntries) + assertNull(viewModel.distanceInput.value) + } + + @Test + fun sanitizeDistanceInput_dropsEverythingButDigits() { + assertEquals("1200", sanitizeDistanceInput(" 1,200 m ")) + assertEquals("", sanitizeDistanceInput("abc")) + } +} From a40800cc6ba269d1e989a28c8645c12ceb67aa83 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 23:31:56 +0200 Subject: [PATCH 2/2] Add an edit button for the distance on the entry details A pencil in the top bar opens a dialog with a numeric field, prefilled with the current distance; Save is disabled until it is a valid number. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/entry/EntryDetailsScreen.kt | 50 ++++++++ .../ui/entry/EntryDetailsEditScreenTest.kt | 113 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditScreenTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsScreen.kt index f3a3cdc..e19697b 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/entry/EntryDetailsScreen.kt @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -17,6 +19,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -32,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -58,6 +62,10 @@ object EntryDetailsScreenTestTags { const val DELETE_BUTTON = "EntryDetailsScreenDeleteButton" const val CONFIRM_DELETE_BUTTON = "EntryDetailsScreenConfirmDeleteButton" const val CANCEL_DELETE_BUTTON = "EntryDetailsScreenCancelDeleteButton" + const val EDIT_BUTTON = "EntryDetailsScreenEditButton" + const val DISTANCE_FIELD = "EntryDetailsScreenDistanceField" + const val SAVE_DISTANCE_BUTTON = "EntryDetailsScreenSaveDistanceButton" + const val CANCEL_EDIT_BUTTON = "EntryDetailsScreenCancelEditButton" } private val ZONE = ZoneId.systemDefault() @@ -101,6 +109,42 @@ fun EntryDetailsScreen( ) } + val distanceInput by viewModel.distanceInput.collectAsState() + val isDistanceInputValid by viewModel.isDistanceInputValid.collectAsState() + distanceInput?.let { input -> + AlertDialog( + onDismissRequest = viewModel::onDistanceEditCancelled, + title = { Text("Edit distance") }, + text = { + OutlinedTextField( + value = input, + onValueChange = viewModel::onDistanceInputChanged, + label = { Text("Distance in metres") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.testTag(EntryDetailsScreenTestTags.DISTANCE_FIELD), + ) + }, + confirmButton = { + TextButton( + onClick = viewModel::onDistanceEditSaved, + enabled = isDistanceInputValid, + modifier = Modifier.testTag(EntryDetailsScreenTestTags.SAVE_DISTANCE_BUTTON), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = viewModel::onDistanceEditCancelled, + modifier = Modifier.testTag(EntryDetailsScreenTestTags.CANCEL_EDIT_BUTTON), + ) { + Text("Cancel") + } + }, + ) + } + Scaffold( topBar = { TopNavigationMenu( @@ -108,6 +152,12 @@ fun EntryDetailsScreen( onGoBack = { navigationActions?.goBack() }, actions = { if (details != null) { + IconButton( + onClick = viewModel::onEditDistance, + modifier = Modifier.testTag(EntryDetailsScreenTestTags.EDIT_BUTTON), + ) { + Icon(Icons.Outlined.Edit, contentDescription = "Edit distance") + } IconButton( onClick = { confirmingDelete = true }, modifier = Modifier.testTag(EntryDetailsScreenTestTags.DELETE_BUTTON), diff --git a/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditScreenTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditScreenTest.kt new file mode 100644 index 0000000..83ed7ac --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/entry/EntryDetailsEditScreenTest.kt @@ -0,0 +1,113 @@ +package com.github.se.pooltrack.ui.entry + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.github.se.pooltrack.model.entry.Entry +import com.github.se.pooltrack.utils.FakeEntryRepository +import com.github.se.pooltrack.utils.FakeSubscriptionRepository +import com.github.se.pooltrack.utils.MainDispatcherRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EntryDetailsEditScreenTest { + + @get:Rule val mainDispatcherRule = MainDispatcherRule() + @get:Rule val composeTestRule = createComposeRule() + + private val first = Entry(1_000_000_000_000L, subscriptionId = "sub-1") + private val second = + Entry(1_000_259_200_000L, subscriptionId = "sub-1", swimDistanceMeters = 1200) + + private fun show(timestamp: Long, repository: FakeEntryRepository) { + val viewModel = EntryDetailsViewModel(repository, FakeSubscriptionRepository()) + composeTestRule.setContent { EntryDetailsScreen(timestamp, viewModel) } + } + + private fun openEditor() { + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.EDIT_BUTTON).performClick() + } + + @Test + fun editingTheDistance_savesTheNewValue() { + val repository = FakeEntryRepository(listOf(first, second)) + show(second.timestampEpochMilli, repository) + openEditor() + composeTestRule.onNodeWithText("Edit distance").assertIsDisplayed() + + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD).performTextClearance() + composeTestRule + .onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD) + .performTextInput("1500") + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.SAVE_DISTANCE_BUTTON).performClick() + composeTestRule.waitForIdle() + + assertEquals(1500, repository.storedEntries.last().swimDistanceMeters) + composeTestRule.onAllNodesWithText("Edit distance").assertCountEquals(0) + composeTestRule + .onNodeWithTag(EntryDetailsScreenTestTags.SWIM_DISTANCE) + .assertTextContains("1500 m") + } + + @Test + fun editing_startsFromTheLoggedDistance() { + show(second.timestampEpochMilli, FakeEntryRepository(listOf(first, second))) + + openEditor() + + composeTestRule + .onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD) + .assertTextContains("1200") + } + + @Test + fun anInvalidDistance_cannotBeSaved() { + show(first.timestampEpochMilli, FakeEntryRepository(listOf(first, second))) + openEditor() + + composeTestRule + .onNodeWithTag(EntryDetailsScreenTestTags.SAVE_DISTANCE_BUTTON) + .assertIsNotEnabled() + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD).performTextInput("0") + composeTestRule + .onNodeWithTag(EntryDetailsScreenTestTags.SAVE_DISTANCE_BUTTON) + .assertIsNotEnabled() + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD).performTextClearance() + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD).performTextInput("800") + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.SAVE_DISTANCE_BUTTON).assertIsEnabled() + } + + @Test + fun cancellingTheEdit_keepsTheDistance() { + val repository = FakeEntryRepository(listOf(first, second)) + show(second.timestampEpochMilli, repository) + openEditor() + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.DISTANCE_FIELD).performTextInput("9") + + composeTestRule.onNodeWithTag(EntryDetailsScreenTestTags.CANCEL_EDIT_BUTTON).performClick() + + assertEquals(1200, repository.storedEntries.last().swimDistanceMeters) + composeTestRule.onAllNodesWithText("Edit distance").assertCountEquals(0) + } + + @Test + fun theEditButton_isHidden_whenTheEntryDoesNotExist() { + show(42L, FakeEntryRepository(listOf(first, second))) + + composeTestRule.onAllNodesWithTag(EntryDetailsScreenTestTags.EDIT_BUTTON).assertCountEquals(0) + } +}