diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 59d6a8f..9ef7771 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -186,8 +186,17 @@ class CalendarRepository @Inject constructor( suspend fun getLocalCalendars(): List = guarded { api.getLocalCalendars() } - suspend fun addLocalCalendar(name: String, color: String) = - guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) } + suspend fun addLocalCalendar( + name: String, color: String, + isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null, + ) = guarded { + api.addLocalCalendar(jsonBody(buildMap { + put("name", name) + put("color", color) + if (isBirthday) put("is_birthday", true) + if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore) + })) + } suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() } @@ -342,8 +351,9 @@ class CalendarRepository @Inject constructor( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean = false, reminders: List? = null, + rrule: String? = null, birthYear: Int? = null, ) = guarded { - api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear)) .ensureSuccess() } @@ -599,6 +609,7 @@ class CalendarRepository @Inject constructor( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean = false, reminders: List? = null, + rrule: String? = null, birthYear: Int? = null, ) = jsonBody( buildMap { calendarId?.let { put("calendar_id", it) } @@ -611,6 +622,8 @@ class CalendarRepository @Inject constructor( if (!color.isNullOrBlank()) put("color", color) put("private", isPrivate) if (reminders != null) put("reminders", org.json.JSONArray(reminders)) + if (!rrule.isNullOrBlank()) put("rrule", rrule) + if (birthYear != null) put("birth_year", birthYear) } ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt index fb10c17..602572b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -36,6 +36,8 @@ data class LocalCalendar( val permission: String? = null, val group: Boolean = false, @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, + @Json(name = "is_birthday") val isBirthday: Boolean = false, + @Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null, ) @JsonClass(generateAdapter = false) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 4bb0ff6..65c3e61 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -38,7 +38,16 @@ data class CalEvent( val reminders: List = emptyList(), // True for events from a calendar shared with the user read-only. val readOnly: Boolean = false, + // True for events from a birthday calendar — clients show a cake icon and + // the server bakes the age into `displayTitle`. + val isBirthday: Boolean = false, ) { + /** + * Title to render: the server-decorated one (birthday age, group prefix) + * wins over the raw title, which is kept for editing. + */ + val renderTitle: String + get() = displayTitle?.takeIf { it.isNotBlank() } ?: title /** * Group view supplies a server-resolved colour (display_color); otherwise * per-event override colour, then the calendar's colour, then a stable @@ -129,6 +138,7 @@ data class CalEvent( (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } } ?: emptyList(), readOnly = json.optBoolean("read_only", false), + isBirthday = json.optBoolean("is_birthday", false), ) } } 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 2cf4f81..8341d2b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -135,6 +135,16 @@ object L10n { "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", "local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe", "local.create" to "Erstellen", + "birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen", + "birthday.is_calendar" to "Geburtstagskalender", + "birthday.person" to "Name", "birthday.person_ph" to "Name der Person", + "birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt", + "birthday.target" to "Geburtstagskalender", + "birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".", + "birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus", + "birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher", + "birthday.notify.days" to "%d Tage vorher", + "birthday.created" to "Geburtstag „%s\" hinzugefügt", "ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL", "ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren", "ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.", @@ -281,6 +291,16 @@ object L10n { "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", "local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color", "local.create" to "Create", + "birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday", + "birthday.is_calendar" to "Birthday calendar", + "birthday.person" to "Name", "birthday.person_ph" to "Person's name", + "birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown", + "birthday.target" to "Birthday calendar", + "birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.", + "birthday.notify" to "Reminder", "birthday.notify.off" to "Off", + "birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before", + "birthday.notify.days" to "%d days before", + "birthday.created" to "Birthday \"%s\" added", "ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL", "ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe", "ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt index 66f905a..f5f2a3f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -233,8 +233,8 @@ fun AccountsScreen( } when (addDialog) { - AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color -> - vm.addLocal(name, color, onChanged); addDialog = null + AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color, isBirthday, notify -> + vm.addLocal(name, color, onChanged, isBirthday, notify); addDialog = null } AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c -> vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null @@ -488,14 +488,47 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () - // ---- Add dialogs ---- @Composable -private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { +@OptIn(ExperimentalMaterial3Api::class) +private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String, Boolean, Int?) -> Unit) { var name by remember { mutableStateOf("") } + var birthday by remember { mutableStateOf(false) } + var notify by remember { mutableStateOf(-1) } // -1 off, 0 on the day, N days before + var notifyMenu by remember { mutableStateOf(false) } val color = "#34a853" - FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) { + FormDialog( + tr("accounts.local.add"), onDismiss, + confirmEnabled = name.isNotBlank(), + onConfirm = { onConfirm(name.trim(), color, birthday, if (birthday && notify >= 0) notify else null) }, + ) { OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + FilterChip(selected = birthday, onClick = { birthday = !birthday }, label = { Text(tr("birthday.is_calendar")) }) + if (birthday) { + Spacer(Modifier.size(8.dp)) + Box { + FilterChip( + selected = notify >= 0, + onClick = { notifyMenu = true }, + label = { Text("${tr("birthday.notify")}: ${notifyLabel(notify)}") }, + ) + DropdownMenu(expanded = notifyMenu, onDismissRequest = { notifyMenu = false }) { + listOf(-1, 0, 1, 2, 3, 7).forEach { d -> + DropdownMenuItem(text = { Text(notifyLabel(d)) }, onClick = { notify = d; notifyMenu = false }) + } + } + } + } } } +@Composable +private fun notifyLabel(d: Int): String = when { + d < 0 -> tr("birthday.notify.off") + d == 0 -> tr("birthday.notify.same_day") + d == 1 -> tr("birthday.notify.one_day") + else -> tr("birthday.notify.days", d) +} + @Composable private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) { var name by remember { mutableStateOf("") } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt index e2b70a8..3654f99 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor( } } - fun addLocal(name: String, color: String, onChanged: () -> Unit) = - mutate(onChanged) { repository.addLocalCalendar(name, color) } + fun addLocal( + name: String, color: String, onChanged: () -> Unit, + isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null, + ) = mutate(onChanged) { + repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore) + } fun deleteLocal(id: Int, onChanged: () -> Unit) = mutate(onChanged) { repository.deleteLocalCalendar(id) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt index 6c3cfe4..f1b1376 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt @@ -98,7 +98,12 @@ private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: ( Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { - Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + EventLabel( + event = event, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + fontWeight = FontWeight.Medium, + ) if (event.location.isNotBlank()) { Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt new file mode 100644 index 0000000..b452937 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt @@ -0,0 +1,127 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.ui.tr +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Minimal "new birthday" mask (parity with iOS/web): pick a birthday calendar, + * enter a name and a date (with an optional "year unknown"). Saves an all-day, + * yearly-recurring local event; the server adds the age suffix and cake icon. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BirthdayDialog( + calendars: List, + onDismiss: () -> Unit, + onSave: (name: String, date: LocalDate, yearKnown: Boolean, calendarId: Int) -> Unit, +) { + var name by remember { mutableStateOf("") } + var yearUnknown by remember { mutableStateOf(false) } + var selectedCalId by remember { mutableStateOf(calendars.firstOrNull()?.id ?: -1) } + var pickedDate by remember { mutableStateOf(LocalDate.now()) } + var showPicker by remember { mutableStateOf(false) } + var calMenu by remember { mutableStateOf(false) } + + val hasCalendars = calendars.isNotEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(tr("birthday.new_title")) }, + text = { + Column { + if (!hasCalendars) { + Text(tr("birthday.no_calendars"), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + OutlinedTextField( + name, { name = it }, + label = { Text(tr("birthday.person")) }, + singleLine = true, modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + FilterChip( + selected = true, onClick = { showPicker = true }, + label = { Text("${tr("birthday.date")}: ${formatBirthday(pickedDate, yearUnknown)}") }, + ) + Spacer(Modifier.size(8.dp)) + FilterChip( + selected = yearUnknown, onClick = { yearUnknown = !yearUnknown }, + label = { Text(tr("birthday.year_unknown")) }, + ) + if (calendars.size > 1) { + Spacer(Modifier.size(8.dp)) + Box { + FilterChip( + selected = false, onClick = { calMenu = true }, + label = { Text(calendars.firstOrNull { it.id == selectedCalId }?.name ?: tr("birthday.target")) }, + ) + DropdownMenu(expanded = calMenu, onDismissRequest = { calMenu = false }) { + calendars.forEach { c -> + DropdownMenuItem(text = { Text(c.name) }, onClick = { selectedCalId = c.id; calMenu = false }) + } + } + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = hasCalendars && name.isNotBlank(), + onClick = { onSave(name.trim(), pickedDate, !yearUnknown, selectedCalId) }, + ) { Text(tr("common.save")) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } }, + ) + + if (showPicker) { + val dpState = rememberDatePickerState( + initialSelectedDateMillis = pickedDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton(onClick = { + dpState.selectedDateMillis?.let { millis -> + pickedDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() + } + showPicker = false + }) { Text(tr("common.save")) } + }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text(tr("common.cancel")) } }, + ) { DatePicker(state = dpState) } + } +} + +private val DAY_MONTH = DateTimeFormatter.ofPattern("dd.MM.") +private val DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("dd.MM.yyyy") + +private fun formatBirthday(date: LocalDate, yearUnknown: Boolean): String = + date.format(if (yearUnknown) DAY_MONTH else DAY_MONTH_YEAR) 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 8e5b58c..7ad8a32 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 @@ -5,8 +5,10 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -78,7 +80,7 @@ private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS } data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( onLogout: () -> Unit, @@ -129,6 +131,9 @@ fun CalendarScreen( var editor by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } var dayPreview by remember { mutableStateOf(null) } + var fabMenuOpen by remember { mutableStateOf(false) } + var showBirthday by remember { mutableStateOf(false) } + var birthdayCals by remember { mutableStateOf>(emptyList()) } // Continuous month scrolling val monthListState = rememberLazyListState() @@ -167,14 +172,37 @@ fun CalendarScreen( ) }, floatingActionButton = { - FloatingActionButton( - onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.navigationBarsPadding(), - ) { - Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) + // Tap = new event; long-press opens a small menu (new event / new + // birthday). A transparent overlay carries the combined click so the + // long-press is reliable on the Material FAB. + Box(Modifier.navigationBarsPadding()) { + FloatingActionButton( + onClick = {}, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) + } + Box( + Modifier + .matchParentSize() + .clip(CircleShape) + .combinedClickable( + onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + onLongClick = { fabMenuOpen = true }, + ), + ) + DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) { + DropdownMenuItem( + text = { Text(tr("cal.new_event")) }, + onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + ) + DropdownMenuItem( + text = { Text(tr("birthday.new")) }, + onClick = { fabMenuOpen = false; showBirthday = true }, + ) + } } }, // Edge-to-edge: we place the system-bar insets ourselves (top bar gets @@ -251,6 +279,18 @@ fun CalendarScreen( ) } + if (showBirthday) { + androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCals = vm.birthdayCalendars() } + BirthdayDialog( + calendars = birthdayCals, + onDismiss = { showBirthday = false }, + onSave = { name, date, yearKnown, calId -> + vm.createBirthday(calId, name, date, yearKnown) {} + showBirthday = false + }, + ) + } + // Keep the last event during the close animation. var lastDetail by remember { mutableStateOf(null) } detailEvent?.let { lastDetail = it } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 91c217c..9fa4515 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -9,7 +9,9 @@ import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.GroupMember +import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.util.Dates import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -501,6 +503,33 @@ class CalendarViewModel @Inject constructor( } } + /** Birthday calendars the user may write to (own or shared read/write). */ + suspend fun birthdayCalendars(): List = + runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()) + .filter { it.isBirthday && (it.owned || it.permission == "read_write") } + + /** + * Create a manual birthday: an all-day, yearly-recurring local event whose + * age suffix and cake icon are added server-side. When the year is unknown + * we anchor to 1970 and send no birth_year (so no age is shown). + */ + fun createBirthday( + calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit, + ) { + viewModelScope.launch { + val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth) + val start = Dates.startOfDay(anchor) + val end = Dates.startOfDay(anchor.plusDays(1)) + runCatching { + repository.createLocalEvent( + calendarId, name, start, end, isAllDay = true, + location = "", description = "", color = null, + rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null, + ) + }.onSuccess { loadVisible(force = true); onDone() } + } + } + /** * Banish ("permanently hide") a calendar, or lift the banish. Unlike the * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for 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 index 3d47640..2a7bced 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt @@ -154,13 +154,11 @@ private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Un bottom = 4.dp, ), ) { - Text( - event.title, - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + EventLabel( + event = event, + color = color.contrastingTextColor(), fontSize = 12.sp, fontWeight = FontWeight.Medium, - color = color.contrastingTextColor(), ) } } @@ -186,10 +184,9 @@ private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) 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, + EventLabel( + event = event, + color = MaterialTheme.colorScheme.onSurface, fontSize = 12.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt new file mode 100644 index 0000000..e2e7be7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt @@ -0,0 +1,57 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cake +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent + +/** + * Event label for the calendar grids: an optional leading cake icon for + * birthday events, followed by the render title (which carries the + * server-computed age). The icon inherits the text colour/size so the label + * matches whatever bar it's dropped into. + */ +@Composable +fun EventLabel( + event: CalEvent, + color: Color, + fontSize: TextUnit = 10.sp, + fontWeight: FontWeight = FontWeight.Medium, + maxLines: Int = 1, + modifier: Modifier = Modifier, +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + if (event.isBirthday) { + Icon( + Icons.Filled.Cake, + contentDescription = null, + tint = color, + modifier = Modifier + .size(fontSize.value.dp) + .padding(end = 2.dp), + ) + } + Text( + event.renderTitle, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + fontSize = fontSize, + fontWeight = fontWeight, + color = color, + modifier = Modifier.weight(1f, fill = false), + ) + } +} 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 202eb4d..7484ca5 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 @@ -354,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () -> .padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart, ) { - Text( - bar.event.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + EventLabel( + event = bar.event, + color = bar.textColor, fontSize = 10.sp, fontWeight = FontWeight.Medium, - color = bar.textColor, ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt index a55b4d6..13d8b47 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt @@ -167,13 +167,12 @@ private fun TimedEvent( .padding(horizontal = 4.dp, vertical = 2.dp), ) { Column { - Text( - event.title, - fontSize = 10.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + EventLabel( + event = event, color = color.contrastingTextColor(), + fontSize = 10.sp, fontWeight = FontWeight.Medium, + maxLines = 2, ) if (height > 36.dp) { Text( @@ -212,7 +211,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) { .clickable(onClick = onClick) .padding(horizontal = 3.dp, vertical = 1.dp), ) { - Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor()) + EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp) } } 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 5417794..893cb78 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 @@ -98,7 +98,7 @@ fun EventDetailScreen( ) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { - Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) if (event.calendarName.isNotBlank()) { Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }