30 Commits

Author SHA1 Message Date
d3fbe28843 docs: record v0.3.0 (month/week/day views, view switcher) in CHANGELOG
All checks were successful
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m43s
CI / ci (push) Successful in 10m49s
Build and Release to F-Droid / ci (push) Successful in 6m16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:44:22 +02:00
951fb640a6 feat(day): single-column day view, wire into view switcher
Day view as a one-column slice of the week view: shared TimedBlock/
AllDaySpan layout, per-day swipe navigation, hoisted noon-centred scroll,
animated all-day strip, and a compact top bar showing the full date.

- DayUiState / DayViewModel / DayScreen under ui/day
- reuse layoutDay/layoutAllDay/coversDay from the week package
- add Day to IMPLEMENTED_VIEWS; CalendarHost routes it explicitly
- day_today_action strings (en/de)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:40:02 +02:00
94fa206e2e refactor(week): polish timeline — rounded viewport, scroll persistence, week badge
All checks were successful
CI / ci (push) Successful in 11m40s
- Rounded, permanently-soft day-column scroll viewport via two viewports
  sharing one scroll state (gutter + columns stay aligned); plain
  rectangular column cards inside
- Vertical scroll position now persists across week swipes; noon-centring
  only runs on first entry into the week view (from month/day)
- All-day strip height is hoisted + animated, shared by both swipe pages,
  so it slides along and resizes smoothly instead of jumping
- Multi-line event time label so the end time isn't clipped in narrow
  columns; hour labels centred in the gutter
- Calendar-week (ISO) badge in the header gutter, aligned with the date
  numbers; dropped the redundant "All-day" gutter label
- Small breathing room between the top section and the timeline

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:05:40 +02:00
6a90bade8a feat(ui): month card grid + week timeline, wire view switcher
Replace the throwaway debug screen with the first real calendar UI and a
functional Month <-> Week switcher, on Material 3 Expressive.

Month view (S1):
- Material 3 Expressive card-per-day grid; only the current month's weeks
  render (neighbouring days left blank)
- per-day event dots with "+N" overflow, today via primaryContainer
- spring-based press feedback from the active motion scheme
- swipe + drawer navigation, Loading/Failure/Success states

Week view (S2):
- vertical time schedule with overlap-resolved lanes (per-day clipping,
  midnight spanning, instant events)
- all-day / multi-day events as connected horizontal spans
- single scroll container (gutter + day columns stay aligned), columns
  bundled in a rounded container, noon-centred on load
- top section colour-shifts with the app bar on scroll; swipe navigation,
  three states

Shared / infra:
- CalendarHost holds the active view; RootScreen renders it post-permission
- ui/common building blocks: CalendarDrawer, CalendarFailure,
  ViewSwitcherPill, pastelize, observable locale, M3 Expressive slide
  transition (motionScheme fastSpatialSpec)
- unit tests for the week layout (lanes, clipping, all-day spans)
- build: compileSdk 37, material3 pinned to 1.5.0-alpha21 for Expressive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:05:40 +02:00
0132201cf9 style(icon): regenerate F-Droid icon.png to match launcher exactly
All checks were successful
CI / ci (push) Successful in 10m41s
Build and Release to F-Droid / ci (push) Successful in 5m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m27s
Re-render both locale catalog icons (512x512) from the same logo as the
Android adaptive launcher icon, baking in the foreground group transform
(scale 0.5, pivot 114,108, translate 2,8) over the slate background so the
F-Droid render is pixel-faithful to the on-device icon.

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

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

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

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

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

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

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

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

View File

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

View File

@@ -5,9 +5,9 @@
| Version | Milestone | Status | | Version | Milestone | Status |
|---|---|---| |---|---|---|
| v0.1 | Foundation & CI | complete | | v0.1 | Foundation & CI | complete |
| v0.2 | Data Layer & Permission Flow | pending | | v0.2 | Data Layer & Permission Flow | complete |
| v0.3 | Month view | pending | | v0.3 | Month view | in progress |
| v0.4 | Week view | pending | | v0.4 | Week view | in progress |
| v0.5 | Day view | pending | | v0.5 | Day view | pending |
| v0.6 | Event Detail Sheet | pending | | v0.6 | Event Detail Sheet | pending |
| v0.7 | Filter & Settings | pending | | v0.7 | Filter & Settings | pending |

