Compare commits

..

10 Commits

Author SHA1 Message Date
Guido Schmit
6654012cbb feat: reminders data layer (Android) — model, settings, repository
CalEvent gains `reminders` (parsed from the server); AppSettings/SettingsStore
gain `defaultReminderMinutes` (null = off) and it's sent via updateSettings;
createLocalEvent/updateLocalEvent/eventBody and CalendarViewModel.saveEvent
thread `reminders` through. UI (editor + settings picker) and the local
notification scheduling follow next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:31:32 +02:00
Guido Schmit
807db6a57b feat: non-emoji group icons (Material icons) for consistent look (Android)
Group icons are semantic keys rendered as native Material icons (GroupIcons)
in the picker, group list, top-bar switcher and banner — mirroring iOS/web —
instead of OS emoji. Legacy emoji values render as a fallback. decorateGroup
fallback no longer prepends a glyph (server display_title is authoritative).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:24:48 +02:00
Guido Schmit
644b532104 feat: hide individual member calendars in the group view (Android)
The calendar filter, in group mode, lists the group's members (+ the shared
group calendar) with toggles to hide each individually. Filtering is client-side
via CalendarViewModel.hiddenGroupKeys (gm:<id> / gc), reset on group switch;
members + colours loaded from the group detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:10:11 +02:00
Guido Schmit
87fc8df146 feat: render server display_title for group events (consistent across clients)
CalEvent parses display_title; decorateGroup uses it (group icon + owner prefix
from the server) instead of the hardcoded glyph, with a fallback for older
servers. Raw title kept for editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:54:23 +02:00
Guido Schmit
7a9bdba557 fix: Android Gruppen-Umschalter sichtbar + Menü überlappt Navigationsleiste nicht
Top-Bar-Gruppenumschalter ist jetzt eine tonale Pille (Akzentfarbe im
Gruppenmodus) statt eines flachen Icons – klar als Button erkennbar; das
Dropdown markiert die aktive Auswahl. Gruppen-Verwaltung: Tipp auf eine Gruppe
öffnet direkt deren Ansicht, Verwalten liegt aufs Zahnrad. Menü-Bottom-Sheet
respektiert jetzt das Navigationsleisten-Inset (+ Scroll-Fallback), sodass
Abmelden/Server wechseln nicht mehr hinter den Android-Buttons liegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:08:42 +02:00
Guido Schmit
3352aa3be7 feat: Android Gruppen + kombinierte Ansicht direkt im Kalender
Neuer Gruppen-Verwaltungsscreen (Liste, Erstellen, Verwalten: Name, wählbares
Emoji-Icon, Mitglieder, server-definierte Mitgliederfarben, Löschen) über das
Menü. Top-Bar-Umschalter (Mein Kalender / Gruppen) + Akzent-Banner schalten den
Kalender in die kombinierte Gruppenansicht: Events laufen durch die normale
Cache-/Render-Pipeline (Monat/Woche/Tag/Termine), mit Namens-Präfix der
Besitzer und 👥 für Gruppentermine; im Gruppenmodus kein Sichtbarkeitsfilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:36:26 +02:00
Guido Schmit
e0d1b24afc feat: Android Kalenderfarben, Sharing & iCal-Import/Export (lokale Kalender)
AccountsScreen überarbeitet: editierbare Farb-Punkte (lokal, iCal, sowie
Unterkalender von CalDAV/Google/HA via Farbwähler-Dialog); lokale Kalender
zeigen Gruppen-Marker + "geteilt von" und ein Aktionsmenü (Teilen/Importieren/
Exportieren/Löschen je nach Besitz & Berechtigung). Teilen-Sheet mit
Benutzersuche + Berechtigung; Import via OpenDocument, Export via CreateDocument.
Repository: importIcsFile(bytes) + Farb-Update-Methoden; ViewModel um Farb-,
Sharing- und Import/Export-Logik erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:17:53 +02:00
Guido Schmit
716dd01abb feat: Android Profil-Kapitel in Einstellungen + Kalenderfarb-Endpunkte
Einstellungen zeigen jetzt ein Profil-Kapitel: Anzeigename (editierbar),
Anmeldename (read-only), E-Mail + Speichern; Privatsphäre (private Termine
beschäftigt/verbergen) und geteilter Kalender (Dropdown der eigenen lokalen
Kalender) — serverseitig geladen und gezielt gespeichert.
Datenebene um Farb-Endpunkte ergänzt (lokal/iCal PUT + setCalendarColor für
caldav/google/ha) als Basis für die Farbbearbeitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:07:06 +02:00
Guido Schmit
4a44c20b12 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>
2026-05-31 21:54:19 +02:00
Guido Schmit
d10a4dc79f feat: Android Datenebene für Sharing/Gruppen/Import-Export/Ersteller
- Modelle: CalEvent (creator/isPrivate/owner/isGroupEvent/displayColor),
  LocalCalendar (owned/sharedBy/permission/group), AppSettings
  (privateEventVisibility/groupVisibleCalendarId), UserProfile (displayName);
  neue Modelle Group/GroupMember/DirectoryUser/CalendarShareEntry.
- API (Retrofit): Profil-Update, Sharing-CRUD, Gruppen-CRUD + combined,
  Mitglieder-Farbe, iCal Import (multipart)/Export, Kalenderfarbe pro Quelle,
  gezielte Settings-PUTs (private_visibility/group_visible).
- Repository: passende Methoden inkl. private-Flag bei lokalen Events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:35:54 +02:00
21 changed files with 1618 additions and 39 deletions

View File

