feat(ui): month card grid + week timeline, wire view switcher
Replace the throwaway debug screen with the first real calendar UI and a functional Month <-> Week switcher, on Material 3 Expressive. Month view (S1): - Material 3 Expressive card-per-day grid; only the current month's weeks render (neighbouring days left blank) - per-day event dots with "+N" overflow, today via primaryContainer - spring-based press feedback from the active motion scheme - swipe + drawer navigation, Loading/Failure/Success states Week view (S2): - vertical time schedule with overlap-resolved lanes (per-day clipping, midnight spanning, instant events) - all-day / multi-day events as connected horizontal spans - single scroll container (gutter + day columns stay aligned), columns bundled in a rounded container, noon-centred on load - top section colour-shifts with the app bar on scroll; swipe navigation, three states Shared / infra: - CalendarHost holds the active view; RootScreen renders it post-permission - ui/common building blocks: CalendarDrawer, CalendarFailure, ViewSwitcherPill, pastelize, observable locale, M3 Expressive slide transition (motionScheme fastSpatialSpec) - unit tests for the week layout (lanes, clipping, all-day spans) - build: compileSdk 37, material3 pinned to 1.5.0-alpha21 for Expressive Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarHost(modifier: Modifier = Modifier) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Month) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
|
||||
when (view) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
modifier = modifier,
|
||||
)
|
||||
// Month, plus Day as a fallback until the day view lands (v0.5).
|
||||
else -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,18 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.ui.debug.DebugScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
|
||||
@Composable
|
||||
@@ -42,14 +39,12 @@ fun RootScreen(modifier: Modifier = Modifier) {
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
Scaffold(modifier = modifier) { innerPadding ->
|
||||
if (hasPermission) {
|
||||
DebugScreen(modifier = Modifier.padding(innerPadding))
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
if (hasPermission) {
|
||||
CalendarHost(modifier = modifier)
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||
* One explanation line + one recovery action, never a toast.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Text(stringResource(actionRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||
* springy settle, rather than a fixed easing curve.
|
||||
*
|
||||
* Read it in a composable scope (this helper) so it can be captured by the
|
||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
): ContentTransform {
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
/**
|
||||
* The top-level calendar views the user can switch between (spec M1).
|
||||
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
|
||||
*/
|
||||
enum class CalendarView {
|
||||
Month,
|
||||
Week,
|
||||
Day,
|
||||
}
|
||||
|
||||
/**
|
||||
* Views that actually have a screen today. The view-switcher pill cycles
|
||||
* through these in order; Day joins once its screen lands.
|
||||
*/
|
||||
val IMPLEMENTED_VIEWS: List<CalendarView> = listOf(CalendarView.Month, CalendarView.Week)
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
val i = available.indexOf(this)
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||
* weekday/month name formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun currentLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||
*/
|
||||
@Composable
|
||||
fun ViewSwitcherPill(
|
||||
current: CalendarView,
|
||||
onCycle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val labelRes = when (current) {
|
||||
CalendarView.Month -> R.string.view_month
|
||||
CalendarView.Week -> R.string.view_week
|
||||
CalendarView.Day -> R.string.view_day
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(stringResource(labelRes))
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.graphics.Color
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
@Composable
|
||||
fun DebugScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DebugViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
DebugBanner()
|
||||
when (val s = state) {
|
||||
DebugUiState.Loading -> LoadingContent()
|
||||
is DebugUiState.Failure -> FailureContent()
|
||||
is DebugUiState.Success -> SuccessContent(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugBanner() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureContent() {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.state_failure_provider),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessContent(state: DebugUiState.Success) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
item { SectionHeader(stringResource(R.string.debug_calendars_header)) }
|
||||
if (state.calendars.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_no_calendars),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(state.calendars, key = { "cal-${it.id}" }) { CalendarRow(it) }
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
item { SectionHeader(stringResource(R.string.debug_events_header)) }
|
||||
|
||||
if (state.nextEvents.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_no_events),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
state.nextEvents,
|
||||
// Recurring events share Instances._ID across occurrences, so
|
||||
// include the start instant to keep the LazyColumn key unique.
|
||||
key = { "evt-${it.instanceId}-${it.start.toEpochMilliseconds()}" },
|
||||
) { EventRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(text = text, style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarRow(cal: CalendarSource) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(Color(cal.color), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = " ${cal.displayName} (${cal.accountName})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance) {
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(text = event.title, style = MaterialTheme.typography.bodyMedium)
|
||||
val date = "%04d-%02d-%02d".format(start.year, start.month.ordinal + 1, start.day)
|
||||
val time = "%02d:%02d".format(start.hour, start.minute)
|
||||
Text(
|
||||
text = "$date $time",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
sealed interface DebugUiState {
|
||||
data object Loading : DebugUiState
|
||||
data class Failure(val reason: FailureReason) : DebugUiState
|
||||
data class Success(
|
||||
val calendars: List<CalendarSource>,
|
||||
val nextEvents: List<EventInstance>,
|
||||
) : DebugUiState
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
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.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.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_DEBUG_EVENTS = 50
|
||||
private val DEBUG_WINDOW = 30.days
|
||||
|
||||
@HiltViewModel
|
||||
class DebugViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<DebugUiState> = run {
|
||||
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val range = now..(now + DEBUG_WINDOW)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
DebugUiState.Success(
|
||||
calendars = calendars,
|
||||
nextEvents = instances.take(MAX_DEBUG_EVENTS),
|
||||
) as DebugUiState
|
||||
}
|
||||
.catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DebugUiState.Loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentMonth = when (val s = state) {
|
||||
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
|
||||
val goNext = {
|
||||
slideDir = 1
|
||||
viewModel.goToNext()
|
||||
}
|
||||
val goPrev = {
|
||||
slideDir = -1
|
||||
viewModel.goToPrev()
|
||||
}
|
||||
val jumpToToday = {
|
||||
slideDir = 0
|
||||
viewModel.goToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the month swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
onToday = {
|
||||
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 */ },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentMonth,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.month_today_action)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = DayOfWeek.MONDAY)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 6.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is MonthUiState.Success -> "success-${s.month}"
|
||||
is MonthUiState.Failure -> "failure-${s.reason}"
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
label = "month-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthGrid(state = s, weekStart = DayOfWeek.MONDAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
month: YearMonth,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatMonthYear(month),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
val locale = currentLocale()
|
||||
val days = remember(weekStart, locale) {
|
||||
(0 until 7).map { offset ->
|
||||
DayOfWeek.entries[((weekStart.ordinal + offset) % 7)]
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
days.forEach { dow ->
|
||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
weekStart: DayOfWeek,
|
||||
) {
|
||||
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
|
||||
// Show only the weeks the current month actually touches; leading/trailing
|
||||
// days of neighbouring months are left blank rather than rendered.
|
||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
val daysInMonth =
|
||||
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
|
||||
val weeks = (leadOffset + daysInMonth + 6) / 7
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repeat(weeks) { row ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(7) { col ->
|
||||
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
|
||||
val inMonth =
|
||||
date.month == state.month.month && date.year == state.month.year
|
||||
if (inMonth) {
|
||||
DayCard(
|
||||
date = date,
|
||||
isToday = date == state.today,
|
||||
data = state.cells[date],
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DayCard(
|
||||
date: LocalDate,
|
||||
isToday: Boolean,
|
||||
data: DayCellData?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||
val cellLabel = buildString {
|
||||
if (isToday) append(todayPrefix).append(", ")
|
||||
append(date.year).append('-')
|
||||
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
|
||||
append(date.day.toString().padStart(2, '0'))
|
||||
data?.let { append(", ").append(it.count).append(" Events") }
|
||||
}
|
||||
|
||||
// M3 Expressive press feedback: a spatial spring from the active motion
|
||||
// scheme drives a subtle scale, instead of a fixed easing curve.
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.94f else 1f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
label = "day-card-press",
|
||||
)
|
||||
|
||||
Card(
|
||||
onClick = { /* TODO: open the day view (S3) for this date */ },
|
||||
interactionSource = interactionSource,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.semantics { contentDescription = cellLabel },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 4.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
EventDotRow(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDotRow(data: DayCellData?) {
|
||||
if (data == null || data.swatches.isEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
return
|
||||
}
|
||||
val dark = isSystemInDarkTheme()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
data.swatches.forEach { argb ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(pastelize(argb, dark), CircleShape),
|
||||
)
|
||||
}
|
||||
if (data.count > data.swatches.size) {
|
||||
Text(
|
||||
text = "+${data.count - data.swatches.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthGridLoading() {
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(6) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = shape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMonthYear(ym: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(ym.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${ym.year}"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
|
||||
/**
|
||||
* Per-day aggregation surfaced to the month grid. We only need
|
||||
* - the total event count (drives the optional "+N" indicator), and
|
||||
* - up to three calendar colors for the dot row.
|
||||
*
|
||||
* The day cell never holds full event objects — the detail sheet pulls those
|
||||
* lazily.
|
||||
*/
|
||||
data class DayCellData(
|
||||
val count: Int,
|
||||
val swatches: List<Int>,
|
||||
)
|
||||
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
data class Success(
|
||||
val month: YearMonth,
|
||||
val today: LocalDate,
|
||||
val cells: Map<LocalDate, DayCellData>,
|
||||
) : MonthUiState
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@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 todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
ym: YearMonth,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): MonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
|
||||
.mapValues { (_, evs) ->
|
||||
DayCellData(
|
||||
count = evs.size,
|
||||
swatches = evs.map { it.color }.distinct().take(3),
|
||||
)
|
||||
}
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
today = todayDate,
|
||||
cells = byDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||
* trailing days of the previous month and the leading days of the next month.
|
||||
*/
|
||||
internal fun monthGridRange(
|
||||
ym: YearMonth,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): ClosedRange<Instant> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
|
||||
val start = gridStart.atStartOfDayIn(zone)
|
||||
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
|
||||
return start..end
|
||||
}
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
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
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WeekScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// The static header + all-day strip share the app bar's scrolled colour so
|
||||
// the whole top region elevates together once the timeline scrolls under it.
|
||||
val topSectionColor by animateColorAsState(
|
||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
label = "week-top-section-color",
|
||||
)
|
||||
|
||||
val isOnCurrentWeek = when (val s = state) {
|
||||
is WeekUiState.Success -> s.weekStart == s.today.startOfWeek(DayOfWeek.MONDAY)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
val jumpToToday = { slideDir = 0; viewModel.goToToday() }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the week swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
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 */ },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = !isOnCurrentWeek,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = jumpToToday,
|
||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.week_today_action)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(state = s, topSectionColor = topSectionColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSuccess(state: WeekUiState.Success, topSectionColor: Color) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor)
|
||||
.animateContentSize(),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today)
|
||||
AllDayStrip(state = state)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WeekTopBar(
|
||||
weekStart: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatWeekRange(weekStart),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Match the static top section exactly: plain surface, lifting to
|
||||
// surfaceContainer once content scrolls under the bar.
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||
val locale = currentLocale()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
days.forEach { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the 28dp circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(state: WeekUiState.Success) {
|
||||
if (state.allDaySpans.isEmpty()) return
|
||||
val dark = isSystemInDarkTheme()
|
||||
val lanes = state.allDaySpans.maxOf { it.lane } + 1
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(GUTTER_WIDTH),
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.week_all_day),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.padding(top = 2.dp, end = 4.dp),
|
||||
)
|
||||
}
|
||||
// Span bars are positioned absolutely so a multi-day event is one
|
||||
// connected bar across columns rather than a chip per day.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(ALL_DAY_ROW_HEIGHT * lanes),
|
||||
) {
|
||||
val colWidth = maxWidth / 7
|
||||
state.allDaySpans.forEach { span ->
|
||||
val spanCols = span.endCol - span.startCol + 1
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colWidth * span.startCol,
|
||||
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.width(colWidth * spanCols)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(state: WeekUiState.Success) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Center the timeline on noon once the content is measured. Deriving the
|
||||
// target from maxValue (known after layout) is reliable, unlike reading
|
||||
// the viewport height during the first composition.
|
||||
LaunchedEffect(scrollState) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
|
||||
// One scroll container for the whole timeline. Gutter and day columns
|
||||
// are siblings inside it, so they share the exact same scroll position
|
||||
// and can never drift apart.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
// Hour gutter
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.height(totalHeight),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Day columns bundled in a rounded container so the whole block has
|
||||
// soft corners (rounded at the day's start and end).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val colWidth = maxWidth
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scrollState = rememberScrollState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header skeleton
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp)
|
||||
.height(36.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
val monthName = { d: LocalDate ->
|
||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
||||
} else if (weekStart.year == end.year) {
|
||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
||||
} else {
|
||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
||||
"${end.day}. ${monthName(end)} ${end.year}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* One timed event clipped to a single day and assigned a horizontal lane so
|
||||
* overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander
|
||||
* aufgelöst").
|
||||
*
|
||||
* @param startMin minutes from this day's midnight, clamped to [0, 1440]
|
||||
* @param endMin minutes from this day's midnight, clamped to [startMin, 1440];
|
||||
* equal to [startMin] for instant events (render enforces a
|
||||
* minimum tap-target height)
|
||||
* @param lane 0-based column within [laneCount]
|
||||
* @param laneCount number of columns the event's overlap-cluster needs
|
||||
*/
|
||||
data class TimedBlock(
|
||||
val event: EventInstance,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
val lane: Int,
|
||||
val laneCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* An all-day (or multi-day) event laid out as a single horizontal bar spanning
|
||||
* [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping
|
||||
* spans don't collide. A multi-day event is one connected bar — not one chip per
|
||||
* day.
|
||||
*
|
||||
* @param startCol first visible covered column, 0..6 (clamped to the week)
|
||||
* @param endCol last visible covered column, 0..6, inclusive
|
||||
* @param lane 0-based stacking row
|
||||
*/
|
||||
data class AllDaySpan(
|
||||
val event: EventInstance,
|
||||
val startCol: Int,
|
||||
val endCol: Int,
|
||||
val lane: Int,
|
||||
)
|
||||
|
||||
sealed interface WeekUiState {
|
||||
data object Loading : WeekUiState
|
||||
data class Failure(val reason: FailureReason) : WeekUiState
|
||||
data class Success(
|
||||
val weekStart: LocalDate,
|
||||
val today: LocalDate,
|
||||
/** The seven days of the week, [weekStart] first. */
|
||||
val days: List<LocalDate>,
|
||||
/** All-day/multi-day events as connected horizontal spans. */
|
||||
val allDaySpans: List<AllDaySpan>,
|
||||
/** Timed events, clipped to each day with lanes resolved. */
|
||||
val timedByDay: Map<LocalDate, List<TimedBlock>>,
|
||||
) : WeekUiState
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@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 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)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = WeekUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_weekStartDate.value = _weekStartDate.value.minus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_weekStartDate.value = _weekStartDate.value.plus(7, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_weekStartDate.value = todayDate.startOfWeek(weekStart)
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): WeekUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
||||
*/
|
||||
internal fun layoutAllDay(
|
||||
events: List<EventInstance>,
|
||||
days: List<LocalDate>,
|
||||
zone: TimeZone,
|
||||
): List<AllDaySpan> {
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
|
||||
|
||||
val raw = events
|
||||
.mapNotNull { ev ->
|
||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
}
|
||||
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
||||
|
||||
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
||||
return raw.map { r ->
|
||||
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
||||
if (lane == -1) {
|
||||
laneEnd.add(r.endCol)
|
||||
lane = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[lane] = r.endCol
|
||||
}
|
||||
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the seven days starting at [start]. */
|
||||
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = start.atStartOfDayIn(zone)
|
||||
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
|
||||
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
|
||||
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
return start < dayEnd && end > dayStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||
* chained-overlapping events), matching the common phone week-view behaviour.
|
||||
*
|
||||
* All-day events are ignored here — they live in the all-day strip.
|
||||
*/
|
||||
internal fun layoutDay(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<TimedBlock> {
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
|
||||
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
|
||||
|
||||
val raw = events.asSequence()
|
||||
.filterNot { it.isAllDay }
|
||||
.mapNotNull { ev ->
|
||||
if (ev.start == ev.end) {
|
||||
// Instant event: keep only if the point falls inside this day.
|
||||
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
Raw(ev, m, m)
|
||||
} else {
|
||||
val s = maxOf(ev.start, dayStart)
|
||||
val e = minOf(ev.end, dayEnd)
|
||||
if (e <= s) return@mapNotNull null
|
||||
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
|
||||
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
|
||||
Raw(ev, startMin, endMin)
|
||||
}
|
||||
}
|
||||
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
|
||||
.toList()
|
||||
|
||||
val result = ArrayList<TimedBlock>(raw.size)
|
||||
var i = 0
|
||||
while (i < raw.size) {
|
||||
// Grow a cluster of chained-overlapping events.
|
||||
var clusterEnd = raw[i].endMin
|
||||
var j = i + 1
|
||||
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||
j++
|
||||
}
|
||||
val cluster = raw.subList(i, j)
|
||||
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val lanes = IntArray(cluster.size)
|
||||
cluster.forEachIndexed { k, r ->
|
||||
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||
if (placed == -1) {
|
||||
laneEnd.add(r.endMin)
|
||||
placed = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[placed] = r.endMin
|
||||
}
|
||||
lanes[k] = placed
|
||||
}
|
||||
val laneCount = laneEnd.size
|
||||
cluster.forEachIndexed { k, r ->
|
||||
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -20,10 +20,26 @@
|
||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||
<string name="permission_retry_button">Erneut versuchen</string>
|
||||
|
||||
<!-- Debug-Screen (wegwerfbar — entfällt mit Plan 03) -->
|
||||
<string name="debug_banner">DEBUG — wird mit Plan 03 durch die Monatsansicht ersetzt</string>
|
||||
<string name="debug_calendars_header">Kalender</string>
|
||||
<string name="debug_events_header">Nächste 50 Termine</string>
|
||||
<string name="debug_no_calendars">Keine Kalender eingerichtet. Füge einen über DAVx5 oder die System-Einstellungen hinzu.</string>
|
||||
<string name="debug_no_events">Keine anstehenden Termine in den nächsten 30 Tagen.</string>
|
||||
<!-- Monatsansicht (S1) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
<string name="month_next">Nächster Monat</string>
|
||||
<string name="month_today_action">Heute</string>
|
||||
<string name="month_more_actions">Weitere Aktionen</string>
|
||||
<string name="month_open_menu">Menü öffnen</string>
|
||||
<string name="month_action_filter">Kalender</string>
|
||||
<string name="month_action_jump_to_date">Zu Datum springen…</string>
|
||||
<string name="month_action_settings">Einstellungen</string>
|
||||
<string name="month_a11y_today_prefix">Heute</string>
|
||||
|
||||
<!-- Wochenansicht (S2) -->
|
||||
<string name="week_all_day">Ganztägig</string>
|
||||
<string name="week_today_action">Diese Woche</string>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</string>
|
||||
<string name="view_day">Tag</string>
|
||||
</resources>
|
||||
|
||||
@@ -21,10 +21,26 @@
|
||||
<string name="permission_open_settings_button">Open system settings</string>
|
||||
<string name="permission_retry_button">Try again</string>
|
||||
|
||||
<!-- Debug screen (wegwerfbar — entfällt mit Plan 03) -->
|
||||
<string name="debug_banner">DEBUG — replaced by month view in Plan 03</string>
|
||||
<string name="debug_calendars_header">Calendars</string>
|
||||
<string name="debug_events_header">Next 50 events</string>
|
||||
<string name="debug_no_calendars">No calendars configured. Add one via DAVx5 or system settings.</string>
|
||||
<string name="debug_no_events">No upcoming events in the next 30 days.</string>
|
||||
<!-- Month view (S1) -->
|
||||
<string name="month_prev">Previous month</string>
|
||||
<string name="month_next">Next month</string>
|
||||
<string name="month_today_action">Today</string>
|
||||
<string name="month_more_actions">More actions</string>
|
||||
<string name="month_open_menu">Open menu</string>
|
||||
<string name="month_action_filter">Calendars</string>
|
||||
<string name="month_action_jump_to_date">Jump to date…</string>
|
||||
<string name="month_action_settings">Settings</string>
|
||||
<string name="month_a11y_today_prefix">Today</string>
|
||||
|
||||
<!-- Week view (S2) -->
|
||||
<string name="week_all_day">All-day</string>
|
||||
<string name="week_today_action">This week</string>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
<string name="view_week">Week</string>
|
||||
<string name="view_day">Day</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user