diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 5422d7f..3eed091 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 diff --git a/.planning/STATE.md b/.planning/STATE.md index 7159fbf..05e5b44 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,9 +4,9 @@ ## Status -**Milestone:** v0.4 — Week view (in progress) -**Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet, -filter, settings, jump-to-date) still stubbed +**Milestone:** v0.5 — Calendar 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c869c..e959d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67b2b71..75cd4de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt index 1fd3236..fd76ebc 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt @@ -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 = PreferenceDataStoreFactory.create( + produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, + ) + return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) } @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc28f42..9d41606 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,17 @@ + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 06a0fdd..c64c7f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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()) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 182703d..6a87154 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -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,16 +44,26 @@ 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): Flow> = - ticks - .onStart { emit(Unit) } - .reQuery { - dataSource.instances( - beginMillis = range.start.toEpochMillis(), - endMillis = range.endInclusive.toEpochMillis(), - ) - } - .flowOn(io) + combine( + ticks + .onStart { emit(Unit) } + .reQuery { + dataSource.instances( + beginMillis = range.start.toEpochMillis(), + endMillis = range.endInclusive.toEpochMillis(), + ) + }, + 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt new file mode 100644 index 0000000..6c25196 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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, +) { + + val themeMode: Flow = store.data.map { prefs -> + prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM) + } + + val dynamicColor: Flow = store.data.map { prefs -> + prefs[DYNAMIC_COLOR_KEY] ?: true + } + + val weekStart: Flow = 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 > String?.toEnum(default: E): E = + this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index f1f172b..ef04a99 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -20,6 +20,7 @@ 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 @@ -30,7 +31,7 @@ import kotlinx.datetime.LocalDate */ @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. @@ -59,6 +60,12 @@ fun CalendarHost(modifier: Modifier = Modifier) { 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()) { @@ -67,17 +74,20 @@ fun CalendarHost(modifier: Modifier = Modifier) { selectedView = view, onSelectView = onSelectView, onEventClick = onEventClick, + onOpenSettings = onOpenSettings, ) CalendarView.Day -> DayScreen( selectedView = view, onSelectView = onSelectView, onEventClick = onEventClick, + onOpenSettings = onOpenSettings, initialDateIso = pendingDayIso, ) CalendarView.Month -> MonthScreen( selectedView = view, onSelectView = onSelectView, onOpenDay = onOpenDay, + onOpenSettings = onOpenSettings, ) } @@ -97,5 +107,14 @@ fun CalendarHost(modifier: Modifier = Modifier) { ) } } + + // Settings (M4) — full-screen destination, slides over the calendar. + AnimatedVisibility( + visible = showSettings, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + SettingsScreen(onBack = { showSettings = false }) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index bc1d498..e85213a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -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,53 +19,72 @@ 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 { - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), - ) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - NavigationDrawerItem( - label = { Text(stringResource(R.string.month_today_action)) }, - selected = false, - onClick = onToday, - modifier = Modifier.padding(horizontal = 12.dp), - ) - NavigationDrawerItem( - label = { Text(stringResource(R.string.month_action_jump_to_date)) }, - selected = false, - onClick = onJumpToDate, - modifier = Modifier.padding(horizontal = 12.dp), - ) - NavigationDrawerItem( - label = { Text(stringResource(R.string.month_action_filter)) }, - selected = false, - onClick = onFilter, - modifier = Modifier.padding(horizontal = 12.dp), - ) - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - NavigationDrawerItem( - label = { Text(stringResource(R.string.month_action_settings)) }, - selected = false, - onClick = onSettings, - modifier = Modifier.padding(horizontal = 12.dp), - ) + Column(Modifier.fillMaxHeight()) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), + ) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + NavigationDrawerItem( + 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), + ) + 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), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index a6b271e..5d93b0a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -107,6 +107,7 @@ fun DayScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -152,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() } + }, ) }, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index d4dd3c8..ef6fb84 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -66,6 +66,7 @@ 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 @@ -347,11 +348,10 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) { // --- helpers ------------------------------------------------------------- +// Observable locale read (shared helper) — avoids NonObservableLocale / +// LocalContextConfigurationRead lint by going through LocalConfiguration. @Composable -private fun currentDetailLocale(): Locale { - val config = LocalContext.current.resources.configuration - return config.locales[0] ?: Locale.getDefault() -} +private fun currentDetailLocale(): Locale = currentLocale() private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) { AttendeeStatus.Accepted -> R.string.event_attendee_accepted diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt new file mode 100644 index 0000000..4a6bd18 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -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, + 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), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt new file mode 100644 index 0000000..2fe8ebb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt @@ -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) : FilterUiState +} + +/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */ +data class AccountGroup( + val account: String, + val calendars: List, +) + +data class CalendarRow( + val id: Long, + val displayName: String, + val color: Int, + val visible: Boolean, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt new file mode 100644 index 0000000..639c968 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt @@ -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 = + 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, + hidden: Set, +): List = + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ff1541c..fbf0f55 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -85,11 +85,13 @@ 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) @@ -126,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() } + }, ) }, ) { @@ -162,9 +165,10 @@ fun MonthScreen( .padding(innerPadding) .fillMaxSize(), ) { - WeekdayHeader(weekStart = DayOfWeek.MONDAY) + WeekdayHeader(weekStart = weekStart) MonthContent( state = state, + weekStart = weekStart, slideDir = slideDir, onSwipeNext = goNext, onSwipePrev = goPrev, @@ -179,6 +183,7 @@ fun MonthScreen( @Composable private fun MonthContent( state: MonthUiState, + weekStart: DayOfWeek, slideDir: Int, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, @@ -223,7 +228,7 @@ private fun MonthContent( is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Success -> MonthGrid( state = s, - weekStart = DayOfWeek.MONDAY, + weekStart = weekStart, onOpenDay = onOpenDay, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 9fbaa2d..3f21ff9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = 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,23 +63,24 @@ class MonthViewModel @Inject constructor( private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) val month: StateFlow = _month - val state: StateFlow = _month - .flatMapLatest { ym -> - val range = monthGridRange(ym, weekStart, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(ym, calendars, instances) + val state: StateFlow = + combine(_month, weekStart) { ym, ws -> ym to ws } + .flatMapLatest { (ym, ws) -> + val range = monthGridRange(ym, ws, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(ym, calendars, instances) + } } - } - .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = MonthUiState.Loading, - ) + .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = MonthUiState.Loading, + ) fun goToPrev() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt new file mode 100644 index 0000000..05e1178 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppLanguage.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..f97c1a2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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 SettingDropdownRow( + title: String, + selected: T, + options: List, + 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 + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt new file mode 100644 index 0000000..b9374d8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..db72770 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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 = + 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) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index dd3b94e..7fbfbef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -87,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 @@ -113,6 +112,7 @@ fun WeekScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -135,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 } @@ -152,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() } + }, ) }, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 182adfa..8c37bc7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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,48 +43,68 @@ 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 = _weekStartDate - - val state: StateFlow = _weekStartDate - .flatMapLatest { start -> - val range = weekRange(start, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(start, calendars, instances) - } - } - .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) + /** First day of the week, from the Settings preference (AUTO → locale). */ + private val weekStart: StateFlow = settingsPrefs.weekStart + .map { it.resolveFirstDay(locale) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), - initialValue = WeekUiState.Loading, + initialValue = DayOfWeek.MONDAY, ) + // Anchor is a representative day inside the visible week; the actual week + // start is derived against [weekStart], so changing the first-day preference + // re-frames the same week instead of jumping. + private val _anchor = MutableStateFlow(todayDate) + + val weekStartDate: StateFlow = + combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY), + ) + + val state: StateFlow = + combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } + .distinctUntilChanged() + .flatMapLatest { start -> + val range = weekRange(start, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(start, calendars, instances) + } + } + .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = WeekUiState.Loading, + ) + fun goToPrev() { - _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( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 955ec9c..bd85f5f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -26,8 +26,6 @@ Heute Weitere Aktionen Menü öffnen - Kalender - Zu Datum springen… Einstellungen Heute @@ -75,4 +73,33 @@ Monat Woche Tag + + + Kalender + + + Einstellungen + Zurück + Darstellung + Design + System + Hell + Dunkel + Dynamische Farben + Erfordert Android 12 oder neuer + Wochenstart + Automatisch + Montag + Sonntag + Sprache + App-Sprache + Systemstandard + Deutsch + English + Über + Version + Lizenz + MIT + Quellcode + Öffnen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb3e6d1..74cc5ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,8 +27,6 @@ Today More actions Open menu - Calendars - Jump to date… Settings Today @@ -76,4 +74,34 @@ Month Week Day + + + Calendars + + + Settings + Back + Appearance + Theme + System + Light + Dark + Dynamic colour + Requires Android 12 or newer + Week starts on + Automatic + Monday + Sunday + Language + App language + System default + Deutsch + English + About + Version + License + MIT + Source code + Open + https://gitea.jeanlucmakiola.de/makiolaj/calendula diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index b168b02..05b909d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -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 = + 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) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt new file mode 100644 index 0000000..1e4c337 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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 = + 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt new file mode 100644 index 0000000..123ab8b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt @@ -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") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ffc1ac..265d273 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }