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>
This commit is contained in:
2026-06-10 22:55:33 +02:00
parent efa0abbaed
commit adcbed6e02
30 changed files with 1346 additions and 141 deletions

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,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<Instant>): Flow<List<EventInstance>> =
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)

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

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

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

View File

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

View File

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

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

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

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,23 +63,24 @@ 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)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, calendars, instances)
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),
) { 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)

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

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

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,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<LocalDate> = _weekStartDate
val state: StateFlow<WeekUiState> = _weekStartDate
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
/** 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 = WeekUiState.Loading,
initialValue = DayOfWeek.MONDAY,
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [weekStart], so changing the first-day preference
// re-frames the same week instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
val weekStartDate: StateFlow<LocalDate> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
)
val state: StateFlow<WeekUiState> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.distinctUntilChanged()
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)
fun goToPrev() {
_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(