View File

@@ -1,22 +1,28 @@
# Calendula — Current State # Calendula — Current State
*Last updated: 2026-06-08* *Last updated: 2026-06-10*
## Status ## Status
**Milestone:** v0.1Foundation & CI **Milestone:** v0.4Week view (in progress)
**Phase:** Plan 01 complete; ready to start Plan 02 **Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet,
filter, settings, jump-to-date) still stubbed
## Progress ## Progress
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`) - [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color) - [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`) - [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green - [x] Plan 02 written and executed — data layer + permission flow + debug screen
- [ ] Plan 02 written (Data Layer & Permission Flow) - [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
- [x] View-switcher (M1) wired — cycles Month ↔ Week (Day joins once S3 lands)
- [ ] Day view (S3)
- [ ] Event-detail sheet (S4) — week/month event taps are currently no-ops
- [ ] Filter sheet (M3), Settings (M4), Jump-to-date (M2) — drawer entries stubbed
## Next ## Next
1. Write Plan 02: Data Layer & Permission Flow 1. Day view (S3) — slot it into the view-switcher cycle
2. Execute Plan 02 2. Event-detail sheet (S4) — wire month-day and week-event taps to it
3. Iterate on UI design (mockups) before screens are built 3. Revisit month/week UI polish + shared anchor-date continuity across views

View File

@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.0] — 2026-06-10
### Added
- Month view (S1): Material 3 Expressive card-per-day grid (only the current
month's weeks; neighbouring days left blank), per-day event dots with "+N"
overflow, today emphasised via `primaryContainer`, spring-based press
feedback from the active motion scheme, swipe + drawer navigation,
Loading/Failure/Success states
- Week view (S2): vertical time schedule with overlap-resolved lanes,
separate all-day strip, midnight-spanning events clipped per day, swipe
navigation, Loading/Failure/Success states
- Day view (S3): single-column slice of the week schedule reusing its
overlap-lane layout, per-day swipe navigation, noon-centred scroll that
persists across swipes, animated all-day strip, compact top bar with the
full date, Loading/Failure/Success states
- Functional view-switcher (M1) cycling Month ↔ Week ↔ Day
- Shared calendar UI building blocks in `ui/common/` (navigation drawer,
failure screen, view-switcher pill, color pastelizer, observable locale)
### Removed
- Throwaway debug screen — superseded by the month view
## [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 ## [0.1.0] — 2026-06-08
### Added ### Added

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*/
@Composable
fun CalendarHost(modifier: Modifier = Modifier) {
var view by rememberSaveable { mutableStateOf(CalendarView.Month) }
val onSelectView: (CalendarView) -> Unit = { view = it }
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
modifier = modifier,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
modifier = modifier,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
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.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) }
}
if (hasPermission) {
CalendarHost(modifier = modifier)
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.graphics.Color
/**
* Soften a raw calendar color toward a pastel that fits the active theme.
* - Keeps the hue (so users still recognise their calendars)
* - Caps saturation so harsh provider colors stop screaming
* - Pins value/brightness to a band that reads on both light and dark surfaces
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}

View File

@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* Navigation drawer shared by every top-level calendar screen (M2/M3/M4
* entry points). Stateless — the host screen owns the drawer state and wires
* the callbacks.
*/
@Composable
fun CalendarDrawer(
onToday: () -> Unit,
onJumpToDate: () -> Unit,
onFilter: () -> Unit,
onSettings: () -> Unit,
) {
ModalDrawerSheet {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.month_today_action)) },
selected = false,
onClick = onToday,
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.month_action_jump_to_date)) },
selected = false,
onClick = onJumpToDate,
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.month_action_filter)) },
selected = false,
onClick = onFilter,
modifier = Modifier.padding(horizontal = 12.dp),
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
label = { Text(stringResource(R.string.month_action_settings)) },
selected = false,
onClick = onSettings,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}

View File

@@ -0,0 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common
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.FilledTonalButton
import androidx.compose.material3.MaterialTheme
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* Full-screen failure state shared by every calendar screen (spec §7).
* One explanation line + one recovery action, never a toast.
*/
@Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val titleRes = when (reason) {
FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown
}
val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onRetry) {
Text(stringResource(actionRes))
}
}
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve.
*
* Read it in a composable scope (this helper) so it can be captured by the
* non-composable `AnimatedContent` transitionSpec lambda.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
): ContentTransform {
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}

