3 Commits

Author SHA1 Message Date
7d36d22fd5 Merge pull request 'feat: calendar filter in drawer + settings (v0.5.0)' (#1) from feat/filter-settings-v0.5.0 into main
Some checks failed
CI / ci (push) Has been cancelled
Build and Release to F-Droid / ci (push) Successful in 7m48s
Build and Release to F-Droid / build-and-deploy (push) Successful in 9m50s
2026-06-10 20:57:57 +00:00
adcbed6e02 feat(filter,settings): calendar filter in drawer + settings (v0.5.0)
All checks were successful
CI / ci (push) Successful in 12m42s
CI / ci (pull_request) Successful in 12m23s
M3 — calendar filter: the navigation drawer now hosts the calendar list
inline (grouped by account, colour swatch + checkbox per calendar). Hidden
calendars are persisted app-side and filtered centrally in the repository,
so month/week/day re-filter live the moment a checkbox flips. Drawer trimmed
to Today, the calendar filter, and Settings, with leading icons and a clear
title/section type scale; the stubbed jump-to-date entry (M2) was removed.

M4 — settings: full-screen destination with appearance (theme System/Light/
Dark, Material You dynamic colour auto-disabled < API 31, week start Auto/Mon/
Sun), language (per-app locales via AppCompat, persisted to API 29), and an
about section (version, licence, source link). Theme is driven by one
activity-scoped settings source so changes apply app-wide at once. Week start
now drives the month grid and week view; Auto follows the locale.

Also:
- default view switched from month to week
- Settings screen handles system back (was closing the app)
- fix pre-existing NonObservableLocale/LocalContextConfigurationRead lint
  errors in EventDetailScreen so CI lint is green again
- versionName/versionCode bumped to 0.5.0 / 5

Tests: repository hidden-filter (incl. live re-emit), SettingsPrefs round-trip
+ week-start resolution, filter grouping. lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:55:33 +02:00
efa0abbaed feat(detail): full-screen event detail (S4) with humanized recurrence
Some checks failed
CI / ci (push) Failing after 6m55s
Build and Release to F-Droid / ci (push) Successful in 7m48s
Build and Release to F-Droid / build-and-deploy (push) Successful in 9m40s
Tapping an event in the week/day timeline opens a full-screen detail
destination (MD3 list→detail, not a bottom sheet) overlaying the calendar
with a slide transition. One card per field (when, calendar, location,
description, attendees, recurrence) with leading icons; location taps open
a maps intent. Loading/Failure/Success throughout.

Recurrence is humanized from the RRULE — e.g. "Every week on Tue and Thu
until 31 Dec 2026" — covering FREQ/INTERVAL/BYDAY/UNTIL/COUNT with
abbreviated, italicised day names and localized list formatting, falling
back to a generic label for rules it can't render.

Also:
- fix: recurring events failed to open (series row stores DURATION, not
  DTEND, so the mapper dropped them as EventNotFound). The detail keeps
  them and shows the tapped occurrence's own times from Instances.
- feat: month day cell → opens the day view anchored to that date.
- build: add material-icons-extended (R8 strips unused icons in release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:52:35 +02:00
34 changed files with 2246 additions and 165 deletions

View File

@@ -6,15 +6,19 @@
|---|---|---|
| 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 |
| v1.0 | Polish + jump-to-date (M2), F-Droid release | pending |
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.
Jump-to-date (M2) was deferred out of v0.5 and folds into the v1.0 polish pass.
## v1.0 — First Public Release
All V1 features shipped, polished, on F-Droid. Read-only calendar.
Remaining before v1.0: jump-to-date (M2) and a UI polish/QA pass.
## v2.0 — Write Support

View File

@@ -4,9 +4,9 @@
## Status
**Milestone:** v0.4Week view (in progress)
**Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet,
filter, settings, jump-to-date) still stubbed
**Milestone:** v0.5Calendar filter (M3) + Settings (M4) (complete)
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date
(M2), which is deferred to the v1.0 polish pass
## Progress
@@ -16,13 +16,15 @@ 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) — drawer entry still stubbed (deferred to v1.0)
## 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. Jump-to-date (M2) — date picker from the drawer, reachable on every view
2. UI polish / QA pass across all views before v1.0
3. F-Droid release of v1.0

View File

@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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; M2 returns in v1.0)
- 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

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionCode = 5
versionName = "0.5.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)

View File

@@ -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

View File

@@ -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>

View File

@@ -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())
}
}

View File

@@ -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)

View File

@@ -11,16 +11,26 @@ private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
val end = getLong(EventDetailProjection.IDX_DTEND)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
return null
}
if (end < begin) {
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
// 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

View File

@@ -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

View File

@@ -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 })
}
}
}

View File

@@ -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),
)
}

View File

@@ -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" },
) {

View File

@@ -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>,

View File

@@ -0,0 +1,536 @@
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.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.People
import androidx.compose.material.icons.filled.Place
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.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.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
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.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.EventInstance
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 with a short accent line in the calendar colour underneath.
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
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,
)
}
}
// 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).
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 = description, style = MaterialTheme.typography.bodyMedium)
}
}
// Attendees (conditional).
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees),
) {
attendees.forEach { AttendeeRow(it) }
}
}
// 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,
) {
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(attendeeStatusLabel(attendee.status)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@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
}
/**
* 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.
}
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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),
)
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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),

View File

@@ -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)
}
}

View File

@@ -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
},
)

View File

@@ -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,
)

View File

@@ -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) }
}
}

View File

@@ -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" },
) {

View File

@@ -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(

View File

@@ -26,8 +26,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 +36,36 @@
<!-- 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>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
@@ -45,4 +73,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>

View File

@@ -27,8 +27,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 +37,36 @@
<!-- 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>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
@@ -46,4 +74,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>

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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" }