feat: day preview popup, smoother month scrolling, UI polish

- Long-press on a day now opens a preview popup (ported from the iOS
  DayContextPreviewView): all-day events as colored bars with tapered
  ends when they continue beyond the day, timed events as dot+time+title
  rows, plus quick actions (new event, week view, day view)
- Month scroll performance: stable item keys + contentType in the
  LazyColumn, remembered Instant.now()/theme colors, remembered callbacks
  so visible week rows stay skippable during recomposition
- Fix "+N" and "KW" labels overlapping the lowest event lane (row bottom
  padding 6dp -> 16dp, mirrors the iOS fix)
- Event detail: delete is now a quiet red text button instead of a
  full-width filled container
- Top bar: hairline divider so the bar/calendar boundary is visible
- FAB: explicit circular shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-06-12 20:37:49 +02:00
parent 6654012cbb
commit e7bed5c5a8
5 changed files with 314 additions and 18 deletions

View File

@@ -38,6 +38,9 @@ object L10n {
"cal.no_events_title" to "Keine Termine",
"cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.",
"cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis",
"cal.show_in_day_view" to "In Tagesansicht öffnen",
"cal.show_in_week_view" to "In Wochenansicht öffnen",
"cal.no_events_day" to "Keine Termine an diesem Tag",
"menu.section.settings" to "Einstellungen", "menu.profile" to "Profil",
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
"menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin",
@@ -165,6 +168,9 @@ object L10n {
"cal.no_events_title" to "No events",
"cal.no_events_body" to "No events in the next 90 days.",
"cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event",
"cal.show_in_day_view" to "Open in day view",
"cal.show_in_week_view" to "Open in week view",
"cal.no_events_day" to "No events on this day",
"menu.section.settings" to "Settings", "menu.profile" to "Profile",
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
"menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin",

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -94,6 +95,7 @@ fun CalendarScreen(
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
// Continuous month scrolling
val monthListState = rememberLazyListState()
@@ -137,6 +139,7 @@ fun CalendarScreen(
floatingActionButton = {
FloatingActionButton(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
@@ -152,6 +155,8 @@ fun CalendarScreen(
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
Box(Modifier.fillMaxSize()) {
// remember: stable callbacks keep the lazily composed week rows
// skippable during scroll (fresh lambdas would recompose them all).
CalendarBody(
state = state,
vm = vm,
@@ -159,10 +164,10 @@ fun CalendarScreen(
scrollToTodaySignal = todaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = { visibleMonth = it },
onEventClick = { detailEvent = it },
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
onDayLongPress = { date -> editor = EditorRequest(null, date) },
onVisibleMonthChange = remember { { visibleMonth = it } },
onEventClick = remember { { detailEvent = it } },
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
onDayLongPress = remember { { date -> dayPreview = date } },
)
}
}
@@ -192,6 +197,18 @@ fun CalendarScreen(
)
}
dayPreview?.let { date ->
DayPreviewDialog(
date = date,
events = remember(state.events, date) { vm.eventsOn(date, state.events) },
onDismiss = { dayPreview = null },
onEventClick = { ev -> dayPreview = null; detailEvent = ev },
onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) },
onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) },
onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) },
)
}
// Keep the last event during the close animation.
var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
detailEvent?.let { lastDetail = it }
@@ -330,6 +347,7 @@ private fun CompactTopBar(
) {
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
Surface(color = MaterialTheme.colorScheme.background) {
Column {
Row(
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -373,6 +391,11 @@ private fun CompactTopBar(
}
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
}
Divider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
}
}

View File

@@ -0,0 +1,258 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
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.Add
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.TextStyle
/**
* Long-press day preview (mirrors the iOS DayContextPreviewView): all-day
* events as colored bars with tapered ends when they continue beyond this day,
* timed events as dot + time + title rows, followed by quick actions.
*/
@Composable
fun DayPreviewDialog(
date: LocalDate,
events: List<CalEvent>,
onDismiss: () -> Unit,
onEventClick: (CalEvent) -> Unit,
onCreateEvent: () -> Unit,
onOpenDay: () -> Unit,
onOpenWeek: () -> Unit,
) {
val lang = LocalLang.current
val sorted = remember(events) {
events.sortedWith(compareByDescending<CalEvent> { it.isAllDay }.thenBy { it.startDate })
}
val weekdayAbbr = remember(date, lang) {
date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase()
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(
Modifier
.width(290.dp)
.padding(vertical = 14.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.Bottom,
) {
Text(
weekdayAbbr,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp, bottom = 3.dp),
)
Text(
"${date.dayOfMonth}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
if (sorted.isEmpty()) {
Text(
tr("cal.no_events_day"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
} else {
sorted.forEach { ev ->
if (ev.isAllDay) {
AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) })
} else {
TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) })
}
}
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent)
PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek)
PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay)
}
}
}
}
@Composable
private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor)
val startDay = localDate(event.startDate)
val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive
val cLeft = startDay.isBefore(date)
val cRight = lastDay.isAfter(date)
val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) }
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp)
.clip(shape)
.background(color)
.clickable(onClick = onClick)
.padding(
start = if (cLeft) 14.dp else 8.dp,
end = if (cRight) 14.dp else 8.dp,
top = 4.dp,
bottom = 4.dp,
),
) {
Text(
event.title,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = color.contrastingTextColor(),
)
}
}
@Composable
private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(colorFromHex(event.effectiveColor)),
)
Text(
timeLabel(event.startDate, lang),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).width(42.dp),
)
Text(
event.title,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
/**
* Bar shape with tapered (pointed) ends on the sides where the event continues
* beyond the shown day — port of the iOS ChevronBarShape.
*/
private class ChevronBarShape(
private val continuesLeft: Boolean,
private val continuesRight: Boolean,
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val r = with(density) { 4.dp.toPx() }
if (!continuesLeft && !continuesRight) {
return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r)))
}
val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2)
val p = Path()
if (continuesLeft && continuesRight) {
p.moveTo(t, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else if (continuesLeft) {
p.moveTo(t, 0f)
p.lineTo(size.width, 0f)
p.lineTo(size.width, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else {
p.moveTo(0f, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(0f, size.height)
}
p.close()
return Outline.Generic(p)
}
}

View File

@@ -62,7 +62,8 @@ private const val MAX_LANES = 4
private val DAY_NUM_H = 22.dp
private val LANE_H = 15.dp
private val LANE_SPACE = 2.dp
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp
// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them.
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp
private enum class DividerEdge { NONE, TOP, BOTTOM }
@@ -97,11 +98,14 @@ fun MonthView(
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0))
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
val todayColor = colorFromHex(settings.todayColor)
val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant
val outline = MaterialTheme.colorScheme.outline
val onBackground = MaterialTheme.colorScheme.onBackground
val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) }
val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) }
val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) }
val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) }
val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) }
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
val density = LocalDensity.current
@@ -144,7 +148,10 @@ fun MonthView(
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
val dimPast = settings.dimPastEvents
val now = java.time.Instant.now()
val cwLabel = tr("cal.cw")
// remember: a fresh Instant per recomposition would invalidate every visible
// WeekRow (the main source of scroll jank); minute precision is plenty here.
val now = remember { java.time.Instant.now() }
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
@@ -163,7 +170,7 @@ fun MonthView(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount) { index ->
items(weekCount, key = { it }, contentType = { "week" }) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
weekStart = weekStart,
@@ -173,6 +180,7 @@ fun MonthView(
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
@@ -196,6 +204,7 @@ private fun WeekRow(
dimPast: Boolean,
now: java.time.Instant,
lang: String,
cwLabel: String,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
@@ -208,7 +217,6 @@ private fun WeekRow(
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
val rowStartsNewMonth = days[0].dayOfMonth == 1
val cwLabel = tr("cal.cw")
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }

View File

@@ -28,7 +28,6 @@ import androidx.compose.material.icons.filled.Notes
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -126,13 +125,15 @@ fun EventDetailScreen(
Text(tr("event.copy_title"))
}
if (canDelete) {
Spacer(Modifier.height(12.dp))
Button(
Spacer(Modifier.height(4.dp))
// Deliberately low-key (plain text button): destructive action
// should be findable, not the loudest element on the screen.
TextButton(
onClick = { confirmDelete = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null)
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(tr("common.delete"))
}