View File

@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.common
/**
* The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
*/
enum class CalendarView {
Month,
Week,
Day,
}
/**
* Views that actually have a screen today. The view-switcher pill cycles
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
val i = available.indexOf(this)
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.util.Locale
/**
* Current display [Locale], read observably from [LocalConfiguration] so the UI
* recomposes after a locale change (lint: NonObservableLocale). Used for
* weekday/month name formatting.
*/
@Composable
fun currentLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
) {
val labelRes = when (current) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
) {
Text(stringResource(labelRes))
}
}

View File

@@ -0,0 +1,552 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
private fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
modifier: Modifier = Modifier,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the day swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
DayTopBar(
date = date,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnToday,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.day_today_action)) },
)
}
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
)
}
}
}
@Composable
private fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
AllDayStrip(
state = state,
height = allDayHeight,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DayTopBar(
date: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatDayTitle(date),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = modifier
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val barWidth = maxWidth
state.allDay.forEach { span ->
AllDayBar(
event = span.event,
dark = dark,
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
@Composable
private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day column)
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(HOUR_HEIGHT),
) {
if (h > 0) {
Text(
text = "%02d".format(h),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
EventBlock(
block = block,
dark = dark,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
}
}
}
}
@Composable
private fun DayLoading() {
val totalHeight = HOUR_HEIGHT * 24
val scrollState = rememberScrollState()
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}

View File

@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}

View File

@@ -0,0 +1,106 @@
package de.jeanlucmakiola.calendula.ui.day
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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
_date.value = todayDate
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}

View File

@@ -0,0 +1,464 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnCurrentMonth = when (val s = state) {
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
else -> true
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) }
val goNext = {
slideDir = 1
viewModel.goToNext()
}
val goPrev = {
slideDir = -1
viewModel.goToPrev()
}
val jumpToToday = {
slideDir = 0
viewModel.goToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the month swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = {
jumpToToday()
scope.launch { drawerState.close() }
},
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: open date picker */ },
onFilter = { scope.launch { drawerState.close() } /* TODO: open filter sheet */ },
onSettings = { scope.launch { drawerState.close() } /* TODO: navigate to settings */ },
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MonthTopBar(
month = month,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnCurrentMonth,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.month_today_action)) },
)
}
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
WeekdayHeader(weekStart = DayOfWeek.MONDAY)
MonthContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
)
}
}
}
}
@Composable
private fun MonthContent(
state: MonthUiState,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
) {
val density = LocalDensity.current
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is MonthUiState.Success -> "success-${s.month}"
is MonthUiState.Failure -> "failure-${s.reason}"
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "month-transition",
) { s ->
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(state = s, weekStart = DayOfWeek.MONDAY)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MonthTopBar(
month: YearMonth,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatMonthYear(month),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek) {
val locale = currentLocale()
val days = remember(weekStart, locale) {
(0 until 7).map { offset ->
DayOfWeek.entries[((weekStart.ordinal + offset) % 7)]
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium,
color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
weekStart: DayOfWeek,
) {
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
// Show only the weeks the current month actually touches; leading/trailing
// days of neighbouring months are left blank rather than rendered.
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
val weeks = (leadOffset + daysInMonth + 6) / 7
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(weeks) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(7) { col ->
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
val inMonth =
date.month == state.month.month && date.year == state.month.year
if (inMonth) {
DayCard(
date = date,
isToday = date == state.today,
data = state.cells[date],
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DayCard(
date: LocalDate,
isToday: Boolean,
data: DayCellData?,
modifier: Modifier = Modifier,
) {
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
val cellLabel = buildString {
if (isToday) append(todayPrefix).append(", ")
append(date.year).append('-')
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
append(date.day.toString().padStart(2, '0'))
data?.let { append(", ").append(it.count).append(" Events") }
}
// M3 Expressive press feedback: a spatial spring from the active motion
// scheme drives a subtle scale, instead of a fixed easing curve.
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.94f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "day-card-press",
)
Card(
onClick = { /* TODO: open the day view (S3) for this date */ },
interactionSource = interactionSource,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
),
modifier = modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.semantics { contentDescription = cellLabel },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 4.dp, bottom = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
)
Spacer(Modifier.height(2.dp))
EventDotRow(data)
}
}
}
@Composable
private fun EventDotRow(data: DayCellData?) {
if (data == null || data.swatches.isEmpty()) {
Spacer(Modifier.height(6.dp))
return
}
val dark = isSystemInDarkTheme()
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
data.swatches.forEach { argb ->
Box(
modifier = Modifier
.size(6.dp)
.background(pastelize(argb, dark), CircleShape),
)
}
if (data.count > data.swatches.size) {
Text(
text = "+${data.count - data.swatches.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun MonthGridLoading() {
val shape = MaterialTheme.shapes.medium
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(6) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = shape,
),
)
}
}
}
}
}
private fun formatMonthYear(ym: YearMonth): String {
val locale = Locale.getDefault()
val name = java.time.Month.of(ym.month.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, locale)
return "$name ${ym.year}"
}

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
/**
* Per-day aggregation surfaced to the month grid. We only need
* - the total event count (drives the optional "+N" indicator), and
* - up to three calendar colors for the dot row.
*
* The day cell never holds full event objects — the detail sheet pulls those
* lazily.
*/
data class DayCellData(
val count: Int,
val swatches: List<Int>,
)
sealed interface MonthUiState {
data object Loading : MonthUiState
data class Failure(val reason: FailureReason) : MonthUiState
data class Success(
val month: YearMonth,
val today: LocalDate,
val cells: Map<LocalDate, DayCellData>,
) : MonthUiState
}

