From baf5fbf2b65298abb7d28a3ba81c63ff31781fa3 Mon Sep 17 00:00:00 2001 From: Leo CACHEUX Date: Wed, 15 Oct 2025 23:31:10 +0200 Subject: [PATCH 1/3] Dose deletion --- .../nvp/app/repository/StorageRepository.kt | 4 ++ .../net/cacheux/nvp/app/ui/ScreenWrapper.kt | 4 +- .../app/viewmodel/BaseMainScreenViewModel.kt | 8 +++ .../kotlin/net/cacheux/nvp/model/Dose.kt | 5 +- .../kotlin/net/cacheux/nvp/model/DoseGroup.kt | 4 ++ .../nvplib/storage/room/RoomDoseStorage.kt | 4 ++ .../nvplib/storage/room/dao/DoseDao.kt | 3 + .../storage/room/entities/DoseWithPen.kt | 3 +- .../storage/room/entities/PenWithDoses.kt | 3 +- .../nvplib/storage/room/entities/RoomDose.kt | 6 +- .../storage/room/RoomDoseStorageTest.kt | 15 +++++ .../net/cacheux/nvplib/storage/DoseStorage.kt | 2 + .../net/cacheux/nvp/ui/DoseGroupDetails.kt | 57 ++++++++++++++++++- .../kotlin/net/cacheux/nvp/ui/DoseList.kt | 15 +++-- .../kotlin/net/cacheux/nvp/ui/MainScreen.kt | 6 +- 15 files changed, 124 insertions(+), 15 deletions(-) diff --git a/app/src/commonMain/kotlin/net/cacheux/nvp/app/repository/StorageRepository.kt b/app/src/commonMain/kotlin/net/cacheux/nvp/app/repository/StorageRepository.kt index 4e84aba..537757f 100644 --- a/app/src/commonMain/kotlin/net/cacheux/nvp/app/repository/StorageRepository.kt +++ b/app/src/commonMain/kotlin/net/cacheux/nvp/app/repository/StorageRepository.kt @@ -25,6 +25,10 @@ class StorageRepository( storage.deletePen(serial) } + suspend fun deleteDose(id: Long) { + storage.deleteDose(id) + } + override suspend fun getStopCondition(): StopCondition { val currentTime = System.currentTimeMillis() val stopMap = storage.listAllPens().first().map { diff --git a/app/src/commonMain/kotlin/net/cacheux/nvp/app/ui/ScreenWrapper.kt b/app/src/commonMain/kotlin/net/cacheux/nvp/app/ui/ScreenWrapper.kt index 93fa4f5..1609569 100644 --- a/app/src/commonMain/kotlin/net/cacheux/nvp/app/ui/ScreenWrapper.kt +++ b/app/src/commonMain/kotlin/net/cacheux/nvp/app/ui/ScreenWrapper.kt @@ -51,7 +51,9 @@ fun ScreenWrapper( message = mainScreenViewModel.getReadMessage().collectAsState().value?.let { stringResource(it) }, - onDismissMessage = { mainScreenViewModel.clearPopup() }, + onDismissMessage = mainScreenViewModel::clearPopup, + + onDoseDeletion = mainScreenViewModel::deleteDoses, dropdownMenuParams = MainDropdownMenuParams( loadingFileAvailable = true, diff --git a/app/src/commonMain/kotlin/net/cacheux/nvp/app/viewmodel/BaseMainScreenViewModel.kt b/app/src/commonMain/kotlin/net/cacheux/nvp/app/viewmodel/BaseMainScreenViewModel.kt index cc72912..e12060d 100644 --- a/app/src/commonMain/kotlin/net/cacheux/nvp/app/viewmodel/BaseMainScreenViewModel.kt +++ b/app/src/commonMain/kotlin/net/cacheux/nvp/app/viewmodel/BaseMainScreenViewModel.kt @@ -64,6 +64,14 @@ open class BaseMainScreenViewModel ( val store = repository.getDataStore() + fun deleteDoses(doses: List) { + coroutineScope.launch { + doses.forEach { + storageRepository.deleteDose(it.id) + } + } + } + fun loadCsvFile(input: InputStream) { input.reader().use { it.readText().csvToDoseList().let { doseList -> diff --git a/model/src/commonMain/kotlin/net/cacheux/nvp/model/Dose.kt b/model/src/commonMain/kotlin/net/cacheux/nvp/model/Dose.kt index 3b67b8b..6e15975 100644 --- a/model/src/commonMain/kotlin/net/cacheux/nvp/model/Dose.kt +++ b/model/src/commonMain/kotlin/net/cacheux/nvp/model/Dose.kt @@ -5,9 +5,10 @@ data class Dose( val value: Int, val ignored: Boolean = false, val serial: String = "", - val color: String = "" + val color: String = "", + val id: Long = 0 ): DatedItem { - fun ignored() = Dose(time, value, true, serial, color) + fun ignored() = Dose(time, value, true, serial, color, id) fun displayedValue() = String.format("%.1f", value.toFloat() / 10) diff --git a/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt b/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt index fe0251e..f8b0126 100644 --- a/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt +++ b/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt @@ -35,6 +35,10 @@ class DoseGroup( fun displayedTotal() = String.format("%.1f", getTotal().toFloat() / 10) override fun date() = timestampToDate(getTime()) + + fun containsSameDosesAs(group: DoseGroup): Boolean { + return doses.any { group.doses.contains(it) } + } } fun List.toDoseListWithIgnoredFlag(config: DoseGroupConfig): List { diff --git a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorage.kt b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorage.kt index b3eb11b..2767479 100644 --- a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorage.kt +++ b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorage.kt @@ -72,6 +72,10 @@ class RoomDoseStorage( database.doseDao().deletePen(serial) } + override suspend fun deleteDose(id: Long) { + database.doseDao().deleteDoseById(id) + } + override fun listAllPens(): Flow> = database.doseDao().listAllPens().map { it.map { pen -> pen.toPenInfos() } } diff --git a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/dao/DoseDao.kt b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/dao/DoseDao.kt index 0b311d7..c995ac1 100644 --- a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/dao/DoseDao.kt +++ b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/dao/DoseDao.kt @@ -49,6 +49,9 @@ interface DoseDao { @Query("DELETE FROM pen WHERE serial = :serial") suspend fun deletePenBySerial(serial: String) + @Query("DELETE FROM dose WHERE id = :id") + suspend fun deleteDoseById(id: Long) + @Query("DELETE FROM dose") suspend fun deleteAllDoses() diff --git a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/DoseWithPen.kt b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/DoseWithPen.kt index c8aa3a0..6cb6cd7 100644 --- a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/DoseWithPen.kt +++ b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/DoseWithPen.kt @@ -16,6 +16,7 @@ data class DoseWithPen( time = dose.time, value = dose.value, serial = pen.serial, - color = pen.color + color = pen.color, + id = dose.id ) } diff --git a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/PenWithDoses.kt b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/PenWithDoses.kt index 9996f79..84eacba 100644 --- a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/PenWithDoses.kt +++ b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/PenWithDoses.kt @@ -22,7 +22,8 @@ data class PenWithDoses( time = it.time, value = it.value, serial = roomPen.serial, - color = roomPen.color + color = roomPen.color, + id = it.id ) } } diff --git a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/RoomDose.kt b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/RoomDose.kt index f7b6889..3ed5654 100644 --- a/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/RoomDose.kt +++ b/storage/room/src/commonMain/kotlin/net/cacheux/nvplib/storage/room/entities/RoomDose.kt @@ -15,10 +15,12 @@ data class RoomDose( fun Dose.toRoomDose(penId: Long = 0) = RoomDose( time = time, value = value, - pen = penId + pen = penId, + id = id ) fun RoomDose.toDose() = Dose( time = time, - value = value + value = value, + id = id ) diff --git a/storage/room/src/jvmTest/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorageTest.kt b/storage/room/src/jvmTest/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorageTest.kt index 75aa574..db979e1 100644 --- a/storage/room/src/jvmTest/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorageTest.kt +++ b/storage/room/src/jvmTest/kotlin/net/cacheux/nvplib/storage/room/RoomDoseStorageTest.kt @@ -120,6 +120,21 @@ class RoomDoseStorageTest { assertEquals(12346850L, storage.getLastDose("ABCD2345").first()?.time) } + @Test + fun testDeleteDoses() = runBlocking { + val storage = initStorage().apply { createDataset() } + + assertEquals(5, storage.getAllDoses().first().size) + + storage.deleteDose(3) + storage.deleteDose(4) + + assertEquals(3, storage.getAllDoses().first().size) + assertEquals(12345678L, storage.getAllDoses().first()[2].time) + assertEquals(12345690L, storage.getAllDoses().first()[1].time) + assertEquals(12346850L, storage.getAllDoses().first()[0].time) + } + private fun initStorage(): RoomDoseStorage = RoomDoseStorage( Room.inMemoryDatabaseBuilder() diff --git a/storage/storage-interface/src/main/kotlin/net/cacheux/nvplib/storage/DoseStorage.kt b/storage/storage-interface/src/main/kotlin/net/cacheux/nvplib/storage/DoseStorage.kt index 33e5695..e91b408 100644 --- a/storage/storage-interface/src/main/kotlin/net/cacheux/nvplib/storage/DoseStorage.kt +++ b/storage/storage-interface/src/main/kotlin/net/cacheux/nvplib/storage/DoseStorage.kt @@ -18,5 +18,7 @@ interface DoseStorage { suspend fun deletePen(serial: String) + suspend fun deleteDose(id: Long) + fun listAllPens(): Flow> } diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt index 3060782..c7d4b39 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt @@ -1,15 +1,28 @@ package net.cacheux.nvp.ui import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -25,13 +38,53 @@ val format = SimpleDateFormat("dd/MM/yyyy HH:mm:ss") @Composable fun DoseGroupDetails( doseGroup: DoseGroup, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onDoseDeletion: (List) -> Unit = {} ) { + var editMode by remember { mutableStateOf(false) } + + val selected = remember { mutableStateListOf() } + Column( modifier = modifier.background(Color.White) + .pointerInput(doseGroup) { + detectTapGestures( + onLongPress = { editMode = true } + ) + } ) { doseGroup.doses.forEach { - DoseDetails(dose = it) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (editMode) { + Checkbox( + checked = selected.contains(it), + onCheckedChange = { checked -> + if (checked) { + selected.add(it) + } else { + selected.remove(it) + } + } + ) + } + DoseDetails(dose = it, modifier = Modifier.weight(1f)) + } + + } + + if (editMode) { + Spacer(modifier = Modifier.height(4.dp)) + Button( + onClick = { + onDoseDeletion(selected) + }, + modifier = Modifier.fillMaxWidth().padding(8.dp), + enabled = selected.isNotEmpty() + ) { + Text("Delete selected") + } } } } diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseList.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseList.kt index ca8383c..1d95b00 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseList.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseList.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import net.cacheux.nvp.model.Dose import net.cacheux.nvp.model.DoseGroup import net.cacheux.nvp.model.groupByDate import net.cacheux.nvp.model.testDateTime @@ -40,7 +41,8 @@ val headerDate = SimpleDateFormat("dd/MM/YYYY") fun DoseList( items: List, currentDoseGroup: DoseGroup? = null, - onDoseClick: (DoseGroup) -> Unit = {} + onDoseClick: (DoseGroup) -> Unit = {}, + onDoseDeletion: (List) -> Unit = {}, ) { if (items.isEmpty()) { Box( @@ -67,8 +69,9 @@ fun DoseList( items(doses) { item -> DoseListItem( dose = item, - isCurrent = item == currentDoseGroup, - onClick = onDoseClick + isCurrent = currentDoseGroup?.containsSameDosesAs(item) ?: false, + onClick = onDoseClick, + onDoseDeletion = onDoseDeletion ) HorizontalDivider() } @@ -99,7 +102,8 @@ fun DoseListHeader( fun DoseListItem( dose: DoseGroup, isCurrent: Boolean = false, - onClick: (DoseGroup) -> Unit = {} + onClick: (DoseGroup) -> Unit = {}, + onDoseDeletion: (List) -> Unit = {}, ) { val format = SimpleDateFormat("HH:mm:ss") @@ -129,7 +133,8 @@ fun DoseListItem( if (isCurrent) { DoseGroupDetails( doseGroup = dose, - modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 8.dp) + modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 8.dp), + onDoseDeletion = onDoseDeletion ) } } diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/MainScreen.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/MainScreen.kt index 3711b2d..348fb05 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/MainScreen.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/MainScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import net.cacheux.nvp.model.Dose import net.cacheux.nvp.model.DoseGroup import net.cacheux.nvp.model.testDateTime import net.cacheux.nvp.model.testDoseGroup @@ -56,6 +57,8 @@ fun MainScreen( loading: Boolean = false, onDismissMessage: () -> Unit = {}, + onDoseDeletion: (List) -> Unit = {}, + sideMenuParams: SideMenuParams = SideMenuParams(), dropdownMenuParams: MainDropdownMenuParams = MainDropdownMenuParams(), dropdownMenuActions: MainDropdownMenuActions = MainDropdownMenuActions() @@ -125,7 +128,8 @@ fun MainScreen( currentDoseGroup.value = it } } - } + }, + onDoseDeletion = onDoseDeletion ) } } From 44490c71a2ebf8dbb365daab867e27cb1987ad2e Mon Sep 17 00:00:00 2001 From: Leo CACHEUX Date: Fri, 17 Oct 2025 16:51:54 +0200 Subject: [PATCH 2/3] Add confirm dialog --- .../kotlin/net/cacheux/nvp/model/DoseGroup.kt | 2 +- .../composeResources/values-fr/strings.xml | 3 ++ .../composeResources/values/strings.xml | 3 ++ .../net/cacheux/nvp/ui/ActionPreference.kt | 30 +++------------ .../net/cacheux/nvp/ui/ConfirmDialog.kt | 37 +++++++++++++++++++ .../net/cacheux/nvp/ui/DoseGroupDetails.kt | 22 ++++++++++- 6 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ConfirmDialog.kt diff --git a/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt b/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt index f8b0126..4e97b9d 100644 --- a/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt +++ b/model/src/commonMain/kotlin/net/cacheux/nvp/model/DoseGroup.kt @@ -37,7 +37,7 @@ class DoseGroup( override fun date() = timestampToDate(getTime()) fun containsSameDosesAs(group: DoseGroup): Boolean { - return doses.any { group.doses.contains(it) } + return doses.any { group.doses.map { it.id }.contains(it.id) } } } diff --git a/ui/src/commonMain/composeResources/values-fr/strings.xml b/ui/src/commonMain/composeResources/values-fr/strings.xml index 430c59a..e5d37bd 100644 --- a/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -17,6 +17,9 @@ Réinitialiser Supprimer + Supprimer la sélection + Êtes vous sûr de vouloir supprimer les doses sélectionnées ? + Nom Couleur diff --git a/ui/src/commonMain/composeResources/values/strings.xml b/ui/src/commonMain/composeResources/values/strings.xml index bd4eb69..1a1fb13 100644 --- a/ui/src/commonMain/composeResources/values/strings.xml +++ b/ui/src/commonMain/composeResources/values/strings.xml @@ -17,6 +17,9 @@ Reset Clear + Delete selected + Are you sure you want to delete the selected doses? + Name Color diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ActionPreference.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ActionPreference.kt index 2bc734e..69d4133 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ActionPreference.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ActionPreference.kt @@ -6,10 +6,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -18,10 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import net.cacheux.nvp.ui.ui.generated.resources.Res -import net.cacheux.nvp.ui.ui.generated.resources.cancel -import net.cacheux.nvp.ui.ui.generated.resources.ok -import org.jetbrains.compose.resources.stringResource @Composable fun ActionPreference( @@ -51,24 +45,12 @@ fun ActionPreference( Spacer(modifier = Modifier.weight(1f)) if (showDialog) { - AlertDialog( - onDismissRequest = { showDialog = false }, - title = { Text(text = label) }, - text = { Text(text = confirmMessage ?: "") }, - confirmButton = { - TextButton(onClick = { - action() - showDialog = false - }) { - Text(stringResource(Res.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { showDialog = false }) { - Text(stringResource(Res.string.cancel)) - } - } + ConfirmDialog( + label = label, + confirmMessage = confirmMessage, + action = action, + onDismiss = { showDialog = false } ) } } -} \ No newline at end of file +} diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ConfirmDialog.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ConfirmDialog.kt new file mode 100644 index 0000000..bb1d9fe --- /dev/null +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/ConfirmDialog.kt @@ -0,0 +1,37 @@ +package net.cacheux.nvp.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import net.cacheux.nvp.ui.ui.generated.resources.Res +import net.cacheux.nvp.ui.ui.generated.resources.cancel +import net.cacheux.nvp.ui.ui.generated.resources.ok +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ConfirmDialog( + label: String, + confirmMessage: String?, + action: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = label) }, + text = { Text(text = confirmMessage ?: "") }, + confirmButton = { + TextButton(onClick = { + action() + onDismiss() + }) { + Text(stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt index c7d4b39..2c56a39 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt @@ -29,6 +29,10 @@ import androidx.compose.ui.unit.dp import net.cacheux.nvp.model.Dose import net.cacheux.nvp.model.DoseGroup import net.cacheux.nvp.model.testDateTime +import net.cacheux.nvp.ui.ui.generated.resources.Res +import net.cacheux.nvp.ui.ui.generated.resources.delete_dose_warning +import net.cacheux.nvp.ui.ui.generated.resources.delete_selected +import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import java.text.SimpleDateFormat import java.util.Date @@ -75,15 +79,29 @@ fun DoseGroupDetails( } if (editMode) { + var showConfirm by remember { mutableStateOf(false) } + Spacer(modifier = Modifier.height(4.dp)) Button( onClick = { - onDoseDeletion(selected) + showConfirm = true }, modifier = Modifier.fillMaxWidth().padding(8.dp), enabled = selected.isNotEmpty() ) { - Text("Delete selected") + Text(stringResource(Res.string.delete_selected)) + } + + if (showConfirm) { + ConfirmDialog( + label = stringResource(Res.string.delete_selected), + confirmMessage = stringResource(Res.string.delete_dose_warning), + action = { + onDoseDeletion(selected.toList()) + selected.clear() + }, + onDismiss = { showConfirm = false } + ) } } } From e0dbed80bea0e7ae0c1ebe98d11458b45fe4544e Mon Sep 17 00:00:00 2001 From: Leo CACHEUX Date: Fri, 17 Oct 2025 18:33:58 +0200 Subject: [PATCH 3/3] Add integration test for dose deletion --- app/build.gradle.kts | 2 +- .../net/cacheux/nvp/app/MainScreenTest.kt | 74 +++++++++++++++++++ .../net/cacheux/nvp/app/SettingsTest.kt | 2 +- gradle/libs.versions.toml | 16 ++-- .../net/cacheux/nvp/ui/DoseGroupDetails.kt | 4 + 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ed65d5..4f152ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,7 +36,7 @@ kotlin { val androidInstrumentedTest by getting { dependencies { - implementation(libs.espresso.core) + implementation(libs.androidx.test.runner) implementation(libs.androidx.test.ext.junit) implementation(libs.hilt.android.testing) implementation(libs.mockito.android) diff --git a/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/MainScreenTest.kt b/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/MainScreenTest.kt index 33eecf5..cdc42f2 100644 --- a/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/MainScreenTest.kt +++ b/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/MainScreenTest.kt @@ -1,22 +1,36 @@ package net.cacheux.nvp.app import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import net.cacheux.nvp.app.repository.PenInfoRepository import net.cacheux.nvp.ui.ui.generated.resources.Res +import net.cacheux.nvp.ui.ui.generated.resources.delete_dose_warning +import net.cacheux.nvp.ui.ui.generated.resources.delete_selected +import net.cacheux.nvp.ui.ui.generated.resources.ok import net.cacheux.nvp.ui.ui.generated.resources.open_drawer import net.cacheux.nvp.ui.ui.generated.resources.reading_pen +import net.cacheux.nvplib.storage.DoseStorage import org.jetbrains.compose.resources.getString +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -31,6 +45,9 @@ import javax.inject.Inject @RunWith(AndroidJUnit4::class) class MainScreenTest { + @Inject + lateinit var doseStorage: DoseStorage + @Inject lateinit var penInfoRepository: PenInfoRepository @@ -85,6 +102,63 @@ class MainScreenTest { assertTrue(activity.isFinishing) } } + + @Test + fun testDoseDeletion() = runBlocking { + composeTestRule.run { + doseStorage.insertData() + + assertEquals(9, doseStorage.getAllDoses().first().size) + + waitForIdle() + + onNodeWithText("52.0").performClick() + waitForIdle() + + onNodeWithText("2.0").isDisplayed() + + onNodeWithTag("doseGroupDetails", useUnmergedTree = true).performTouchInput { + longClick() + } + waitForIdle() + + onNodeWithText(getString(Res.string.delete_selected)) + .assertIsDisplayed() + .assertIsNotEnabled() + + onNodeWithTag("doseCheck8").assertIsDisplayed() + onNodeWithTag("doseCheck9").assertIsDisplayed() + .performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.delete_selected)) + .assertIsDisplayed() + .assertIsEnabled() + .performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.delete_dose_warning)) + .assertIsDisplayed() + + onNodeWithText(getString(Res.string.ok)) + .performClick() + waitForIdle() + + onNodeWithText(getString(Res.string.delete_dose_warning)) + .assertIsNotDisplayed() + + onNodeWithTag("doseCheck8").assertExists() + onNodeWithTag("doseCheck9").assertDoesNotExist() + + onNodeWithText(getString(Res.string.delete_selected)) + .assertIsDisplayed() + .assertIsNotEnabled() + + onNodeWithText("52.0").assertDoesNotExist() + + assertEquals(8, doseStorage.getAllDoses().first().size) + } + } } fun AndroidComposeTestRule.pressBack() { diff --git a/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/SettingsTest.kt b/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/SettingsTest.kt index 6c6bb3b..4520c64 100644 --- a/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/SettingsTest.kt +++ b/app/src/androidInstrumentedTest/kotlin/net/cacheux/nvp/app/SettingsTest.kt @@ -14,7 +14,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules -import jakarta.inject.Inject import kotlinx.coroutines.runBlocking import net.cacheux.nvp.ui.ui.generated.resources.Res import net.cacheux.nvp.ui.ui.generated.resources.auto_ignore @@ -33,6 +32,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import javax.inject.Inject @HiltAndroidTest @UninstallModules(NvpModule::class) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd1c717..2a797f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,12 @@ java = "17" android-minSdk = "24" android-compileSdk = "36" agp = "8.13.0" -compose-plugin="1.8.2" +compose-plugin="1.9.0" hilt = "2.57.1" kotlin = "2.1.21" coroutines = "1.10.2" -room = "2.7.2" -sqlite = "2.5.2" +room = "2.8.2" +sqlite = "2.6.1" androidx-datastore = "1.1.7" [plugins] @@ -32,7 +32,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } -androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.11.0" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "androidx-datastore" } androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "androidx-datastore" } androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.9.3" } @@ -51,8 +51,8 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers # Testing junit = { group = "junit", name = "junit", version = "4.13.2" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.3.0" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.7.0" } -mockito-android = { group = "org.mockito", name = "mockito-android", version = "5.19.0" } -mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "6.0.0" } +androidx-test-runner = { group = "androidx.test", name = "runner", version="1.7.0" } +mockito-android = { group = "org.mockito", name = "mockito-android", version = "5.20.0" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "6.1.0" } turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version = "1.9.0" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version = "1.9.3" } diff --git a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt index 2c56a39..b54afe5 100644 --- a/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt +++ b/ui/src/commonMain/kotlin/net/cacheux/nvp/ui/DoseGroupDetails.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -51,6 +52,7 @@ fun DoseGroupDetails( Column( modifier = modifier.background(Color.White) + .testTag("doseGroupDetails") .pointerInput(doseGroup) { detectTapGestures( onLongPress = { editMode = true } @@ -63,6 +65,8 @@ fun DoseGroupDetails( ) { if (editMode) { Checkbox( + modifier = Modifier. + testTag("doseCheck${it.id}"), checked = selected.contains(it), onCheckedChange = { checked -> if (checked) {