@@ -10,6 +10,10 @@ import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
@@ -19,6 +23,8 @@ import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates import com.scarriffle.calendarr.util.Dates
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import retrofit2.HttpException import retrofit2.HttpException
import java.time.Instant import java.time.Instant
@@ -42,6 +48,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()
@@ -89,9 +98,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)
} }
@@ -115,6 +126,9 @@ class CalendarRepository @Inject constructor(
"language" to s.language, "language" to s.language,
"month_divider_color" to s.monthDividerColor, "month_divider_color" to s.monthDividerColor,
"month_label_color" to s.monthLabelColor, "month_label_color" to s.monthLabelColor,
"private_event_visibility" to s.privateEventVisibility,
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL),
) )
).ensureSuccess() ).ensureSuccess()
} }
@@ -200,6 +214,27 @@ class CalendarRepository @Inject constructor(
suspend fun deleteHomeAssistantAccount(id: Int) = suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() } guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** Change a local calendar's colour. */
suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded {
api.updateLocalCalendar(id, jsonBody("color" to color)).ensureSuccess()
}
/** Change an iCal subscription's colour. */
suspend fun updateICalColor(id: Int, color: String) = guarded {
api.updateICalSubscription(id, jsonBody("color" to color)).ensureSuccess()
}
/** Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). */
suspend fun setCalendarColor(source: String, calendarId: Int, color: String) = guarded {
val body = jsonBody("color" to color)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */ /** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded { suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden) val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)
@@ -265,19 +300,183 @@ class CalendarRepository @Inject constructor(
suspend fun createLocalEvent( suspend fun createLocalEvent(
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,
) = guarded { ) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders))
.ensureSuccess() .ensureSuccess()
} }
suspend fun updateLocalEvent( suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant, uid: String, 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,
) = guarded { ) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color)) api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders))
.ensureSuccess() .ensureSuccess()
} }
// ---- Sharing ----
suspend fun getUserDirectory(): List<DirectoryUser> = guarded {
val resp = api.getUserDirectory()
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(DirectoryUser(o.optInt("id"), o.optString("display_name")))
}
}
}
suspend fun getShares(calendarId: Int): List<CalendarShareEntry> = guarded {
val resp = api.getShares(calendarId)
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(CalendarShareEntry(o.optInt("user_id"), o.optString("display_name"), o.optString("permission")))
}
}
}
suspend fun addShare(calendarId: Int, userId: Int, permission: String) = guarded {
api.addShare(calendarId, jsonBody("user_id" to userId, "permission" to permission)).ensureSuccess()
}
suspend fun removeShare(calendarId: Int, userId: Int) = guarded {
api.removeShare(calendarId, userId).ensureSuccess()
}
// ---- iCal import/export ----
suspend fun importIcs(calendarId: Int, part: okhttp3.MultipartBody.Part): Triple<Int, Int, List<String>> = guarded {
val resp = api.importCalendar(calendarId, part)
resp.ensureSuccess()
val o = JSONObject(resp.body()?.string() ?: "{}")
val errors = o.optJSONArray("errors")
val errList = buildList<String> { if (errors != null) for (i in 0 until errors.length()) add(errors.optString(i)) }
Triple(o.optInt("imported"), o.optInt("skipped"), errList)
}
/** Build the multipart body from raw bytes and import (server form field: "file"). */
suspend fun importIcsFile(calendarId: Int, bytes: ByteArray, filename: String): Triple<Int, Int, List<String>> {
val body = bytes.toRequestBody("text/calendar".toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
return importIcs(calendarId, part)
}
suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
val resp = api.exportCalendar(calendarId)
resp.ensureSuccess()
resp.body()?.bytes() ?: ByteArray(0)
}
// ---- Groups ----
suspend fun getGroups(): List<Group> = guarded {
val resp = api.getGroups()
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(Group(
id = o.optInt("id"),
name = o.optString("name"),
icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null },
role = o.optString("role", "member"),
memberCount = o.optInt("member_count", 0),
groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"),
groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null },
members = emptyList(),
))
}
}
}
suspend fun getGroup(id: Int): Group = guarded {
val resp = api.getGroup(id)
resp.ensureSuccess()
val o = JSONObject(resp.body()?.string() ?: "{}")
val membersArr = o.optJSONArray("members")
val members = buildList<GroupMember> {
if (membersArr != null) for (i in 0 until membersArr.length()) {
val m = membersArr.optJSONObject(i) ?: continue
add(GroupMember(
m.optInt("id"), m.optString("display_name"), m.optString("role", "member"),
if (m.isNull("color")) null else m.optString("color").ifBlank { null },
))
}
}
Group(
id = o.optInt("id"),
name = o.optString("name"),
icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null },
role = "",
memberCount = members.size,
groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"),
groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null },
members = members,
)
}
suspend fun createGroup(name: String, memberIds: List<Int>, icon: String?) = guarded {
val arr = org.json.JSONArray()
memberIds.forEach { arr.put(it) }
api.createGroup(jsonBody("name" to name, "member_ids" to arr, "icon" to icon)).ensureSuccess()
}
suspend fun updateGroup(id: Int, name: String?, icon: String?) = guarded {
api.updateGroup(id, jsonBody("name" to name, "icon" to icon)).ensureSuccess()
}
suspend fun addGroupMember(groupId: Int, userId: Int) = guarded {
api.addGroupMember(groupId, jsonBody("user_id" to userId)).ensureSuccess()
}
suspend fun removeGroupMember(groupId: Int, userId: Int) = guarded {
api.removeGroupMember(groupId, userId).ensureSuccess()
}
suspend fun setGroupMemberColor(groupId: Int, userId: Int, color: String) = guarded {
api.setGroupMemberColor(groupId, userId, jsonBody("color" to color)).ensureSuccess()
}
suspend fun deleteGroup(id: Int) = guarded { api.deleteGroup(id).ensureSuccess() }
// ---- Profile & targeted settings ----
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded {
val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email))
resp.ensureSuccess()
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
}
suspend fun updatePrivateVisibility(value: String) = guarded {
api.updateSettings(jsonBody("private_event_visibility" to value)).ensureSuccess()
}
suspend fun updateGroupVisibleCalendar(calendarId: Int?) = guarded {
// Send explicit JSON null to clear (jsonBody drops Kotlin nulls).
api.updateSettings(jsonBody("group_visible_calendar_id" to (calendarId ?: org.json.JSONObject.NULL)))
.ensureSuccess()
}
suspend fun fetchGroupCombined(groupId: Int, start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
val resp = api.fetchGroupCombined(groupId, Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val root = JSONObject(resp.body()?.string() ?: "{}")
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
buildList {
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
}
suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() } suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() }
suspend fun createCalDAVEvent( suspend fun createCalDAVEvent(
@@ -358,6 +557,7 @@ class CalendarRepository @Inject constructor(
private fun eventBody( private fun eventBody(
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,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -368,6 +568,8 @@ class CalendarRepository @Inject constructor(
put("location", location) put("location", location)
put("description", description) put("description", description)
if (!color.isNullOrBlank()) put("color", color) if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
} }
) )
} }

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