View File

@@ -0,0 +1,128 @@
package de.jeanlucmakiola.calendula.ui.month
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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MonthViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
// V1: week starts Monday. DataStore-driven preference comes with Settings.
private val weekStart: DayOfWeek = DayOfWeek.MONDAY
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
val state: StateFlow<MonthUiState> = _month
.flatMapLatest { ym ->
val range = monthGridRange(ym, weekStart, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, calendars, instances)
}
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
}
private fun buildState(
ym: YearMonth,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
.mapValues { (_, evs) ->
DayCellData(
count = evs.size,
swatches = evs.map { it.color }.distinct().take(3),
)
}
return MonthUiState.Success(
month = ym,
today = todayDate,
cells = byDay,
)
}
}
/**
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
* trailing days of the previous month and the leading days of the next month.
*/
internal fun monthGridRange(
ym: YearMonth,
weekStart: DayOfWeek,
zone: TimeZone,
): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
val start = gridStart.atStartOfDayIn(zone)
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
return start..end
}
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,693 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */
private fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp
val lanes = allDaySpans.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) {
is WeekUiState.Success -> s.weekStart == s.today.startOfWeek(DayOfWeek.MONDAY)
else -> true
}
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the week swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
WeekTopBar(
weekStart = weekStart,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
AnimatedVisibility(
visible = !isOnCurrentWeek,
enter = scaleIn(),
exit = scaleOut(),
) {
ExtendedFloatingActionButton(
onClick = jumpToToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(stringResource(R.string.week_today_action)) },
)
}
},
) { innerPadding ->
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun WeekContent(
state: WeekUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// week during a swipe, so the strip slides along but never jumps in height;
// it just springs smoothly from the old to the new size.
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "all-day-strip-height",
)
// Whole-page horizontal swipe. It sits one level above the timeline's
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "week-transition",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
)
}
}
}
@Composable
private fun WeekSuccess(
state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
AllDayStrip(state = state, height = allDayHeight)
}
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekTopBar(
weekStart: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatWeekRange(weekStart),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Match the static top section exactly: plain surface, lifting to
// surfaceContainer once content scrolls under the bar.
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) {
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers.
Column(
modifier = Modifier.width(GUTTER_WIDTH),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
WeekNumberBadge(weekNumber = weekNumber)
}
days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the 28dp circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
if (isToday) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */
@Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
val dark = isSystemInDarkTheme()
Row(
modifier = Modifier
.fillMaxWidth()
// Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) {
// Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH))
// Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val colWidth = maxWidth / 7
state.allDaySpans.forEach { span ->
val spanCols = span.endCol - span.startCol + 1
AllDayBar(
event = span.event,
dark = dark,
modifier = Modifier
.offset(
x = colWidth * span.startCol,
y = ALL_DAY_ROW_HEIGHT * span.lane,
)
.width(colWidth * spanCols)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
@Composable
private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns)
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(HOUR_HEIGHT),
) {
if (h > 0) {
Text(
text = "%02d".format(h),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day columns: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
}
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
EventBlock(
block = block,
dark = dark,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
}
}
}
}
@Composable
private fun WeekLoading() {
val totalHeight = HOUR_HEIGHT * 24
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp)
.height(36.dp)
.background(
MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(8.dp),
),
)
}
}
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault()
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
val monthName = { d: LocalDate ->
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
}
return if (weekStart.month == end.month && weekStart.year == end.year) {
"${weekStart.day}.${end.day}. ${monthName(weekStart)} ${weekStart.year}"
} else if (weekStart.year == end.year) {
"${weekStart.day}. ${monthName(weekStart)} ${end.day}. ${monthName(end)} ${end.year}"
} else {
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} " +
"${end.day}. ${monthName(end)} ${end.year}"
}
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
/**
* One timed event clipped to a single day and assigned a horizontal lane so
* overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander
* aufgelöst").
*
* @param startMin minutes from this day's midnight, clamped to [0, 1440]
* @param endMin minutes from this day's midnight, clamped to [startMin, 1440];
* equal to [startMin] for instant events (render enforces a
* minimum tap-target height)
* @param lane 0-based column within [laneCount]
* @param laneCount number of columns the event's overlap-cluster needs
*/
data class TimedBlock(
val event: EventInstance,
val startMin: Int,
val endMin: Int,
val lane: Int,
val laneCount: Int,
)
/**
* An all-day (or multi-day) event laid out as a single horizontal bar spanning
* [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping
* spans don't collide. A multi-day event is one connected bar — not one chip per
* day.
*
* @param startCol first visible covered column, 0..6 (clamped to the week)
* @param endCol last visible covered column, 0..6, inclusive
* @param lane 0-based stacking row
*/
data class AllDaySpan(
val event: EventInstance,
val startCol: Int,
val endCol: Int,
val lane: Int,
)
sealed interface WeekUiState {
data object Loading : WeekUiState
data class Failure(val reason: FailureReason) : WeekUiState
data class Success(
val weekStart: LocalDate,
val today: LocalDate,
/** The seven days of the week, [weekStart] first. */
val days: List<LocalDate>,
/** All-day/multi-day events as connected horizontal spans. */
val allDaySpans: List<AllDaySpan>,
/** Timed events, clipped to each day with lanes resolved. */
val timedByDay: Map<LocalDate, List<TimedBlock>>,
) : WeekUiState
}

