feat: Android Ersteller-Anzeige + Privat-Flag

Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis; Editor hat
Privat-Toggle (nur lokale Kalender, durch saveEvent/Repo durchgereicht).
Login speichert userId + displayName (CredentialStore) für Vergleiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 21:54:19 +02:00
parent d10a4dc79f
commit 4a44c20b12
8 changed files with 60 additions and 15 deletions

View File

@@ -46,6 +46,9 @@ class CalendarRepository @Inject constructor(
) { ) {
private val api get() = apiProvider.api() private val api get() = apiProvider.api()
/** Current logged-in user id (0 if unknown) — for creator/owner comparisons. */
val currentUserId: Int get() = credentialStore.userId
private suspend fun <T> guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) { private suspend fun <T> guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) {
try { try {
block() block()
@@ -93,9 +96,11 @@ class CalendarRepository @Inject constructor(
val user = json.optJSONObject("user") val user = json.optJSONObject("user")
val uname = user?.optString("username") ?: username val uname = user?.optString("username") ?: username
val isAdmin = user?.optBoolean("is_admin", false) ?: false val isAdmin = user?.optBoolean("is_admin", false) ?: false
val uid = user?.optInt("id", 0) ?: 0
val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname
credentialStore.serverUrl = ApiProvider.normalize(baseUrl) credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin) credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
apiProvider.invalidate() apiProvider.invalidate()
LoginResult(token, uname, isAdmin) LoginResult(token, uname, isAdmin)
} }

View File

@@ -46,17 +46,27 @@ class CredentialStore @Inject constructor(
get() = prefs.getBoolean(KEY_IS_ADMIN, false) get() = prefs.getBoolean(KEY_IS_ADMIN, false)
set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply() set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply()
var userId: Int
get() = prefs.getInt(KEY_USER_ID, 0)
set(value) = prefs.edit().putInt(KEY_USER_ID, value).apply()
var displayName: String?
get() = prefs.getString(KEY_DISPLAY_NAME, null)
set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply()
/** True once a server URL has been entered (setup step complete). */ /** True once a server URL has been entered (setup step complete). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
/** True once we hold an auth token (logged in). */ /** True once we hold an auth token (logged in). */
val isLoggedIn: Boolean get() = !token.isNullOrBlank() val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveLogin(token: String, username: String, isAdmin: Boolean) { fun saveLogin(token: String, username: String, isAdmin: Boolean, userId: Int = 0, displayName: String? = null) {
prefs.edit() prefs.edit()
.putString(KEY_TOKEN, token) .putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username) .putString(KEY_USERNAME, username)
.putBoolean(KEY_IS_ADMIN, isAdmin) .putBoolean(KEY_IS_ADMIN, isAdmin)
.putInt(KEY_USER_ID, userId)
.putString(KEY_DISPLAY_NAME, displayName ?: username)
.apply() .apply()
} }
@@ -79,5 +89,7 @@ class CredentialStore @Inject constructor(
const val KEY_TOKEN = "auth_token" const val KEY_TOKEN = "auth_token"
const val KEY_USERNAME = "username" const val KEY_USERNAME = "username"
const val KEY_IS_ADMIN = "is_admin" const val KEY_IS_ADMIN = "is_admin"
const val KEY_USER_ID = "user_id"
const val KEY_DISPLAY_NAME = "display_name"
} }
} }

View File

@@ -30,13 +30,16 @@ data class CalEvent(
// Only set in the group combined view: // Only set in the group combined view:
val owner: EventPerson? = null, val owner: EventPerson? = null,
val isGroupEvent: Boolean = false, val isGroupEvent: Boolean = false,
val displayColor: String? = null,
) { ) {
/** /**
* Per-event override colour, then the calendar's colour, then a stable * Group view supplies a server-resolved colour (display_color); otherwise
* per-event override colour, then the calendar's colour, then a stable
* per-calendar palette colour (so events never collapse to one default). * per-calendar palette colour (so events never collapse to one default).
*/ */
val effectiveColor: String val effectiveColor: String
get() = color?.takeIf { it.isNotBlank() } get() = displayColor?.takeIf { it.isNotBlank() }
?: color?.takeIf { it.isNotBlank() }
?: calendarColor.takeIf { it.isNotBlank() } ?: calendarColor.takeIf { it.isNotBlank() }
?: fallbackColorFor("$source:$calendarId") ?: fallbackColorFor("$source:$calendarId")
@@ -113,6 +116,7 @@ data class CalEvent(
isPrivate = json.optBoolean("private", false), isPrivate = json.optBoolean("private", false),
owner = personFrom(json, "owner"), owner = personFrom(json, "owner"),
isGroupEvent = json.optBoolean("is_group_event", false), isGroupEvent = json.optBoolean("is_group_event", false),
displayColor = json.strOrNull("display_color"),
) )
} }
} }

View File

