22 Commits

Author SHA1 Message Date
2a2b919041 docs: record v0.2.0 data-layer + permission flow in CHANGELOG, planning
All checks were successful
CI / ci (push) Successful in 9m53s
Build and Release to F-Droid / ci (push) Successful in 5m58s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m7s
2026-06-08 17:58:02 +02:00
3ced240e23 test: instrumented repository smoke against real CalendarContract 2026-06-08 17:57:12 +02:00
035ac9b003 test: replace placeholder smoke with permission-rationale assert 2026-06-08 17:56:03 +02:00
c03389abe0 ui: replace placeholder with RootScreen routing permission ↔ debug 2026-06-08 17:55:34 +02:00
98f8433156 ui: add DebugScreen showing calendars + next 50 instances 2026-06-08 17:54:23 +02:00
8fbbab30e2 ui: add DebugViewModel combining calendars + next 30d instances 2026-06-08 17:52:50 +02:00
ef0a4b0568 ui: add PermissionScreen with rationale and denied recovery 2026-06-08 17:50:33 +02:00
43f12812b6 ui: add PermissionViewModel with three-state machine 2026-06-08 17:49:39 +02:00
2400d5482c i18n: add permission + debug screen strings (en, de) 2026-06-08 17:49:06 +02:00
4d54501ed4 di: wire CalendarRepository, DataSource, DataStore, IoDispatcher 2026-06-08 17:48:34 +02:00
748df761bf data: add CalendarPrefs (hidden calendar ids in DataStore) 2026-06-08 17:47:55 +02:00
d13f2f07a5 data: add CalendarRepository + Impl with SharedFlow re-emit on data-source ticks 2026-06-08 17:47:13 +02:00
7abb2e6ab4 data: add CalendarDataSource seam returning domain lists (not Cursors)
Deviation from Plan 02: changing from Cursor-returning interface to
domain-returning interface so the repository unit tests can use a simple
fake without constructing ContentObserver/Handler/Looper on the JVM
(which would either crash or no-op via the mockable.jar stubs).
2026-06-08 17:44:19 +02:00
fb003d8806 data: add ColumnReader.toEventDetailCore() and toAttendee() mappers 2026-06-08 17:42:42 +02:00
40b531fa52 data: add ColumnReader.toEventInstance() with defensive validation (§8) 2026-06-08 17:41:29 +02:00
0e4c47febe data: add ColumnReader abstraction + Cursor.toCalendarSource mapper
Deviation from Plan 02: the JVM mockable-android.jar stubs every Cursor
method even with isReturnDefaultValues=true (returns null/0 regardless of
the underlying MatrixCursor backing). Introduce an internal ColumnReader
interface so mappers stay pure-Kotlin and JVM-testable via MapColumnReader,
while production reads through CursorColumnReader.
2026-06-08 17:40:37 +02:00
fb723fba68 data: add CalendarContract column projections + indices
Some checks failed
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
2026-06-08 17:37:23 +02:00
Jean-Luc Makiola
ffc7ed414f fix(fdroid): correct metadata format to fastlane convention + add icon.png
All checks were successful
CI / ci (push) Successful in 8m52s
Inspection of the local Hetzner-synced F-Droid repo after v0.1.0
revealed that fdroidserver only partially picked up Calendula's
metadata: summary was sourced from the YAML fallback (en-US only),
description appeared only for the "de" locale (not de-DE), and no
icon was shown anywhere. Root cause: we wrote Google Play conventions
(short_description.txt, full_description.txt, bare locale code "de")
where fdroidserver expects the fastlane format that the sibling
HouseHoldKeaper repo already uses successfully.

Changes:
- de/ -> de-DE/ (BCP-47 with region matches HHK and is more reliably
  parsed by fdroidserver)
- short_description.txt -> summary.txt
- full_description.txt -> description.txt
- Add icon.png (512x512) per locale, composed from the adaptive icon's
  foreground path + slate background (rendered via rsvg-convert).
  Required because XML-only adaptive icons in the APK aren't
  auto-rasterized by fdroidserver.

Verified locally against the previously-broken index by composing the
new icon and renaming the files in-tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 17:37:05 +02:00
af75965a31 domain: add pure-Kotlin models (CalendarSource, EventInstance, EventDetail, …) 2026-06-08 17:36:39 +02:00
1b456d2133 data: add TimeBridge helpers for epoch-millis ↔ kotlin.time.Instant 2026-06-08 17:35:49 +02:00
a826e82bdc build: add kotlinx-datetime, coroutines, turbine, hilt-nav-compose, lifecycle-compose 2026-06-08 17:32:45 +02:00
ed680b4482 docs: add Plan 02 - Data Layer & Permission Flow implementation plan
21 bite-sized tasks covering domain models, CalendarContract data layer
(Cursor mappers with §8 defensive validation, ContentObserver-backed
SharedFlow repository), DataStore-persisted hidden-calendar set, Hilt
wiring, READ_CALENDAR permission flow (rationale + denied recovery), and
a wegwerfbarer Debug screen that visually validates data is flowing.

Out of scope: Month/Week/Day views (Plans 03-05), Event Detail Sheet
(Plan 06), Filter/Settings (Plan 07).
2026-06-08 17:30:41 +02:00
49 changed files with 5122 additions and 55 deletions

View File

@@ -10,8 +10,8 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
### Active (V1)
- [x] Foundation & CI infrastructure
- [ ] Data Layer over `CalendarContract`
- [ ] Permission flow (`READ_CALENDAR`)
- [x] Data Layer over `CalendarContract`
- [x] Permission flow (`READ_CALENDAR`)
- [ ] Month view (S1)
- [ ] Week view (S2)
- [ ] Day view (S3)

View File

@@ -5,7 +5,7 @@
| Version | Milestone | Status |
|---|---|---|
| v0.1 | Foundation & CI | complete |
| v0.2 | Data Layer & Permission Flow | pending |
| v0.2 | Data Layer & Permission Flow | complete |
| v0.3 | Month view | pending |
| v0.4 | Week view | pending |
| v0.5 | Day view | pending |

View File

@@ -4,19 +4,20 @@
## Status
**Milestone:** v0.1Foundation & CI
**Phase:** Plan 01 complete; ready to start Plan 02
**Milestone:** v0.2Data Layer & Permission Flow
**Phase:** Plan 02 complete; UI-design iteration pending before Plan 03
## Progress
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`)
- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green
- [ ] Plan 02 written (Data Layer & Permission Flow)
- [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
- [ ] UI-design iteration (mockups for Month/Week/Day/Detail/Filter/Settings, all three states)
- [ ] Plan 03 (Month view)
## Next
1. Write Plan 02: Data Layer & Permission Flow
2. Execute Plan 02
3. Iterate on UI design (mockups) before screens are built
1. Iterate on UI design (mockups per screen, all three states)
2. Write Plan 03: Month view
3. Execute Plan 03 — Debug screen gets replaced by month view

View File

@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.0] — 2026-06-08
### Added
- Domain models for calendars, event instances, event detail, attendees
- `CalendarContract`-backed `CalendarRepository` with `ContentObserver`-driven live updates
- DataStore preference for app-side hidden-calendar visibility
- `READ_CALENDAR` permission flow (rationale + denied recovery + system-settings shortcut)
- Wegwerfbarer Debug-Screen: zeigt alle Kalender + die nächsten 50 Termine ab heute
- Hilt-Wiring für Data-Layer (Repository, DataSource, DataStore, IO-Dispatcher)
- Unit-Tests für Cursor-Mapping (alle §8-Defensiv-Cases), Repository-Flows mit Turbine, DataStore round-trip
- Instrumented smoke test against the real CalendarContract provider
## [0.1.1] — 2026-06-08
### Fixed
- F-Droid metadata format: renamed locale dirs from `de/` to `de-DE/`,
`short_description.txt` to `summary.txt`, `full_description.txt` to
`description.txt` (fastlane format that fdroidserver actually reads,
matching the working HouseHoldKeaper convention)
- Added `icon.png` (512x512) per locale; fdroidserver does NOT
auto-extract icons from APKs that only contain XML adaptive icons
(which is what minSdk-29 apps produce), so the app was rendered
blank-iconed in F-Droid clients
### Changed
- CI pipeline cleanup: `lintDebug`/`testDebugUnitTest` instead of full
`lint`/`test` (cuts ~50% of lint work since release variant lint is
redundant for V1 single-variant build)
- Release workflow drops the lint step from its CI-sanity job since
the same lint already ran via `ci.yaml` when the underlying commit
hit main
## [0.1.0] — 2026-06-08
### Added

View File

@@ -74,7 +74,10 @@ android {
}
testOptions {
unitTests.all { it.useJUnitPlatform() }
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
}
@@ -87,6 +90,7 @@ kotlin {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -96,10 +100,14 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -107,9 +115,13 @@ dependencies {
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}

View File

@@ -4,23 +4,27 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Smoke: launches MainActivity and asserts the permission rationale renders
* when calendar access has not yet been granted. Without GrantPermissionRule
* the system reports NOT_GRANTED on first launch so we land in PermissionScreen.
*/
@RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun appName_isDisplayed_onLaunch() {
composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
}
private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
@Test
fun tagline_isDisplayed_onLaunch() {
composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed()
fun permissionRationale_isDisplayed_onLaunch_withoutPermission() {
composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
.assertIsDisplayed()
}
}

View File

@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Instant
@RunWith(AndroidJUnit4::class)
class CalendarRepositorySmokeTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private fun newRepo(): CalendarRepositoryImpl {
val dataSource = AndroidCalendarDataSource(context)
return CalendarRepositoryImpl(dataSource, Dispatchers.IO)
}
@Test
fun calendars_returnsListWithoutCrashing() = runBlocking {
val repo = newRepo()
val first = repo.calendars().first()
assertThat(first).isNotNull()
}
@Test
fun instances_returnsListWithoutCrashing() = runBlocking {
val repo = newRepo()
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val oneDayLater = Instant.fromEpochMilliseconds(System.currentTimeMillis() + 86_400_000L)
val first = repo.instances(now..oneDayLater).first()
assertThat(first).isNotNull()
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.jeanlucmakiola.calendula.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PermissionScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
@Test
fun rationale_renders_title_and_button() {
composeTestRule.setContent {
PermissionScreen(onGranted = {})
}
composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
.assertIsDisplayed()
composeTestRule.onNodeWithText(res.getString(R.string.permission_request_button))
.assertIsDisplayed()
}
}

View File

@@ -4,19 +4,10 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
@AndroidEntryPoint
@@ -26,34 +17,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
CalendulaTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
RootScreen(modifier = Modifier.fillMaxSize())
}
}
}
}
}
@Composable
private fun PlaceholderScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displayMedium,
)
Text(
text = stringResource(R.string.app_tagline),
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderPreview() {
CalendulaTheme { PlaceholderScreen() }
}

View File

@@ -0,0 +1,112 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import javax.inject.Inject
import javax.inject.Singleton
/**
* Domain-shaped seam over Android's ContentResolver. Returns parsed lists so
* the repository can be unit-tested without constructing Cursors or
* ContentObservers on the JVM.
*
* Cursor handling and the ContentObserver-to-listener bridge live entirely
* in AndroidCalendarDataSource.
*/
interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
fun registerChangeListener(listener: () -> Unit)
fun unregisterChangeListener(listener: () -> Unit)
}
@Singleton
class AndroidCalendarDataSource @Inject constructor(
@ApplicationContext private val context: Context,
) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
override fun calendars(): List<CalendarSource> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query(
uri,
InstanceProjection.COLUMNS,
null, null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
EventDetailProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) null
else CursorColumnReader(c).toEventDetailCore(attendees)
}
}
override fun registerChangeListener(listener: () -> Unit) {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
listener()
}
}
observers[listener] = obs
resolver.registerContentObserver(
CalendarContract.CONTENT_URI,
/* notifyForDescendants = */ true,
obs,
)
}
override fun unregisterChangeListener(listener: () -> Unit) {
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
}
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
CalendarContract.Attendees.CONTENT_URI,
AttendeeProjection.COLUMNS,
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** Iterate every row and map; drops nulls. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
}

View File

@@ -0,0 +1,13 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
)

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
}
class NoSuchEventException(eventId: Long) :
NoSuchElementException("No event with id=$eventId")

View File

@@ -0,0 +1,62 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* One ContentResolver-backed observer for the lifetime of the App process.
* Each public flow re-queries on subscribe and after every tick from the
* data source.
*/
@Singleton
class CalendarRepositoryImpl @Inject constructor(
private val dataSource: CalendarDataSource,
@IoDispatcher private val io: CoroutineDispatcher,
) : CalendarRepository {
private val ticks = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
)
init {
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
}
override fun calendars(): Flow<List<CalendarSource>> =
ticks
.onStart { emit(Unit) }
.reQuery { dataSource.calendars() }
.flowOn(io)
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
ticks
.onStart { emit(Unit) }
.reQuery {
dataSource.instances(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
)
}
.flowOn(io)
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
collect { emit(block()) }
}

View File

@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor
/**
* Read-only view over a single row's columns by index. Lets the mappers work
* on pure-Kotlin test fixtures (MapColumnReader) on the JVM, while the
* production path adapts an Android Cursor row via CursorColumnReader.
*/
internal interface ColumnReader {
fun getLong(index: Int): Long
fun getString(index: Int): String?
fun getInt(index: Int): Int
fun isNull(index: Int): Boolean
}
internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getLong(index: Int): Long = cursor.getLong(index)
override fun getString(index: Int): String? = cursor.getString(index)
override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index)
}

View File

@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
val end = getLong(EventDetailProjection.IDX_DTEND)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
return null
}
if (end < begin) {
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
return null
}
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
val instance = EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(EventDetailProjection.IDX_CALENDAR_ID),
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(EventDetailProjection.IDX_LOCATION),
)
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
)
}
internal fun ColumnReader.toAttendee(): Attendee = Attendee(
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
email = getString(AttendeeProjection.IDX_EMAIL),
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
)
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED -> AttendeeStatus.Accepted
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> AttendeeStatus.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE -> AttendeeStatus.Tentative
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
else -> AttendeeStatus.Unknown
}

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
private const val TAG = "InstanceMapper"
internal fun ColumnReader.toEventInstance(): EventInstance? {
val begin = getLong(InstanceProjection.IDX_BEGIN)
val end = getLong(InstanceProjection.IDX_END)
if (begin < 0L) {
Log.w(TAG, "Dropping row with negative begin=$begin")
return null
}
if (end < begin) {
Log.w(TAG, "Dropping row with end=$end < begin=$begin")
return null
}
val rawTitle = getString(InstanceProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
eventId = getLong(InstanceProjection.IDX_EVENT_ID),
calendarId = getLong(InstanceProjection.IDX_CALENDAR_ID),
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
)
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
)
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
const val IDX_ACCOUNT_NAME = 2
const val IDX_ACCOUNT_TYPE = 3
const val IDX_COLOR = 4
const val IDX_VISIBLE = 5
}
internal object InstanceProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Instances._ID,
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.EVENT_COLOR,
CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
)
const val IDX_INSTANCE_ID = 0
const val IDX_EVENT_ID = 1
const val IDX_CALENDAR_ID = 2
const val IDX_TITLE = 3
const val IDX_BEGIN = 4
const val IDX_END = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
}
internal object EventDetailProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.ORGANIZER,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_ID,
)
const val IDX_EVENT_ID = 0
const val IDX_TITLE = 1
const val IDX_DESCRIPTION = 2
const val IDX_ORGANIZER = 3
const val IDX_RRULE = 4
const val IDX_EVENT_COLOR = 5
const val IDX_CALENDAR_COLOR = 6
const val IDX_DTSTART = 7
const val IDX_DTEND = 8
const val IDX_ALL_DAY = 9
const val IDX_LOCATION = 10
const val IDX_CALENDAR_ID = 11
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_STATUS,
)
const val IDX_NAME = 0
const val IDX_EMAIL = 1
const val IDX_STATUS = 2
}
internal object Fallbacks {
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
const val UNTITLED_EVENT = "(Ohne Titel)"
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import kotlin.time.Instant
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "calendula_prefs",
)
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindCalendarDataSource(
impl: AndroidCalendarDataSource,
): CalendarDataSource
@Binds
@Singleton
abstract fun bindCalendarRepository(
impl: CalendarRepositoryImpl,
): CalendarRepository
}
@Module
@InstallIn(SingletonComponent::class)
object DataProvideModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* App-side preference for "calendars the user has hidden in this app",
* separate from the system's per-calendar VISIBLE flag.
*
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
* silently dropped (defensive — see CalendarPrefsTest).
*/
@Singleton
class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[HIDDEN_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(HIDDEN_IDS_KEY)
} else {
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
}
}

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.time.Instant
data class CalendarSource(
val id: Long,
val displayName: String,
val accountName: String,
val accountType: String,
val color: Int,
val isVisibleInSystem: Boolean,
)
data class EventInstance(
val instanceId: Long,
val eventId: Long,
val calendarId: Long,
val title: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val color: Int,
val location: String?,
)
data class EventDetail(
val instance: EventInstance,
val description: String?,
val organizer: String?,
val attendees: List<Attendee>,
val rrule: String?,
)
data class Attendee(
val name: String,
val email: String?,
val status: AttendeeStatus,
)
enum class AttendeeStatus {
Accepted,
Declined,
Tentative,
NeedsAction,
Unknown,
}
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,
ProviderUnavailable,
EventNotFound,
Unknown,
}

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.ui.debug.DebugScreen
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
@Composable
fun RootScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
Scaffold(modifier = modifier) { innerPadding ->
if (hasPermission) {
DebugScreen(modifier = Modifier.padding(innerPadding))
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = Modifier.padding(innerPadding),
)
}
}
}

View File

@@ -0,0 +1,159 @@
package de.jeanlucmakiola.calendula.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@Composable
fun DebugScreen(
modifier: Modifier = Modifier,
viewModel: DebugViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(modifier = modifier.fillMaxSize()) {
DebugBanner()
when (val s = state) {
DebugUiState.Loading -> LoadingContent()
is DebugUiState.Failure -> FailureContent()
is DebugUiState.Success -> SuccessContent(s)
}
}
}
@Composable
private fun DebugBanner() {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.tertiaryContainer)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = stringResource(R.string.debug_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
@Composable
private fun LoadingContent() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun FailureContent() {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.state_failure_provider),
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Composable
private fun SuccessContent(state: DebugUiState.Success) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item { SectionHeader(stringResource(R.string.debug_calendars_header)) }
if (state.calendars.isEmpty()) {
item {
Text(
text = stringResource(R.string.debug_no_calendars),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(state.calendars, key = { it.id }) { CalendarRow(it) }
}
item { Spacer(Modifier.height(16.dp)) }
item { SectionHeader(stringResource(R.string.debug_events_header)) }
if (state.nextEvents.isEmpty()) {
item {
Text(
text = stringResource(R.string.debug_no_events),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(state.nextEvents, key = { it.instanceId }) { EventRow(it) }
}
}
}
@Composable
private fun SectionHeader(text: String) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(text = text, style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
}
}
@Composable
private fun CalendarRow(cal: CalendarSource) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(12.dp)
.background(Color(cal.color), CircleShape),
)
Text(
text = " ${cal.displayName} (${cal.accountName})",
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun EventRow(event: EventInstance) {
val zone = TimeZone.currentSystemDefault()
val start = event.start.toLocalDateTime(zone)
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(text = event.title, style = MaterialTheme.typography.bodyMedium)
val date = "%04d-%02d-%02d".format(start.year, start.month.ordinal + 1, start.day)
val time = "%02d:%02d".format(start.hour, start.minute)
Text(
text = "$date $time",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,14 @@
package de.jeanlucmakiola.calendula.ui.debug
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
sealed interface DebugUiState {
data object Loading : DebugUiState
data class Failure(val reason: FailureReason) : DebugUiState
data class Success(
val calendars: List<CalendarSource>,
val nextEvents: List<EventInstance>,
) : DebugUiState
}

View File

@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.debug
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import javax.inject.Inject
private const val MAX_DEBUG_EVENTS = 50
private val DEBUG_WINDOW = 30.days
@HiltViewModel
class DebugViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
val state: StateFlow<DebugUiState> = run {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val range = now..(now + DEBUG_WINDOW)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
DebugUiState.Success(
calendars = calendars,
nextEvents = instances.take(MAX_DEBUG_EVENTS),
) as DebugUiState
}
.catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DebugUiState.Loading,
)
}
}

View File

@@ -0,0 +1,130 @@
package de.jeanlucmakiola.calendula.ui.permission
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PermissionViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) viewModel.onGranted() else viewModel.onDenied()
}
LaunchedEffect(state) {
if (state == PermissionUiState.Granted) onGranted()
}
when (state) {
is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(Manifest.permission.READ_CALENDAR) },
modifier = modifier,
)
is PermissionUiState.Denied -> DeniedContent(
onRetry = {
viewModel.onRetry()
launcher.launch(Manifest.permission.READ_CALENDAR)
},
modifier = modifier,
)
is PermissionUiState.Granted -> {
// Transient — LaunchedEffect above fires and parent replaces us.
}
}
}
@Composable
private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onRequest) {
Text(stringResource(R.string.permission_request_button))
}
}
}
@Composable
private fun DeniedContent(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.permission_denied_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onRetry) {
Text(stringResource(R.string.permission_retry_button))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
},
) {
Text(stringResource(R.string.permission_open_settings_button))
}
}
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.ui.permission
sealed interface PermissionUiState {
data object Rationale : PermissionUiState
data object Denied : PermissionUiState
data object Granted : PermissionUiState
}

View File

@@ -0,0 +1,27 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class PermissionViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
fun onGranted() {
_state.value = PermissionUiState.Granted
}
fun onDenied() {
_state.value = PermissionUiState.Denied
}
fun onRetry() {
_state.value = PermissionUiState.Rationale
}
}

View File

@@ -10,4 +10,20 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Kalender-Zugriff</string>
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
<string name="permission_request_button">Weiter</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
<string name="permission_retry_button">Erneut versuchen</string>
<!-- Debug-Screen (wegwerfbar — entfällt mit Plan 03) -->
<string name="debug_banner">DEBUG — wird mit Plan 03 durch die Monatsansicht ersetzt</string>
<string name="debug_calendars_header">Kalender</string>
<string name="debug_events_header">Nächste 50 Termine</string>
<string name="debug_no_calendars">Keine Kalender eingerichtet. Füge einen über DAVx5 oder die System-Einstellungen hinzu.</string>
<string name="debug_no_events">Keine anstehenden Termine in den nächsten 30 Tagen.</string>
</resources>

View File

@@ -11,4 +11,20 @@
<string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">Calendar access</string>
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
<string name="permission_request_button">Continue</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
<string name="permission_open_settings_button">Open system settings</string>
<string name="permission_retry_button">Try again</string>
<!-- Debug screen (wegwerfbar — entfällt mit Plan 03) -->
<string name="debug_banner">DEBUG — replaced by month view in Plan 03</string>
<string name="debug_calendars_header">Calendars</string>
<string name="debug_events_header">Next 50 events</string>
<string name="debug_no_calendars">No calendars configured. Add one via DAVx5 or system settings.</string>
<string name="debug_no_events">No upcoming events in the next 30 days.</string>
</resources>

View File

@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarMapperTest {
private fun reader(
id: Long = 1L,
displayName: String? = "Cal",
accountName: String? = "x@y",
accountType: String? = "LOCAL",
color: Int = 0,
visible: Int = 1,
): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName,
CalendarProjection.IDX_ACCOUNT_NAME to accountName,
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible,
)
@Test
fun `happy path maps all six columns`() {
val src = reader(
id = 42L,
displayName = "Work",
accountName = "x@y",
accountType = "com.google",
color = 0xFF112233.toInt(),
visible = 1,
).toCalendarSource()
assertThat(src).isEqualTo(
de.jeanlucmakiola.calendula.domain.CalendarSource(
id = 42L,
displayName = "Work",
accountName = "x@y",
accountType = "com.google",
color = 0xFF112233.toInt(),
isVisibleInSystem = true,
)
)
}
@Test
fun `null displayName falls back to placeholder`() {
val src = reader(displayName = null).toCalendarSource()
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
}
@Test
fun `visible flag 0 maps to false`() {
assertThat(reader(visible = 0).toCalendarSource().isVisibleInSystem).isFalse()
}
@Test
fun `visible flag 1 maps to true`() {
assertThat(reader(visible = 1).toCalendarSource().isVisibleInSystem).isTrue()
}
@Test
fun `null accountName and accountType coerce to empty string`() {
val src = reader(accountName = null, accountType = null).toCalendarSource()
assertThat(src.accountName).isEqualTo("")
assertThat(src.accountType).isEqualTo("")
}
}

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.data.calendar
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarRepositoryImplTest {
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
private fun makeEvent(id: Long, title: String = "E $id"): EventInstance = EventInstance(
instanceId = id, eventId = id, calendarId = 1L,
title = title,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
)
@Test
fun `calendars emits initial query result on subscribe`() = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
val first = awaitItem()
assertThat(first.map { it.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars re-emits after change listener tick`() = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L))
}
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L))
fake.tick()
assertThat(awaitItem().map { it.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances forwards epoch-millis bounds to data source`() = runTest {
var observedBegin: Long? = null
var observedEnd: Long? = null
val fake = FakeCalendarDataSource().apply {
instancesResult = {b, e ->
observedBegin = b
observedEnd = e
listOf(makeEvent(10L))
}
}
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
repo.instances(range).test {
awaitItem()
cancelAndIgnoreRemainingEvents()
}
assertThat(observedBegin).isEqualTo(1_000L)
assertThat(observedEnd).isEqualTo(2_000L)
}
@Test
fun `instances passes-through whatever the data source returns`() = runTest {
val fake = FakeCalendarDataSource().apply {
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
}
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
val first = awaitItem()
assertThat(first.map { it.title }).containsExactly("Good")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `eventDetail throws NoSuchEventException when data source returns null`() = runTest {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { null }
}
val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined)
try {
repo.eventDetail(eventId = 999L)
error("Expected NoSuchEventException")
} catch (expected: NoSuchEventException) {
assertThat(expected.message).contains("999")
}
}
}

View File

@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import org.junit.jupiter.api.Test
class EventDetailMapperTest {
private fun detailReader(
eventId: Long = 1L,
title: String? = "Meet",
description: String? = "Body",
organizer: String? = "x@y",
rrule: String? = null,
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L,
dtend: Long = 1_000_003_600L,
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
EventDetailProjection.IDX_DESCRIPTION to description,
EventDetailProjection.IDX_ORGANIZER to organizer,
EventDetailProjection.IDX_RRULE to rrule,
EventDetailProjection.IDX_EVENT_COLOR to eventColor,
EventDetailProjection.IDX_CALENDAR_COLOR to calendarColor,
EventDetailProjection.IDX_DTSTART to dtstart,
EventDetailProjection.IDX_DTEND to dtend,
EventDetailProjection.IDX_ALL_DAY to allDay,
EventDetailProjection.IDX_LOCATION to location,
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
)
private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader =
MapColumnReader(
AttendeeProjection.IDX_NAME to name,
AttendeeProjection.IDX_EMAIL to email,
AttendeeProjection.IDX_STATUS to status,
)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {
val detail = detailReader().toEventDetailCore(attendees = emptyList())
assertThat(detail).isNotNull()
assertThat(detail!!.description).isEqualTo("Body")
assertThat(detail.organizer).isEqualTo("x@y")
assertThat(detail.instance.title).isEqualTo("Meet")
assertThat(detail.instance.location).isEqualTo("Berlin")
assertThat(detail.attendees).isEmpty()
}
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toEventDetailCore(attendees = emptyList())
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
.toEventDetailCore(attendees = emptyList())
assertThat(detail).isNull()
}
@Test
fun `rrule passes through when present`() {
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
.toEventDetailCore(attendees = emptyList())
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
// Raw CalendarContract.Attendees status integer constants from the Android
// source (kept inline so the test doesn't depend on the mockable.jar's
// possibly-stubbed constants):
// ACCEPTED=1, DECLINED=2, INVITED=3, TENTATIVE=4, NONE=0
@Test
fun `attendee status maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1).toAttendee().status)
.isEqualTo(AttendeeStatus.Accepted)
assertThat(attendeeReader("B", "b@x", 2).toAttendee().status)
.isEqualTo(AttendeeStatus.Declined)
assertThat(attendeeReader("C", "c@x", 4).toAttendee().status)
.isEqualTo(AttendeeStatus.Tentative)
assertThat(attendeeReader("D", "d@x", 3).toAttendee().status)
.isEqualTo(AttendeeStatus.NeedsAction)
assertThat(attendeeReader("E", "e@x", 0).toAttendee().status)
.isEqualTo(AttendeeStatus.Unknown)
assertThat(attendeeReader("F", "f@x", 99).toAttendee().status)
.isEqualTo(AttendeeStatus.Unknown)
}
@Test
fun `attendee with null name maps to empty string`() {
val a = attendeeReader(null, "alice@x", 1).toAttendee()
assertThat(a.name).isEqualTo("")
}
@Test
fun `attendee email passes through nullably`() {
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
}
}

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
* a provider change so the repository re-queries.
*/
internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
private val listeners = mutableListOf<() -> Unit>()
override fun calendars(): List<CalendarSource> = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun registerChangeListener(listener: () -> Unit) {
listeners += listener
}
override fun unregisterChangeListener(listener: () -> Unit) {
listeners -= listener
}
fun tick() {
listeners.forEach { it() }
}
}