View File

@@ -0,0 +1,227 @@
package de.jeanlucmakiola.calendula.ui.week
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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
const val MINUTES_PER_DAY: Int = 24 * 60
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class WeekViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
// V1: week starts Monday. DataStore-driven preference comes with Settings.
private val weekStart: DayOfWeek = DayOfWeek.MONDAY
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _weekStartDate = MutableStateFlow(todayDate.startOfWeek(weekStart))
val weekStartDate: StateFlow<LocalDate> = _weekStartDate
val state: StateFlow<WeekUiState> = _weekStartDate
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)
fun goToPrev() {
_weekStartDate.value = _weekStartDate.value.minus(7, DateTimeUnit.DAY)
}
fun goToNext() {
_weekStartDate.value = _weekStartDate.value.plus(7, DateTimeUnit.DAY)
}
fun goToToday() {
_weekStartDate.value = todayDate.startOfWeek(weekStart)
}
private fun buildState(
start: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): WeekUiState {
if (calendars.isEmpty()) {
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success(
weekStart = start,
today = todayDate,
days = days,
allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
)
}
}
/**
* Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column;
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
*/
internal fun layoutAllDay(
events: List<EventInstance>,
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
val laneEnd = ArrayList<Int>() // last occupied column per lane
return raw.map { r ->
var lane = laneEnd.indexOfFirst { it < r.startCol }
if (lane == -1) {
laneEnd.add(r.endCol)
lane = laneEnd.size - 1
} else {
laneEnd[lane] = r.endCol
}
AllDaySpan(r.event, r.startCol, r.endCol, lane)
}
}
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
/** Half-open instant range covering the seven days starting at [start]. */
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = start.atStartOfDayIn(zone)
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart
}
/**
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
* chained-overlapping events), matching the common phone week-view behaviour.
*
* All-day events are ignored here — they live in the all-day strip.
*/
internal fun layoutDay(
events: List<EventInstance>,
day: LocalDate,
zone: TimeZone,
): List<TimedBlock> {
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.filterNot { it.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
Raw(ev, m, m)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
}
}
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
.toList()
val result = ArrayList<TimedBlock>(raw.size)
var i = 0
while (i < raw.size) {
// Grow a cluster of chained-overlapping events.
var clusterEnd = raw[i].endMin
var j = i + 1
while (j < raw.size && raw[j].startMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
j++
}
val cluster = raw.subList(i, j)
// Greedy first-fit column assignment (= max overlap depth in the cluster).
val laneEnd = ArrayList<Int>()
val lanes = IntArray(cluster.size)
cluster.forEachIndexed { k, r ->
var placed = laneEnd.indexOfFirst { it <= r.startMin }
if (placed == -1) {
laneEnd.add(r.endMin)
placed = laneEnd.size - 1
} else {
laneEnd[placed] = r.endMin
}
lanes[k] = placed
}
val laneCount = laneEnd.size
cluster.forEachIndexed { k, r ->
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
}
i = j
}
return result
}

