Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ 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
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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -101,13 +109,55 @@ 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(
Screen.EntryDetails,
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ 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

/**
* 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(
Expand Down Expand Up @@ -49,4 +53,47 @@ class EntryDetailsViewModel(
val entry = details.value?.entry ?: return
viewModelScope.launch { entryRepository.deleteEntry(entry) }
}

private val _distanceInput = MutableStateFlow<String?>(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<String?> = _distanceInput.asStateFlow()

/** Whether [distanceInput] is a distance that can be saved, see [parseSwimDistance]. */
val isDistanceInputValid: StateFlow<Boolean> =
_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)
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading