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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user