diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 0a0c20f..ebb8af6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest @@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, LocalShowHourLines provides settings.showHourLines, + LocalTimelineScale provides settings.timelineScale, LocalSoftenColors provides settings.softenColors, ) { RootScreen( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 52a2518..b5ceced 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -270,6 +271,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } } + /** + * How tall an hour is drawn in the week and day timelines (#56). Defaults to + * [TimelineScale.Regular] — the historical 56dp scale. + */ + val timelineScale: Flow = store.data.map { prefs -> + prefs[TIMELINE_SCALE_KEY].toEnum(TimelineScale.Regular) + } + + suspend fun setTimelineScale(scale: TimelineScale) { + store.edit { it[TIMELINE_SCALE_KEY] = scale.name } + } + /** * Where the jump-to-today control lives (issue #60). Default OFF — the * historical layout, where it's an extended FAB that fades in above the "+" @@ -808,6 +821,7 @@ class SettingsPrefs @Inject constructor( internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") + internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt new file mode 100644 index 0000000..958025b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -0,0 +1,94 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.annotation.StringRes +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * How tall one hour is drawn in the week and day timelines (#56). + * + * One shared setting for both views: they are the same grid at different widths, + * and a scale that only applied to one of them would read as a bug. + * + * [FitDay] is the answer to the actual complaint behind the issue — on a tall + * phone the default scale shows about half a day, so a whole week can hide + * appointments below the fold. It derives the hour height from the timeline's + * own viewport instead of a fixed value. + */ +enum class TimelineScale { + /** Whole day in one screen: the hour height follows the viewport. */ + FitDay, + + /** Denser than the default, still a fixed height. */ + Compact, + + /** The historical 56dp scale. */ + Regular, + + /** Roomier blocks, more scrolling. */ + Comfortable, +} + +/** The scale the timelines draw at, from the `timelineScale` preference. */ +val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular } + +/** + * Hour height for this scale. [viewportHeight] is the visible height of the + * scrolling timeline and is only consulted by [TimelineScale.FitDay]. + * + * The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop + * being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its + * blocks absurdly. On a screen too short for the whole day the clamp wins and + * the timeline still scrolls a little — honest, rather than unreadable. + */ +fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { + TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX) + TimelineScale.Compact -> 40.dp + TimelineScale.Regular -> 56.dp + TimelineScale.Comfortable -> 80.dp +} + +/** + * Shortest an event block may render, as a fraction of an hour. Blocks keep a + * floor so a 15-minute event stays tappable, but the floor scales with the hour + * height — a fixed 24dp would swallow half an hour once zoomed out and make + * short events overlap their neighbours. + */ +const val MIN_EVENT_FRACTION = 26f / 60f + +/** + * Narrowest an event block may be and still wrap its title over several lines. + * + * Wrapping is driven by the block's height, so a tall block on a lane-split + * column would otherwise stack two or three characters per line — "Da/ily", + * "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column + * clears this on any phone; a split one never does, while the day view's much + * wider columns keep wrapping even several lanes deep. + */ +val MIN_TITLE_WRAP_WIDTH = 36.dp + +/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ +val FIT_DAY_MIN = 24.dp + +/** Largest hour height [TimelineScale.FitDay] will resolve to. */ +val FIT_DAY_MAX = 96.dp + +@get:StringRes +val TimelineScale.labelRes: Int + get() = when (this) { + TimelineScale.FitDay -> R.string.timeline_scale_fit_day + TimelineScale.Compact -> R.string.timeline_scale_compact + TimelineScale.Regular -> R.string.timeline_scale_regular + TimelineScale.Comfortable -> R.string.timeline_scale_comfortable + } + +@get:StringRes +val TimelineScale.descriptionRes: Int + get() = when (this) { + TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary + TimelineScale.Compact -> R.string.timeline_scale_compact_summary + TimelineScale.Regular -> R.string.timeline_scale_regular_summary + TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 842486e..6ed0599 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -87,6 +87,9 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale +import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines @@ -100,13 +103,11 @@ import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt -private val HOUR_HEIGHT = 56.dp private val GUTTER_WIDTH = 48.dp /** Start inset for the gutter's hour labels so they centre on the top bar's * hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's * 4dp inset + 24dp half icon button), matching the week view. */ private val GUTTER_CONTENT_START_INSET = 8.dp -private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -264,7 +265,6 @@ private fun DayContent( onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val density = LocalDensity.current val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -275,11 +275,9 @@ private fun DayContent( val scrollState = rememberScrollState() LaunchedEffect(Unit) { 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) + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) } // Single, hoisted all-day strip height — shared by the outgoing and incoming @@ -485,12 +483,17 @@ private fun Timeline( onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, ) { - val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() + val scale = LocalTimelineScale.current - Box(modifier = Modifier.fillMaxSize()) { + // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the + // timeline's own viewport height, which is only known here — below the top + // bar, date header and all-day strip. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val hourHeight = scale.hourHeight(maxHeight) + val totalHeight = hourHeight * 24 // Gutter and day column are two scroll viewports that SHARE one scroll // state, so they stay perfectly aligned. The day-column viewport is a // static, rounded-clipped window — the content scrolls inside it, so the @@ -509,7 +512,7 @@ private fun Timeline( Box( modifier = Modifier .fillMaxWidth() - .height(HOUR_HEIGHT), + .height(hourHeight), ) { if (h > 0) { Text( @@ -537,6 +540,7 @@ private fun Timeline( dark = dark, date = state.date, today = state.today, + hourHeight = hourHeight, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -554,11 +558,12 @@ private fun DayColumnCard( dark: Boolean, date: LocalDate, today: LocalDate, + hourHeight: Dp, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( @@ -587,14 +592,16 @@ private fun DayColumnCard( }, ) { val colWidth = maxWidth + val minEventHeight = hourHeight * MIN_EVENT_FRACTION 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 + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight EventBlock( block = block, dark = dark, + height = height, onClick = { onEventClick(block.event) }, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) @@ -605,7 +612,7 @@ private fun DayColumnCard( } // Current-time line, on top of the events, only on today's column. if (date == today) { - NowLine(date = date, hourHeight = HOUR_HEIGHT) + NowLine(date = date, hourHeight = hourHeight) } } } @@ -615,6 +622,7 @@ private fun DayColumnCard( private fun EventBlock( block: TimedBlock, dark: Boolean, + height: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -623,7 +631,20 @@ private fun EventBlock( val locale = currentLocale() val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) - val showTime = block.endMin - block.startMin >= 45 + val density = LocalDensity.current + val titleLineHeight = with(density) { + MaterialTheme.typography.labelMedium.lineHeight.toDp() + } + val timeLineHeight = with(density) { + MaterialTheme.typography.labelSmall.lineHeight.toDp() + } + // What's left for text once the 2.dp top/bottom padding is paid for. A block + // that cannot afford both lines spends its space on the title, and one too + // short even for that drops the title rather than serving a sliced one. + val available = height - 4.dp + val showTime = block.endMin - block.startMin >= 45 && + available >= titleLineHeight + timeLineHeight + val showTitle = available >= titleLineHeight val soften = LocalSoftenColors.current val fill = eventFill(block.event.color, dark, soften) Box( @@ -634,13 +655,15 @@ private fun EventBlock( .semantics { contentDescription = "$title, $timeLabel" }, ) { Column { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = if (showTime) 1 else 2, - overflow = TextOverflow.Ellipsis, - color = eventInk(fill, alpha = 0.85f), - ) + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = if (showTime) 1 else 2, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = 0.85f), + ) + } if (showTime) { Text( text = timeLabel, @@ -656,17 +679,22 @@ private fun EventBlock( @Composable private fun DayLoading() { - val totalHeight = HOUR_HEIGHT * 24 + val scale = LocalTimelineScale.current val scrollState = rememberScrollState() - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { - Spacer(Modifier.width(GUTTER_WIDTH)) - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Same scale resolution as the loaded timeline, so the skeleton's column + // doesn't resize the moment the real day arrives. + val totalHeight = scale.hourHeight(maxHeight) * 24 + Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + Box( + modifier = Modifier + .weight(1f) + .height(totalHeight) + .padding(horizontal = 2.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5c5c19e..680fa5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -57,6 +58,8 @@ data class SettingsUiState( val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, + /** How tall an hour is drawn in the week and day timelines (#56). */ + val timelineScale: TimelineScale = TimelineScale.Regular, /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 0e6000d..27397fb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN @@ -164,8 +165,9 @@ class SettingsViewModel @Inject constructor( prefs.drawerViewOrder, prefs.monthViewStyle, prefs.widgetSize, - ) { quickSwitch, drawer, monthStyle, widgetSize -> - ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize) + prefs.timelineScale, + ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> + ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -189,6 +191,7 @@ class SettingsViewModel @Inject constructor( drawerViewOrder = misc.viewCustomization.drawerOrder, monthViewStyle = misc.viewCustomization.monthViewStyle, widgetSize = misc.viewCustomization.widgetSize, + timelineScale = misc.viewCustomization.timelineScale, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -295,6 +298,7 @@ class SettingsViewModel @Inject constructor( val drawerOrder: List, val monthViewStyle: MonthViewStyle, val widgetSize: WidgetSize, + val timelineScale: TimelineScale, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -581,6 +585,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setMonthViewStyle(style) } } + fun setTimelineScale(scale: TimelineScale) { + viewModelScope.launch { prefs.setTimelineScale(scale) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 9483132..fd1dc62 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -33,7 +33,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.common.descriptionRes import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes @@ -68,6 +70,7 @@ internal fun ViewsScreen( var showTimeFormat by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) } + var showTimelineScale by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_views), @@ -144,10 +147,16 @@ internal fun ViewsScreen( Spacer(Modifier.height(8.dp)) SectionHeader(stringResource(R.string.settings_week_day_header)) + GroupedRow( + title = stringResource(R.string.settings_timeline_scale), + summary = stringResource(state.timelineScale.labelRes), + position = Position.Top, + onClick = { showTimelineScale = true }, + ) GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), - position = Position.Alone, + position = Position.Bottom, trailing = { Switch( checked = state.showHourLines, @@ -321,6 +330,19 @@ internal fun ViewsScreen( onDismiss = { showPastEvents = false }, ) } + if (showTimelineScale) { + OptionPicker( + title = stringResource(R.string.settings_timeline_scale), + header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) }, + predictiveBack = true, + options = TimelineScale.entries, + selected = state.timelineScale, + label = { stringResource(it.labelRes) }, + summary = { stringResource(it.descriptionRes) }, + onSelect = viewModel::setTimelineScale, + onDismiss = { showTimelineScale = false }, + ) + } if (showAgendaScreenRange) { AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index ea5243c..319a102 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -97,6 +97,10 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale +import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH +import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines @@ -113,15 +117,12 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock 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 /** Start inset for the gutter's content (week badge + hour labels) so it centres * on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp * (the app bar's 4dp inset + 24dp half icon button). */ private val GUTTER_CONTENT_START_INSET = 8.dp -private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -294,7 +295,6 @@ private fun WeekContent( onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val density = LocalDensity.current val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -306,11 +306,9 @@ private fun WeekContent( val scrollState = rememberScrollState() LaunchedEffect(Unit) { 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) + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) } // Single, hoisted all-day strip height — shared by the outgoing and incoming @@ -620,12 +618,17 @@ private fun Timeline( onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, ) { - val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() + val scale = LocalTimelineScale.current - Box(modifier = Modifier.fillMaxSize()) { + // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the + // timeline's own viewport height, which is only known here — below the top + // bar, day header and all-day strip. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val hourHeight = scale.hourHeight(maxHeight) + val totalHeight = hourHeight * 24 // Gutter and day columns are two scroll viewports that SHARE one scroll // state, so they stay perfectly aligned. The day-column viewport is a // static, rounded-clipped window — the content scrolls inside it, so the @@ -645,7 +648,7 @@ private fun Timeline( Box( modifier = Modifier .fillMaxWidth() - .height(HOUR_HEIGHT), + .height(hourHeight), ) { if (h > 0) { Text( @@ -680,6 +683,7 @@ private fun Timeline( dark = dark, date = day, today = state.today, + hourHeight = hourHeight, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -699,11 +703,12 @@ private fun DayColumnCard( dark: Boolean, date: LocalDate, today: LocalDate, + hourHeight: Dp, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( @@ -731,15 +736,17 @@ private fun DayColumnCard( }, ) { val colWidth = maxWidth + val minEventHeight = hourHeight * MIN_EVENT_FRACTION 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 + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight EventBlock( block = block, dark = dark, height = height, + width = laneWidth, onClick = { onEventClick(block.event) }, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) @@ -750,7 +757,7 @@ private fun DayColumnCard( } // Current-time line, on top of the events, only on today's column. if (date == today) { - NowLine(date = date, hourHeight = HOUR_HEIGHT) + NowLine(date = date, hourHeight = hourHeight) } } } @@ -761,6 +768,7 @@ private fun EventBlock( block: TimedBlock, dark: Boolean, height: Dp, + width: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -769,10 +777,6 @@ private fun EventBlock( val locale = currentLocale() val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) - // Only full-width (non-overlapping) blocks that are tall enough show the time. - // On narrow overlapping columns we drop it so the title can wrap to fill the - // whole block, mirroring Google Calendar. - val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1 val density = LocalDensity.current val titleLineHeight = with(density) { MaterialTheme.typography.labelMedium.lineHeight.toDp() @@ -780,11 +784,30 @@ private fun EventBlock( val timeLineHeight = with(density) { MaterialTheme.typography.labelSmall.lineHeight.toDp() } - // Wrap the title across as many lines as the block can fit (minus the 2.dp - // top/bottom padding and the reserved time line) instead of clipping it to a - // single character on slim, overlapping blocks. - val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp - val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + // What's left for text once the 2.dp top/bottom padding is paid for. + val available = height - 4.dp + // Only full-width (non-overlapping) blocks that are tall enough show the + // time. On narrow overlapping columns we drop it so the title can wrap to + // fill the whole block, mirroring Google Calendar — and a block that cannot + // afford both lines spends its space on the title. + val showTime = block.endMin - block.startMin >= 45 && + block.laneCount == 1 && + available >= titleLineHeight + timeLineHeight + // A short block drops the title rather than serving a horizontally sliced + // one: half a letter reads as a rendering fault, while a bare colour chip + // reads as what it is — an event too brief to label. Tap still opens it, and + // the semantics description carries the full title either way. + val showTitle = available >= titleLineHeight + // Wrap the title across as many lines as the block can fit — but only once a + // line is wide enough to hold more than a syllable. Below that the extra + // lines just stack fragments of the word, and one ellipsised line reads + // better. + val contentHeight = available - if (showTime) timeLineHeight else 0.dp + val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { + 1 + } else { + (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val soften = LocalSoftenColors.current @@ -797,13 +820,15 @@ private fun EventBlock( .semantics { contentDescription = "$title, $timeLabel" }, ) { Column { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis, - color = eventInk(fill, alpha = 0.85f), - ) + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = 0.85f), + ) + } if (showTime) { Text( text = timeLabel, @@ -819,7 +844,7 @@ private fun EventBlock( @Composable private fun WeekLoading() { - val totalHeight = HOUR_HEIGHT * 24 + val scale = LocalTimelineScale.current val scrollState = rememberScrollState() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton @@ -838,16 +863,21 @@ private fun WeekLoading() { ) } } - 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), - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Same scale resolution as the loaded timeline, so the skeleton's + // columns don't resize the moment the real week arrives. + val totalHeight = scale.hourHeight(maxHeight) * 24 + 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), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cedc6ff..2b34eda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -363,6 +363,16 @@ Following the system: %1$s Hour lines Show a separator line at each hour in week and day view + Hour height + How much vertical space one hour takes in week and day view. Both views share this setting. + Fit whole day + All 24 hours on one screen, no scrolling + Compact + More hours per screen, smaller blocks + Regular + The standard spacing + Comfortable + Roomier blocks, more scrolling Dim completed events Fade events that have already ended in month and week view Past events diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 7693801..05f97b9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -98,6 +99,16 @@ class SettingsPrefsTest { assertThat(prefs.showHourLines.first()).isTrue() } + @Test + fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest { + // Regular is the historical 56dp scale — an existing install that never + // opened the setting must keep the timeline it had (#56). + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular) + prefs.setTimelineScale(TimelineScale.FitDay) + assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay) + } + @Test fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt new file mode 100644 index 0000000..cd394e6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt @@ -0,0 +1,74 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TimelineScaleTest { + + /** A Pixel-7-ish timeline viewport: what the issue reporter is looking at. */ + private val phoneViewport = 670.dp + + @Test + fun `Regular is the timeline's original 56dp constant`() { + // The default must not move: an install that never opens the setting has + // to keep the week and day views it already had (#56). + assertThat(TimelineScale.Regular.hourHeight(phoneViewport)).isEqualTo(56.dp) + } + + @Test + fun `the fixed scales ignore the viewport`() { + for (scale in listOf(TimelineScale.Compact, TimelineScale.Regular, TimelineScale.Comfortable)) { + assertThat(scale.hourHeight(200.dp)).isEqualTo(scale.hourHeight(2000.dp)) + } + } + + @Test + fun `the fixed scales get taller in listed order`() { + assertThat(TimelineScale.Compact.hourHeight(phoneViewport)) + .isLessThan(TimelineScale.Regular.hourHeight(phoneViewport)) + assertThat(TimelineScale.Regular.hourHeight(phoneViewport)) + .isLessThan(TimelineScale.Comfortable.hourHeight(phoneViewport)) + } + + @Test + fun `fit-day puts all 24 hours inside a phone viewport`() { + // The whole point of the issue: no vertical scrolling to see the day. + val h = TimelineScale.FitDay.hourHeight(phoneViewport) + assertThat(h * 24).isAtMost(phoneViewport) + // …and it uses the space, rather than leaving most of it empty. + assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f) + } + + @Test + fun `fit-day clamps instead of shrinking hours past legibility`() { + // A very short viewport (split screen, tiny device) would otherwise give + // hour rows too small for the gutter's 24 labels; the clamp wins and the + // timeline keeps a little scroll. + assertThat(TimelineScale.FitDay.hourHeight(120.dp)).isEqualTo(FIT_DAY_MIN) + } + + @Test + fun `fit-day clamps instead of stretching hours on a very tall viewport`() { + assertThat(TimelineScale.FitDay.hourHeight(4000.dp)).isEqualTo(FIT_DAY_MAX) + } + + @Test + fun `the minimum event height matches the old 24dp floor at the default scale`() { + // MIN_EVENT_FRACTION replaced a hardcoded 24dp; at Regular it must still + // land there, or short events change size for everyone who never touched + // the setting. + val floor = TimelineScale.Regular.hourHeight(phoneViewport) * MIN_EVENT_FRACTION + assertThat(floor.value).isWithin(0.5f).of(24f) + } + + @Test + fun `the minimum event height stays a fixed share of an hour`() { + // A fixed dp floor would swallow ever more of the day as the scale drops; + // as a fraction it always means the same duration. + for (scale in TimelineScale.entries) { + val hour = scale.hourHeight(phoneViewport) + assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION) + } + } +}