From 98738ce987e7c33fbca83f8f54ec4f1b013071a6 Mon Sep 17 00:00:00 2001 From: Leonardo Colman Lopes Date: Fri, 31 Jan 2025 18:59:10 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20Improve=20PauseRepository=20+=20Pau?= =?UTF-8?q?seRepositoryTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Leonardo Colman Lopes --- .../use/pause/repository/PauseRepository.kt | 6 - .../colman/petals/widget/BreakPeriodPart.kt | 50 ----- .../colman/petals/widget/PetalsAppWidget.kt | 34 --- .../WidgetConcentrationDiscomfortPart.kt | 79 ------- .../colman/petals/widget/WidgetUsagePart.kt | 205 ------------------ .../pause/repository/PauseRepositoryTest.kt | 200 ++++++++++++++--- 6 files changed, 167 insertions(+), 407 deletions(-) delete mode 100644 app/src/main/kotlin/br/com/colman/petals/widget/BreakPeriodPart.kt delete mode 100644 app/src/main/kotlin/br/com/colman/petals/widget/PetalsAppWidget.kt delete mode 100644 app/src/main/kotlin/br/com/colman/petals/widget/WidgetConcentrationDiscomfortPart.kt delete mode 100644 app/src/main/kotlin/br/com/colman/petals/widget/WidgetUsagePart.kt diff --git a/app/src/main/kotlin/br/com/colman/petals/use/pause/repository/PauseRepository.kt b/app/src/main/kotlin/br/com/colman/petals/use/pause/repository/PauseRepository.kt index a73f34e3..bef7dfee 100644 --- a/app/src/main/kotlin/br/com/colman/petals/use/pause/repository/PauseRepository.kt +++ b/app/src/main/kotlin/br/com/colman/petals/use/pause/repository/PauseRepository.kt @@ -2,11 +2,9 @@ package br.com.colman.petals.use.pause.repository import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList -import app.cash.sqldelight.coroutines.mapToOneOrNull import br.com.colman.petals.PauseQueries import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -21,10 +19,6 @@ class PauseRepository( pauses.map(PauseEntity::toPause).sortedWith(compareBy({ it.startTime }, { it.endTime })) } - fun get(dispatcher: CoroutineDispatcher = IO): Flow { - return pauseQueries.selectFirst().asFlow().mapToOneOrNull(dispatcher).map { it?.toPause() } - } - fun insert(pause: Pause) { pauseQueries.insert(pause.toEntity()) } diff --git a/app/src/main/kotlin/br/com/colman/petals/widget/BreakPeriodPart.kt b/app/src/main/kotlin/br/com/colman/petals/widget/BreakPeriodPart.kt deleted file mode 100644 index 861bab22..00000000 --- a/app/src/main/kotlin/br/com/colman/petals/widget/BreakPeriodPart.kt +++ /dev/null @@ -1,50 +0,0 @@ -package br.com.colman.petals.widget - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.layout.Alignment.Horizontal.Companion.CenterHorizontally -import androidx.glance.layout.Alignment.Vertical.Companion.CenterVertically -import androidx.glance.layout.Column -import androidx.glance.layout.padding -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import br.com.colman.petals.R -import br.com.colman.petals.use.pause.repository.PauseRepository -import org.koin.compose.koinInject - -@Composable -fun BreakPeriodPart() { - val pauseRepository: PauseRepository = koinInject() - val pause by pauseRepository.get().collectAsState(null) - val isPaused = pause?.isActive() ?: false - - Column(GlanceModifier.padding(4.dp), CenterVertically, CenterHorizontally) { - if (isPaused) { - Text( - stringResource(id = R.string.break_period), - style = TextStyle( - fontWeight = FontWeight.Bold, - color = ColorProvider(Color.Red), - fontSize = 18.sp - ) - ) - } else { - Text( - text = stringResource(id = R.string.no_break_period), - style = TextStyle( - fontWeight = FontWeight.Bold, - color = ColorProvider(Color.Green), - fontSize = 18.sp - ) - ) - } - } -} diff --git a/app/src/main/kotlin/br/com/colman/petals/widget/PetalsAppWidget.kt b/app/src/main/kotlin/br/com/colman/petals/widget/PetalsAppWidget.kt deleted file mode 100644 index 19dbd0af..00000000 --- a/app/src/main/kotlin/br/com/colman/petals/widget/PetalsAppWidget.kt +++ /dev/null @@ -1,34 +0,0 @@ -package br.com.colman.petals.widget - -import android.content.Context -import androidx.compose.material.MaterialTheme -import androidx.glance.GlanceId -import androidx.glance.GlanceModifier -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.provideContent -import androidx.glance.background -import androidx.glance.layout.Alignment.Horizontal.Companion.CenterHorizontally -import androidx.glance.layout.Alignment.Vertical.Companion.CenterVertically -import androidx.glance.layout.Column -import androidx.glance.layout.fillMaxSize - -object PetalsAppWidget : GlanceAppWidget() { - override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - Column( - GlanceModifier.fillMaxSize().background(MaterialTheme.colors.onBackground), - CenterVertically, - CenterHorizontally - ) { - WidgetUsagePart() - WidgetConcentrationDiscomfortPart() - BreakPeriodPart() - } - } - } -} - -class PetalsAppWidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget = PetalsAppWidget -} diff --git a/app/src/main/kotlin/br/com/colman/petals/widget/WidgetConcentrationDiscomfortPart.kt b/app/src/main/kotlin/br/com/colman/petals/widget/WidgetConcentrationDiscomfortPart.kt deleted file mode 100644 index bc0f1f95..00000000 --- a/app/src/main/kotlin/br/com/colman/petals/widget/WidgetConcentrationDiscomfortPart.kt +++ /dev/null @@ -1,79 +0,0 @@ -package br.com.colman.petals.widget - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.LocalContext -import androidx.glance.layout.Alignment.Horizontal.Companion.CenterHorizontally -import androidx.glance.layout.Alignment.Vertical.Companion.CenterVertically -import androidx.glance.layout.Column -import androidx.glance.layout.padding -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import br.com.colman.petals.R -import br.com.colman.petals.use.repository.UseRepository -import br.com.colman.petals.withdrawal.data.DiscomfortDataPoints -import br.com.colman.petals.withdrawal.data.ThcConcentrationDataPoints -import br.com.colman.petals.withdrawal.interpolator.Interpolator -import br.com.colman.petals.withdrawal.view.SecondsPerDay -import kotlinx.coroutines.flow.filterNotNull -import org.koin.compose.koinInject -import java.time.LocalDateTime -import java.time.temporal.ChronoUnit - -@Composable -fun WidgetConcentrationDiscomfortPart() { - val useRepository = koinInject() - - val lastUseDate by useRepository.getLastUseDate().filterNotNull().collectAsState(LocalDateTime.now().minusYears(10)) - val thcInterpolator = Interpolator(ThcConcentrationDataPoints) - val discomfortInterpolator = Interpolator(DiscomfortDataPoints) - - val currentPercentageTHC = - thcInterpolator.calculatePercentage(ChronoUnit.SECONDS.between(lastUseDate, LocalDateTime.now())) * 100 - - val currentDiscomfort = discomfortInterpolator.value( - ChronoUnit.SECONDS.between(lastUseDate, LocalDateTime.now()).toDouble().div( - SecondsPerDay - ) - ) - - val thcConcentrationString = LocalContext.current.getString( - R.string.current_thc_concentration, - "%.3f".format(currentPercentageTHC) - ) - - val discomfortString = LocalContext.current.getString( - R.string.current_withdrawal_discomfort, - "%.3f".format(currentDiscomfort) - ) - - Column( - GlanceModifier.padding(4.dp), - CenterVertically, - CenterHorizontally - ) { - Text( - thcConcentrationString, - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 14.sp - ) - ) - Text( - discomfortString, - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 14.sp - ) - ) - } -} diff --git a/app/src/main/kotlin/br/com/colman/petals/widget/WidgetUsagePart.kt b/app/src/main/kotlin/br/com/colman/petals/widget/WidgetUsagePart.kt deleted file mode 100644 index 4f400fff..00000000 --- a/app/src/main/kotlin/br/com/colman/petals/widget/WidgetUsagePart.kt +++ /dev/null @@ -1,205 +0,0 @@ -package br.com.colman.petals.widget - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.LocalContext -import androidx.glance.layout.Alignment -import androidx.glance.layout.Column -import androidx.glance.layout.Row -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.padding -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import br.com.colman.petals.R -import br.com.colman.petals.settings.SettingsRepository -import br.com.colman.petals.use.TimeUnit -import br.com.colman.petals.use.repository.UseRepository -import br.com.colman.petals.utils.truncatedToMinute -import kotlinx.coroutines.delay -import org.koin.compose.koinInject -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.format.DateTimeFormatter.ofPattern -import java.time.temporal.ChronoUnit -import java.util.* - -@Composable -fun WidgetUsagePart() { - val useRepository: UseRepository = koinInject() - val lastUseDate = useRepository.getLastUseDate().collectAsState(LocalDateTime.now()) - var millis by remember { - mutableStateOf( - ChronoUnit.MILLIS.between( - lastUseDate.value, - LocalDateTime.now() - ) - ) - } - - LaunchedEffect(millis) { - while (true) { - delay(11) - millis = ChronoUnit.MILLIS.between(lastUseDate.value, LocalDateTime.now()) - } - } - - val allLabels = listOf( - TimeUnit.Year, - TimeUnit.Month, - TimeUnit.Day, - TimeUnit.Hour, - TimeUnit.Minute, - TimeUnit.Second, - TimeUnit.Millisecond - ) - - val enabledLabels = allLabels.dropLast(1) - - var millisCopy = millis - val labels = enabledLabels.map { - val unitsTotal = millisCopy / it.millis - millisCopy -= unitsTotal * it.millis - it to unitsTotal - } - - Column( - modifier = GlanceModifier.padding(4.dp), - verticalAlignment = Alignment.Vertical.CenterVertically, - horizontalAlignment = Alignment.Horizontal.CenterHorizontally - ) { - LastUseDateView() - Column( - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - YearsMonthsDaysView(labels) - HoursMinutesSecondsView(labels) - } - } -} - -@Composable -private fun LastUseDateView() { - val useRepository: UseRepository = koinInject() - val settingsRepository = koinInject() - - val lastUseDate by useRepository.getLastUseDate().collectAsState(null) - val dateFormat by settingsRepository.dateFormat.collectAsState(settingsRepository.dateFormatList[0]) - val timeFormat by settingsRepository.timeFormat.collectAsState(settingsRepository.timeFormatList[0]) - val dateString = lastUseDate?.let { ofPattern("%s %s".format(dateFormat, timeFormat)).format(it) }.orEmpty() - - Column( - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = LocalContext.current.getString(R.string.quit_date_text), - style = TextStyle( - fontWeight = FontWeight.Medium, - color = ColorProvider(Color.White), - fontSize = 20.sp - ) - ) - val dateStringWithExtras = if (lastUseDate?.is420() == true) dateString else "$dateString 🥦🥦" - Text( - text = dateStringWithExtras, - style = TextStyle( - fontWeight = FontWeight.Medium, - color = ColorProvider(Color.White), - fontSize = 20.sp - ) - ) - } -} - -@Composable -private fun YearsMonthsDaysView(labels: List>) { - Row( - modifier = GlanceModifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - labels.filter { it.first in listOf(TimeUnit.Year, TimeUnit.Month, TimeUnit.Day) } - .forEach { (label, amount) -> - Row { - Text( - text = LocalContext.current.getString(label.unitName), - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 16.sp - ) - ) - Text( - text = ": $amount ", - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 16.sp - ) - ) - } - } - } -} - -@Composable -private fun HoursMinutesSecondsView(labels: List>) { - Row( - modifier = GlanceModifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - labels.filter { it.first in listOf(TimeUnit.Hour, TimeUnit.Minute, TimeUnit.Second) } - .forEach { (label, amount) -> - Row { - if (LocalContext.current.getString(label.unitName) != "Hours") { - Text( - text = formatLongAsTwoDigitString(amount), - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 16.sp - ) - ) - } else { - Text( - text = "$amount", - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 16.sp - ) - ) - } - if (LocalContext.current.getString(label.unitName) != "Seconds") { - Text( - text = ":", - style = TextStyle( - fontWeight = FontWeight.Normal, - color = ColorProvider(Color.White), - fontSize = 16.sp - ) - ) - } - } - } - } -} - -private fun LocalDateTime.is420() = toLocalTime().truncatedToMinute() == LocalTime.of(16, 20) - -private fun formatLongAsTwoDigitString(input: Long): String { - return String.format(Locale.US, "%02d", input) -} diff --git a/app/src/test/kotlin/br/com/colman/petals/use/pause/repository/PauseRepositoryTest.kt b/app/src/test/kotlin/br/com/colman/petals/use/pause/repository/PauseRepositoryTest.kt index f57913ff..cee6449c 100644 --- a/app/src/test/kotlin/br/com/colman/petals/use/pause/repository/PauseRepositoryTest.kt +++ b/app/src/test/kotlin/br/com/colman/petals/use/pause/repository/PauseRepositoryTest.kt @@ -2,17 +2,20 @@ package br.com.colman.petals.use.pause.repository import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import br.com.colman.petals.Database +import io.kotest.assertions.throwables.shouldThrowAny import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldBeSortedWith import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.shouldBe import io.kotest.property.Arb import io.kotest.property.arbitrary.localTime import io.kotest.property.arbitrary.next import kotlinx.coroutines.flow.first +import java.time.LocalTime +import java.time.temporal.ChronoUnit +import br.com.colman.petals.Pause as PauseEntity class PauseRepositoryTest : FunSpec({ @@ -25,49 +28,180 @@ class PauseRepositoryTest : FunSpec({ val pause = Pause(Arb.localTime().next(), Arb.localTime().next()) val otherPause = Pause(Arb.localTime().next(), Arb.localTime().next()) - test("Insert") { - target.insert(pause) - database.pauseQueries.selectFirst().executeAsOne().toPause() shouldBe pause + context("Get All") { + test("returns empty list when database is empty") { + target.getAll().first() shouldBe emptyList() + } + + test("returns all inserted pauses") { + target.insert(pause) + target.insert(otherPause) + target.getAll().first() shouldContainAll listOf(pause, otherPause) + } + + test("sorts by start time then end time") { + val earlyStart = pause.copy(startTime = LocalTime.NOON.minusHours(1)) + val lateStart = otherPause.copy(startTime = LocalTime.NOON) + + target.insert(lateStart) + target.insert(earlyStart) + target.getAll().first() shouldBe listOf(earlyStart, lateStart) + } + + test("sorts by end time when start times are equal") { + val earlyEnd = pause.copy( + startTime = LocalTime.NOON, + endTime = LocalTime.NOON.plusHours(1) + ) + val lateEnd = otherPause.copy( + startTime = LocalTime.NOON, + endTime = LocalTime.NOON.plusHours(2) + ) + + target.insert(lateEnd) + target.insert(earlyEnd) + target.getAll().first() shouldBe listOf(earlyEnd, lateEnd) + } } - test("Updating existing value after insert") { - target.insert(pause) - val updatedPause = pause.copy(isEnabled = false) - target.update(updatedPause) - database.pauseQueries.selectFirst().executeAsOne().toPause() shouldBe updatedPause + context("Update Operations") { + test("does nothing for non-existent pause") { + val nonExistentPause = Pause(LocalTime.MIN, LocalTime.MAX, id = "999") + target.update(nonExistentPause) + target.getAll().first() shouldBe emptyList() + } + + test("modifies existing pause") { + target.insert(pause) + val updated = pause.copy(isEnabled = false) + target.update(updated) + target.getAll().first() shouldContain updated + } + + test("persists time changes") { + target.insert(pause) + val updated = pause.copy( + startTime = LocalTime.NOON, + endTime = LocalTime.MIDNIGHT + ) + target.update(updated) + target.getAll().first().single() shouldBe updated + } } - test("Get all") { - target.insert(pause) - target.insert(otherPause) - database.pauseQueries.selectAll().executeAsList().map { it.toPause() } shouldContain pause - database.pauseQueries.selectAll().executeAsList().map { it.toPause() } shouldContain otherPause + context("Insert Operations") { + test("stores new pause") { + target.insert(pause) + target.getAll().first() shouldContain pause + } + + test("stores disabled state correctly") { + val disabledPause = pause.copy(isEnabled = false) + target.insert(disabledPause) + target.getAll().first() + .find { it.id == disabledPause.id } + ?.isEnabled shouldBe false + } + + test("throws constraint exception for duplicate IDs") { + val pause1 = pause.copy(id = "1") + val pause2 = otherPause.copy(id = "1") + target.insert(pause1) + shouldThrowAny { + target.insert(pause2) + } + } + + test("handles boundary time values") { + val minTime = LocalTime.MIN + val maxTime = LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS) + val boundaryPause = Pause(minTime, maxTime) + + target.insert(boundaryPause) + val retrieved = target.getAll().first().single() + + retrieved.startTime shouldBe minTime + retrieved.endTime shouldBe maxTime + } } - test("Get all should be sorted") { - target.insert(pause) - target.insert(otherPause) + context("Delete Operations") { + test("removes pause by ID") { + target.insert(pause) + target.delete(pause) + target.getAll().first() shouldNotContain pause + } - target.getAll().first().shouldBeSortedWith(compareBy({ it.startTime }, { it.endTime })) + test("ignores non-existent pauses") { + target.insert(pause) + target.delete(Pause(LocalTime.MIN, LocalTime.MAX, id = "999")) + target.getAll().first() shouldContain pause + } } - test("Delete by id") { - target.insert(pause) - target.insert(otherPause) - database.pauseQueries.selectAll().executeAsList().map { - it.toPause() - } shouldContainExactlyInAnyOrder listOf(pause, otherPause) + context("Conversion Logic") { + test("Pause to Entity converts times to ISO strings") { + val normalPause = Pause( + startTime = LocalTime.of(14, 30, 45), + endTime = LocalTime.of(15, 0), + isEnabled = true + ) - target.delete(pause) - database.pauseQueries.selectAll().executeAsList().map { it.toPause() } shouldNotContain pause + val entity = normalPause.toEntity() + entity.start_time shouldBe "14:30:45" + entity.end_time shouldBe "15:00:00" + entity.is_enabled shouldBe 1L + } - target.delete(otherPause) - database.pauseQueries.selectAll().executeAsList().map { it.toPause() } shouldNotContain otherPause - } + test("Entity to Pause parses ISO time strings") { + val entity = PauseEntity( + start_time = "23:59:59", + end_time = "00:00:00", + id = "123", + is_enabled = 0L + ) + + val convertedPause = entity.toPause() + convertedPause.startTime shouldBe LocalTime.of(23, 59, 59) + convertedPause.endTime shouldBe LocalTime.MIDNIGHT + convertedPause.isEnabled shouldBe false + } + + test("Disabled state round-trip conversion") { + val original = Pause( + startTime = LocalTime.NOON, + endTime = LocalTime.MIDNIGHT, + isEnabled = false + ) + + val roundTripped = original.toEntity().toPause() + roundTripped shouldBe original + } + + test("Boundary time round-trip conversion") { + val minTime = LocalTime.MIN + val maxTime = LocalTime.MAX.truncatedTo(ChronoUnit.SECONDS) + + val original = Pause(minTime, maxTime) + val roundTripped = original.toEntity().toPause() + + roundTripped.startTime shouldBe minTime + roundTripped.endTime shouldBe maxTime + } + + test("ID preservation in conversions") { + val normalPause = Pause( + startTime = LocalTime.NOON, + endTime = LocalTime.NOON.plusHours(1), + id = "unique-123" + ) + + val entity = normalPause.toEntity() + entity.id shouldBe "unique-123" - test("Get") { - database.pauseQueries.insert(pause.toEntity()) - target.get().first() shouldBe pause + val convertedBack = entity.toPause() + convertedBack.id shouldBe "unique-123" + } } isolationMode = IsolationMode.InstancePerTest