diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 5ce56ce..df1d32e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -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", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index b0af4a9..02b8b4a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -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(null) } var editor by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } + var dayPreview by remember { mutableStateOf(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(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), + ) + } } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt new file mode 100644 index 0000000..3d47640 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt @@ -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, + onDismiss: () -> Unit, + onEventClick: (CalEvent) -> Unit, + onCreateEvent: () -> Unit, + onOpenDay: () -> Unit, + onOpenWeek: () -> Unit, +) { + val lang = LocalLang.current + val sorted = remember(events) { + events.sortedWith(compareByDescending { 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) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index c25055c..9632d53 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -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) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt index 59fdb9d..5417794 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -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")) }