View File

@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class InstanceMapperTest {
private fun reader(
instanceId: Long = 10L,
eventId: Long = 1L,
calendarId: Long = 1L,
title: String? = "Meet",
begin: Long = 1_000_000_000L,
end: Long = 1_000_003_600L,
allDay: Int = 0,
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId,
InstanceProjection.IDX_CALENDAR_ID to calendarId,
InstanceProjection.IDX_TITLE to title,
InstanceProjection.IDX_BEGIN to begin,
InstanceProjection.IDX_END to end,
InstanceProjection.IDX_ALL_DAY to allDay,
InstanceProjection.IDX_EVENT_COLOR to eventColor,
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location,
)
@Test
fun `happy path - non-allday event`() {
val inst = reader().toEventInstance()
assertThat(inst).isNotNull()
assertThat(inst!!.title).isEqualTo("Meet")
assertThat(inst.isAllDay).isFalse()
assertThat(inst.start).isEqualTo(Instant.fromEpochMilliseconds(1_000_000_000L))
assertThat(inst.end).isEqualTo(Instant.fromEpochMilliseconds(1_000_003_600L))
}
@Test
fun `event color falls back to calendar color when null`() {
val inst = reader(eventColor = null, calendarColor = 0xFF112233.toInt()).toEventInstance()
assertThat(inst!!.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `event color wins over calendar color when present`() {
val inst = reader(
eventColor = 0xFFDEADBE.toInt(),
calendarColor = 0xFF112233.toInt(),
).toEventInstance()
assertThat(inst!!.color).isEqualTo(0xFFDEADBE.toInt())
}
@Test
fun `null title falls back to placeholder`() {
val inst = reader(title = null).toEventInstance()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
fun `empty title falls back to placeholder`() {
val inst = reader(title = "").toEventInstance()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
fun `dtend before dtstart drops the row`() {
val inst = reader(begin = 2000L, end = 1000L).toEventInstance()
assertThat(inst).isNull()
}
@Test
fun `dtstart before unix epoch drops the row`() {
val inst = reader(begin = -1L, end = 1000L).toEventInstance()
assertThat(inst).isNull()
}
@Test
fun `all-day flag 1 maps to true`() {
val inst = reader(allDay = 1).toEventInstance()
assertThat(inst!!.isAllDay).isTrue()
}
@Test
fun `location passes through when present`() {
val inst = reader(location = "Berlin").toEventInstance()
assertThat(inst!!.location).isEqualTo("Berlin")
}
}

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* Test-only ColumnReader. Backed by a Map<Int, Any?>; any missing index is
* treated as null. Numeric getters coerce via toLong/toInt; non-numeric values
* yield zero (matching Android Cursor behavior for type-mismatched reads).
*/
internal class MapColumnReader(values: Map<Int, Any?>) : ColumnReader {
private val data: Map<Int, Any?> = values
constructor(vararg pairs: Pair<Int, Any?>) : this(pairs.toMap())
override fun getLong(index: Int): Long = when (val v = data[index]) {
is Number -> v.toLong()
is String -> v.toLongOrNull() ?: 0L
else -> 0L
}
override fun getString(index: Int): String? = data[index]?.toString()
override fun getInt(index: Int): Int = when (val v = data[index]) {
is Number -> v.toInt()
is String -> v.toIntOrNull() ?: 0
else -> 0
}
override fun isNull(index: Int): Boolean = data[index] == null
}

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class TimeBridgeTest {
@Test
fun `epoch millis round-trips through Instant`() {
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
val instant = original.toKotlinInstantFromEpochMillis()
assertThat(instant.toEpochMillis()).isEqualTo(original)
}
@Test
fun `zero millis maps to Instant epoch`() {
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
}
@Test
fun `negative epoch millis is supported`() {
val original = -1_000_000L
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
}
}

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class CalendarPrefsTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
@Test
fun `hiddenCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `setHiddenCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store)
prefs.setHiddenCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
}
@Test
fun `setting empty set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setHiddenCalendarIds(emptySet())
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store)
store.updateData { p ->
val mutable = p.toMutablePreferences()
mutable[CalendarPrefs.HIDDEN_IDS_KEY] = "1,abc,3"
mutable
}
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
}
}

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class ModelsTest {
@Test
fun `CalendarSource is a data class with structural equality`() {
val a = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
val b = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
assertThat(a).isEqualTo(b)
}
@Test
fun `EventInstance is a data class with structural equality`() {
val start = Instant.fromEpochMilliseconds(0L)
val end = Instant.fromEpochMilliseconds(3_600_000L)
val a = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
val b = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
assertThat(a).isEqualTo(b)
}
@Test
fun `AttendeeStatus enum has all five variants`() {
assertThat(AttendeeStatus.values().toSet()).isEqualTo(
setOf(
AttendeeStatus.Accepted,
AttendeeStatus.Declined,
AttendeeStatus.Tentative,
AttendeeStatus.NeedsAction,
AttendeeStatus.Unknown,
)
)
}
@Test
fun `FailureReason enum has all five variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo(
setOf(
FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured,
FailureReason.ProviderUnavailable,
FailureReason.EventNotFound,
FailureReason.Unknown,
)
)
}
@Test
fun `EventDetail composes EventInstance plus extras`() {
val instance = EventInstance(
instanceId = 10L,
eventId = 1L,
calendarId = 1L,
title = "Meet",
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(60_000L),
isAllDay = false,
color = 0xFFAABBCC.toInt(),
location = null,
)
val detail = EventDetail(
instance = instance,
description = "Brief description",
organizer = "x@y",
attendees = listOf(Attendee("Alice", "alice@x", AttendeeStatus.Accepted)),
rrule = "FREQ=WEEKLY",
)
assertThat(detail.instance.title).isEqualTo("Meet")
assertThat(detail.attendees).hasSize(1)
}
}

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.calendula.ui.debug
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.time.Instant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DebugViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private class FakeRepo(
val calendarsFlow: MutableStateFlow<List<CalendarSource>> = MutableStateFlow(emptyList()),
val instancesFlow: MutableStateFlow<List<EventInstance>> = MutableStateFlow(emptyList()),
) : CalendarRepository {
override fun calendars(): Flow<List<CalendarSource>> = calendarsFlow
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = instancesFlow
override suspend fun eventDetail(eventId: Long): EventDetail =
throw NoSuchEventException(eventId)
}
private fun makeCal(id: Long, name: String = "C $id"): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
private fun makeEvent(id: Long, title: String = "E $id") = EventInstance(
instanceId = id, eventId = id, calendarId = 1L,
title = title,
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(60_000L),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
)
@Test
fun `initial state value is Loading before any subscriber`() {
val repo = FakeRepo()
val vm = DebugViewModel(repo, testDispatcher)
assertThat(vm.state.value).isEqualTo(DebugUiState.Loading)
}
@Test
fun `Success contains calendars and capped events after subscription`() = runTest {
val repo = FakeRepo(
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
instancesFlow = MutableStateFlow(listOf(makeEvent(10L, "X"))),
)
val vm = DebugViewModel(repo, testDispatcher)
vm.state.test {
val success = awaitItem() as DebugUiState.Success
assertThat(success.calendars.map { it.id }).containsExactly(1L)
assertThat(success.nextEvents.map { it.title }).containsExactly("X")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances are capped at 50`() = runTest {
val repo = FakeRepo(
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
instancesFlow = MutableStateFlow((1L..100L).map { makeEvent(it, "E$it") }),
)
val vm = DebugViewModel(repo, testDispatcher)
vm.state.test {
val success = awaitItem() as DebugUiState.Success
assertThat(success.nextEvents).hasSize(50)
assertThat(success.nextEvents.first().instanceId).isEqualTo(1L)
assertThat(success.nextEvents.last().instanceId).isEqualTo(50L)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `state updates when repository emits new data`() = runTest {
val repo = FakeRepo()
val vm = DebugViewModel(repo, testDispatcher)
vm.state.test {
// Empty initial: combine fires once because both StateFlows have initial empty value
val empty = awaitItem() as DebugUiState.Success
assertThat(empty.calendars).isEmpty()
assertThat(empty.nextEvents).isEmpty()
repo.calendarsFlow.value = listOf(makeCal(1L), makeCal(2L))
val updated = awaitItem() as DebugUiState.Success
assertThat(updated.calendars.map { it.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -17,6 +17,12 @@ junitPlatform = "6.1.0"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
[libraries]
# AndroidX core
@@ -53,6 +59,25 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
# Domain time
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
# Coroutines (transitively pulled by hilt-android, pinned explicit)
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Test - Flow assertions
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
# Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }