Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0132201cf9 | |||
| b792ddc2f0 | |||
| 440fa57161 | |||
|
|
00b5aeaac7 | ||
| 2a2b919041 | |||
| 3ced240e23 | |||
| 035ac9b003 | |||
| c03389abe0 | |||
| 98f8433156 | |||
| 8fbbab30e2 | |||
| ef0a4b0568 | |||
| 43f12812b6 | |||
| 2400d5482c | |||
| 4d54501ed4 | |||
| 748df761bf | |||
| d13f2f07a5 | |||
| 7abb2e6ab4 | |||
| fb003d8806 | |||
| 40b531fa52 | |||
| 0e4c47febe | |||
| fb723fba68 | |||
|
|
ffc7ed414f | ||
| af75965a31 | |||
| 1b456d2133 | |||
| a826e82bdc | |||
| ed680b4482 |
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -4,19 +4,20 @@
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** v0.1 — Foundation & CI
|
||||
**Phase:** Plan 01 complete; ready to start Plan 02
|
||||
**Milestone:** v0.2 — Data 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
|
||||
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,95 @@
|
||||
<?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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
android:viewportWidth="232"
|
||||
android:viewportHeight="232">
|
||||
<!--
|
||||
Stylized "1" centered in the 108x108 viewport.
|
||||
Reference: kalendae (the first day of the month) - etymological root
|
||||
of both "Calendar" and "Calendula".
|
||||
Color is off-white for high contrast on the slate background.
|
||||
Android adaptive icon spec: 108dp canvas.
|
||||
|
||||
Centering Logic:
|
||||
- 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
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
34
design/icon/calendula_launcher.svg
Normal file
34
design/icon/calendula_launcher.svg
Normal 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 |
13
design/icon/calendula_mark.svg
Normal file
13
design/icon/calendula_mark.svg
Normal 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 |
File diff suppressed because it is too large
Load Diff
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/de-DE/icon.png
Normal file
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/de-DE/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png
Normal file
BIN
fdroid-metadata/de.jeanlucmakiola.calendula/en-US/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
13
gradle/gradle-daemon-jvm.properties
Normal file
13
gradle/gradle-daemon-jvm.properties
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.1.1"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
@@ -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" }
|
||||
|
||||
@@ -11,6 +11,9 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
|
||||
Reference in New Issue
Block a user