20 Commits

Author SHA1 Message Date
0132201cf9 style(icon): regenerate F-Droid icon.png to match launcher exactly
All checks were successful
CI / ci (push) Successful in 10m41s
Build and Release to F-Droid / ci (push) Successful in 5m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m27s
Re-render both locale catalog icons (512x512) from the same logo as the
Android adaptive launcher icon, baking in the foreground group transform
(scale 0.5, pivot 114,108, translate 2,8) over the slate background so the
F-Droid render is pixel-faithful to the on-device icon.

Add design/icon/calendula_launcher.svg as the composed full-bleed source
of truth for store/F-Droid renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:38:08 +02:00
b792ddc2f0 style: fix launcher icon scaling and centering, update AGP
All checks were successful
CI / ci (push) Successful in 9m58s
2026-06-08 20:30:40 +02:00
440fa57161 fix(debug): use prefixed composite keys in LazyColumn
All checks were successful
CI / ci (push) Successful in 9m35s
Cal.id and Event.instanceId share a numeric range, and the LazyColumn
keys both sections — colliding values (e.g. cal-id=4 + event-instance-id=4)
crashed with "Key '4' was already used". Additionally, Instances._ID is
inherited from the parent Event, so recurring events produce multiple
rows with the same instanceId; the start instant disambiguates them.
2026-06-08 20:19:58 +02:00
Jean-Luc Makiola
00b5aeaac7 feat(icon): line-art cal + calendula bloom + numeral "1"
All checks were successful
CI / ci (push) Successful in 9m42s
Replaces the simple numeral-only foreground from v0.1.0. The new mark
keeps the kalendae reference explicit (a bold "1" inside the
calendar body) and adds a small calendula bloom as a badge in the
bottom-right corner so the app's "calendula" brand reads at first
glance.

- design/icon/calendula_mark.svg: source SVG (232x232 viewport,
  monochrome, lawnicons-style strokes 12/8)
- app/src/main/res/drawable/ic_launcher_foreground.xml: regenerated
  as a VectorDrawable preserving the source path data. Off-white
  (#FAF6F0) strokes on the existing slate background. Reused as the
  <monochrome> slot so Android 13+ themed-icon launchers can recolor
  it from wallpaper.
- fdroid-metadata/.../{en-US,de-DE}/icon.png: 512x512 PNG composed
  from the same source SVG with the slate background baked in, so
  F-Droid clients show a fully rendered tile in the app catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 18:24:04 +02:00
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
44 changed files with 1807 additions and 62 deletions

View File

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

View File

@@ -5,7 +5,7 @@
| Version | Milestone | Status | | Version | Milestone | Status |
|---|---|---| |---|---|---|
| v0.1 | Foundation & CI | complete | | 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.3 | Month view | pending |
| v0.4 | Week view | pending | | v0.4 | Week view | pending |
| v0.5 | Day view | pending | | v0.5 | Day view | pending |

View File

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

View File

@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.1] — 2026-06-09
### Changed
- Regenerated the F-Droid catalog `icon.png` (512x512, both locales) so it
is pixel-faithful to the on-device adaptive launcher icon: same slate
background (`#5C6B7A`), off-white mark (`#FAF6F0`), and the foreground
group transform (`scale 0.5`, pivot `114,108`, translate `2,8`) baked in.
- Added `design/icon/calendula_launcher.svg` — the composed full-bleed
icon (background + transformed mark) as the single source of truth for
store/F-Droid renders.
## [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
### Changed
- Redesigned launcher icon: line-art calendar with a stylized "1" inside
(kalendae reference) and a small calendula bloom badge in the
bottom-right corner. Replaces the simple "1"-only foreground from
v0.1.0. Source SVG checked in at `design/icon/calendula_mark.svg`,
also used to regenerate the F-Droid catalog `icon.png` (512x512)
per locale.
## [0.1.1] — 2026-06-08 ## [0.1.1] — 2026-06-08
### Fixed ### Fixed

