Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3697a58e5b | |||
| e290c92d78 | |||
| 9c4ebbc65a | |||
| c0d413ba11 | |||
| dca0245a42 | |||
| 024512959f | |||
| e78da3d7c1 | |||
| 2cb8b59fb7 | |||
| 7d36d22fd5 | |||
| adcbed6e02 | |||
| efa0abbaed |
@@ -17,7 +17,7 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
|
||||
- [ ] Day view (S3)
|
||||
- [ ] Event Detail Sheet (S4)
|
||||
- [ ] 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)
|
||||
- [ ] Settings screen (M4)
|
||||
- [ ] Empty / no-permission / no-calendars states
|
||||
|
||||
@@ -6,15 +6,52 @@
|
||||
|---|---|---|
|
||||
| v0.1 | Foundation & CI | complete |
|
||||
| v0.2 | Data Layer & Permission Flow | complete |
|
||||
| v0.3 | Month view | in progress |
|
||||
| v0.4 | Week view | in progress |
|
||||
| v0.5 | Day view | pending |
|
||||
| v0.6 | Event Detail Sheet | pending |
|
||||
| v0.7 | Filter & Settings | pending |
|
||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||
| v0.6 | Full event read — surface every readable field | complete |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-10*
|
||||
*Last updated: 2026-06-11*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** v0.4 — Week view (in progress)
|
||||
**Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet,
|
||||
filter, settings, jump-to-date) still stubbed
|
||||
**Milestone:** v1.0.0 — First public release (shipped 2026-06-11)
|
||||
**Phase:** V1 is complete and released. All screens done, the read model
|
||||
surfaces every readable `CalendarContract` field, and the onboarding screen
|
||||
got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support)
|
||||
|
||||
## 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] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
|
||||
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
|
||||
- [x] View-switcher (M1) wired — cycles Month ↔ Week (Day joins once S3 lands)
|
||||
- [ ] Day view (S3)
|
||||
- [ ] Event-detail sheet (S4) — week/month event taps are currently no-ops
|
||||
- [ ] Filter sheet (M3), Settings (M4), Jump-to-date (M2) — drawer entries stubbed
|
||||
- [x] Day view (S3) — single-column slice reusing the week layout
|
||||
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
|
||||
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
|
||||
- [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
|
||||
|
||||
1. Day view (S3) — slot it into the view-switcher cycle
|
||||
2. Event-detail sheet (S4) — wire month-day and week-event taps to it
|
||||
3. Revisit month/week UI polish + shared anchor-date continuity across views
|
||||
1. v1.0.0 released — monitor the F-Droid build/publish
|
||||
2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX)
|
||||
|
||||
105
CHANGELOG.md
105
CHANGELOG.md
@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- Event detail (S4): full-screen destination (MD3 list→detail, not a bottom
|
||||
sheet) opened by tapping an event in the week/day timeline — title with a
|
||||
calendar-colour accent line, a card per field (when, calendar, location,
|
||||
description, attendees, recurrence) with leading icons, location tap opens a
|
||||
maps intent, Loading/Failure/Success states, slide-in/out over the calendar
|
||||
- Human-readable recurrence: RRULE rendered as e.g. "Every week on _Tue_ and
|
||||
_Thu_ until 31 Dec 2026" (FREQ/INTERVAL/BYDAY/UNTIL/COUNT, abbreviated +
|
||||
italicised day names, localized list formatting), with a generic fallback
|
||||
- Month → day navigation: tapping a day cell opens the day view on that date
|
||||
|
||||
### Fixed
|
||||
- Recurring events failed to open in the detail view: the series row stores
|
||||
DURATION instead of DTEND, so the mapper dropped it (EventNotFound). The
|
||||
detail now keeps such events and shows the tapped occurrence's own times
|
||||
(from CalendarContract.Instances) instead of the series start
|
||||
|
||||
## [0.3.0] — 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 7
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -89,6 +89,7 @@ kotlin {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -99,6 +100,7 @@ dependencies {
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -24,7 +28,10 @@ class CalendarRepositorySmokeTest {
|
||||
|
||||
private fun newRepo(): CalendarRepositoryImpl {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -4,10 +4,16 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -16,7 +22,19 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -62,13 +63,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
EventDetailProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.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()
|
||||
|
||||
/** Iterate every row and map; skips nothing. */
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -23,6 +25,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CalendarRepositoryImpl @Inject constructor(
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : CalendarRepository {
|
||||
|
||||
@@ -41,7 +44,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
.reQuery { dataSource.calendars() }
|
||||
.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>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
@@ -49,8 +58,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
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) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
|
||||
@@ -2,25 +2,45 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
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.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
|
||||
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 end = getLong(EventDetailProjection.IDX_DTEND)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
if (end < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
}
|
||||
|
||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
@@ -44,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
||||
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(
|
||||
instance = instance,
|
||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||
attendees = attendees,
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +93,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
||||
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||
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) {
|
||||
@@ -66,3 +109,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||
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.EVENT_LOCATION,
|
||||
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
|
||||
@@ -74,6 +79,11 @@ internal object EventDetailProjection {
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_LOCATION = 10
|
||||
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 {
|
||||
@@ -81,11 +91,25 @@ internal object AttendeeProjection {
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||
)
|
||||
|
||||
const val IDX_NAME = 0
|
||||
const val IDX_EMAIL = 1
|
||||
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 {
|
||||
|
||||
@@ -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 attendees: List<Attendee>,
|
||||
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(
|
||||
val name: String,
|
||||
val email: String?,
|
||||
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 {
|
||||
@@ -45,6 +67,48 @@ enum class AttendeeStatus {
|
||||
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 {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
@@ -18,24 +31,90 @@ import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
*/
|
||||
@Composable
|
||||
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 }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
view = CalendarView.Day
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
// it overlays whichever calendar view is active. We forward the tapped
|
||||
// occurrence's own times (eventId + begin + end, packed as a saveable
|
||||
// long[]) so recurring events show the correct date, not the series start.
|
||||
// [heldKey] keeps the last shown key alive through the slide-out (when
|
||||
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
|
||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldKey = 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()
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (view) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
modifier = modifier,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
modifier = modifier,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
modifier = modifier,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer the live key; fall back to the held one only while sliding out.
|
||||
val activeKey = detailKey ?: heldKey
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
eventId = key[0],
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
@@ -13,20 +19,28 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
|
||||
/**
|
||||
* Navigation drawer shared by every top-level calendar screen (M2/M3/M4
|
||||
* entry points). Stateless — the host screen owns the drawer state and wires
|
||||
* the callbacks.
|
||||
* Navigation drawer shared by every top-level calendar screen.
|
||||
*
|
||||
* 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
|
||||
fun CalendarDrawer(
|
||||
onToday: () -> Unit,
|
||||
onJumpToDate: () -> Unit,
|
||||
onFilter: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
@@ -35,31 +49,42 @@ fun CalendarDrawer(
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.month_today_action)) },
|
||||
selected = false,
|
||||
onClick = onToday,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.month_action_jump_to_date)) },
|
||||
selected = false,
|
||||
onClick = onJumpToDate,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.month_action_filter)) },
|
||||
selected = false,
|
||||
onClick = onFilter,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
|
||||
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
||||
// between the top actions and the pinned Settings entry.
|
||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||
CalendarFilterList(modifier = Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
||||
selected = false,
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -105,12 +106,20 @@ private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
fun DayScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||
|
||||
// When opened from the month grid, anchor to the tapped date.
|
||||
LaunchedEffect(initialDateIso) {
|
||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -144,9 +153,10 @@ fun DayScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
|
||||
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
|
||||
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -182,6 +192,7 @@ fun DayScreen(
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
@@ -198,6 +209,7 @@ private fun DayContent(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -265,6 +277,7 @@ private fun DayContent(
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -276,6 +289,7 @@ private fun DaySuccess(
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
@@ -283,6 +297,7 @@ private fun DaySuccess(
|
||||
AllDayStrip(
|
||||
state = state,
|
||||
height = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
@@ -290,7 +305,7 @@ private fun DaySuccess(
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(state = state, scrollState = scrollState)
|
||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +352,7 @@ private fun DayTopBar(
|
||||
private fun AllDayStrip(
|
||||
state: DayUiState.Success,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -364,6 +380,7 @@ private fun AllDayStrip(
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||
.width(barWidth)
|
||||
@@ -376,11 +393,17 @@ private fun AllDayStrip(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
@@ -396,7 +419,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
|
||||
private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
@@ -443,6 +470,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
@@ -456,6 +484,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
@@ -477,6 +506,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
@@ -492,6 +522,7 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -500,6 +531,7 @@ private fun EventBlock(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
|
||||
@@ -78,6 +78,11 @@ class DayViewModel @Inject constructor(
|
||||
_date.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_date.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
day: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.icu.text.ListFormatter
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
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.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
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.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.DayOfWeek
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Read-only full-screen event detail (spec S4, realised as a navigation
|
||||
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
||||
* gesture and the top-bar arrow both return to the calendar. The only action is
|
||||
* tapping the location to open a maps intent.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventDetailScreen(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
viewModel.open(eventId, beginMillis, endMillis)
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.event_detail_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
val contentModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
when (val s = state) {
|
||||
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
|
||||
is EventDetailUiState.Failure -> CalendarFailure(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = pastelize(instance.color, dark)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title row: title on the left, a "Free" pill pinned top-right when the
|
||||
// event doesn't block your time. Busy is the default for nearly every
|
||||
// event, so it's left implicit — only Free is worth surfacing. A
|
||||
// cancelled event strikes through its title.
|
||||
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))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.height(3.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))
|
||||
|
||||
// Every piece of info shares one card design: a tonal container with a
|
||||
// leading icon in the gutter and the value to the right. 12dp gaps stack
|
||||
// them cleanly.
|
||||
val gap = 12.dp
|
||||
|
||||
// "When" — date/all-day plus the time range.
|
||||
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||
if (whenSecondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = whenSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// separate colour dot is needed.
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconTint = accent,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
Text(
|
||||
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
// Location (conditional, tap → maps).
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
) {
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openInMaps(context, location) }
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.People,
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||
) {
|
||||
Text(
|
||||
text = recurrenceText(rrule, locale),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||
@Composable
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttendeeRow(attendee: Attendee) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
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 = stringResource(attendeeStatusLabel(attendee.status)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SkeletonBar(widthFraction = 1f, height = 64.dp)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(widthFraction)
|
||||
.height(height)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
// Observable locale read (shared helper) — avoids NonObservableLocale /
|
||||
// LocalContextConfigurationRead lint by going through LocalConfiguration.
|
||||
@Composable
|
||||
private fun currentDetailLocale(): Locale = currentLocale()
|
||||
|
||||
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||
AttendeeStatus.Declined -> R.string.event_attendee_declined
|
||||
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
|
||||
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
|
||||
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.
|
||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||
* monthly/yearly BYDAY, etc.).
|
||||
*/
|
||||
@Composable
|
||||
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
val base = when (freq) {
|
||||
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||
}
|
||||
|
||||
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||
// The day names + their joined block are tracked so only the names (not the
|
||||
// commas/conjunction) can be italicised in the final string.
|
||||
val byDay = parts["BYDAY"]
|
||||
var dayNames: List<String>? = null
|
||||
var joinedDays: String? = null
|
||||
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||
if (days.isNotEmpty()) {
|
||||
val joined = ListFormatter.getInstance(locale).format(days)
|
||||
dayNames = days
|
||||
joinedDays = joined
|
||||
stringResource(R.string.recurrence_on_days, base, joined)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
|
||||
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val full = when {
|
||||
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||
else -> main
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
append(full)
|
||||
val names = dayNames
|
||||
val joined = joinedDays
|
||||
if (names != null && joined != null) {
|
||||
// Italicise each day name within the joined block only — leaving the
|
||||
// separators and conjunction ("und"/"and") in the regular style.
|
||||
val regionStart = full.indexOf(joined)
|
||||
if (regionStart >= 0) {
|
||||
val regionEnd = regionStart + joined.length
|
||||
var cursor = regionStart
|
||||
for (name in names) {
|
||||
val at = full.indexOf(name, cursor)
|
||||
if (at in regionStart until regionEnd) {
|
||||
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||
cursor = at + name.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
"SU" -> DayOfWeek.SUNDAY
|
||||
else -> return null
|
||||
}
|
||||
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an event's time into a primary line (date, or "All day") and an
|
||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||
* single primary line spanning both ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun formatWhen(
|
||||
instance: EventInstance,
|
||||
zone: TimeZone,
|
||||
locale: Locale,
|
||||
): Pair<String, String?> {
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
if (instance.isAllDay) {
|
||||
// All-day end is the exclusive next midnight; step back to the last
|
||||
// covered day so a one-day event reads as a single date.
|
||||
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||
} else {
|
||||
allDayLabel to
|
||||
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||
}
|
||||
}
|
||||
|
||||
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||
dateFull.format(startLdt.toLocalDate()) to
|
||||
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||
} else {
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||
"$start – $end" to null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
||||
|
||||
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||
private fun openInMaps(context: Context, query: String) {
|
||||
val encoded = Uri.encode(query)
|
||||
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||
try {
|
||||
context.startActivity(geo)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
val web = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
|
||||
)
|
||||
try {
|
||||
context.startActivity(web)
|
||||
} catch (e2: ActivityNotFoundException) {
|
||||
// No browser either — nothing sensible to do; swallow.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
|
||||
*/
|
||||
sealed interface EventDetailUiState {
|
||||
data object Loading : EventDetailUiState
|
||||
data class Failure(val reason: FailureReason) : EventDetailUiState
|
||||
data class Success(
|
||||
val detail: EventDetail,
|
||||
/** Display name of the owning calendar, null if it can't be resolved. */
|
||||
val calendarName: String?,
|
||||
) : EventDetailUiState
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
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.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Loads a single event's detail on demand for the bottom sheet (spec S4).
|
||||
* The event id is set via [open]; the sheet observes [state].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class EventDetailViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
// Bumped by retry() to re-run the load for the same target.
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
val state: StateFlow<EventDetailUiState> =
|
||||
combine(_target, _reload) { target, _ -> target }
|
||||
.flatMapLatest { target ->
|
||||
if (target == null) {
|
||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||
} else {
|
||||
flow {
|
||||
emit(EventDetailUiState.Loading)
|
||||
emit(loadDetail(target))
|
||||
}
|
||||
}
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = EventDetailUiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
|
||||
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||
* override the series DTSTART/DTEND so recurring events show the correct
|
||||
* date instead of the first occurrence.
|
||||
*/
|
||||
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
_target.value = Target(eventId, beginMillis, endMillis)
|
||||
}
|
||||
|
||||
/** Re-run the current load after a failure. */
|
||||
fun retry() {
|
||||
_reload.value += 1
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
// occurrence's time so recurring events render correctly.
|
||||
val corrected = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
val calendarName = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
?.displayName
|
||||
EventDetailUiState.Success(corrected, calendarName)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
EventDetailUiState.Failure(FailureReason.EventNotFound)
|
||||
} catch (e: SecurityException) {
|
||||
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
|
||||
} catch (e: Exception) {
|
||||
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
|
||||
}
|
||||
|
||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||
}
|
||||
@@ -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
|
||||
@@ -84,11 +84,14 @@ import java.util.Locale
|
||||
fun MonthScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -125,9 +128,10 @@ fun MonthScreen(
|
||||
jumpToToday()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: open date picker */ },
|
||||
onFilter = { scope.launch { drawerState.close() } /* TODO: open filter sheet */ },
|
||||
onSettings = { scope.launch { drawerState.close() } /* TODO: navigate to settings */ },
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -161,13 +165,15 @@ fun MonthScreen(
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = DayOfWeek.MONDAY)
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
weekStart = weekStart,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,10 +183,12 @@ fun MonthScreen(
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
weekStart: DayOfWeek,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 6.dp.toPx() }
|
||||
@@ -218,7 +226,11 @@ private fun MonthContent(
|
||||
when (s) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthGrid(state = s, weekStart = DayOfWeek.MONDAY)
|
||||
is MonthUiState.Success -> MonthGrid(
|
||||
state = s,
|
||||
weekStart = weekStart,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +302,7 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
weekStart: DayOfWeek,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
@@ -323,6 +336,7 @@ private fun MonthGrid(
|
||||
date = date,
|
||||
isToday = date == state.today,
|
||||
data = state.cells[date],
|
||||
onClick = { onOpenDay(date) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
@@ -340,6 +354,7 @@ private fun DayCard(
|
||||
date: LocalDate,
|
||||
isToday: Boolean,
|
||||
data: DayCellData?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||
@@ -362,7 +377,7 @@ private fun DayCard(
|
||||
)
|
||||
|
||||
Card(
|
||||
onClick = { /* TODO: open the day view (S3) for this date */ },
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -29,6 +32,7 @@ import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -37,13 +41,21 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
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
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
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
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
@@ -51,9 +63,10 @@ class MonthViewModel @Inject constructor(
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
val state: StateFlow<MonthUiState> = _month
|
||||
.flatMapLatest { ym ->
|
||||
val range = monthGridRange(ym, weekStart, zone)
|
||||
val state: StateFlow<MonthUiState> =
|
||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
||||
.flatMapLatest { (ym, ws) ->
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
|
||||
@@ -6,28 +6,65 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.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.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.foundation.Image
|
||||
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
|
||||
fun PermissionScreen(
|
||||
onGranted: () -> Unit,
|
||||
@@ -69,24 +106,68 @@ private fun RationaleContent(
|
||||
onRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
PermissionScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = false) },
|
||||
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 = stringResource(R.string.permission_rationale_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_rationale_body),
|
||||
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,26 +177,11 @@ private fun DeniedContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_denied_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_denied_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(stringResource(R.string.permission_retry_button))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
PermissionScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = true) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
@@ -123,8 +189,170 @@ private fun DeniedContent(
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.permission_open_settings_button))
|
||||
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 = stringResource(R.string.permission_denied_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_denied_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
|
||||
* action pinned to the bottom (clear of the navigation bar). The content slot is
|
||||
* centred horizontally; benefit rows fill the width so their own content
|
||||
* left-aligns.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermissionScaffold(
|
||||
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,
|
||||
) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -86,7 +87,6 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -111,6 +111,8 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
fun WeekScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -133,7 +135,10 @@ fun WeekScreen(
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -150,9 +155,10 @@ fun WeekScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
|
||||
onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ },
|
||||
onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ },
|
||||
onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ },
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -188,6 +194,7 @@ fun WeekScreen(
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
@@ -204,6 +211,7 @@ private fun WeekContent(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -274,6 +282,7 @@ private fun WeekContent(
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +294,7 @@ private fun WeekSuccess(
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -293,12 +303,12 @@ private fun WeekSuccess(
|
||||
.background(topSectionColor),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today)
|
||||
AllDayStrip(state = state, height = allDayHeight)
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(state = state, scrollState = scrollState)
|
||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +443,11 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
||||
private fun AllDayStrip(
|
||||
state: WeekUiState.Success,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Row(
|
||||
@@ -461,6 +475,7 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colWidth * span.startCol,
|
||||
@@ -476,11 +491,17 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
@@ -496,7 +517,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
||||
private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
@@ -551,6 +576,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
@@ -566,6 +592,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
@@ -587,6 +614,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
@@ -602,6 +630,7 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -610,6 +639,7 @@ private fun EventBlock(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -15,8 +17,10 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -28,6 +32,7 @@ import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -38,21 +43,41 @@ const val MINUTES_PER_DAY: Int = 24 * 60
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
// V1: week starts Monday. DataStore-driven preference comes with Settings.
|
||||
private val weekStart: DayOfWeek = DayOfWeek.MONDAY
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _weekStartDate = MutableStateFlow(todayDate.startOfWeek(weekStart))
|
||||
val weekStartDate: StateFlow<LocalDate> = _weekStartDate
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
val state: StateFlow<WeekUiState> = _weekStartDate
|
||||
// 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(
|
||||
@@ -71,15 +96,15 @@ class WeekViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_weekStartDate.value = _weekStartDate.value.minus(7, DateTimeUnit.DAY)
|
||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_weekStartDate.value = _weekStartDate.value.plus(7, DateTimeUnit.DAY)
|
||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_weekStartDate.value = todayDate.startOfWeek(weekStart)
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
|
||||
@@ -12,13 +12,20 @@
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Kalender-Zugriff</string>
|
||||
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
|
||||
<string name="permission_request_button">Weiter</string>
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</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">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||
<string name="permission_retry_button">Erneut versuchen</string>
|
||||
<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) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
@@ -26,8 +33,6 @@
|
||||
<string name="month_today_action">Heute</string>
|
||||
<string name="month_more_actions">Weitere Aktionen</string>
|
||||
<string name="month_open_menu">Menü öffnen</string>
|
||||
<string name="month_action_filter">Kalender</string>
|
||||
<string name="month_action_jump_to_date">Zu Datum springen…</string>
|
||||
<string name="month_action_settings">Einstellungen</string>
|
||||
<string name="month_a11y_today_prefix">Heute</string>
|
||||
|
||||
@@ -38,6 +43,67 @@
|
||||
<!-- Tagesansicht (S3) -->
|
||||
<string name="day_today_action">Heute</string>
|
||||
|
||||
<!-- Event-Detail-Screen (S4) -->
|
||||
<string name="event_detail_back">Zurück</string>
|
||||
<string name="event_detail_edit">Bearbeiten</string>
|
||||
<string name="event_detail_delete">Löschen</string>
|
||||
<string name="event_detail_all_day">Ganztägig</string>
|
||||
<string name="event_detail_calendar">Kalender</string>
|
||||
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||
<string name="event_detail_location">Ort</string>
|
||||
<string name="event_detail_description">Beschreibung</string>
|
||||
<string name="event_detail_attendees">Teilnehmer</string>
|
||||
<string name="event_detail_recurrence">Wiederholung</string>
|
||||
<string name="event_detail_recurring">Wiederkehrender Termin</string>
|
||||
<string name="recurrence_daily">Jeden Tag</string>
|
||||
<string name="recurrence_weekly">Jede Woche</string>
|
||||
<string name="recurrence_monthly">Jeden Monat</string>
|
||||
<string name="recurrence_yearly">Jedes Jahr</string>
|
||||
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
|
||||
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
|
||||
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
|
||||
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
|
||||
<string name="recurrence_on_days">%1$s am %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s bis %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
|
||||
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
|
||||
<string name="event_attendee_accepted">Zugesagt</string>
|
||||
<string name="event_attendee_declined">Abgesagt</string>
|
||||
<string name="event_attendee_tentative">Vorläufig</string>
|
||||
<string name="event_attendee_needs_action">Keine Antwort</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 -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
@@ -45,4 +111,33 @@
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</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>
|
||||
|
||||
@@ -13,13 +13,20 @@
|
||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">Calendar access</string>
|
||||
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
|
||||
<string name="permission_request_button">Continue</string>
|
||||
<string name="permission_rationale_title">See all your events, beautifully</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">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
<string name="permission_open_settings_button">Open system settings</string>
|
||||
<string name="permission_retry_button">Try again</string>
|
||||
<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) -->
|
||||
<string name="month_prev">Previous month</string>
|
||||
@@ -27,8 +34,6 @@
|
||||
<string name="month_today_action">Today</string>
|
||||
<string name="month_more_actions">More actions</string>
|
||||
<string name="month_open_menu">Open menu</string>
|
||||
<string name="month_action_filter">Calendars</string>
|
||||
<string name="month_action_jump_to_date">Jump to date…</string>
|
||||
<string name="month_action_settings">Settings</string>
|
||||
<string name="month_a11y_today_prefix">Today</string>
|
||||
|
||||
@@ -39,6 +44,67 @@
|
||||
<!-- Day view (S3) -->
|
||||
<string name="day_today_action">Today</string>
|
||||
|
||||
<!-- Event detail screen (S4) -->
|
||||
<string name="event_detail_back">Back</string>
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
<string name="event_detail_delete">Delete</string>
|
||||
<string name="event_detail_all_day">All day</string>
|
||||
<string name="event_detail_calendar">Calendar</string>
|
||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||
<string name="event_detail_location">Location</string>
|
||||
<string name="event_detail_description">Description</string>
|
||||
<string name="event_detail_attendees">Attendees</string>
|
||||
<string name="event_detail_recurrence">Recurrence</string>
|
||||
<string name="event_detail_recurring">Repeating event</string>
|
||||
<string name="recurrence_daily">Every day</string>
|
||||
<string name="recurrence_weekly">Every week</string>
|
||||
<string name="recurrence_monthly">Every month</string>
|
||||
<string name="recurrence_yearly">Every year</string>
|
||||
<string name="recurrence_every_n_days">Every %1$d days</string>
|
||||
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
|
||||
<string name="recurrence_every_n_months">Every %1$d months</string>
|
||||
<string name="recurrence_every_n_years">Every %1$d years</string>
|
||||
<string name="recurrence_on_days">%1$s on %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s until %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d times</string>
|
||||
<string name="event_detail_not_found">This event no longer exists.</string>
|
||||
<string name="event_attendee_accepted">Accepted</string>
|
||||
<string name="event_attendee_declined">Declined</string>
|
||||
<string name="event_attendee_tentative">Tentative</string>
|
||||
<string name="event_attendee_needs_action">No response</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 -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
@@ -46,4 +112,34 @@
|
||||
<string name="view_month">Month</string>
|
||||
<string name="view_week">Week</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>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,15 +14,29 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
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 =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||
|
||||
private fun makeEvent(id: Long, title: String = "E $id"): EventInstance = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = 1L,
|
||||
private fun makeEvent(
|
||||
id: Long,
|
||||
title: String = "E $id",
|
||||
calendarId: Long = 1L,
|
||||
): EventInstance = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = calendarId,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
@@ -26,11 +44,11 @@ class CalendarRepositoryImplTest {
|
||||
)
|
||||
|
||||
@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 {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
val first = awaitItem()
|
||||
@@ -40,11 +58,11 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@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 {
|
||||
calendarsResult = listOf(makeCal(1L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||
@@ -58,7 +76,7 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@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 observedEnd: Long? = null
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
@@ -68,7 +86,7 @@ class CalendarRepositoryImplTest {
|
||||
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)
|
||||
repo.instances(range).test {
|
||||
@@ -80,11 +98,11 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@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 {
|
||||
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)
|
||||
repo.instances(range).test {
|
||||
@@ -95,11 +113,56 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@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 {
|
||||
eventDetailResult = { null }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined)
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.eventDetail(eventId = 999L)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
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.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
|
||||
|
||||
class EventDetailMapperTest {
|
||||
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
|
||||
allDay: Int = 0,
|
||||
location: String? = "Berlin",
|
||||
calendarId: Long = 7L,
|
||||
status: Any? = null,
|
||||
availability: Any? = null,
|
||||
accessLevel: Any? = null,
|
||||
timezone: String? = null,
|
||||
selfStatus: Any? = null,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||
EventDetailProjection.IDX_TITLE to title,
|
||||
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
|
||||
EventDetailProjection.IDX_ALL_DAY to allDay,
|
||||
EventDetailProjection.IDX_LOCATION to location,
|
||||
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(
|
||||
AttendeeProjection.IDX_NAME to name,
|
||||
AttendeeProjection.IDX_EMAIL to email,
|
||||
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
|
||||
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!!.description).isEqualTo("Body")
|
||||
assertThat(detail.organizer).isEqualTo("x@y")
|
||||
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
|
||||
@Test
|
||||
fun `event color falls back to calendar color when null`() {
|
||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||
.toEventDetailCore(attendees = emptyList())
|
||||
.toDetail()
|
||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dtend before dtstart drops detail`() {
|
||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
|
||||
.toEventDetailCore(attendees = emptyList())
|
||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||
assertThat(detail).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rrule passes through when present`() {
|
||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
||||
.toEventDetailCore(attendees = emptyList())
|
||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||
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", "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
|
||||
- Event-Detail-Sheet (read-only Detailansicht)
|
||||
- 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)
|
||||
- Permission-Flow für `READ_CALENDAR`
|
||||
- Empty-States und Error-Recovery
|
||||
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
|
||||
- Tests + CI ab Tag 1
|
||||
|
||||
### Out-of-Scope (V2+)
|
||||
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
|
||||
- Event-Create/Edit/Delete (V2)
|
||||
- Home-Screen-Widget
|
||||
- Volltextsuche
|
||||
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
|
||||
- Immer erreichbar von allen Hauptansichten
|
||||
- State persistent (zuletzt aktive Ansicht)
|
||||
|
||||
**M2 - Heute / Springe-zu-Datum**
|
||||
- Schnell zurück zu "heute"
|
||||
- Springe zu beliebigem Datum via Datum-Picker
|
||||
**M2 - Heute**
|
||||
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
|
||||
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
|
||||
- Erreichbar von allen Hauptansichten
|
||||
|
||||
**M3 - Kalender-Filter (Bottom-Sheet)**
|
||||
|
||||
@@ -4,6 +4,7 @@ kotlin = "2.3.21"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
@@ -27,6 +28,7 @@ androidxTestRules = "1.7.0"
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
|
||||
@@ -42,6 +44,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
|
||||
# Material 3 (Expressive lives in this artifact for 1.5+)
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
|
||||
# Hilt
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
|
||||
Reference in New Issue
Block a user