feat(android): single birthday-calendar model + group-visible exclusion
- one birthday calendar per user: BirthdayDialog targets the single calendar and
offers activation when none exists; VM birthdayCalendar()/ensureBirthdayCalendar()
- removed the birthday toggle from the generic new-calendar dialog
- exclude the birthday calendar from the group-visible ("shared calendar") picker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,9 @@ object L10n {
|
|||||||
"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.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
|
||||||
|
"birthday.calendar_name" to "Geburtstage",
|
||||||
|
"birthday.activate" to "Geburtstagskalender aktivieren",
|
||||||
|
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||||
"birthday.is_calendar" to "Geburtstagskalender",
|
"birthday.is_calendar" to "Geburtstagskalender",
|
||||||
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
|
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
|
||||||
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
|
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
|
||||||
@@ -292,6 +295,9 @@ object L10n {
|
|||||||
"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.new" to "New birthday", "birthday.new_title" to "Add new birthday",
|
||||||
|
"birthday.calendar_name" to "Birthdays",
|
||||||
|
"birthday.activate" to "Enable birthday calendar",
|
||||||
|
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||||
"birthday.is_calendar" to "Birthday calendar",
|
"birthday.is_calendar" to "Birthday calendar",
|
||||||
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
|
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
|
||||||
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
|
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ fun AccountsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (addDialog) {
|
when (addDialog) {
|
||||||
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color, isBirthday, notify ->
|
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color ->
|
||||||
vm.addLocal(name, color, onChanged, isBirthday, notify); addDialog = null
|
vm.addLocal(name, color, onChanged); 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,45 +488,12 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
|
|||||||
// ---- Add dialogs ----
|
// ---- Add dialogs ----
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
|
||||||
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(
|
FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) {
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.scarriffle.calendarr.ui.calendar
|
package com.scarriffle.calendarr.ui.calendar
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -8,8 +7,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.DatePicker
|
import androidx.compose.material3.DatePicker
|
||||||
import androidx.compose.material3.DatePickerDialog
|
import androidx.compose.material3.DatePickerDialog
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -32,33 +29,31 @@ import java.time.ZoneOffset
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal "new birthday" mask (parity with iOS/web): pick a birthday calendar,
|
* Minimal "new birthday" mask for the user's single birthday calendar: name +
|
||||||
* enter a name and a date (with an optional "year unknown"). Saves an all-day,
|
* date (with an optional "year unknown"). Saves an all-day, yearly-recurring
|
||||||
* yearly-recurring local event; the server adds the age suffix and cake icon.
|
* local event; the server adds the age suffix and cake icon. If no birthday
|
||||||
|
* calendar exists yet, offers to activate one.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BirthdayDialog(
|
fun BirthdayDialog(
|
||||||
calendars: List<LocalCalendar>,
|
calendar: LocalCalendar?,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (name: String, date: LocalDate, yearKnown: Boolean, calendarId: Int) -> Unit,
|
onActivate: () -> Unit,
|
||||||
|
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var yearUnknown by remember { mutableStateOf(false) }
|
var yearUnknown by remember { mutableStateOf(false) }
|
||||||
var selectedCalId by remember { mutableStateOf(calendars.firstOrNull()?.id ?: -1) }
|
|
||||||
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
|
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||||
var showPicker by remember { mutableStateOf(false) }
|
var showPicker by remember { mutableStateOf(false) }
|
||||||
var calMenu by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val hasCalendars = calendars.isNotEmpty()
|
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(tr("birthday.new_title")) },
|
title = { Text(tr("birthday.new_title")) },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
if (!hasCalendars) {
|
if (calendar == null) {
|
||||||
Text(tr("birthday.no_calendars"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
} else {
|
} else {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
name, { name = it },
|
name, { name = it },
|
||||||
@@ -75,28 +70,18 @@ fun BirthdayDialog(
|
|||||||
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
|
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
|
||||||
label = { Text(tr("birthday.year_unknown")) },
|
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 = {
|
confirmButton = {
|
||||||
|
if (calendar == null) {
|
||||||
|
TextButton(onClick = onActivate) { Text(tr("birthday.activate")) }
|
||||||
|
} else {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = hasCalendars && name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
onClick = { onSave(name.trim(), pickedDate, !yearUnknown, selectedCalId) },
|
onClick = { onSave(name.trim(), pickedDate, !yearUnknown) },
|
||||||
) { Text(tr("common.save")) }
|
) { Text(tr("common.save")) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
|
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -74,6 +75,7 @@ import com.scarriffle.calendarr.ui.menu.MenuSheet
|
|||||||
import com.scarriffle.calendarr.ui.profile.ProfileScreen
|
import com.scarriffle.calendarr.ui.profile.ProfileScreen
|
||||||
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
|
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
|
||||||
@@ -133,7 +135,9 @@ fun CalendarScreen(
|
|||||||
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
var fabMenuOpen by remember { mutableStateOf(false) }
|
var fabMenuOpen by remember { mutableStateOf(false) }
|
||||||
var showBirthday by remember { mutableStateOf(false) }
|
var showBirthday by remember { mutableStateOf(false) }
|
||||||
var birthdayCals by remember { mutableStateOf<List<com.scarriffle.calendarr.domain.model.LocalCalendar>>(emptyList()) }
|
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
|
||||||
|
val birthdayScope = rememberCoroutineScope()
|
||||||
|
val birthdayCalName = tr("birthday.calendar_name")
|
||||||
|
|
||||||
// Continuous month scrolling
|
// Continuous month scrolling
|
||||||
val monthListState = rememberLazyListState()
|
val monthListState = rememberLazyListState()
|
||||||
@@ -280,12 +284,13 @@ fun CalendarScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showBirthday) {
|
if (showBirthday) {
|
||||||
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCals = vm.birthdayCalendars() }
|
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
|
||||||
BirthdayDialog(
|
BirthdayDialog(
|
||||||
calendars = birthdayCals,
|
calendar = birthdayCal,
|
||||||
onDismiss = { showBirthday = false },
|
onDismiss = { showBirthday = false },
|
||||||
onSave = { name, date, yearKnown, calId ->
|
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
|
||||||
vm.createBirthday(calId, name, date, yearKnown) {}
|
onSave = { name, date, yearKnown ->
|
||||||
|
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
|
||||||
showBirthday = false
|
showBirthday = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -503,10 +503,18 @@ class CalendarViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Birthday calendars the user may write to (own or shared read/write). */
|
/** The user's single birthday calendar, or null if not activated yet. */
|
||||||
suspend fun birthdayCalendars(): List<LocalCalendar> =
|
suspend fun birthdayCalendar(): LocalCalendar? =
|
||||||
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
|
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
|
||||||
.filter { it.isBirthday && (it.owned || it.permission == "read_write") }
|
.firstOrNull { it.isBirthday && it.owned }
|
||||||
|
|
||||||
|
/** The birthday calendar, creating the single one (named [name]) if none. */
|
||||||
|
suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? {
|
||||||
|
birthdayCalendar()?.let { return it }
|
||||||
|
return runCatching {
|
||||||
|
repository.addLocalCalendar(name, "#E0407F", isBirthday = true)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a manual birthday: an all-day, yearly-recurring local event whose
|
* Create a manual birthday: an all-day, yearly-recurring local event whose
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
||||||
}
|
}
|
||||||
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
|
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
|
||||||
ownLocalCalendars = cals.filter { it.owned && !it.group }
|
// A birthday calendar may be shared directly, but never stand in
|
||||||
|
// as the group-visible personal calendar.
|
||||||
|
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user