View File

@@ -121,6 +121,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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) @RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest { class MainActivitySmokeTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
fun appName_isDisplayed_onLaunch() {
composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
}
@Test @Test
fun tagline_isDisplayed_onLaunch() { fun permissionRationale_isDisplayed_onLaunch_withoutPermission() {
composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed() 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
@AndroidEntryPoint @AndroidEntryPoint
@@ -26,34 +17,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
CalendulaTheme { CalendulaTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> RootScreen(modifier = Modifier.fillMaxSize())
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
}
} }
} }
} }
} }
@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,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,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,164 @@
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 = { "cal-${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,
// Recurring events share Instances._ID across occurrences, so
// include the start instant to keep the LazyColumn key unique.
key = { "evt-${it.instanceId}-${it.start.toEpochMilliseconds()}" },
) { 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

@@ -1,16 +1,95 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
Calendula launcher icon foreground.
Converted from design/icon/calendula_mark.svg (232x232 viewport).
Composition: rounded line-art calendar with a stylized "1" inside
(referencing kalendae, the Latin word for the first day of the month
that is the etymological root of both "calendar" and "calendula"),
plus a small Calendula bloom as a badge in the bottom-right corner.
Strokes render in off-white (#FAF6F0) over the slate background
drawable (drawable/ic_launcher_background.xml = @color/ic_launcher_background).
The same vector is reused as the <monochrome> slot in the adaptive icon
so Android 13+ themed-icon launchers can recolor it from wallpaper.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="232"
android:viewportHeight="108"> android:viewportHeight="232">
<!-- <!--
Stylized "1" centered in the 108x108 viewport. Android adaptive icon spec: 108dp canvas.
Reference: kalendae (the first day of the month) - etymological root
of both "Calendar" and "Calendula". Centering Logic:
Color is off-white for high contrast on the slate background. - The calendar body is a 142x142 square centered at (114, 108).
- The viewport center is (116, 116).
- We use pivot (114, 108) and translate by (2, 8) to align the
calendar's geometric center perfectly with the canvas center,
ignoring the visual weight of the bloom badge.
Scale:
- Scaled by 0.50 to provide significant padding, preventing a
"zoomed in" look on home screens and splash screens.
--> -->
<group
android:pivotX="114"
android:pivotY="108"
android:scaleX="0.50"
android:scaleY="0.50"
android:translateX="2"
android:translateY="8">
<!-- Calendar body (rounded square with horizontal header divider) -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeMiterLimit="12"
android:pathData="M43,69H185M185,115V63C185,48.64 173.359,37 159,37H69C54.64,37 43,48.64 43,63V153C43,167.359 54.64,179 69,179H124" />
<!-- Numeral "1" inside the calendar body -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M103,110L113.999,99V142.428" />
<!-- Calendula bloom: 8 petals around a filled center -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M163.072,136.714C163.072,142.886 168.214,153.429 170.786,157.929C173.357,153.429 178.5,142.886 178.5,136.714C178.5,130.543 173.357,129 170.786,129C168.214,129 163.072,130.543 163.072,136.714Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M178.5,186.857C178.5,180.686 173.357,170.143 170.786,165.643C168.214,170.143 163.072,180.686 163.072,186.857C163.072,193.029 168.214,194.572 170.786,194.572C173.357,194.572 178.5,193.029 178.5,186.857Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M195.857,154.072C189.686,154.072 179.143,159.214 174.643,161.786C179.143,164.357 189.686,169.5 195.857,169.5C202.029,169.5 203.572,164.357 203.572,161.786C203.572,159.214 202.029,154.072 195.857,154.072Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M145.714,170.008C151.886,170.008 162.429,164.865 166.929,162.294C162.429,159.722 151.886,154.58 145.714,154.58C139.543,154.58 138,159.722 138,162.294C138,164.865 139.543,170.008 145.714,170.008Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M194.768,174.858C190.404,170.494 179.312,166.676 174.312,165.312C175.676,170.312 179.494,181.404 183.858,185.768C188.222,190.132 192.949,187.586 194.768,185.768C196.586,183.949 199.132,179.222 194.768,174.858Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M146.804,149.222C151.168,153.586 162.259,157.404 167.26,158.768C165.896,153.767 162.077,142.676 157.714,138.312C153.35,133.948 148.622,136.494 146.804,138.312C144.986,140.13 142.44,144.858 146.804,149.222Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M183.858,138.312C179.494,142.676 175.676,153.767 174.312,158.768C179.312,157.404 190.404,153.586 194.768,149.222C199.132,144.858 196.586,140.13 194.768,138.312C192.949,136.494 188.222,133.948 183.858,138.312Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M157.714,185.768C162.077,181.404 165.896,170.312 167.26,165.312C162.259,166.676 151.168,170.494 146.804,174.858C142.44,179.222 144.986,183.949 146.804,185.768C148.622,187.586 153.35,190.132 157.714,185.768Z" />
<!-- Calendula center disc -->
<path <path
android:fillColor="#FFFAF6F0" android:fillColor="#FFFAF6F0"
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" /> android:pathData="M170.786,169C174.77,169 178,165.77 178,161.786C178,157.802 174.77,154.572 170.786,154.572C166.802,154.572 163.572,157.802 163.572,161.786C163.572,165.77 166.802,169 170.786,169Z" />
</group>
</vector> </vector>

View File

@@ -10,4 +10,20 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string> <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_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</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> </resources>

View File

@@ -11,4 +11,20 @@
<string name="state_failure_no_calendars">No calendars configured.</string> <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_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</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> </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,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,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()
}
}
}

View File

@@ -0,0 +1,34 @@
<svg width="232" height="232" viewBox="0 0 232 232" xmlns="http://www.w3.org/2000/svg">
<!--
Composed Calendula launcher icon (full-bleed), generated to exactly match the
Android adaptive icon defined by:
- drawable/ic_launcher_background.xml (solid @color/ic_launcher_background = #5C6B7A)
- drawable/ic_launcher_foreground.xml (off-white #FAF6F0 mark from calendula_mark.svg)
The adaptive foreground group transform (scaleX/Y=0.50, pivot 114,108,
translate 2,8) is reproduced here as the SVG transform "translate(59,62) scale(0.5)"
because Android applies it as: p' = scale*p + pivot*(1-scale) + translate
x' = 0.5*x + 114*0.5 + 2 = 0.5*x + 59
y' = 0.5*y + 108*0.5 + 8 = 0.5*y + 62
This is the single source of truth for the F-Droid / store icon.png renders.
-->
<rect x="0" y="0" width="232" height="232" fill="#5C6B7A"/>
<g transform="translate(59,62) scale(0.5)" fill="none" stroke="#FAF6F0">
<!-- Calendar body (rounded square with horizontal header divider) -->
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Numeral "1" inside the calendar body -->
<path d="M103 110L113.999 99V142.428" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Calendula bloom: 8 petals around a filled center -->
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke-width="8"/>
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke-width="8"/>
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke-width="8"/>
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke-width="8"/>
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke-width="8"/>
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke-width="8"/>
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke-width="8"/>
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke-width="8"/>
<!-- Calendula center disc (filled, matches foreground <fillColor> slot) -->
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="#FAF6F0" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,13 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke="black" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M103 110L113.999 99V142.428" stroke="black" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke="black" stroke-width="8"/>
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke="black" stroke-width="8"/>
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke="black" stroke-width="8"/>
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke="black" stroke-width="8"/>
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke="black" stroke-width="8"/>
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke="black" stroke-width="8"/>
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke="black" stroke-width="8"/>
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke="black" stroke-width="8"/>
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="black" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.1.1" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.3.21"
ksp = "2.3.9" ksp = "2.3.9"
hilt = "2.59.2" hilt = "2.59.2"

View File

@@ -11,6 +11,9 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)