@@ -34,6 +34,7 @@ class SettingsStore @Inject constructor(
language = prefs.getString(K_LANGUAGE, null) ?: "de", language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
) )
fun saveSettings(s: AppSettings) { fun saveSettings(s: AppSettings) {
@@ -50,6 +51,7 @@ class SettingsStore @Inject constructor(
.putString(K_LANGUAGE, s.language) .putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor) .putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor) .putString(K_LABEL, s.monthLabelColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.apply() .apply()
} }
@@ -83,6 +85,7 @@ class SettingsStore @Inject constructor(
const val K_LANGUAGE = "language" const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color" const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color" const val K_LABEL = "month_label_color"
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"

View File

@@ -7,6 +7,7 @@ import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.UserProfile import com.scarriffle.calendarr.domain.model.UserProfile
import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
@@ -14,9 +15,11 @@ import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -48,6 +51,65 @@ interface CalendarrApi {
@PUT("api/settings/") @PUT("api/settings/")
suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody> suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/profile/")
suspend fun updateProfile(@Body body: RequestBody): Response<ResponseBody>
// ---- Sharing ----
@GET("api/users/directory")
suspend fun getUserDirectory(): Response<ResponseBody>
@GET("api/local/calendars/{id}/shares")
suspend fun getShares(@Path("id") id: Int): Response<ResponseBody>
@POST("api/local/calendars/{id}/shares")
suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}/shares/{userId}")
suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
// ---- iCal import/export ----
@Multipart
@POST("api/local/calendars/{id}/import")
suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response<ResponseBody>
@GET("api/local/calendars/{id}/export")
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- Groups ----
@GET("api/groups/")
suspend fun getGroups(): Response<ResponseBody>
@GET("api/groups/{id}")
suspend fun getGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/")
suspend fun createGroup(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/groups/{id}")
suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}")
suspend fun deleteGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/{id}/members")
suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}/members/{userId}")
suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
@PUT("api/groups/{id}/members/{userId}/color")
suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response<ResponseBody>
@GET("api/groups/{id}/combined")
suspend fun fetchGroupCombined(
@Path("id") id: Int,
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
// ---- Profile ---- // ---- Profile ----
@PATCH("api/profile/") @PATCH("api/profile/")
@@ -90,6 +152,9 @@ interface CalendarrApi {
@POST("api/local/calendars") @POST("api/local/calendars")
suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar
@PUT("api/local/calendars/{id}")
suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}") @DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody> suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
@@ -101,6 +166,9 @@ interface CalendarrApi {
@POST("api/ical/subscriptions") @POST("api/ical/subscriptions")
suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription
@PUT("api/ical/subscriptions/{id}")
suspend fun updateICalSubscription(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/ical/subscriptions/{id}") @DELETE("api/ical/subscriptions/{id}")
suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody> suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody>

View File

@@ -29,6 +29,11 @@ data class LocalCalendar(
val name: String = "", val name: String = "",
val color: String = "#34a853", val color: String = "#34a853",
val enabled: Boolean = true, val enabled: Boolean = true,
val type: String = "local",
val owned: Boolean = true,
@Json(name = "shared_by") val sharedBy: String? = null,
val permission: String? = null,
val group: Boolean = false,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -81,6 +86,7 @@ data class HACalendar(
data class UserProfile( data class UserProfile(
val id: Int, val id: Int,
val username: String = "", val username: String = "",
@Json(name = "display_name") val displayName: String? = null,
val email: String? = null, val email: String? = null,
@Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "is_admin") val isAdmin: Boolean = false,
@Json(name = "has_avatar") val hasAvatar: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false,

View File

@@ -22,6 +22,11 @@ data class AppSettings(
@Json(name = "language") val language: String = "de", @Json(name = "language") val language: String = "de",
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
@Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0",
// How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
// Minutes-before-start applied to all events client-side; null = off.
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
) { ) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
} }

View File

@@ -8,6 +8,9 @@ import java.time.Instant
* A unified calendar event, blended from all server sources * A unified calendar event, blended from all server sources
* (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. * (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`.
*/ */
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
data class EventPerson(val id: Int?, val displayName: String)
data class CalEvent( data class CalEvent(
val id: String, val id: String,
val url: String, val url: String,
@@ -22,13 +25,26 @@ data class CalEvent(
val calendarName: String, val calendarName: String,
val calendarColor: String, val calendarColor: String,
val source: String, val source: String,
val creator: EventPerson? = null,
val isPrivate: Boolean = false,
// Only set in the group combined view:
val owner: EventPerson? = null,
val isGroupEvent: Boolean = false,
val displayColor: String? = null,
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
val displayTitle: String? = null,
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
val reminders: List<Int> = emptyList(),
) { ) {
/** /**
* 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")
@@ -54,6 +70,16 @@ data class CalEvent(
return optString(key, "").takeIf { it.isNotBlank() && it != "null" } return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
} }
/** Parse a {id, display_name} person object (creator/owner). */
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
val obj = json?.optJSONObject(key) ?: return null
val name = obj.strOrNull("display_name") ?: return null
val id = if (obj.isNull("id")) null else obj.opt("id")?.let {
when (it) { is Number -> it.toInt(); is String -> it.toIntOrNull(); else -> null }
}
return EventPerson(id, name)
}
/** Parse one event object from the `/api/caldav/events` aggregate response. */ /** Parse one event object from the `/api/caldav/events` aggregate response. */
fun fromJson(json: JSONObject): CalEvent? { fun fromJson(json: JSONObject): CalEvent? {
val title = json.strOrNull("title") ?: return null val title = json.strOrNull("title") ?: return null
@@ -91,6 +117,15 @@ data class CalEvent(
calendarName = json.strOrNull("calendar_name") ?: "", calendarName = json.strOrNull("calendar_name") ?: "",
calendarColor = json.strOrNull("calendarColor") ?: "", calendarColor = json.strOrNull("calendarColor") ?: "",
source = json.strOrNull("source") ?: "local", source = json.strOrNull("source") ?: "local",
creator = personFrom(json, "creator"),
isPrivate = json.optBoolean("private", false),
owner = personFrom(json, "owner"),
isGroupEvent = json.optBoolean("is_group_event", false),
displayColor = json.strOrNull("display_color"),
displayTitle = json.strOrNull("display_title"),
reminders = json.optJSONArray("reminders")?.let { arr ->
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(),
) )
} }
} }

View File

@@ -0,0 +1,27 @@
package com.scarriffle.calendarr.domain.model
/** Lightweight user from /api/users/directory (for sharing/group pickers). */
data class DirectoryUser(val id: Int, val displayName: String)
/** A share entry on a local calendar. */
data class CalendarShareEntry(val userId: Int, val displayName: String, val permission: String)
/** A member of a group, with their server-defined colour. */
data class GroupMember(
val id: Int,
val displayName: String,
val role: String,
val color: String? = null,
)
/** A user group + its shared group calendar. */
data class Group(
val id: Int,
val name: String,
val icon: String? = null,
val role: String,
val memberCount: Int,
val groupCalendarId: Int?,
val groupCalendarColor: String? = null,
val members: List<GroupMember>,
)

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",
@@ -122,6 +122,32 @@ object L10n {
"ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich", "ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich",
"ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)", "ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden", "ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profil",
"profile.display_name" to "Anzeigename", "profile.login_name" to "Anmeldename",
"settings.privacy" to "Privatsphäre", "settings.private_visibility" to "Private Termine",
"settings.private.busy" to "Als beschäftigt zeigen", "settings.private.hidden" to "Verbergen",
"settings.private_visibility.desc" to "Legt fest, wie deine privaten Termine anderen Gruppenmitgliedern erscheinen.",
"settings.group_visible" to "Geteilter Kalender",
"settings.group_visible.desc" to "Dieser Kalender wird in der Gruppenansicht für andere sichtbar.",
"group.visible.none" to "Keiner",
"share.title" to "Teilen", "share.with" to "Geteilt mit", "share.add" to "Person hinzufügen",
"share.permission.read" to "Lesen", "share.permission.read_write" to "Bearbeiten",
"share.none" to "Noch nicht geteilt", "share.search" to "Benutzer suchen",
"accounts.shared.header" to "Kalender von anderen", "accounts.shared_by" to "geteilt von %@",
"accounts.color" to "Farbe ändern", "accounts.action.share" to "Teilen",
"accounts.action.import" to "Importieren", "accounts.action.export" to "Exportieren",
"import.title" to "iCal importieren", "import.result" to "%d importiert, %d übersprungen",
"import.failed" to "Import fehlgeschlagen", "export.failed" to "Export fehlgeschlagen",
"menu.groups" to "Gruppen", "groups.title" to "Gruppen", "groups.empty" to "Noch keine Gruppen",
"groups.create" to "Gruppe erstellen", "groups.name" to "Gruppenname",
"groups.members" to "Mitglieder", "groups.member_count" to "%d Mitglieder",
"groups.icon" to "Symbol", "groups.manage" to "Verwalten",
"groups.delete" to "Gruppe löschen", "groups.delete_confirm" to "Diese Gruppe löschen?",
"groups.leave" to "Gruppe verlassen", "groups.member_color" to "Farbe",
"groups.add_member" to "Mitglied hinzufügen", "groups.view" to "Gruppenansicht",
"groups.calendar" to "Gruppenkalender", "group.event" to "Gruppentermin",
"group.switch.personal" to "Mein Kalender",
// Auth screens // Auth screens
"auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL", "auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL",
"auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.", "auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.",
@@ -188,7 +214,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",
@@ -223,6 +249,32 @@ object L10n {
"ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily", "ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily",
"ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)", "ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect", "ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profile",
"profile.display_name" to "Display name", "profile.login_name" to "Login name",
"settings.privacy" to "Privacy", "settings.private_visibility" to "Private events",
"settings.private.busy" to "Show as busy", "settings.private.hidden" to "Hidden",
"settings.private_visibility.desc" to "Controls how your private events appear to other group members.",
"settings.group_visible" to "Shared calendar",
"settings.group_visible.desc" to "This calendar is shown to others in the group view.",
"group.visible.none" to "None",
"share.title" to "Share", "share.with" to "Shared with", "share.add" to "Add person",
"share.permission.read" to "Read", "share.permission.read_write" to "Read & write",
"share.none" to "Not shared yet", "share.search" to "Search users",
"accounts.shared.header" to "Calendars from others", "accounts.shared_by" to "shared by %@",
"accounts.color" to "Change color", "accounts.action.share" to "Share",
"accounts.action.import" to "Import", "accounts.action.export" to "Export",
"import.title" to "Import iCal", "import.result" to "%d imported, %d skipped",
"import.failed" to "Import failed", "export.failed" to "Export failed",
"menu.groups" to "Groups", "groups.title" to "Groups", "groups.empty" to "No groups yet",
"groups.create" to "Create group", "groups.name" to "Group name",
"groups.members" to "Members", "groups.member_count" to "%d members",
"groups.icon" to "Icon", "groups.manage" to "Manage",
"groups.delete" to "Delete group", "groups.delete_confirm" to "Delete this group?",
"groups.leave" to "Leave group", "groups.member_color" to "Color",
"groups.add_member" to "Add member", "groups.view" to "Group view",
"groups.calendar" to "Group calendar", "group.event" to "Group event",
"group.switch.personal" to "My calendar",
// Auth screens // Auth screens
"auth.server_title" to "Connect server", "auth.server_url" to "Server URL", "auth.server_title" to "Connect server", "auth.server_url" to "Server URL",
"auth.server_hint" to "Enter the address of your Calendarr server.", "auth.server_hint" to "Enter the address of your Calendarr server.",

View File

@@ -1,5 +1,7 @@
package com.scarriffle.calendarr.ui.accounts package com.scarriffle.calendarr.ui.accounts
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -13,23 +15,33 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
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.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -37,15 +49,33 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.components.PasswordField import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
private enum class AddType { LOCAL, CALDAV, ICAL, HA } private enum class AddType { LOCAL, CALDAV, ICAL, HA }
/** What colour the picker dialog is currently editing. */
private sealed interface ColorTarget {
val current: String
data class Local(val id: Int, override val current: String) : ColorTarget
data class ICal(val id: Int, override val current: String) : ColorTarget
data class Source(val source: String, val id: Int, override val current: String) : ColorTarget
}
private fun safeFileName(name: String): String {
val cleaned = name.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
return (cleaned.ifBlank { "calendar" }) + ".ics"
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AccountsScreen( fun AccountsScreen(
@@ -53,7 +83,38 @@ fun AccountsScreen(
onChanged: () -> Unit, onChanged: () -> Unit,
vm: AccountsViewModel = hiltViewModel(), vm: AccountsViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val lang = LocalLang.current
var addDialog by remember { mutableStateOf<AddType?>(null) } var addDialog by remember { mutableStateOf<AddType?>(null) }
var editingColor by remember { mutableStateOf<ColorTarget?>(null) }
var shareCalId by remember { mutableStateOf<Int?>(null) }
// Import: pick a document, read its bytes, upload to importTarget.
var importTarget by remember { mutableStateOf<Int?>(null) }
val openDoc = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val calId = importTarget
importTarget = null
if (uri != null && calId != null) {
val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes != null) {
vm.importIcs(
calId, bytes, "import.ics",
result = { imported, skipped -> L10n.t("import.result", lang, imported, skipped) },
onChanged = onChanged,
)
}
}
}
// Export: fetch bytes first, then let the user save them to a chosen location.
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
val createDoc = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/calendar")) { uri ->
val bytes = pendingExport
pendingExport = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold( Scaffold(
@@ -71,39 +132,69 @@ fun AccountsScreen(
return@Scaffold return@Scaffold
} }
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
// Local // Local (incl. shared-with-me and group calendars)
item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } } item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } }
if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) } if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) }
items(vm.local, key = { "l${it.id}" }) { cal -> items(vm.local, key = { "l${it.id}" }) { cal ->
AccountRow(cal.name, cal.color) { vm.deleteLocal(cal.id, onChanged) } LocalCalendarRow(
cal = cal,
onColor = { editingColor = ColorTarget.Local(cal.id, cal.color) },
onShare = { shareCalId = cal.id },
onImport = { importTarget = cal.id; openDoc.launch(arrayOf("*/*")) },
onExport = { vm.exportIcs(cal.id) { bytes -> pendingExport = bytes; createDoc.launch(safeFileName(cal.name)) } },
onDelete = { vm.deleteLocal(cal.id, onChanged) },
)
} }
// CalDAV // CalDAV
item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } } item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } }
if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) } if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) }
items(vm.caldav, key = { "c${it.id}" }) { acc -> items(vm.caldav, key = { "c${it.id}" }) { acc ->
AccountRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } Column {
AccountHeaderRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: acc.color) {
editingColor = ColorTarget.Source("caldav", cal.id, cal.color ?: acc.color)
}
}
}
} }
// iCal // iCal
item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } } item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } }
if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) } if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) }
items(vm.ical, key = { "i${it.id}" }) { sub -> items(vm.ical, key = { "i${it.id}" }) { sub ->
AccountRow(sub.name, sub.color) { vm.deleteICal(sub.id, onChanged) } EditableColorRow(sub.name, sub.color, onColor = { editingColor = ColorTarget.ICal(sub.id, sub.color) }) {
vm.deleteICal(sub.id, onChanged)
}
} }
// Google // Google
item { SectionHeaderNoAdd(tr("accounts.google.header")) } item { SectionHeaderNoAdd(tr("accounts.google.header")) }
if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) } if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) }
items(vm.google, key = { "g${it.id}" }) { acc -> items(vm.google, key = { "g${it.id}" }) { acc ->
AccountRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } Column {
AccountHeaderRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#4285f4") {
editingColor = ColorTarget.Source("google", cal.id, cal.color ?: "#4285f4")
}
}
}
} }
// Home Assistant // Home Assistant
item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } } item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } }
if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) } if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) }
items(vm.homeAssistant, key = { "h${it.id}" }) { acc -> items(vm.homeAssistant, key = { "h${it.id}" }) { acc ->
AccountRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } Column {
AccountHeaderRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#46bdc6") {
editingColor = ColorTarget.Source("homeassistant", cal.id, cal.color ?: "#46bdc6")
}
}
}
} }
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
@@ -127,6 +218,34 @@ fun AccountsScreen(
} }
null -> Unit null -> Unit
} }
editingColor?.let { target ->
ColorPickerDialog(
initial = target.current,
title = tr("accounts.color"),
onDismiss = { editingColor = null },
onConfirm = { hex ->
when (target) {
is ColorTarget.Local -> vm.setLocalColor(target.id, hex, onChanged)
is ColorTarget.ICal -> vm.setICalColor(target.id, hex, onChanged)
is ColorTarget.Source -> vm.setSourceColor(target.source, target.id, hex, onChanged)
}
editingColor = null
},
)
}
shareCalId?.let { id ->
SharingSheet(vm = vm, calendarId = id, onDismiss = { shareCalId = null })
}
vm.infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { vm.clearInfo() },
confirmButton = { TextButton(onClick = { vm.clearInfo() }) { Text(tr("common.ok")) } },
text = { Text(msg) },
)
}
} }
@Composable @Composable
@@ -155,13 +274,45 @@ private fun EmptyRow(text: String) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp))
} }
/** Tappable colour swatch. */
@Composable @Composable
private fun AccountRow(name: String, color: String, onDelete: () -> Unit) { private fun ColorDot(hex: String, editable: Boolean, onClick: () -> Unit) {
val base = Modifier.size(if (editable) 18.dp else 14.dp).clip(CircleShape).background(colorFromHex(hex))
Box(if (editable) base.clickable(onClick = onClick) else base)
}
/** Account header (CalDAV/Google/HA) with a delete button; colour edited on the child rows. */
@Composable
private fun AccountHeaderRow(name: String, color: String, onDelete: () -> Unit) {
Row( Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp), Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color))) Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color)))
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
/** A child calendar of a server-managed account: editable colour + name. */
@Composable
private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
/** A simple row (iCal) with editable colour + delete. */
@Composable
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
@@ -169,6 +320,120 @@ private fun AccountRow(name: String, color: String, onDelete: () -> Unit) {
} }
} }
/** Local calendar row: colour, group marker, "shared by", and an actions menu. */
@Composable
private fun LocalCalendarRow(
cal: LocalCalendar,
onColor: () -> Unit,
onShare: () -> Unit,
onImport: () -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit,
) {
var menu by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(cal.color, editable = cal.owned, onClick = onColor)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
if (cal.group) {
Spacer(Modifier.size(6.dp))
Icon(Icons.Filled.People, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
val by = cal.sharedBy
if (!cal.owned && by != null) {
Text(tr("accounts.shared_by", by), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = tr("accounts.action.share"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
if (cal.owned && !cal.group) {
DropdownMenuItem(text = { Text(tr("accounts.action.share")) }, onClick = { menu = false; onShare() })
}
if (cal.owned || cal.permission == "read_write") {
DropdownMenuItem(text = { Text(tr("accounts.action.import")) }, onClick = { menu = false; onImport() })
}
DropdownMenuItem(text = { Text(tr("accounts.action.export")) }, onClick = { menu = false; onExport() })
if (cal.owned) {
DropdownMenuItem(text = { Text(tr("common.delete")) }, onClick = { menu = false; onDelete() })
}
}
}
}
}
/** Manage who a local calendar is shared with (owner only). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -> Unit) {
LaunchedEffect(calendarId) { vm.loadSharing(calendarId) }
var permission by remember { mutableStateOf("read") }
var search by remember { mutableStateOf("") }
val sharedIds = vm.shares.map { it.userId }.toSet()
val candidates = vm.directory.filter {
it.id !in sharedIds && (search.isBlank() || it.displayName.contains(search, ignoreCase = true))
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
) {
Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(16.dp))
Text(tr("share.with"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (vm.shares.isEmpty()) {
Text(tr("share.none"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 6.dp))
} else {
vm.shares.forEach { s ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(s.displayName, modifier = Modifier.weight(1f))
Text(
if (s.permission == "read_write") tr("share.permission.read_write") else tr("share.permission.read"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = { vm.removeShare(calendarId, s.userId) }) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
}
Divider(Modifier.padding(vertical = 16.dp))
Text(tr("share.add"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = permission == "read", onClick = { permission = "read" }, label = { Text(tr("share.permission.read")) })
FilterChip(selected = permission == "read_write", onClick = { permission = "read_write" }, label = { Text(tr("share.permission.read_write")) })
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text(tr("share.search")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
candidates.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable { vm.addShare(calendarId, u.id, permission) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(u.displayName, modifier = Modifier.weight(1f))
Icon(Icons.Filled.Add, contentDescription = tr("share.add"), tint = MaterialTheme.colorScheme.primary)
}
}
}
}
}
// ---- Add dialogs ---- // ---- Add dialogs ----
@Composable @Composable

View File

@@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.CalDAVAccount import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
@@ -85,4 +87,69 @@ class AccountsViewModel @Inject constructor(
fun deleteGoogle(id: Int, onChanged: () -> Unit) = fun deleteGoogle(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteGoogleAccount(id) } mutate(onChanged) { repository.deleteGoogleAccount(id) }
// ---- Calendar colour ----
fun setLocalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateLocalCalendarColor(id, color) }
fun setICalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateICalColor(id, color) }
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) }
// ---- Sharing ----
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
fun loadSharing(calendarId: Int) {
viewModelScope.launch {
shares = runCatching { repository.getShares(calendarId) }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
}
}
fun addShare(calendarId: Int, userId: Int, permission: String) {
viewModelScope.launch {
runCatching { repository.addShare(calendarId, userId, permission) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
fun removeShare(calendarId: Int, userId: Int) {
viewModelScope.launch {
runCatching { repository.removeShare(calendarId, userId) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
// ---- Import / export ----
var infoMessage by mutableStateOf<String?>(null)
private set
fun clearInfo() { infoMessage = null }
fun clearError() { error = null }
fun importIcs(calendarId: Int, bytes: ByteArray, filename: String, result: (Int, Int) -> String, onChanged: () -> Unit) {
viewModelScope.launch {
runCatching { repository.importIcsFile(calendarId, bytes, filename) }
.onSuccess { (imported, skipped, _) -> infoMessage = result(imported, skipped); load(); onChanged() }
.onFailure { error = it.message }
}
}
fun exportIcs(calendarId: Int, onBytes: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.exportIcs(calendarId) }
.onSuccess { onBytes(it) }
.onFailure { error = it.message }
}
}
} }

View File

@@ -36,6 +36,20 @@ fun CalendarFilterSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val groupMode = state.activeGroup != null
// In group mode the filter lists members (+ the group calendar) so they can
// be hidden individually, Outlook-style; otherwise the normal calendars.
val groupEntries: List<CalendarFilterEntry> = if (groupMode) {
buildList {
state.activeGroupMembers.forEach { m ->
add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4"))
}
add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4"))
}
} else emptyList()
val rows = if (groupMode) groupEntries else events
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
@@ -46,21 +60,24 @@ fun CalendarFilterSheet(
) { ) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Row { Row {
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
TextButton(onClick = { TextButton(onClick = {
vm.setHiddenCalendars(events.map { it.key }.toSet()) if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet())
}) { Text(tr("filter.show_all")) }
TextButton(onClick = {
if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet())
else vm.setHiddenCalendars(rows.map { it.key }.toSet())
}) { Text(tr("filter.hide_all")) } }) { Text(tr("filter.hide_all")) }
} }
} }
if (events.isEmpty()) { if (rows.isEmpty()) {
Text( Text(
tr("filter.empty"), tr("filter.empty"),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp), modifier = Modifier.padding(vertical = 24.dp),
) )
} }
events.forEach { entry -> rows.forEach { entry ->
val visible = entry.key !in state.hiddenKeys val visible = entry.key !in hiddenSet
Row( Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp), Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -73,7 +90,10 @@ fun CalendarFilterSheet(
) )
Switch( Switch(
checked = visible, checked = visible,
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) }, onCheckedChange = {
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it)
else vm.setCalendarHidden(entry.key, hidden = !it)
},
) )
} }
} }

View File

@@ -5,6 +5,8 @@ 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.background
import androidx.compose.foundation.clickable
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
@@ -16,10 +18,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Today import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -35,6 +39,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -44,6 +49,7 @@ 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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -55,15 +61,18 @@ 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.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.accounts.AccountsScreen import com.scarriffle.calendarr.ui.accounts.AccountsScreen
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventDetailScreen
import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.event.EventEditorSheet
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.groups.GroupsScreen
import com.scarriffle.calendarr.ui.menu.MenuSheet 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 java.time.LocalDate import java.time.LocalDate
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } 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)
@@ -113,6 +122,9 @@ fun CalendarScreen(
viewType = state.viewType, viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching, loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen, viewMenuOpen = viewMenuOpen,
groups = state.groups,
activeGroup = state.activeGroup,
onSwitchGroup = { vm.switchGroup(it) },
onMenu = { showMenu = true }, onMenu = { showMenu = true },
onPrev = { goPrev() }, onPrev = { goPrev() },
onToday = { goToday() }, onToday = { goToday() },
@@ -136,6 +148,9 @@ fun CalendarScreen(
state.error?.let { err -> state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
} }
state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
CalendarBody( CalendarBody(
state = state, state = state,
@@ -162,6 +177,7 @@ fun CalendarScreen(
onProfile = { showMenu = false; overlay = Overlay.PROFILE }, onProfile = { showMenu = false; overlay = Overlay.PROFILE },
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS }, onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
onGroups = { showMenu = false; overlay = Overlay.GROUPS },
onSync = { showMenu = false; vm.syncWithServer() }, onSync = { showMenu = false; vm.syncWithServer() },
onLogout = { showMenu = false; onLogout() }, onLogout = { showMenu = false; onLogout() },
onSwitchServer = { showMenu = false; onSwitchServer() }, onSwitchServer = { showMenu = false; onSwitchServer() },
@@ -188,6 +204,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 +227,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
} }
}, },
@@ -229,6 +246,11 @@ fun CalendarScreen(
onClose = { overlay = Overlay.NONE }, onClose = { overlay = Overlay.NONE },
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
) )
Overlay.GROUPS -> GroupsScreen(
onClose = { overlay = Overlay.NONE },
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
)
Overlay.NONE -> Unit Overlay.NONE -> Unit
} }
} }
@@ -295,6 +317,9 @@ private fun CompactTopBar(
viewType: CalViewType, viewType: CalViewType,
loading: Boolean, loading: Boolean,
viewMenuOpen: Boolean, viewMenuOpen: Boolean,
groups: List<Group>,
activeGroup: Group?,
onSwitchGroup: (Group?) -> Unit,
onMenu: () -> Unit, onMenu: () -> Unit,
onPrev: () -> Unit, onPrev: () -> Unit,
onToday: () -> Unit, onToday: () -> Unit,
@@ -330,6 +355,9 @@ private fun CompactTopBar(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
if (groups.isNotEmpty()) {
GroupSwitcher(groups = groups, activeGroup = activeGroup, onSwitchGroup = onSwitchGroup)
}
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
Box { Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
@@ -359,6 +387,70 @@ private fun CompactIcon(
} }
} }
/**
* Top-bar switcher: "My calendar" + each group; flips the calendar into the
* group overlay. Rendered as a tonal pill so it stands out from the flat icons
* (filled in the accent colour while a group overlay is active).
*/
@Composable
private fun GroupSwitcher(groups: List<Group>, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) {
var open by remember { mutableStateOf(false) }
val active = activeGroup != null
Box {
Box(
Modifier
.padding(horizontal = 2.dp)
.size(38.dp)
.clip(CircleShape)
.background(if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
.clickable { open = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.People,
contentDescription = tr("groups.title"),
modifier = Modifier.size(21.dp),
tint = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(
text = { Text(tr("group.switch.personal")) },
trailingIcon = { if (!active) Icon(Icons.Filled.Check, contentDescription = null) },
onClick = { open = false; onSwitchGroup(null) },
)
groups.forEach { g ->
DropdownMenuItem(
text = { Text(g.name) },
leadingIcon = { GroupIcon(g.icon) },
trailingIcon = { if (activeGroup?.id == g.id) Icon(Icons.Filled.Check, contentDescription = null) },
onClick = { open = false; onSwitchGroup(g) },
)
}
}
}
}
@Composable
private fun GroupBanner(group: Group, onExit: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp))
Text(
"${tr("groups.view")}: ${group.name}",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onExit) { Text(tr("group.switch.personal")) }
}
}
}
/** Distinct calendars currently present in the cache, for the filter sheet. */ /** Distinct calendars currently present in the cache, for the filter sheet. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> { private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val st = vm.state.value val st = vm.state.value

View File

@@ -6,6 +6,8 @@ import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.domain.model.CalEvent 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.GroupMember
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,8 +40,18 @@ data class CalendarUiState(
val writableCalendars: List<WritableCalendar> = emptyList(), val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(), val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: Set<String> = emptySet(), val banishedKeys: Set<String> = emptySet(),
// Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = emptyList(),
val activeGroup: Group? = null,
// Group overlay: full member list (for the filter) + per-member / group-cal
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(),
val hiddenGroupKeys: Set<String> = emptySet(),
) )
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
const val GROUP_CALENDAR_KEY = "gc"
@HiltViewModel @HiltViewModel
class CalendarViewModel @Inject constructor( class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
@@ -48,6 +60,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()
@@ -65,6 +80,7 @@ class CalendarViewModel @Inject constructor(
init { init {
loadWritableCalendars() loadWritableCalendars()
loadGroups()
initialLoad() initialLoad()
} }
@@ -190,15 +206,38 @@ class CalendarViewModel @Inject constructor(
loadMutex.withLock { loadMutex.withLock {
// Another load (e.g. the background prefetch) may have covered this range. // Another load (e.g. the background prefetch) may have covered this range.
if (isCached(start, end)) return if (isCached(start, end)) return
val group = _state.value.activeGroup
val flag = if (background) "bg" else "fg" val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching { repository.fetchEvents(start, end) } runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end))
else repository.fetchEvents(start, end)
}
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) } _state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
} }
} }
/** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */
private fun decorateGroup(events: List<CalEvent>): List<CalEvent> {
val me = currentUserId
return events.map { ev ->
// Prefer the server-decorated title (group icon + owner prefix) so
// web, iOS and Android render identically; fall back for old servers.
val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() }
if (serverTitle != null) return@map ev.copy(title = serverTitle)
val prefix = when {
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: "
ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: "
else -> ""
}
if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title)
}
}
private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s }
private fun markReady() { private fun markReady() {
_ready.value = true _ready.value = true
} }
@@ -219,12 +258,27 @@ class CalendarViewModel @Inject constructor(
} }
private fun refreshFromCache() { private fun refreshFromCache() {
val hidden = _state.value.hiddenKeys val st = _state.value
val banished = _state.value.banishedKeys // In group mode: server scopes/filters by privacy; locally honour the
val visible = allCachedEvents.filter { ev -> // per-member / group-calendar hide toggles (hiddenGroupKeys).
val visible = if (st.activeGroup != null) {
val hg = st.hiddenGroupKeys
if (hg.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
when {
ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg
ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg
else -> true
}
}
} else {
val hidden = st.hiddenKeys
val banished = st.banishedKeys
allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId) val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished key !in hidden && key !in banished
} }
}
// Skip the state write (and resulting recomposition) when nothing changed. // Skip the state write (and resulting recomposition) when nothing changed.
_state.update { if (it.events == visible) it else it.copy(events = visible) } _state.update { if (it.events == visible) it else it.copy(events = visible) }
} }
@@ -237,6 +291,7 @@ class CalendarViewModel @Inject constructor(
fun syncWithServer() { fun syncWithServer() {
invalidateCache() invalidateCache()
loadGroups()
initialLoad() initialLoad()
} }
@@ -272,6 +327,53 @@ class CalendarViewModel @Inject constructor(
refreshFromCache() refreshFromCache()
} }
// ---- Groups ----
fun loadGroups() {
viewModelScope.launch {
runCatching { repository.getGroups() }
.onSuccess { gs ->
_state.update { st ->
// If the active group was deleted elsewhere, drop back to personal.
val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } }
st.copy(groups = gs, activeGroup = stillActive)
}
}
}
}
/** Flip between personal and a group's combined overlay; reloads the wide window. */
fun switchGroup(group: Group?) {
if (_state.value.activeGroup?.id == group?.id) return
_state.update {
it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList())
}
invalidateCache()
initialLoad()
// Load the full member list (with server colours) for the filter sheet.
if (group != null) {
viewModelScope.launch {
runCatching { repository.getGroup(group.id) }
.onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } }
}
}
}
/** Toggle a single member's calendar / the group calendar in the overlay. */
fun setGroupKeyHidden(key: String, hidden: Boolean) {
_state.update {
val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) }
it.copy(hiddenGroupKeys = next)
}
refreshFromCache()
}
/** Replace the group-overlay hidden set (bulk show/hide all). */
fun setHiddenGroupKeys(keys: Set<String>) {
_state.update { it.copy(hiddenGroupKeys = keys) }
refreshFromCache()
}
// ---- Writable calendars ---- // ---- Writable calendars ----
fun loadWritableCalendars() { fun loadWritableCalendars() {
@@ -298,20 +400,22 @@ class CalendarViewModel @Inject constructor(
location: String, location: String,
description: String, description: String,
color: String?, color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
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, reminders)
"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, reminders)
} }
} else { } else {
createForSource(calendar, title, start, end, isAllDay, location, description, color) createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
} }
} }
result.onSuccess { afterMutation(); onResult(null) } result.onSuccess { afterMutation(); onResult(null) }
@@ -321,10 +425,11 @@ 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,
reminders: List<Int> = emptyList(),
) { ) {
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, reminders)
"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(),

View File

@@ -0,0 +1,364 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Work
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* Cross-platform group-icon keys (stored server-side) rendered as native
* Material icons — consistent everywhere instead of OS-emoji that vary by
* platform. Mirrors iOS GroupIcons / the web SVG set.
*/
object GroupIcons {
val keys = listOf(
"people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star",
)
fun vector(key: String?): ImageVector = when (key) {
"people" -> Icons.Filled.People
"home" -> Icons.Filled.Home
"heart" -> Icons.Filled.Favorite
"work" -> Icons.Filled.Work
"school" -> Icons.Filled.School
"sports" -> Icons.Filled.DirectionsRun
"party" -> Icons.Filled.Celebration
"pet" -> Icons.Filled.Pets
"travel" -> Icons.Filled.Flight
"music" -> Icons.Filled.MusicNote
"food" -> Icons.Filled.Restaurant
"star" -> Icons.Filled.Star
else -> Icons.Filled.People
}
fun isKey(s: String?): Boolean = s != null && s in keys
}
/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */
@Composable
fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) {
if (GroupIcons.isKey(icon)) {
Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
} else if (!icon.isNullOrEmpty()) {
Text(icon, modifier = modifier)
} else {
Icon(Icons.Filled.People, contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
onOpenGroupView: (Group) -> Unit = {},
vm: GroupsViewModel = hiltViewModel(),
) {
var createOpen by remember { mutableStateOf(false) }
var manageId by remember { mutableStateOf<Int?>(null) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("groups.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
if (vm.groups.isEmpty()) {
item {
Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp))
}
}
items(vm.groups, key = { it.id }) { g ->
Row(
Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(g.name, style = MaterialTheme.typography.bodyLarge)
Text(
tr("groups.member_count", g.memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { manageId = g.id }) {
Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Divider()
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
}
}
}
if (createOpen) {
GroupEditSheet(
vm = vm,
existing = null,
onDismiss = { createOpen = false },
onSaved = { createOpen = false; onChanged() },
)
}
manageId?.let { id ->
val existing = vm.groups.firstOrNull { it.id == id }
GroupEditSheet(
vm = vm,
existing = existing,
onDismiss = { manageId = null },
onSaved = { manageId = null; onChanged() },
)
}
}
/** Create (existing == null) or manage (existing != null) a group. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun GroupEditSheet(
vm: GroupsViewModel,
existing: Group?,
onDismiss: () -> Unit,
onSaved: () -> Unit,
) {
val me = vm.currentUserId
var name by remember { mutableStateOf(existing?.name ?: "") }
var icon by remember { mutableStateOf(if (GroupIcons.isKey(existing?.icon)) existing!!.icon!! else "people") }
var selected by remember { mutableStateOf(setOf<Int>()) }
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
var detail by remember { mutableStateOf<Group?>(null) }
var loaded by remember { mutableStateOf(existing == null) }
var confirmDelete by remember { mutableStateOf(false) }
var memberColorTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } // userId, current hex
// Manage: load full details (members + colours) and pre-fill selection.
LaunchedEffect(existing?.id) {
val id = existing?.id ?: return@LaunchedEffect
val g = vm.groupDetail(id)
detail = g
if (g != null) {
name = g.name
icon = if (GroupIcons.isKey(g.icon)) g.icon!! else "people"
val members = g.members.map { it.id }.filter { it != me }.toSet()
existingMembers = members
selected = members
}
loaded = true
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) {
Text(
if (existing == null) tr("groups.create") else tr("groups.manage"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(tr("groups.name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(16.dp))
Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
GroupIcons.keys.forEach { ic ->
val sel = ic == icon
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(8.dp))
.background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant)
.clickable { icon = ic },
contentAlignment = Alignment.Center,
) {
Icon(
GroupIcons.vector(ic),
contentDescription = ic,
tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.size(16.dp))
Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (!loaded) {
Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
} else {
vm.directory.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable {
selected = if (u.id in selected) selected - u.id else selected + u.id
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = u.id in selected, onCheckedChange = {
selected = if (u.id in selected) selected - u.id else selected + u.id
})
Text(u.displayName, modifier = Modifier.padding(start = 4.dp))
}
}
}
// Member colours (manage only)
detail?.members?.takeIf { it.isNotEmpty() }?.let { members ->
Spacer(Modifier.size(8.dp))
Divider()
Spacer(Modifier.size(8.dp))
Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
members.forEach { m ->
val hex = m.color ?: "#4285f4"
Row(
Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex)))
Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp))
}
}
}
Spacer(Modifier.size(20.dp))
Button(onClick = {
val n = name.trim()
if (n.isEmpty()) return@Button
if (existing == null) {
vm.createGroup(n, icon, selected.toList()) { onSaved() }
} else {
vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() }
}
}, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(tr("event.save"))
}
if (existing != null) {
Spacer(Modifier.size(8.dp))
OutlinedButton(
onClick = { confirmDelete = true },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.size(8.dp))
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
}
}
}
memberColorTarget?.let { (userId, hex) ->
ColorPickerDialog(
initial = hex,
title = tr("groups.member_color"),
onDismiss = { memberColorTarget = null },
onConfirm = { picked ->
existing?.let { vm.setMemberColor(it.id, userId, picked) }
// reflect locally
detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) }
memberColorTarget = null
},
)
}
if (confirmDelete && existing != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("groups.delete")) },
text = { Text(tr("groups.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) {
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}

View File

@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GroupsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var groups by mutableStateOf<List<Group>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
var error by mutableStateOf<String?>(null)
private set
val currentUserId: Int get() = repository.currentUserId
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
groups = runCatching { repository.getGroups() }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
loading = false
}
}
suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull()
fun createGroup(name: String, icon: String, memberIds: List<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.createGroup(name, memberIds, icon) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
/** Save name/icon, then reconcile membership against [existingMemberIds]. */
fun saveGroup(id: Int, name: String, icon: String, desired: Set<Int>, existing: Set<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching {
repository.updateGroup(id, name, icon)
for (uid in desired - existing) repository.addGroupMember(id, uid)
for (uid in existing - desired) repository.removeGroupMember(id, uid)
}
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
fun setMemberColor(groupId: Int, userId: Int, color: String) {
viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } }
}
fun deleteGroup(id: Int, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.deleteGroup(id) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
}

View File

@@ -6,13 +6,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
@@ -37,12 +41,20 @@ fun MenuSheet(
onProfile: () -> Unit, onProfile: () -> Unit,
onAppearance: () -> Unit, onAppearance: () -> Unit,
onAccounts: () -> Unit, onAccounts: () -> Unit,
onGroups: () -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onSwitchServer: () -> Unit, onSwitchServer: () -> Unit,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { // Inset for the system navigation bar so the last rows aren't hidden
// behind the Android nav buttons; scroll as a fallback on short screens.
Column(
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 12.dp),
) {
Text( Text(
"Calendarr", "Calendarr",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -52,6 +64,7 @@ fun MenuSheet(
MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
Divider(Modifier.padding(vertical = 4.dp)) Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
Divider(Modifier.padding(vertical = 4.dp)) Divider(Modifier.padding(vertical = 4.dp))

View File

@@ -21,12 +21,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -86,6 +91,9 @@ fun SettingsScreen(
) { padding -> ) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.calview")) Section(tr("settings.calview"))
ChipRow( ChipRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
@@ -175,6 +183,80 @@ private fun Section(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
} }
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
@Composable
private fun ProfileChapter(vm: SettingsViewModel) {
val savedLabel = tr("settings.saved")
Section(tr("settings.nav.profile"))
OutlinedTextField(
value = vm.displayName,
onValueChange = vm::onDisplayNameChange,
label = { Text(tr("profile.display_name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(tr("profile.login_name"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(vm.loginName, fontWeight = FontWeight.Medium)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = vm.email,
onValueChange = vm::onEmailChange,
label = { Text(tr("profile.email")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
vm.profileMessage?.let {
Spacer(Modifier.size(8.dp))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy"))
ChipRow(
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
selected = vm.privateVisibility,
onSelect = vm::changePrivateVisibility,
)
Spacer(Modifier.size(6.dp))
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.group_visible"))
CalendarDropdown(vm)
Spacer(Modifier.size(6.dp))
Text(tr("settings.group_visible.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarDropdown(vm: SettingsViewModel) {
var expanded by remember { mutableStateOf(false) }
val noneLabel = tr("group.visible.none")
val selectedLabel = vm.ownLocalCalendars.firstOrNull { it.id == vm.groupVisibleId }?.name ?: noneLabel
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(tr("settings.group_visible")) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(text = { Text(noneLabel) }, onClick = { vm.changeGroupVisible(0); expanded = false })
vm.ownLocalCalendars.forEach { cal ->
DropdownMenuItem(text = { Text(cal.name) }, onClick = { vm.changeGroupVisible(cal.id); expanded = false })
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) { private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {

View File

@@ -1,10 +1,14 @@
package com.scarriffle.calendarr.ui.settings package com.scarriffle.calendarr.ui.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -27,4 +31,65 @@ class SettingsViewModel @Inject constructor(
var cacheMonths: Int var cacheMonths: Int
get() = settingsStore.cacheMonths get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value } set(value) { settingsStore.cacheMonths = value }
// ---- Profile chapter (server-backed) ----
var displayName by mutableStateOf("")
var loginName by mutableStateOf("")
var email by mutableStateOf("")
private set
var privateVisibility by mutableStateOf("busy")
private set
var groupVisibleId by mutableStateOf(0) // 0 = none
private set
var ownLocalCalendars by mutableStateOf<List<LocalCalendar>>(emptyList())
private set
var profileMessage by mutableStateOf<String?>(null)
private set
init { loadProfile() }
fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v }
private fun loadProfile() {
viewModelScope.launch {
runCatching { repository.getProfile() }.onSuccess { p ->
displayName = p.displayName ?: p.username
loginName = p.username
email = p.email ?: ""
}
runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?: 0
}
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
ownLocalCalendars = cals.filter { it.owned && !it.group }
}
}
}
fun saveProfile(savedLabel: String) {
viewModelScope.launch {
runCatching {
repository.updateProfile(
displayName = displayName.trim().ifEmpty { null },
username = null,
email = email.trim(),
)
}
.onSuccess { profileMessage = savedLabel }
.onFailure { profileMessage = it.message }
}
}
fun changePrivateVisibility(value: String) {
privateVisibility = value
viewModelScope.launch { runCatching { repository.updatePrivateVisibility(value) } }
}
fun changeGroupVisible(id: Int) {
groupVisibleId = id
viewModelScope.launch { runCatching { repository.updateGroupVisibleCalendar(id.takeIf { it != 0 }) } }
}
} }