Compare commits

...

2 Commits

Author SHA1 Message Date
Guido Schmit
7dbaad4b00 feat(android): birthday display, calendar creation and manual entry
- CalEvent parses is_birthday and exposes renderTitle (server age)
- EventLabel renders a cake icon + render title in month/week/day/agenda/preview
- LocalCalendar model + repository/api gain is_birthday, notify-days, rrule,
  birth_year; AccountsScreen can create a birthday calendar with a notify picker
- FAB long-press menu -> "New birthday" opens a minimal name+date dialog
  (year-unknown option) targeting a birthday calendar
- L10n strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
Guido Schmit
5b5a5d5cd8 chore(android): release signing via keystore.properties
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
16 changed files with 399 additions and 40 deletions

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") version "8.10.0" id("com.android.application") version "8.10.0"
id("org.jetbrains.kotlin.android") version "1.9.20" id("org.jetbrains.kotlin.android") version "1.9.20"
@@ -5,6 +8,14 @@ plugins {
id("com.google.dagger.hilt.android") version "2.49" id("com.google.dagger.hilt.android") version "2.49"
} }
// Release signing is driven by a git-ignored keystore.properties in the repo
// root (see keystore.properties.example). Absent (e.g. CI / fresh clone) the
// release build simply stays unsigned — debug builds are unaffected.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
}
android { android {
namespace = "com.scarriffle.calendarr" namespace = "com.scarriffle.calendarr"
compileSdk = 34 compileSdk = 34
@@ -20,10 +31,24 @@ android {
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (keystorePropsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }

View File

@@ -186,8 +186,17 @@ class CalendarRepository @Inject constructor(
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() } suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
suspend fun addLocalCalendar(name: String, color: String) = suspend fun addLocalCalendar(
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) } 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() } 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, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null,
) = guarded { ) = 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() .ensureSuccess()
} }
@@ -599,6 +609,7 @@ class CalendarRepository @Inject constructor(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -611,6 +622,8 @@ class CalendarRepository @Inject constructor(
if (!color.isNullOrBlank()) put("color", color) if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate) put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders)) if (reminders != null) put("reminders", org.json.JSONArray(reminders))
if (!rrule.isNullOrBlank()) put("rrule", rrule)
if (birthYear != null) put("birth_year", birthYear)
} }
) )
} }

View File

@@ -36,6 +36,8 @@ data class LocalCalendar(
val permission: String? = null, val permission: String? = null,
val group: Boolean = false, val group: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, @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) @JsonClass(generateAdapter = false)

View File

@@ -38,7 +38,16 @@ data class CalEvent(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
// True for events from a calendar shared with the user read-only. // True for events from a calendar shared with the user read-only.
val readOnly: Boolean = false, 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 * Group view supplies a server-resolved colour (display_color); otherwise
* per-event override colour, then the calendar's colour, then a stable * 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() } (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(), } ?: emptyList(),
readOnly = json.optBoolean("read_only", false), readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false),
) )
} }
} }

View File

@@ -135,6 +135,16 @@ object L10n {
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", "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.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
"local.create" to "Erstellen", "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.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.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.", "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", "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.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
"local.create" to "Create", "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.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.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", "ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min",

View File

@@ -233,8 +233,8 @@ fun AccountsScreen(
} }
when (addDialog) { when (addDialog) {
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color -> AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color, isBirthday, notify ->
vm.addLocal(name, color, onChanged); addDialog = null vm.addLocal(name, color, onChanged, isBirthday, notify); addDialog = null
} }
AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c -> AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c ->
vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null
@@ -488,12 +488,45 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
// ---- Add dialogs ---- // ---- Add dialogs ----
@Composable @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 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" 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()) 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 @Composable

View File

@@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor(
} }
} }
fun addLocal(name: String, color: String, onChanged: () -> Unit) = fun addLocal(
mutate(onChanged) { repository.addLocalCalendar(name, color) } 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) = fun deleteLocal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteLocalCalendar(id) } mutate(onChanged) { repository.deleteLocalCalendar(id) }

View File

@@ -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))) Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { 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()) { if (event.location.isNotBlank()) {
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }

View File

@@ -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<LocalCalendar>,
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)

View File

@@ -5,8 +5,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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) data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun CalendarScreen( fun CalendarScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
@@ -129,6 +131,9 @@ fun CalendarScreen(
var editor by remember { mutableStateOf<EditorRequest?>(null) } var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) } var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) } var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
var fabMenuOpen by remember { mutableStateOf(false) }
var showBirthday by remember { mutableStateOf(false) }
var birthdayCals by remember { mutableStateOf<List<com.scarriffle.calendarr.domain.model.LocalCalendar>>(emptyList()) }
// Continuous month scrolling // Continuous month scrolling
val monthListState = rememberLazyListState() val monthListState = rememberLazyListState()
@@ -167,15 +172,38 @@ fun CalendarScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
// 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( FloatingActionButton(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, onClick = {},
shape = CircleShape, shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.navigationBarsPadding(),
) { ) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) 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 // Edge-to-edge: we place the system-bar insets ourselves (top bar gets
// statusBarsPadding, the content column gets navigationBarsPadding) so // statusBarsPadding, the content column gets navigationBarsPadding) so
@@ -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. // Keep the last event during the close animation.
var lastDetail by remember { mutableStateOf<CalEvent?>(null) } var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
detailEvent?.let { lastDetail = it } detailEvent?.let { lastDetail = it }

View File

@@ -9,7 +9,9 @@ import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember 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.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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<LocalCalendar> =
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 * Banish ("permanently hide") a calendar, or lift the banish. Unlike the
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for

View File

@@ -154,13 +154,11 @@ private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Un
bottom = 4.dp, bottom = 4.dp,
), ),
) { ) {
Text( EventLabel(
event.title, event = event,
maxLines = 1, color = color.contrastingTextColor(),
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = color.contrastingTextColor(),
) )
} }
} }
@@ -186,10 +184,9 @@ private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit)
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).width(42.dp), modifier = Modifier.padding(start = 8.dp).width(42.dp),
) )
Text( EventLabel(
event.title, event = event,
maxLines = 1, color = MaterialTheme.colorScheme.onSurface,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View File

@@ -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),
)
}
}

View File

@@ -354,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () ->
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Text( EventLabel(
bar.event.title, event = bar.event,
maxLines = 1, color = bar.textColor,
overflow = TextOverflow.Ellipsis,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = bar.textColor,
) )
} }
} }

View File

@@ -167,13 +167,12 @@ private fun TimedEvent(
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = 4.dp, vertical = 2.dp),
) { ) {
Column { Column {
Text( EventLabel(
event.title, event = event,
fontSize = 10.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = color.contrastingTextColor(), color = color.contrastingTextColor(),
fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 2,
) )
if (height > 36.dp) { if (height > 36.dp) {
Text( Text(
@@ -212,7 +211,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp), .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)
} }
} }

View File

@@ -98,7 +98,7 @@ fun EventDetailScreen(
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { 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()) { if (event.calendarName.isNotBlank()) {
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }