Compare commits
2 Commits
e49a2e027f
...
7dbaad4b00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dbaad4b00 | ||
|
|
5b5a5d5cd8 |
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user