Release 2.19.2 (#216)
All checks were successful
All checks were successful
Release 2.19.2 — `versionName` 2.19.2 / `versionCode` 21902. Merging this cuts the release. A meeting you declined is struck through everywhere it appears and no longer schedules a reminder (#180). Tapping an event in month view opens the event instead of its day, in every month style (#187). An edit shows immediately when the same occurrence is re-opened (#196). The month widget's arrows died after a couple of taps: the grid serialised ~740 views and one update ran to 450–540 KB, which overran the launcher's async binder buffer and made Android drop the whole widget host — killing updates for every widget on the home screen. The grid is down to 192 views, and `SizeMode.Exact` is kept so a resized widget reflows instead of clipping (#214). Widget taps now redraw the tapped widget by its own id rather than `updateAll`, whose provider lookup is empty in a cold-woken process (#18). Jump-to-today in seamless weeks lands on the current week row (#191), and the day and week timelines got an end inset (#192). Two follow-ups beyond the issues: the detail re-read keeps the loaded content on screen instead of blinking back to the skeleton, and a month chip tap with no recorded touch down (TalkBack, D-pad) no longer resolves to the previous tap's chip. Release notes: `CHANGELOG.md` 2.19.2 section and `fastlane/metadata/android/en-US/changelogs/21902.txt` (471 chars). Closes #18 Closes #180 Closes #187 Closes #191 Closes #192 Closes #196 Closes #214 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/216
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -17,6 +18,7 @@ class InstanceMapperTest {
|
||||
eventColor: Any? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
location: String? = null,
|
||||
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
InstanceProjection.IDX_INSTANCE_ID to instanceId,
|
||||
InstanceProjection.IDX_EVENT_ID to eventId,
|
||||
@@ -28,6 +30,7 @@ class InstanceMapperTest {
|
||||
InstanceProjection.IDX_EVENT_COLOR to eventColor,
|
||||
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||
InstanceProjection.IDX_LOCATION to location,
|
||||
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -90,4 +93,20 @@ class InstanceMapperTest {
|
||||
val inst = reader(location = "Berlin").toEventInstance()
|
||||
assertThat(inst!!.location).isEqualTo("Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined invitation is marked, any other answer is not`() {
|
||||
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
|
||||
assertThat(
|
||||
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
|
||||
.toEventInstance()!!.isDeclined,
|
||||
).isTrue()
|
||||
listOf(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||
).forEach { status ->
|
||||
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Re-opening an occurrence must re-read it (#196): the view model outlives the
|
||||
* sheet, so an edit that changed no time would otherwise show the pre-save row.
|
||||
* The re-read stays silent — the loaded content must not blink back to the
|
||||
* skeleton on the way.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetailViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun detail(description: String?) = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = description, organizer = null, attendees = emptyList(), rrule = null,
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
|
||||
val prefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val settings = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
|
||||
}
|
||||
|
||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(
|
||||
CalendarSource(
|
||||
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
),
|
||||
)
|
||||
eventDetailResult = { detail(description()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
var stored: String? = null
|
||||
val vm = viewModel(tempDir, fakeSource { stored })
|
||||
val collector = launch(Job()) { vm.state.collect {} }
|
||||
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull()
|
||||
|
||||
// The edit screen saved a description; the tapped occurrence is unchanged.
|
||||
stored = "Bring the roadmap"
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat((vm.state.value as EventDetailUiState.Success).detail.description)
|
||||
.isEqualTo("Bring the roadmap")
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir, fakeSource { null })
|
||||
val seen = mutableListOf<EventDetailUiState>()
|
||||
val collector = launch(Job()) { vm.state.collect { seen += it } }
|
||||
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java)
|
||||
|
||||
seen.clear()
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(seen).doesNotContain(EventDetailUiState.Loading)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Which chip a tap in a month cell lands on (#187) — the geometry the tap layer
|
||||
* uses to tell "open this event" from "open this day", given that the chips take
|
||||
* no pointer input of their own.
|
||||
*/
|
||||
class ChipAtCellYTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
/** Band starts 40px down the cell; each lane is 20px tall. */
|
||||
private val bandTop = 40f
|
||||
private val laneHeight = 20f
|
||||
|
||||
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(zone),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = 0xFF2196F3.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0xFFF44336.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
|
||||
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
|
||||
|
||||
@Test
|
||||
fun `a tap on a lane resolves to the chip seated there`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight + 5f)?.eventId).isEqualTo(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day bar answers on every column it covers`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
assertThat(week.chipAt(col = col, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
|
||||
}
|
||||
// Jul 10 is past the bar's last day.
|
||||
assertThat(week.chipAt(col = 4, cellY = bandTop + 5f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap above the band is the day number, not a chip`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop - 1f)).isNull()
|
||||
assertThat(week.chipAt(col = 1, cellY = 0f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on an empty lane of a day that has chips falls through to the day`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight * 2 + 5f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on the overflow row opens the day rather than a hidden event`() {
|
||||
val events = (1..MAX_EVENT_ROWS + 2).map {
|
||||
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
|
||||
}
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
// The dots sit one lane below the last one the row draws.
|
||||
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
|
||||
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmeasured geometry resolves to no chip`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
|
||||
).isNull()
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
|
||||
).isNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user