@@ -87,7 +87,7 @@ object L10n {
"twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren", "twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren",
"twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort", "twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort",
"twofa.disable" to "Deaktivieren", "twofa.disable" to "Deaktivieren",
"event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.private" to "Privat", "event.created_by" to "Erstellt von",
"event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort", "event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort",
"event.description" to "Beschreibung", "event.calendar_section" to "Kalender", "event.description" to "Beschreibung", "event.calendar_section" to "Kalender",
"event.no_writable" to "Keine beschreibbaren Kalender vorhanden", "event.no_writable" to "Keine beschreibbaren Kalender vorhanden",
@@ -188,7 +188,7 @@ object L10n {
"twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate", "twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate",
"twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password", "twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password",
"twofa.disable" to "Disable", "twofa.disable" to "Disable",
"event.title_placeholder" to "Title", "event.allday" to "All-day", "event.title_placeholder" to "Title", "event.allday" to "All-day", "event.private" to "Private", "event.created_by" to "Created by",
"event.start" to "Start", "event.end" to "End", "event.location" to "Location", "event.start" to "Start", "event.end" to "End", "event.location" to "Location",
"event.description" to "Description", "event.calendar_section" to "Calendar", "event.description" to "Description", "event.calendar_section" to "Calendar",
"event.no_writable" to "No writable calendars available", "event.no_writable" to "No writable calendars available",

View File

@@ -188,6 +188,7 @@ fun CalendarScreen(
if (ev != null) { if (ev != null) {
EventDetailScreen( EventDetailScreen(
event = ev, event = ev,
currentUserId = vm.currentUserId,
onClose = { detailEvent = null }, onClose = { detailEvent = null },
onEdit = { onEdit = {
detailEvent = null detailEvent = null
@@ -210,8 +211,8 @@ fun CalendarScreen(
request = req, request = req,
writableCalendars = state.writableCalendars, writableCalendars = state.writableCalendars,
onDismiss = { editor = null }, onDismiss = { editor = null },
onSave = { cal, title, start, end, allDay, location, desc, color -> onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error -> vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
if (error == null) editor = null if (error == null) editor = null
} }
}, },

View File

@@ -48,6 +48,9 @@ class CalendarViewModel @Inject constructor(
private val zone: ZoneId = ZoneId.systemDefault() private val zone: ZoneId = ZoneId.systemDefault()
/** Current user id (for creator/owner comparisons in the UI). */
val currentUserId: Int get() = repository.currentUserId
/** Serializes network loads so overlapping fetches don't thrash the UI. */ /** Serializes network loads so overlapping fetches don't thrash the UI. */
private val loadMutex = Mutex() private val loadMutex = Mutex()
@@ -298,20 +301,21 @@ class CalendarViewModel @Inject constructor(
location: String, location: String,
description: String, description: String,
color: String?, color: String?,
isPrivate: Boolean,
onResult: (String?) -> Unit, onResult: (String?) -> Unit,
) { ) {
viewModelScope.launch { viewModelScope.launch {
val result = runCatching { val result = runCatching {
if (existing != null && existing.source == calendar.source) { if (existing != null && existing.source == calendar.source) {
when (existing.source) { when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
} }
} else { } else {
createForSource(calendar, title, start, end, isAllDay, location, description, color) createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
} }
} }
result.onSuccess { afterMutation(); onResult(null) } result.onSuccess { afterMutation(); onResult(null) }
@@ -321,10 +325,10 @@ class CalendarViewModel @Inject constructor(
private suspend fun createForSource( private suspend fun createForSource(
calendar: WritableCalendar, title: String, start: Instant, end: Instant, calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
) { ) {
when (calendar.source) { when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)

View File

@@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notes import androidx.compose.material.icons.filled.Notes
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -60,6 +62,7 @@ import com.scarriffle.calendarr.util.colorFromHex
@Composable @Composable
fun EventDetailScreen( fun EventDetailScreen(
event: CalEvent, event: CalEvent,
currentUserId: Int = 0,
onClose: () -> Unit, onClose: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onCopy: () -> Unit, onCopy: () -> Unit,
@@ -109,6 +112,12 @@ fun EventDetailScreen(
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes) if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName) if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() }) DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() })
event.creator?.let { c ->
if (c.id != currentUserId) {
DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}")
}
}
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {

View File

@@ -69,7 +69,7 @@ fun EventEditorSheet(
request: EditorRequest, request: EditorRequest,
writableCalendars: List<WritableCalendar>, writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit, onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
) { ) {
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
val context = LocalContext.current val context = LocalContext.current
@@ -85,6 +85,7 @@ fun EventEditorSheet(
var location by remember { mutableStateOf(template?.location ?: "") } var location by remember { mutableStateOf(template?.location ?: "") }
var description by remember { mutableStateOf(template?.notes ?: "") } var description by remember { mutableStateOf(template?.notes ?: "") }
var color by remember { mutableStateOf(template?.color) } var color by remember { mutableStateOf(template?.color) }
var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) }
val initialStart = template?.startDate val initialStart = template?.startDate
val initialEnd = template?.endDate val initialEnd = template?.endDate
@@ -230,6 +231,15 @@ fun EventEditorSheet(
} }
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
// Private (local calendars only)
if (calendar?.source == "local") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
}
Spacer(Modifier.size(12.dp))
}
// Color // Color
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -270,7 +280,7 @@ fun EventEditorSheet(
start = startDate.atTime(startTime).atZone(zone).toInstant() start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).atZone(zone).toInstant() end = endDate.atTime(endTime).atZone(zone).toInstant()
} }
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color) onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local")
}, },
enabled = writableCalendars.isNotEmpty(), enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),