View File

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

View File

@@ -10,4 +10,39 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string> <string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string> <string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string> <string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Kalender-Zugriff</string>
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
<string name="permission_request_button">Weiter</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
<string name="permission_retry_button">Erneut versuchen</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
<string name="month_next">Nächster Monat</string>
<string name="month_today_action">Heute</string>
<string name="month_more_actions">Weitere Aktionen</string>
<string name="month_open_menu">Menü öffnen</string>
<string name="month_action_filter">Kalender</string>
<string name="month_action_jump_to_date">Zu Datum springen…</string>
<string name="month_action_settings">Einstellungen</string>
<string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) -->
<string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
</resources> </resources>

View File

@@ -11,4 +11,39 @@
<string name="state_failure_no_calendars">No calendars configured.</string> <string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string> <string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">Calendar access</string>
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
<string name="permission_request_button">Continue</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
<string name="permission_open_settings_button">Open system settings</string>
<string name="permission_retry_button">Try again</string>
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</string>
<string name="month_today_action">Today</string>
<string name="month_more_actions">More actions</string>
<string name="month_open_menu">Open menu</string>
<string name="month_action_filter">Calendars</string>
<string name="month_action_jump_to_date">Jump to date…</string>
<string name="month_action_settings">Settings</string>
<string name="month_a11y_today_prefix">Today</string>
<!-- Week view (S2) -->
<string name="week_today_action">This week</string>
<string name="week_number_label">Wk</string>
<!-- Day view (S3) -->
<string name="day_today_action">Today</string>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
<string name="view_week">Week</string>
<string name="view_day">Day</string>
</resources> </resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class WeekLayoutTest {
private val zone = TimeZone.UTC
// 2026-06-10 is a Wednesday; its Monday-anchored week starts 2026-06-08.
private val wed = LocalDate(2026, 6, 10)
private val mon = LocalDate(2026, 6, 8)
private val weekDays = (0..6).map { mon.plusDays(it) }
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
date.atTime(h, m).toInstant(zone)
private fun event(
start: Instant,
end: Instant,
allDay: Boolean = false,
id: Long = 1L,
title: String = "E",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = start,
end = end,
isAllDay = allDay,
color = 0xFF112233.toInt(),
location = null,
)
@Test
fun `startOfWeek snaps to monday`() {
assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
assertThat(mon.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
}
@Test
fun `weekRange spans seven days`() {
val range = weekRange(mon, zone)
assertThat(range.start).isEqualTo(at(mon, 0, 0))
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
}
@Test
fun `coversDay is true for any overlap and false otherwise`() {
val ev = event(at(wed, 9), at(wed, 10))
assertThat(ev.coversDay(wed, zone)).isTrue()
assertThat(ev.coversDay(mon, zone)).isFalse()
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
assertThat(multiDay.coversDay(mon, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
assertThat(multiDay.coversDay(wed, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
}
@Test
fun `single timed event gets one lane`() {
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
assertThat(blocks).hasSize(1)
val b = blocks.single()
assertThat(b.startMin).isEqualTo(9 * 60)
assertThat(b.endMin).isEqualTo(10 * 60 + 30)
assertThat(b.lane).isEqualTo(0)
assertThat(b.laneCount).isEqualTo(1)
}
@Test
fun `overlapping events resolve to side-by-side lanes`() {
val a = event(at(wed, 9), at(wed, 11), id = 1L)
val b = event(at(wed, 10), at(wed, 12), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
}
@Test
fun `back-to-back events reuse one lane`() {
val a = event(at(wed, 9), at(wed, 10), id = 1L)
val b = event(at(wed, 10), at(wed, 11), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone)
assertThat(blocks).hasSize(2)
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `event spanning midnight is clipped to the day`() {
// Starts the previous evening, ends 02:00 on wed.
val ev = event(at(mon.plusDays(1), 22), at(wed, 2))
val blocks = layoutDay(listOf(ev), wed, zone)
assertThat(blocks).hasSize(1)
assertThat(blocks.single().startMin).isEqualTo(0)
assertThat(blocks.single().endMin).isEqualTo(2 * 60)
}
@Test
fun `instant event is kept with zero-length`() {
val ev = event(at(wed, 12), at(wed, 12))
val blocks = layoutDay(listOf(ev), wed, zone)
assertThat(blocks).hasSize(1)
assertThat(blocks.single().startMin).isEqualTo(12 * 60)
assertThat(blocks.single().endMin).isEqualTo(12 * 60)
}
@Test
fun `all-day events are excluded from the timed layout`() {
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
@Test
fun `events on other days are dropped`() {
val ev = event(at(mon, 9), at(mon, 10))
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
@Test
fun `single-day all-day event is a one-column span`() {
// Wed only: start Wed 00:00, end Thu 00:00.
val ev = event(at(weekDays[2], 0), at(weekDays[3], 0), allDay = true)
val spans = layoutAllDay(listOf(ev), weekDays, zone)
assertThat(spans).hasSize(1)
val s = spans.single()
assertThat(s.startCol).isEqualTo(2)
assertThat(s.endCol).isEqualTo(2)
assertThat(s.lane).isEqualTo(0)
}
@Test
fun `multi-day all-day event becomes one span across columns`() {
// Tue..Thu: end Fri 00:00 is exclusive, so Fri is not covered.
val ev = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true)
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
assertThat(s.startCol).isEqualTo(1)
assertThat(s.endCol).isEqualTo(3)
}
@Test
fun `span reaching outside the week is clamped to visible columns`() {
// Starts two days before Monday, ends Wed 00:00 → covers Mon..Tue.
val ev = event(at(mon.plusDays(-2), 0), at(weekDays[2], 0), allDay = true)
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
assertThat(s.startCol).isEqualTo(0)
assertThat(s.endCol).isEqualTo(1)
}
@Test
fun `overlapping all-day spans get separate lanes`() {
val a = event(at(weekDays[0], 0), at(weekDays[3], 0), allDay = true, id = 1L) // Mon..Wed
val b = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Tue..Thu
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
assertThat(spans.map { it.lane }.toSet()).isEqualTo(setOf(0, 1))
}
@Test
fun `disjoint all-day spans reuse one lane`() {
val a = event(at(weekDays[0], 0), at(weekDays[1], 0), allDay = true, id = 1L) // Mon
val b = event(at(weekDays[3], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Thu
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
assertThat(spans.all { it.lane == 0 }).isTrue()
}
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
}

View File

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

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.1.1" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.3.21"
ksp = "2.3.9" ksp = "2.3.9"
hilt = "2.59.2" hilt = "2.59.2"
@@ -17,6 +17,12 @@ junitPlatform = "6.1.0"
truth = "1.4.5" truth = "1.4.5"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espressoCore = "3.7.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] [libraries]
# AndroidX core # AndroidX core
@@ -35,6 +41,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
# Material 3 (Expressive lives in this artifact for 1.5+) # Material 3 (Expressive lives in this artifact for 1.5+)
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
# Hilt # Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
@@ -53,6 +60,25 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View File

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