Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3697a58e5b | |||
| e290c92d78 | |||
| 9c4ebbc65a | |||
| c0d413ba11 | |||
| dca0245a42 | |||
| 024512959f | |||
| e78da3d7c1 | |||
| 2cb8b59fb7 | |||
| 7d36d22fd5 | |||
| adcbed6e02 |
@@ -17,7 +17,7 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
|||||||
- [ ] Day view (S3)
|
- [ ] Day view (S3)
|
||||||
- [ ] Event Detail Sheet (S4)
|
- [ ] Event Detail Sheet (S4)
|
||||||
- [ ] Multi-Calendar Filter (M3)
|
- [ ] Multi-Calendar Filter (M3)
|
||||||
- [ ] Today button + Jump-to-Date (M2)
|
- [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
|
||||||
- [ ] View-Switcher (M1)
|
- [ ] View-Switcher (M1)
|
||||||
- [ ] Settings screen (M4)
|
- [ ] Settings screen (M4)
|
||||||
- [ ] Empty / no-permission / no-calendars states
|
- [ ] Empty / no-permission / no-calendars states
|
||||||
|
|||||||
@@ -6,15 +6,52 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1 | Foundation & CI | complete |
|
| v0.1 | Foundation & CI | complete |
|
||||||
| v0.2 | Data Layer & Permission Flow | complete |
|
| v0.2 | Data Layer & Permission Flow | complete |
|
||||||
| v0.3 | Month view | in progress |
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
| v0.4 | Week view | in progress |
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
| v0.5 | Day view | pending |
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
| v0.6 | Event Detail Sheet | pending |
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
| v0.7 | Filter & Settings | pending |
|
| v1.0 | First public release — polish pass, F-Droid | complete |
|
||||||
|
|
||||||
## v1.0 — First Public Release
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
|
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
|
||||||
|
|
||||||
All V1 features shipped, polished, on F-Droid. Read-only calendar.
|
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
|
||||||
|
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
|
||||||
|
|
||||||
|
## v0.6 — Full event read
|
||||||
|
|
||||||
|
Round out the read-only model so a detail view shows everything the system
|
||||||
|
actually stores, before write support starts. Scope = `CalendarContract`
|
||||||
|
columns we don't yet read/display:
|
||||||
|
|
||||||
|
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
|
||||||
|
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
|
||||||
|
- **Availability** (`TRANSP`) — Free / Busy chip
|
||||||
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
|
`SELF_ATTENDEE_STATUS`
|
||||||
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
|
Deliberately out of v0.6:
|
||||||
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
|
editing, so it folds into v2
|
||||||
|
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
|
||||||
|
(provider limitation, not our choice)
|
||||||
|
|
||||||
|
## v1.0 — First Public Release — shipped 2026-06-11
|
||||||
|
|
||||||
|
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
|
||||||
|
after v0.6 (full event read) plus the onboarding-screen polish pass.
|
||||||
|
|
||||||
|
### Polish backlog (pre-1.0)
|
||||||
|
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
|
||||||
|
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
|
||||||
|
|
||||||
## v2.0 — Write Support
|
## v2.0 — Write Support
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-10*
|
*Last updated: 2026-06-11*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v0.4 — Week view (in progress)
|
**Milestone:** v1.0.0 — First public release (shipped 2026-06-11)
|
||||||
**Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet,
|
**Phase:** V1 is complete and released. All screens done, the read model
|
||||||
filter, settings, jump-to-date) still stubbed
|
surfaces every readable `CalendarContract` field, and the onboarding screen
|
||||||
|
got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -16,13 +17,18 @@ filter, settings, jump-to-date) still stubbed
|
|||||||
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
- [x] Plan 02 written and executed — data layer + permission flow + debug screen
|
||||||
- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
- [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] 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)
|
- [x] Day view (S3) — single-column slice reusing the week layout
|
||||||
- [ ] Day view (S3)
|
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
||||||
- [ ] Event-detail sheet (S4) — week/month event taps are currently no-ops
|
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||||
- [ ] Filter sheet (M3), Settings (M4), Jump-to-date (M2) — drawer entries stubbed
|
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||||
|
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||||
|
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||||
|
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||||
|
attendee role + self-response, foreign timezone, and linkified description
|
||||||
|
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||||
|
URL field was cut — no `CalendarContract` column backs it.)
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Day view (S3) — slot it into the view-switcher cycle
|
1. v1.0.0 released — monitor the F-Droid build/publish
|
||||||
2. Event-detail sheet (S4) — wire month-day and week-event taps to it
|
2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX)
|
||||||
3. Revisit month/week UI polish + shared anchor-date continuity across views
|
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-06-11
|
||||||
|
|
||||||
|
First public release. Calendula is a read-only, Material 3 Expressive calendar
|
||||||
|
that lives entirely on top of Android's `CalendarContract` — every calendar
|
||||||
|
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
|
||||||
|
automatically, with zero telemetry and no internet permission.
|
||||||
|
|
||||||
|
### Highlights (accumulated across v0.1 → v0.6)
|
||||||
|
- Month, week, and day views with a view switcher, swipe navigation, and
|
||||||
|
Loading / Failure / Success states on every screen
|
||||||
|
- Full-screen event detail surfacing every readable `CalendarContract` field —
|
||||||
|
times, recurrence (humanised), location, description (with tappable links),
|
||||||
|
attendees + roles + your own response, reminders, status, availability,
|
||||||
|
access level, and foreign time zones
|
||||||
|
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
|
||||||
|
screen (theme, Material You dynamic colour, week start, app language)
|
||||||
|
- Material 3 Expressive first-run onboarding for calendar access
|
||||||
|
- German + English localization throughout
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `versionName`/`versionCode` bumped to 1.0.0 / 7
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full event read (v0.6): the detail screen now surfaces every readable
|
||||||
|
`CalendarContract` field that V1 had been dropping —
|
||||||
|
- **Reminders** — each configured lead time, humanised ("10 minutes before",
|
||||||
|
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
|
||||||
|
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
|
||||||
|
also strikes through its title (Confirmed shows no chip)
|
||||||
|
- **Availability** — a "Free" pill pinned top-right of the title when the
|
||||||
|
event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP
|
||||||
|
field); the default "Busy" is left implicit to avoid noise on every event
|
||||||
|
- **Access level** — a Private / Confidential chip when the event isn't public
|
||||||
|
- **Attendee role** — organizer / optional / resource badge under each
|
||||||
|
attendee, plus the device user's own response ("Your response: …") from
|
||||||
|
`Events.SELF_ATTENDEE_STATUS`
|
||||||
|
- **Time zone** — shown only for timed events pinned to a zone other than the
|
||||||
|
device's, so cross-zone events read unambiguously
|
||||||
|
- **Linked URLs** — http(s) links in the description are now tappable
|
||||||
|
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
|
||||||
|
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
|
||||||
|
status fields; mappers + unit tests cover every new column's integer codes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned the first-run grant-access screen — the onboarding a new user
|
||||||
|
sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name
|
||||||
|
eyebrow, a benefit-led headline, three trust rows (on-device, every calendar,
|
||||||
|
no tracking) with tonal icon chips, a full-width filled CTA with a trailing
|
||||||
|
arrow, and a "Read-only · no internet permission" footnote (the app declares
|
||||||
|
only `READ_CALENDAR`). The denied/recovery state shares the same shell with a
|
||||||
|
lock-badged hero and Open-settings / Try-again actions
|
||||||
|
- `versionName`/`versionCode` bumped to 0.6.0 / 6
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
|
||||||
|
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
|
||||||
|
are surfaced by linkifying the description instead
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendar filter (M3): the navigation drawer now hosts the calendar list
|
||||||
|
inline — every calendar grouped by account, each with a colour swatch and a
|
||||||
|
visibility switch. Hiding a calendar is persisted app-side (DataStore,
|
||||||
|
separate from the system VISIBLE flag) and applied centrally in the
|
||||||
|
repository, so month/week/day re-filter live the moment a switch flips.
|
||||||
|
The drawer was trimmed to just Today, the calendar filter, and Settings
|
||||||
|
(the stubbed jump-to-date entry was removed; jump-to-date was later cut
|
||||||
|
from scope entirely)
|
||||||
|
- Settings (M4): a full-screen destination with
|
||||||
|
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
|
||||||
|
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
|
||||||
|
- **Language** — app language (System / Deutsch / English) via per-app
|
||||||
|
locales, persisted across cold starts down to Android 10
|
||||||
|
- **About** — version, license, and a link to the source on Gitea
|
||||||
|
- Week-start preference now drives the month grid and week view; "Automatic"
|
||||||
|
follows the active locale (Monday in DE, Sunday in en-US)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Theme is driven by one activity-scoped settings source, so a theme or
|
||||||
|
dynamic-colour change applies app-wide immediately
|
||||||
|
- `versionName`/`versionCode` bumped to 0.5.0 / 5 (the in-repo version had
|
||||||
|
lagged behind the release tags); the About screen reads it directly
|
||||||
|
|
||||||
## [0.4.0] — 2026-06-10
|
## [0.4.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 7
|
||||||
versionName = "0.1.0"
|
versionName = "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,7 @@ kotlin {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -24,7 +28,10 @@ class CalendarRepositorySmokeTest {
|
|||||||
|
|
||||||
private fun newRepo(): CalendarRepositoryImpl {
|
private fun newRepo(): CalendarRepositoryImpl {
|
||||||
val dataSource = AndroidCalendarDataSource(context)
|
val dataSource = AndroidCalendarDataSource(context)
|
||||||
return CalendarRepositoryImpl(dataSource, Dispatchers.IO)
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||||
|
)
|
||||||
|
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -23,6 +23,17 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Persists the per-app language (M4) on API < 33, where the platform
|
||||||
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="autoStoreLocales"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ 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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -16,7 +22,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
CalendulaTheme {
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
|
// and the Settings screen, so a theme change applies app-wide at once.
|
||||||
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val darkTheme = when (settings.themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
CalendulaTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
dynamicColor = settings.dynamicColor,
|
||||||
|
) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.Attendee
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -62,13 +63,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
EventDetailProjection.COLUMNS,
|
EventDetailProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||||
|
|
||||||
|
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
ReminderProjection.COLUMNS,
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||||
|
|
||||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
/** Iterate every row and map; skips nothing. */
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
@@ -23,6 +25,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class CalendarRepositoryImpl @Inject constructor(
|
class CalendarRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: CalendarDataSource,
|
private val dataSource: CalendarDataSource,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : CalendarRepository {
|
) : CalendarRepository {
|
||||||
|
|
||||||
@@ -41,16 +44,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
.reQuery { dataSource.calendars() }
|
.reQuery { dataSource.calendars() }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
|
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||||
|
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||||
|
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||||
|
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||||
|
// so the filter sheet can list and re-enable hidden calendars.
|
||||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
ticks
|
combine(
|
||||||
.onStart { emit(Unit) }
|
ticks
|
||||||
.reQuery {
|
.onStart { emit(Unit) }
|
||||||
dataSource.instances(
|
.reQuery {
|
||||||
beginMillis = range.start.toEpochMillis(),
|
dataSource.instances(
|
||||||
endMillis = range.endInclusive.toEpochMillis(),
|
beginMillis = range.start.toEpochMillis(),
|
||||||
)
|
endMillis = range.endInclusive.toEpochMillis(),
|
||||||
}
|
)
|
||||||
.flowOn(io)
|
},
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { instances, hidden ->
|
||||||
|
if (hidden.isEmpty()) instances
|
||||||
|
else instances.filterNot { it.calendarId in hidden }
|
||||||
|
}.flowOn(io)
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
|
||||||
private const val TAG = "EventDetailMapper"
|
private const val TAG = "EventDetailMapper"
|
||||||
|
|
||||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
internal fun ColumnReader.toEventDetailCore(
|
||||||
|
attendees: List<Attendee>,
|
||||||
|
reminders: List<Reminder>,
|
||||||
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
if (begin < 0L) {
|
||||||
@@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
|||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||||
|
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||||
|
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||||
|
EventStatus.Confirmed
|
||||||
|
} else {
|
||||||
|
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||||
|
}
|
||||||
|
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
instance = instance,
|
instance = instance,
|
||||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||||
|
reminders = reminders,
|
||||||
|
status = status,
|
||||||
|
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||||
|
// default these mappers already return — no isNull guard needed.
|
||||||
|
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||||
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +93,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
|||||||
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||||
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
||||||
|
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
|
||||||
|
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ColumnReader.toReminder(): Reminder = Reminder(
|
||||||
|
minutes = getInt(ReminderProjection.IDX_MINUTES),
|
||||||
|
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||||
@@ -76,3 +109,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
|||||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||||
else -> AttendeeStatus.Unknown
|
else -> AttendeeStatus.Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
|
||||||
|
else -> AttendeeRelationship.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
|
||||||
|
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
|
||||||
|
else -> AttendeeType.None
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
|
||||||
|
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
|
||||||
|
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
|
||||||
|
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
|
||||||
|
else -> EventStatus.Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAvailability(raw: Int): Availability = when (raw) {
|
||||||
|
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
|
||||||
|
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
|
||||||
|
else -> Availability.Busy
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
|
||||||
|
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
|
||||||
|
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
|
||||||
|
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
|
||||||
|
else -> AccessLevel.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
|
||||||
|
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
|
||||||
|
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
|
||||||
|
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
|
||||||
|
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
|
||||||
|
else -> ReminderMethod.Default
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -74,6 +79,11 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ALL_DAY = 9
|
const val IDX_ALL_DAY = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_CALENDAR_ID = 11
|
const val IDX_CALENDAR_ID = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
@@ -81,11 +91,25 @@ internal object AttendeeProjection {
|
|||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_NAME = 0
|
const val IDX_NAME = 0
|
||||||
const val IDX_EMAIL = 1
|
const val IDX_EMAIL = 1
|
||||||
const val IDX_STATUS = 2
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_RELATIONSHIP = 3
|
||||||
|
const val IDX_TYPE = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ReminderProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Reminders.MINUTES,
|
||||||
|
CalendarContract.Reminders.METHOD,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_MINUTES = 0
|
||||||
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
internal object Fallbacks {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import java.time.temporal.WeekFields
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Light/dark override. SYSTEM follows the device setting. */
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
|
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||||
|
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||||
|
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||||
|
*/
|
||||||
|
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||||
|
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||||
|
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||||
|
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||||
|
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
|
* dynamic colour, and week start. Language is handled separately through
|
||||||
|
* AppCompatDelegate (which persists its own per-app locale).
|
||||||
|
*
|
||||||
|
* Enum prefs round-trip by [Enum.name]; an unknown/garbage stored value falls
|
||||||
|
* back to the default rather than throwing (see SettingsPrefsTest).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SettingsPrefs @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
|
||||||
|
prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dynamicColor: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DYNAMIC_COLOR_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||||
|
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
|
store.edit { it[THEME_MODE_KEY] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDynamicColor(enabled: Boolean) {
|
||||||
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
|
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
@@ -29,12 +29,34 @@ data class EventDetail(
|
|||||||
val organizer: String?,
|
val organizer: String?,
|
||||||
val attendees: List<Attendee>,
|
val attendees: List<Attendee>,
|
||||||
val rrule: String?,
|
val rrule: String?,
|
||||||
|
/** Reminders (VALARM) configured on the event, ascending lead time. */
|
||||||
|
val reminders: List<Reminder> = emptyList(),
|
||||||
|
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
|
||||||
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
|
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
|
||||||
|
val availability: Availability = Availability.Busy,
|
||||||
|
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
|
||||||
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
|
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
|
||||||
|
val eventTimezone: String? = null,
|
||||||
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
val status: AttendeeStatus,
|
val status: AttendeeStatus,
|
||||||
|
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
|
||||||
|
val relationship: AttendeeRelationship = AttendeeRelationship.None,
|
||||||
|
/** Required / optional / resource (`ATTENDEE_TYPE`). */
|
||||||
|
val type: AttendeeType = AttendeeType.None,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Reminder(
|
||||||
|
/** Lead time before the event start, in minutes. `-1` means the provider default. */
|
||||||
|
val minutes: Int,
|
||||||
|
val method: ReminderMethod,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AttendeeStatus {
|
enum class AttendeeStatus {
|
||||||
@@ -45,6 +67,48 @@ enum class AttendeeStatus {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AttendeeRelationship {
|
||||||
|
Organizer,
|
||||||
|
Attendee,
|
||||||
|
Performer,
|
||||||
|
Speaker,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AttendeeType {
|
||||||
|
Required,
|
||||||
|
Optional,
|
||||||
|
Resource,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ReminderMethod {
|
||||||
|
Alert,
|
||||||
|
Email,
|
||||||
|
Sms,
|
||||||
|
Alarm,
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Availability {
|
||||||
|
Busy,
|
||||||
|
Free,
|
||||||
|
Tentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AccessLevel {
|
||||||
|
Default,
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ import kotlinx.datetime.LocalDate
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
fun CalendarHost(modifier: Modifier = Modifier) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Month) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
@@ -59,6 +60,12 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings (M4) is hoisted here so it overlays whichever calendar view is
|
||||||
|
// active and survives view switches. (The calendar filter now lives inline
|
||||||
|
// in the navigation drawer, so no overlay state is needed for it.)
|
||||||
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -67,17 +74,20 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,5 +107,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSettings,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SettingsScreen(onBack = { showSettings = false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Today
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
@@ -13,53 +19,72 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation drawer shared by every top-level calendar screen (M2/M3/M4
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
* entry points). Stateless — the host screen owns the drawer state and wires
|
*
|
||||||
* the callbacks.
|
* Visual language (kept deliberately small so sizes don't drift):
|
||||||
|
* - Drawer title — `titleLarge`
|
||||||
|
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
||||||
|
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
|
||||||
|
* (`labelLarge` label + a single 24dp leading icon)
|
||||||
|
*
|
||||||
|
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
|
||||||
|
* its checkboxes lives here rather than in a separate sheet — plus the "today"
|
||||||
|
* jump and a Settings entry (M4). The host screen owns the drawer state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
onJumpToDate: () -> Unit,
|
|
||||||
onFilter: () -> Unit,
|
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Text(
|
Column(Modifier.fillMaxHeight()) {
|
||||||
text = stringResource(R.string.app_name),
|
Text(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
text = stringResource(R.string.app_name),
|
||||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
HorizontalDivider()
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
HorizontalDivider()
|
||||||
NavigationDrawerItem(
|
Spacer(Modifier.height(8.dp))
|
||||||
label = { Text(stringResource(R.string.month_today_action)) },
|
NavigationDrawerItem(
|
||||||
selected = false,
|
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
||||||
onClick = onToday,
|
label = { Text(stringResource(R.string.month_today_action)) },
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
selected = false,
|
||||||
)
|
onClick = onToday,
|
||||||
NavigationDrawerItem(
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
label = { Text(stringResource(R.string.month_action_jump_to_date)) },
|
)
|
||||||
selected = false,
|
Spacer(Modifier.height(8.dp))
|
||||||
onClick = onJumpToDate,
|
HorizontalDivider()
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
||||||
NavigationDrawerItem(
|
// between the top actions and the pinned Settings entry.
|
||||||
label = { Text(stringResource(R.string.month_action_filter)) },
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
selected = false,
|
CalendarFilterList(modifier = Modifier.weight(1f))
|
||||||
onClick = onFilter,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
HorizontalDivider()
|
||||||
)
|
Spacer(Modifier.height(8.dp))
|
||||||
Spacer(Modifier.height(8.dp))
|
NavigationDrawerItem(
|
||||||
HorizontalDivider()
|
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
Spacer(Modifier.height(8.dp))
|
label = { Text(stringResource(R.string.month_action_settings)) },
|
||||||
NavigationDrawerItem(
|
selected = false,
|
||||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
onClick = onSettings,
|
||||||
selected = false,
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
onClick = onSettings,
|
)
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
Spacer(Modifier.height(8.dp))
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Top-level grouping label in the drawer. Text only, so it never reads as a
|
||||||
|
* tappable nav item. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerSectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ fun DayScreen(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -152,9 +153,10 @@ fun DayScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
|
onSettings = {
|
||||||
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
|
onOpenSettings()
|
||||||
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -30,8 +32,10 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
|||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -46,26 +50,38 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
@@ -158,12 +174,30 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title with a short accent line in the calendar colour underneath.
|
// Title row: title on the left, a "Free" pill pinned top-right when the
|
||||||
Text(
|
// event doesn't block your time. Busy is the default for nearly every
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
// cancelled event strikes through its title.
|
||||||
fontWeight = FontWeight.SemiBold,
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
)
|
Text(
|
||||||
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (detail.availability == Availability.Free) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
InfoChip(
|
||||||
|
text = stringResource(R.string.event_availability_free),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -172,6 +206,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Status / access chips — shown only when noteworthy (Confirmed status
|
||||||
|
// and Default/Public access are the silent norm).
|
||||||
|
val hasStatusChips = detail.status != EventStatus.Confirmed ||
|
||||||
|
detail.accessLevel == AccessLevel.Private ||
|
||||||
|
detail.accessLevel == AccessLevel.Confidential
|
||||||
|
if (hasStatusChips) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
StatusChips(detail.status, detail.accessLevel)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// Every piece of info shares one card design: a tonal container with a
|
// Every piece of info shares one card design: a tonal container with a
|
||||||
@@ -193,6 +237,18 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
|
// than the device's, so cross-zone events read unambiguously.
|
||||||
|
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
|
) {
|
||||||
|
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
// separate colour dot is needed.
|
// separate colour dot is needed.
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -227,28 +283,63 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description (conditional).
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
) {
|
) {
|
||||||
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
Text(
|
||||||
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attendees (conditional).
|
// Attendees (conditional). The user's own response leads the list, then
|
||||||
|
// each attendee with their role and reply.
|
||||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.People,
|
icon = Icons.Default.People,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||||
) {
|
) {
|
||||||
|
if (detail.selfStatus != AttendeeStatus.Unknown) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_detail_self_response,
|
||||||
|
stringResource(attendeeStatusLabel(detail.selfStatus)),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
attendees.forEach { AttendeeRow(it) }
|
attendees.forEach { AttendeeRow(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reminders (conditional) — list each lead time before the event.
|
||||||
|
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Notifications,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||||
|
) {
|
||||||
|
reminders
|
||||||
|
.distinctBy { it.minutes }
|
||||||
|
.sortedBy { it.minutes }
|
||||||
|
.forEach { reminder ->
|
||||||
|
Text(
|
||||||
|
text = reminderLeadText(reminder),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recurrence (conditional) — humanised from the RRULE.
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -303,10 +394,20 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(roleRes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -315,6 +416,54 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Status / access pills shown directly under the title accent. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun StatusChips(status: EventStatus, accessLevel: AccessLevel) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
EventStatus.Cancelled -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_cancelled),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Tentative -> InfoChip(
|
||||||
|
text = stringResource(R.string.event_status_tentative),
|
||||||
|
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
EventStatus.Confirmed -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
when (accessLevel) {
|
||||||
|
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
|
||||||
|
AccessLevel.Confidential ->
|
||||||
|
InfoChip(text = stringResource(R.string.event_access_confidential))
|
||||||
|
AccessLevel.Default, AccessLevel.Public -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoChip(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||||
@@ -347,11 +496,10 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
|||||||
|
|
||||||
// --- helpers -------------------------------------------------------------
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Observable locale read (shared helper) — avoids NonObservableLocale /
|
||||||
|
// LocalContextConfigurationRead lint by going through LocalConfiguration.
|
||||||
@Composable
|
@Composable
|
||||||
private fun currentDetailLocale(): Locale {
|
private fun currentDetailLocale(): Locale = currentLocale()
|
||||||
val config = LocalContext.current.resources.configuration
|
|
||||||
return config.locales[0] ?: Locale.getDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||||
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||||
@@ -361,6 +509,77 @@ private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
|||||||
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role badge shown under an attendee's name. Organizer wins over type;
|
||||||
|
* required attendees (the common case) get no badge to keep the list quiet.
|
||||||
|
*/
|
||||||
|
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||||
|
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
|
||||||
|
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
|
||||||
|
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||||
|
@Composable
|
||||||
|
private fun reminderLeadText(reminder: Reminder): String {
|
||||||
|
val minutes = reminder.minutes
|
||||||
|
return when {
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes % 10_080 == 0 -> {
|
||||||
|
val weeks = minutes / 10_080
|
||||||
|
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
||||||
|
}
|
||||||
|
minutes % 1_440 == 0 -> {
|
||||||
|
val days = minutes / 1_440
|
||||||
|
pluralStringResource(R.plurals.reminder_days, days, days)
|
||||||
|
}
|
||||||
|
minutes % 60 == 0 -> {
|
||||||
|
val hours = minutes / 60
|
||||||
|
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
||||||
|
}
|
||||||
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||||
|
* but only when the event is timed and pinned to a zone different from the
|
||||||
|
* device's. Returns null when there's nothing worth showing.
|
||||||
|
*/
|
||||||
|
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||||
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
|
val deviceZone = ZoneId.systemDefault().id
|
||||||
|
if (tz == deviceZone) return null
|
||||||
|
return try {
|
||||||
|
val zone = ZoneId.of(tz)
|
||||||
|
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||||
|
if (name == tz) tz else "$name ($tz)"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
@Composable
|
||||||
|
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
||||||
|
val regex = Regex("""https?://\S+""")
|
||||||
|
val styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||||
|
)
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
for (match in regex.findAll(text)) {
|
||||||
|
// Trim trailing punctuation that commonly abuts a URL in prose.
|
||||||
|
val raw = match.value
|
||||||
|
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
||||||
|
val end = match.range.first + url.length
|
||||||
|
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
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.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
* Every calendar grouped by account, each with a colour swatch and a visibility
|
||||||
|
* switch; toggling writes straight to DataStore and every calendar view
|
||||||
|
* re-filters live. Three states (Loading / Failure / Success).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarFilterList(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: FilterViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
when (val s = state) {
|
||||||
|
FilterUiState.Loading -> FilterLoading(modifier)
|
||||||
|
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
||||||
|
is FilterUiState.Success -> FilterList(
|
||||||
|
groups = s.groups,
|
||||||
|
onSetVisible = viewModel::setVisible,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterList(
|
||||||
|
groups: List<AccountGroup>,
|
||||||
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "header-${group.account}") {
|
||||||
|
Text(
|
||||||
|
text = group.account,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.calendars, key = { it.id }) { cal ->
|
||||||
|
CalendarToggleRow(
|
||||||
|
row = cal,
|
||||||
|
dark = dark,
|
||||||
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarToggleRow(
|
||||||
|
row: CalendarRow,
|
||||||
|
dark: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.background(pastelize(row.color, dark), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = row.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Checkbox(
|
||||||
|
checked = row.visible,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
repeat(4) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 28.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
MaterialTheme.shapes.medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||||
|
val msg = when (reason) {
|
||||||
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
|
else -> R.string.state_failure_provider
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(msg),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||||
|
* visibility; the choice is persisted app-side (separate from the system's
|
||||||
|
* VISIBLE flag) and applied to every calendar view.
|
||||||
|
*/
|
||||||
|
sealed interface FilterUiState {
|
||||||
|
data object Loading : FilterUiState
|
||||||
|
data class Failure(val reason: FailureReason) : FilterUiState
|
||||||
|
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||||
|
data class AccountGroup(
|
||||||
|
val account: String,
|
||||||
|
val calendars: List<CalendarRow>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarRow(
|
||||||
|
val id: Long,
|
||||||
|
val displayName: String,
|
||||||
|
val color: Int,
|
||||||
|
val visible: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
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.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FilterViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val state: StateFlow<FilterUiState> =
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
prefs.hiddenCalendarIds,
|
||||||
|
) { calendars, hidden ->
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
} else {
|
||||||
|
FilterUiState.Success(groupByAccount(calendars, hidden))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = FilterUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Show or hide a single calendar; persists the new hidden set. */
|
||||||
|
fun setVisible(calendarId: Long, visible: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = prefs.hiddenCalendarIds.first()
|
||||||
|
val next = if (visible) current - calendarId else current + calendarId
|
||||||
|
if (next != current) prefs.setHiddenCalendarIds(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group calendars under their owning account, preserving the provider's order
|
||||||
|
* within each group and ordering groups by first appearance. A calendar is
|
||||||
|
* "visible" when its id is *not* in [hidden].
|
||||||
|
*/
|
||||||
|
internal fun groupByAccount(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
hidden: Set<Long>,
|
||||||
|
): List<AccountGroup> =
|
||||||
|
calendars
|
||||||
|
.groupBy { it.accountLabel() }
|
||||||
|
.map { (account, cals) ->
|
||||||
|
AccountGroup(
|
||||||
|
account = account,
|
||||||
|
calendars = cals.map { c ->
|
||||||
|
CalendarRow(
|
||||||
|
id = c.id,
|
||||||
|
displayName = c.displayName,
|
||||||
|
color = c.color,
|
||||||
|
visible = c.id !in hidden,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Account header text: the account name, falling back to its type. */
|
||||||
|
private fun CalendarSource.accountLabel(): String =
|
||||||
|
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
||||||
@@ -85,11 +85,13 @@ fun MonthScreen(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -126,9 +128,10 @@ fun MonthScreen(
|
|||||||
jumpToToday()
|
jumpToToday()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: open date picker */ },
|
onSettings = {
|
||||||
onFilter = { scope.launch { drawerState.close() } /* TODO: open filter sheet */ },
|
onOpenSettings()
|
||||||
onSettings = { scope.launch { drawerState.close() } /* TODO: navigate to settings */ },
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -162,9 +165,10 @@ fun MonthScreen(
|
|||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = DayOfWeek.MONDAY)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
weekStart = weekStart,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
@@ -179,6 +183,7 @@ fun MonthScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
@@ -223,7 +228,7 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
weekStart = DayOfWeek.MONDAY,
|
weekStart = weekStart,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -29,6 +32,7 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -37,13 +41,21 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MonthViewModel @Inject constructor(
|
class MonthViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
private val locale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
// V1: week starts Monday. DataStore-driven preference comes with Settings.
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
private val weekStart: DayOfWeek = DayOfWeek.MONDAY
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||||
|
.map { it.resolveFirstDay(locale) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = DayOfWeek.MONDAY,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
@@ -51,23 +63,24 @@ class MonthViewModel @Inject constructor(
|
|||||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
val state: StateFlow<MonthUiState> = _month
|
val state: StateFlow<MonthUiState> =
|
||||||
.flatMapLatest { ym ->
|
combine(_month, weekStart) { ym, ws -> ym to ws }
|
||||||
val range = monthGridRange(ym, weekStart, zone)
|
.flatMapLatest { (ym, ws) ->
|
||||||
combine(
|
val range = monthGridRange(ym, ws, zone)
|
||||||
repository.calendars(),
|
combine(
|
||||||
repository.instances(range),
|
repository.calendars(),
|
||||||
) { calendars, instances ->
|
repository.instances(range),
|
||||||
buildState(ym, calendars, instances)
|
) { calendars, instances ->
|
||||||
|
buildState(ym, calendars, instances)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.flowOn(io)
|
||||||
.flowOn(io)
|
.stateIn(
|
||||||
.stateIn(
|
scope = viewModelScope,
|
||||||
scope = viewModelScope,
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
initialValue = MonthUiState.Loading,
|
||||||
initialValue = MonthUiState.Loading,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
fun goToPrev() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
|||||||
@@ -6,28 +6,65 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
// MD3 8dp spacing scale, scoped to this screen.
|
||||||
|
private object Space {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
@@ -69,24 +106,68 @@ private fun RationaleContent(
|
|||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
PermissionScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = false) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onRequest,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_request_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(Space.xs))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PrivacyFootnote()
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.xs))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_body),
|
text = stringResource(R.string.permission_rationale_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CalendarMonth,
|
||||||
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_sync_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(Space.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.VisibilityOff,
|
||||||
|
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||||
|
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onRequest) {
|
|
||||||
Text(stringResource(R.string.permission_request_button))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,35 +177,182 @@ private fun DeniedContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Column(
|
PermissionScaffold(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier,
|
||||||
verticalArrangement = Arrangement.Center,
|
hero = { BrandHero(denied = true) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
actions = {
|
||||||
|
Button(
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_open_settings_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.permission_retry_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_denied_title),
|
text = stringResource(R.string.permission_denied_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_denied_body),
|
text = stringResource(R.string.permission_denied_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
}
|
||||||
Button(onClick = onRetry) {
|
}
|
||||||
Text(stringResource(R.string.permission_retry_button))
|
|
||||||
}
|
/**
|
||||||
Spacer(Modifier.height(12.dp))
|
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
||||||
OutlinedButton(
|
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
||||||
onClick = {
|
* centred horizontally; benefit rows fill the width so their own content
|
||||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
* left-aligns.
|
||||||
data = Uri.fromParts("package", context.packageName, null)
|
*/
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
@Composable
|
||||||
}
|
private fun PermissionScaffold(
|
||||||
context.startActivity(intent)
|
hero: @Composable () -> Unit,
|
||||||
},
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = Space.md, vertical = Space.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = Space.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.permission_open_settings_button))
|
Spacer(Modifier.height(Space.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(Space.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(Space.md))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
private fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(Space.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PrivacyFootnote() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.permission_privacy_footnote),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
|
||||||
|
/** UI-facing language choice. AUTO follows the system languages. */
|
||||||
|
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
|
||||||
|
* platform per-app-languages API; below that the appcompat backport persists
|
||||||
|
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
|
||||||
|
* it in DataStore. Setting a locale recreates the activity, which re-reads the
|
||||||
|
* current value for the dropdown.
|
||||||
|
*/
|
||||||
|
object AppLanguage {
|
||||||
|
|
||||||
|
fun current(): LanguagePref {
|
||||||
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
|
if (locales.isEmpty) return LanguagePref.AUTO
|
||||||
|
return when (locales[0]?.language) {
|
||||||
|
"de" -> LanguagePref.GERMAN
|
||||||
|
"en" -> LanguagePref.ENGLISH
|
||||||
|
else -> LanguagePref.AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(pref: LanguagePref) {
|
||||||
|
val locales = when (pref) {
|
||||||
|
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
|
||||||
|
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
|
||||||
|
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
|
||||||
|
* and an about section. A full-screen destination; [onBack] pops it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Intercept the system back button/gesture — without this it falls through
|
||||||
|
// to the activity and closes the app instead of returning to the calendar.
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_appearance))
|
||||||
|
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
selected = state.themeMode,
|
||||||
|
options = ThemeMode.entries,
|
||||||
|
optionLabel = { themeLabel(it) },
|
||||||
|
onSelect = viewModel::setThemeMode,
|
||||||
|
)
|
||||||
|
DynamicColorRow(
|
||||||
|
checked = state.dynamicColor,
|
||||||
|
enabled = state.dynamicColorAvailable,
|
||||||
|
onCheckedChange = viewModel::setDynamicColor,
|
||||||
|
)
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
selected = state.weekStart,
|
||||||
|
options = WeekStartPref.entries,
|
||||||
|
optionLabel = { weekStartLabel(it) },
|
||||||
|
onSelect = viewModel::setWeekStart,
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_language))
|
||||||
|
LanguageRow()
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_section_about))
|
||||||
|
AboutSection()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageRow() {
|
||||||
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
|
// dropdown updates instantly even before the recreation lands.
|
||||||
|
var current by remember { mutableStateOf(AppLanguage.current()) }
|
||||||
|
SettingDropdownRow(
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
selected = current,
|
||||||
|
options = LanguagePref.entries,
|
||||||
|
optionLabel = { languageLabel(it) },
|
||||||
|
onSelect = {
|
||||||
|
current = it
|
||||||
|
AppLanguage.apply(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> SettingDropdownRow(
|
||||||
|
title: String,
|
||||||
|
selected: T,
|
||||||
|
options: List<T>,
|
||||||
|
optionLabel: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = true }
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = optionLabel(selected),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel(option)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(option)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DynamicColorRow(
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_dynamic_color),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (enabled) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (!enabled) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_dynamic_color_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutSection() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val versionName = remember {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull() ?: "—"
|
||||||
|
}
|
||||||
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
|
|
||||||
|
AboutRow(
|
||||||
|
title = stringResource(R.string.settings_version),
|
||||||
|
value = versionName,
|
||||||
|
)
|
||||||
|
AboutRow(
|
||||||
|
title = stringResource(R.string.settings_license),
|
||||||
|
value = stringResource(R.string.settings_license_value),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f).padding(start = 8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_source),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sourceUrl.removePrefix("https://"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.settings_source_open))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutRow(title: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
|
when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||||
|
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||||
|
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
||||||
|
when (pref) {
|
||||||
|
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
||||||
|
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
||||||
|
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun languageLabel(pref: LanguagePref): String = stringResource(
|
||||||
|
when (pref) {
|
||||||
|
LanguagePref.AUTO -> R.string.settings_language_auto
|
||||||
|
LanguagePref.GERMAN -> R.string.settings_language_german
|
||||||
|
LanguagePref.ENGLISH -> R.string.settings_language_english
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
|
* there is no Loading/Failure here — only a populated Success snapshot.
|
||||||
|
* [dynamicColorAvailable] is false below Android 12, where the toggle is shown
|
||||||
|
* disabled.
|
||||||
|
*/
|
||||||
|
data class SettingsUiState(
|
||||||
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
|
val dynamicColor: Boolean = true,
|
||||||
|
val dynamicColorAvailable: Boolean = true,
|
||||||
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
val state: StateFlow<SettingsUiState> =
|
||||||
|
combine(
|
||||||
|
prefs.themeMode,
|
||||||
|
prefs.dynamicColor,
|
||||||
|
prefs.weekStart,
|
||||||
|
) { theme, dynamic, weekStart ->
|
||||||
|
SettingsUiState(
|
||||||
|
themeMode = theme,
|
||||||
|
dynamicColor = dynamic && dynamicColorAvailable,
|
||||||
|
dynamicColorAvailable = dynamicColorAvailable,
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDynamicColor(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,6 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -113,6 +112,7 @@ fun WeekScreen(
|
|||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -135,7 +135,10 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
val isOnCurrentWeek = when (val s = state) {
|
||||||
is WeekUiState.Success -> s.weekStart == s.today.startOfWeek(DayOfWeek.MONDAY)
|
// True when today falls inside the displayed week — independent of which
|
||||||
|
// weekday the user picked as the first day.
|
||||||
|
is WeekUiState.Success ->
|
||||||
|
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +155,10 @@ fun WeekScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
|
onSettings = {
|
||||||
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
|
onOpenSettings()
|
||||||
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -15,8 +17,10 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -28,6 +32,7 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -38,48 +43,68 @@ const val MINUTES_PER_DAY: Int = 24 * 60
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class WeekViewModel @Inject constructor(
|
class WeekViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
private val locale: Locale = Locale.getDefault()
|
||||||
// V1: week starts Monday. DataStore-driven preference comes with Settings.
|
|
||||||
private val weekStart: DayOfWeek = DayOfWeek.MONDAY
|
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
private val _weekStartDate = MutableStateFlow(todayDate.startOfWeek(weekStart))
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
val weekStartDate: StateFlow<LocalDate> = _weekStartDate
|
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||||
|
.map { it.resolveFirstDay(locale) }
|
||||||
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(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = WeekUiState.Loading,
|
initialValue = DayOfWeek.MONDAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Anchor is a representative day inside the visible week; the actual week
|
||||||
|
// start is derived against [weekStart], so changing the first-day preference
|
||||||
|
// re-frames the same week instead of jumping.
|
||||||
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
|
||||||
|
val weekStartDate: StateFlow<LocalDate> =
|
||||||
|
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<WeekUiState> =
|
||||||
|
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.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() {
|
fun goToPrev() {
|
||||||
_weekStartDate.value = _weekStartDate.value.minus(7, DateTimeUnit.DAY)
|
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_weekStartDate.value = _weekStartDate.value.plus(7, DateTimeUnit.DAY)
|
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_weekStartDate.value = todayDate.startOfWeek(weekStart)
|
_anchor.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
|
|||||||
@@ -12,13 +12,20 @@
|
|||||||
<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) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Kalender-Zugriff</string>
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
|
<string name="permission_rationale_body">Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.</string>
|
||||||
<string name="permission_request_button">Weiter</string>
|
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</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_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_open_settings_button">System-Einstellungen öffnen</string>
|
||||||
<string name="permission_retry_button">Erneut versuchen</string>
|
<string name="permission_retry_button">Erneut versuchen</string>
|
||||||
|
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
|
||||||
|
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||||
|
<string name="permission_privacy_footnote">Nur Lesezugriff · keine Internet-Berechtigung</string>
|
||||||
|
|
||||||
<!-- Monatsansicht (S1) -->
|
<!-- Monatsansicht (S1) -->
|
||||||
<string name="month_prev">Vorheriger Monat</string>
|
<string name="month_prev">Vorheriger Monat</string>
|
||||||
@@ -26,8 +33,6 @@
|
|||||||
<string name="month_today_action">Heute</string>
|
<string name="month_today_action">Heute</string>
|
||||||
<string name="month_more_actions">Weitere Aktionen</string>
|
<string name="month_more_actions">Weitere Aktionen</string>
|
||||||
<string name="month_open_menu">Menü öffnen</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_action_settings">Einstellungen</string>
|
||||||
<string name="month_a11y_today_prefix">Heute</string>
|
<string name="month_a11y_today_prefix">Heute</string>
|
||||||
|
|
||||||
@@ -68,6 +73,37 @@
|
|||||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Erinnerungen</string>
|
||||||
|
<string name="event_detail_timezone">Zeitzone</string>
|
||||||
|
<string name="event_status_tentative">Vorläufig</string>
|
||||||
|
<string name="event_status_cancelled">Abgesagt</string>
|
||||||
|
<string name="event_availability_free">Frei</string>
|
||||||
|
<string name="event_access_private">Privat</string>
|
||||||
|
<string name="event_access_confidential">Vertraulich</string>
|
||||||
|
<string name="event_attendee_organizer">Organisator</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Ressource</string>
|
||||||
|
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Zur Startzeit</string>
|
||||||
|
<string name="reminder_default">Standarderinnerung</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d Minute vorher</item>
|
||||||
|
<item quantity="other">%d Minuten vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d Stunde vorher</item>
|
||||||
|
<item quantity="other">%d Stunden vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d Tag vorher</item>
|
||||||
|
<item quantity="other">%d Tage vorher</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d Woche vorher</item>
|
||||||
|
<item quantity="other">%d Wochen vorher</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
@@ -75,4 +111,33 @@
|
|||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
<string name="view_day">Tag</string>
|
<string name="view_day">Tag</string>
|
||||||
|
|
||||||
|
<!-- Kalender-Filter (M3) -->
|
||||||
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
|
<!-- Einstellungen (M4) -->
|
||||||
|
<string name="settings_title">Einstellungen</string>
|
||||||
|
<string name="settings_back">Zurück</string>
|
||||||
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
|
<string name="settings_theme">Design</string>
|
||||||
|
<string name="settings_theme_system">System</string>
|
||||||
|
<string name="settings_theme_light">Hell</string>
|
||||||
|
<string name="settings_theme_dark">Dunkel</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamische Farben</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||||
|
<string name="settings_week_start">Wochenstart</string>
|
||||||
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
|
<string name="settings_week_start_monday">Montag</string>
|
||||||
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
|
<string name="settings_section_language">Sprache</string>
|
||||||
|
<string name="settings_language">App-Sprache</string>
|
||||||
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
|
<string name="settings_language_german">Deutsch</string>
|
||||||
|
<string name="settings_language_english">English</string>
|
||||||
|
<string name="settings_section_about">Über</string>
|
||||||
|
<string name="settings_version">Version</string>
|
||||||
|
<string name="settings_license">Lizenz</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_source">Quellcode</string>
|
||||||
|
<string name="settings_source_open">Öffnen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -13,13 +13,20 @@
|
|||||||
<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) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">Calendar access</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
|
<string name="permission_rationale_body">Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.</string>
|
||||||
<string name="permission_request_button">Continue</string>
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
<string name="permission_denied_title">Calendar access denied</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_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_open_settings_button">Open system settings</string>
|
||||||
<string name="permission_retry_button">Try again</string>
|
<string name="permission_retry_button">Try again</string>
|
||||||
|
<string name="permission_benefit_private_title">Stays on your device</string>
|
||||||
|
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
||||||
|
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||||
|
<string name="permission_privacy_footnote">Read-only · no internet permission</string>
|
||||||
|
|
||||||
<!-- Month view (S1) -->
|
<!-- Month view (S1) -->
|
||||||
<string name="month_prev">Previous month</string>
|
<string name="month_prev">Previous month</string>
|
||||||
@@ -27,8 +34,6 @@
|
|||||||
<string name="month_today_action">Today</string>
|
<string name="month_today_action">Today</string>
|
||||||
<string name="month_more_actions">More actions</string>
|
<string name="month_more_actions">More actions</string>
|
||||||
<string name="month_open_menu">Open menu</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_action_settings">Settings</string>
|
||||||
<string name="month_a11y_today_prefix">Today</string>
|
<string name="month_a11y_today_prefix">Today</string>
|
||||||
|
|
||||||
@@ -69,6 +74,37 @@
|
|||||||
<string name="event_attendee_needs_action">No response</string>
|
<string name="event_attendee_needs_action">No response</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
|
<!-- Event detail — full read (v0.6) -->
|
||||||
|
<string name="event_detail_reminders">Reminders</string>
|
||||||
|
<string name="event_detail_timezone">Time zone</string>
|
||||||
|
<string name="event_status_tentative">Tentative</string>
|
||||||
|
<string name="event_status_cancelled">Cancelled</string>
|
||||||
|
<string name="event_availability_free">Free</string>
|
||||||
|
<string name="event_access_private">Private</string>
|
||||||
|
<string name="event_access_confidential">Confidential</string>
|
||||||
|
<string name="event_attendee_organizer">Organizer</string>
|
||||||
|
<string name="event_attendee_optional">Optional</string>
|
||||||
|
<string name="event_attendee_resource">Resource</string>
|
||||||
|
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||||
|
<string name="reminder_at_time">At time of event</string>
|
||||||
|
<string name="reminder_default">Default reminder</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minute before</item>
|
||||||
|
<item quantity="other">%d minutes before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hour before</item>
|
||||||
|
<item quantity="other">%d hours before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d day before</item>
|
||||||
|
<item quantity="other">%d days before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d week before</item>
|
||||||
|
<item quantity="other">%d weeks before</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
@@ -76,4 +112,34 @@
|
|||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
<string name="view_day">Day</string>
|
<string name="view_day">Day</string>
|
||||||
|
|
||||||
|
<!-- Calendar filter (M3) -->
|
||||||
|
<string name="filter_title">Calendars</string>
|
||||||
|
|
||||||
|
<!-- Settings (M4) -->
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_back">Back</string>
|
||||||
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
|
<string name="settings_theme">Theme</string>
|
||||||
|
<string name="settings_theme_system">System</string>
|
||||||
|
<string name="settings_theme_light">Light</string>
|
||||||
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
|
<string name="settings_week_start">Week starts on</string>
|
||||||
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
|
<string name="settings_week_start_monday">Monday</string>
|
||||||
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
|
<string name="settings_section_language">Language</string>
|
||||||
|
<string name="settings_language">App language</string>
|
||||||
|
<string name="settings_language_auto">System default</string>
|
||||||
|
<string name="settings_language_german">Deutsch</string>
|
||||||
|
<string name="settings_language_english">English</string>
|
||||||
|
<string name="settings_section_about">About</string>
|
||||||
|
<string name="settings_version">Version</string>
|
||||||
|
<string name="settings_license">License</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_source">Source code</string>
|
||||||
|
<string name="settings_source_open">Open</string>
|
||||||
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -10,15 +14,29 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class CalendarRepositoryImplTest {
|
class CalendarRepositoryImplTest {
|
||||||
|
|
||||||
|
private fun newPrefs(tempDir: Path): CalendarPrefs =
|
||||||
|
CalendarPrefs(newDataStore(tempDir))
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
|
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
|
||||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||||
|
|
||||||
private fun makeEvent(id: Long, title: String = "E $id"): EventInstance = EventInstance(
|
private fun makeEvent(
|
||||||
instanceId = id, eventId = id, calendarId = 1L,
|
id: Long,
|
||||||
|
title: String = "E $id",
|
||||||
|
calendarId: Long = 1L,
|
||||||
|
): EventInstance = EventInstance(
|
||||||
|
instanceId = id, eventId = id, calendarId = calendarId,
|
||||||
title = title,
|
title = title,
|
||||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||||
@@ -26,11 +44,11 @@ class CalendarRepositoryImplTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `calendars emits initial query result on subscribe`() = runTest {
|
fun `calendars emits initial query result on subscribe`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
repo.calendars().test {
|
repo.calendars().test {
|
||||||
val first = awaitItem()
|
val first = awaitItem()
|
||||||
@@ -40,11 +58,11 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `calendars re-emits after change listener tick`() = runTest {
|
fun `calendars re-emits after change listener tick`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
calendarsResult = listOf(makeCal(1L))
|
calendarsResult = listOf(makeCal(1L))
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
repo.calendars().test {
|
repo.calendars().test {
|
||||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||||
@@ -58,7 +76,7 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `instances forwards epoch-millis bounds to data source`() = runTest {
|
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||||
var observedBegin: Long? = null
|
var observedBegin: Long? = null
|
||||||
var observedEnd: Long? = null
|
var observedEnd: Long? = null
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
@@ -68,7 +86,7 @@ class CalendarRepositoryImplTest {
|
|||||||
listOf(makeEvent(10L))
|
listOf(makeEvent(10L))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -80,11 +98,11 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `instances passes-through whatever the data source returns`() = runTest {
|
fun `instances passes-through whatever the data source returns`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
repo.instances(range).test {
|
repo.instances(range).test {
|
||||||
@@ -95,11 +113,56 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `eventDetail throws NoSuchEventException when data source returns null`() = runTest {
|
fun `instances drops events whose calendar the user hid`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
prefs.setHiddenCalendarIds(setOf(2L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "Visible", calendarId = 1L),
|
||||||
|
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("Visible")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emits when the hidden set changes`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = newPrefs(tempDir)
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
makeEvent(10L, "A", calendarId = 1L),
|
||||||
|
makeEvent(11L, "B", calendarId = 2L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||||
|
|
||||||
|
prefs.setHiddenCalendarIds(setOf(2L))
|
||||||
|
|
||||||
|
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
eventDetailResult = { null }
|
eventDetailResult = { null }
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repo.eventDetail(eventId = 999L)
|
repo.eventDetail(eventId = 999L)
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class EventDetailMapperTest {
|
class EventDetailMapperTest {
|
||||||
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
|
|||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
|
status: Any? = null,
|
||||||
|
availability: Any? = null,
|
||||||
|
accessLevel: Any? = null,
|
||||||
|
timezone: String? = null,
|
||||||
|
selfStatus: Any? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ALL_DAY to allDay,
|
EventDetailProjection.IDX_ALL_DAY to allDay,
|
||||||
EventDetailProjection.IDX_LOCATION to location,
|
EventDetailProjection.IDX_LOCATION to location,
|
||||||
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
EventDetailProjection.IDX_STATUS to status,
|
||||||
|
EventDetailProjection.IDX_AVAILABILITY to availability,
|
||||||
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader =
|
private fun attendeeReader(
|
||||||
|
name: String?,
|
||||||
|
email: String?,
|
||||||
|
status: Int,
|
||||||
|
relationship: Int = 0,
|
||||||
|
type: Int = 0,
|
||||||
|
): MapColumnReader =
|
||||||
MapColumnReader(
|
MapColumnReader(
|
||||||
AttendeeProjection.IDX_NAME to name,
|
AttendeeProjection.IDX_NAME to name,
|
||||||
AttendeeProjection.IDX_EMAIL to email,
|
AttendeeProjection.IDX_EMAIL to email,
|
||||||
AttendeeProjection.IDX_STATUS to status,
|
AttendeeProjection.IDX_STATUS to status,
|
||||||
|
AttendeeProjection.IDX_RELATIONSHIP to relationship,
|
||||||
|
AttendeeProjection.IDX_TYPE to type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
|
||||||
|
MapColumnReader(
|
||||||
|
ReminderProjection.IDX_MINUTES to minutes,
|
||||||
|
ReminderProjection.IDX_METHOD to method,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MapColumnReader.toDetail(
|
||||||
|
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
||||||
|
reminders: List<Reminder> = emptyList(),
|
||||||
|
) = toEventDetailCore(attendees, reminders)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
||||||
val detail = detailReader().toEventDetailCore(attendees = emptyList())
|
val detail = detailReader().toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.description).isEqualTo("Body")
|
assertThat(detail!!.description).isEqualTo("Body")
|
||||||
assertThat(detail.organizer).isEqualTo("x@y")
|
assertThat(detail.organizer).isEqualTo("x@y")
|
||||||
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `event color falls back to calendar color when null`() {
|
fun `event color falls back to calendar color when null`() {
|
||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toEventDetailCore(attendees = emptyList())
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dtend before dtstart drops detail`() {
|
fun `dtend before dtstart drops detail`() {
|
||||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
|
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail).isNull()
|
assertThat(detail).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rrule passes through when present`() {
|
fun `rrule passes through when present`() {
|
||||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,4 +138,82 @@ class EventDetailMapperTest {
|
|||||||
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
|
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
|
||||||
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
|
||||||
|
@Test
|
||||||
|
fun `attendee relationship maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Organizer)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.Attendee)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
|
||||||
|
.isEqualTo(AttendeeRelationship.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
|
||||||
|
@Test
|
||||||
|
fun `attendee type maps known integer codes`() {
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Required)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Optional)
|
||||||
|
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
|
||||||
|
.isEqualTo(AttendeeType.Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
|
||||||
|
@Test
|
||||||
|
fun `event status null maps to confirmed, codes map through`() {
|
||||||
|
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
|
||||||
|
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
|
||||||
|
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
|
||||||
|
@Test
|
||||||
|
fun `availability null or busy maps to Busy, free maps to Free`() {
|
||||||
|
assertThat(detailReader(availability = null).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 0).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Busy)
|
||||||
|
assertThat(detailReader(availability = 1).toDetail()!!.availability)
|
||||||
|
.isEqualTo(Availability.Free)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
|
||||||
|
@Test
|
||||||
|
fun `access level maps known integer codes, null is Default`() {
|
||||||
|
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Default)
|
||||||
|
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Confidential)
|
||||||
|
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
|
||||||
|
.isEqualTo(AccessLevel.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `event timezone and self status pass through`() {
|
||||||
|
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
|
||||||
|
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminders pass through to the detail`() {
|
||||||
|
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
|
||||||
|
val detail = detailReader().toDetail(reminders = reminders)
|
||||||
|
assertThat(detail!!.reminders).isEqualTo(reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
|
||||||
|
@Test
|
||||||
|
fun `reminder maps minutes and method codes`() {
|
||||||
|
assertThat(reminderReader(10, 1).toReminder())
|
||||||
|
.isEqualTo(Reminder(10, ReminderMethod.Alert))
|
||||||
|
assertThat(reminderReader(60, 2).toReminder())
|
||||||
|
.isEqualTo(Reminder(60, ReminderMethod.Email))
|
||||||
|
assertThat(reminderReader(0, 0).toReminder())
|
||||||
|
.isEqualTo(Reminder(0, ReminderMethod.Default))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 kotlinx.datetime.DayOfWeek
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class SettingsPrefsTest {
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `defaults are system theme, dynamic colour on, auto week start`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
|
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `theme mode round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setThemeMode(ThemeMode.DARK)
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setDynamicColor(false)
|
||||||
|
assertThat(prefs.dynamicColor.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.THEME_MODE_KEY] = "PLAID"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
|
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auto week start follows the locale convention`() {
|
||||||
|
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class FilterGroupingTest {
|
||||||
|
|
||||||
|
private fun cal(
|
||||||
|
id: Long,
|
||||||
|
name: String,
|
||||||
|
account: String,
|
||||||
|
type: String = "com.example",
|
||||||
|
) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
accountName = account,
|
||||||
|
accountType = type,
|
||||||
|
color = 0xFF336699.toInt(),
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `groups calendars under their account, preserving order`() {
|
||||||
|
val calendars = listOf(
|
||||||
|
cal(1, "Personal", "alice@dav"),
|
||||||
|
cal(2, "Work", "alice@dav"),
|
||||||
|
cal(3, "Shared", "team@dav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = groupByAccount(calendars, hidden = emptySet())
|
||||||
|
|
||||||
|
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||||
|
assertThat(groups[0].calendars.map { it.displayName })
|
||||||
|
.containsExactly("Personal", "Work").inOrder()
|
||||||
|
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hidden ids mark calendars not visible`() {
|
||||||
|
val calendars = listOf(
|
||||||
|
cal(1, "Personal", "alice@dav"),
|
||||||
|
cal(2, "Work", "alice@dav"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = groupByAccount(calendars, hidden = setOf(2L))
|
||||||
|
val rows = groups.single().calendars.associateBy { it.id }
|
||||||
|
|
||||||
|
assertThat(rows.getValue(1L).visible).isTrue()
|
||||||
|
assertThat(rows.getValue(2L).visible).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank account name falls back to type`() {
|
||||||
|
val groups = groupByAccount(
|
||||||
|
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
||||||
|
hidden = emptySet(),
|
||||||
|
)
|
||||||
|
assertThat(groups.single().account).isEqualTo("LOCAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- 3 Hauptansichten: Monat, Woche, Tag
|
- 3 Hauptansichten: Monat, Woche, Tag
|
||||||
- Event-Detail-Sheet (read-only Detailansicht)
|
- Event-Detail-Sheet (read-only Detailansicht)
|
||||||
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
|
||||||
- Heute-Button + Jump-to-Date
|
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
|
||||||
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
|
||||||
- Permission-Flow für `READ_CALENDAR`
|
- Permission-Flow für `READ_CALENDAR`
|
||||||
- Empty-States und Error-Recovery
|
- Empty-States und Error-Recovery
|
||||||
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
|||||||
- Tests + CI ab Tag 1
|
- Tests + CI ab Tag 1
|
||||||
|
|
||||||
### Out-of-Scope (V2+)
|
### Out-of-Scope (V2+)
|
||||||
|
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
|
||||||
- Event-Create/Edit/Delete (V2)
|
- Event-Create/Edit/Delete (V2)
|
||||||
- Home-Screen-Widget
|
- Home-Screen-Widget
|
||||||
- Volltextsuche
|
- Volltextsuche
|
||||||
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
|
|||||||
- Immer erreichbar von allen Hauptansichten
|
- Immer erreichbar von allen Hauptansichten
|
||||||
- State persistent (zuletzt aktive Ansicht)
|
- State persistent (zuletzt aktive Ansicht)
|
||||||
|
|
||||||
**M2 - Heute / Springe-zu-Datum**
|
**M2 - Heute**
|
||||||
- Schnell zurück zu "heute"
|
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
|
||||||
- Springe zu beliebigem Datum via Datum-Picker
|
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
|
||||||
- Erreichbar von allen Hauptansichten
|
- Erreichbar von allen Hauptansichten
|
||||||
|
|
||||||
**M3 - Kalender-Filter (Bottom-Sheet)**
|
**M3 - Kalender-Filter (Bottom-Sheet)**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ kotlin = "2.3.21"
|
|||||||
ksp = "2.3.9"
|
ksp = "2.3.9"
|
||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
lifecycleRuntime = "2.10.0"
|
lifecycleRuntime = "2.10.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
@@ -27,6 +28,7 @@ androidxTestRules = "1.7.0"
|
|||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX core
|
# AndroidX core
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user