Compare commits
4 Commits
9467fe7bb6
...
e0d1b24afc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d1b24afc | ||
|
|
716dd01abb | ||
|
|
4a44c20b12 | ||
|
|
d10a4dc79f |
@@ -10,6 +10,10 @@ import com.scarriffle.calendarr.data.remote.jsonBody
|
||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||
import com.scarriffle.calendarr.domain.model.CalDAVAccount
|
||||
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.HomeAssistantAccount
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
import java.time.Instant
|
||||
@@ -42,6 +48,9 @@ class CalendarRepository @Inject constructor(
|
||||
) {
|
||||
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) {
|
||||
try {
|
||||
block()
|
||||
@@ -89,9 +98,11 @@ class CalendarRepository @Inject constructor(
|
||||
val user = json.optJSONObject("user")
|
||||
val uname = user?.optString("username") ?: username
|
||||
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.saveLogin(token, uname, isAdmin)
|
||||
credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
|
||||
apiProvider.invalidate()
|
||||
LoginResult(token, uname, isAdmin)
|
||||
}
|
||||
@@ -115,6 +126,7 @@ class CalendarRepository @Inject constructor(
|
||||
"language" to s.language,
|
||||
"month_divider_color" to s.monthDividerColor,
|
||||
"month_label_color" to s.monthLabelColor,
|
||||
"private_event_visibility" to s.privateEventVisibility,
|
||||
)
|
||||
).ensureSuccess()
|
||||
}
|
||||
@@ -200,6 +212,27 @@ class CalendarRepository @Inject constructor(
|
||||
suspend fun deleteHomeAssistantAccount(id: Int) =
|
||||
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). */
|
||||
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
|
||||
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)
|
||||
@@ -265,19 +298,183 @@ class CalendarRepository @Inject constructor(
|
||||
suspend fun createLocalEvent(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
) = guarded {
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun updateLocalEvent(
|
||||
uid: String, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
) = 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))
|
||||
.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 createCalDAVEvent(
|
||||
@@ -358,6 +555,7 @@ class CalendarRepository @Inject constructor(
|
||||
private fun eventBody(
|
||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
) = jsonBody(
|
||||
buildMap {
|
||||
calendarId?.let { put("calendar_id", it) }
|
||||
@@ -368,6 +566,7 @@ class CalendarRepository @Inject constructor(
|
||||
put("location", location)
|
||||
put("description", description)
|
||||
if (!color.isNullOrBlank()) put("color", color)
|
||||
put("private", isPrivate)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,17 +46,27 @@ class CredentialStore @Inject constructor(
|
||||
get() = prefs.getBoolean(KEY_IS_ADMIN, false)
|
||||
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). */
|
||||
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
|
||||
|
||||
/** True once we hold an auth token (logged in). */
|
||||
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()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putBoolean(KEY_IS_ADMIN, isAdmin)
|
||||
.putInt(KEY_USER_ID, userId)
|
||||
.putString(KEY_DISPLAY_NAME, displayName ?: username)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -79,5 +89,7 @@ class CredentialStore @Inject constructor(
|
||||
const val KEY_TOKEN = "auth_token"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_IS_ADMIN = "is_admin"
|
||||
const val KEY_USER_ID = "user_id"
|
||||
const val KEY_DISPLAY_NAME = "display_name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
|
||||
import com.scarriffle.calendarr.domain.model.ICalSubscription
|
||||
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
||||
import com.scarriffle.calendarr.domain.model.UserProfile
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
@@ -14,9 +15,11 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -48,6 +51,65 @@ interface CalendarrApi {
|
||||
@PUT("api/settings/")
|
||||
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 ----
|
||||
|
||||
@PATCH("api/profile/")
|
||||
@@ -90,6 +152,9 @@ interface CalendarrApi {
|
||||
@POST("api/local/calendars")
|
||||
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}")
|
||||
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
||||
|
||||
@@ -101,6 +166,9 @@ interface CalendarrApi {
|
||||
@POST("api/ical/subscriptions")
|
||||
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}")
|
||||
suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody>
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ data class LocalCalendar(
|
||||
val name: String = "",
|
||||
val color: String = "#34a853",
|
||||
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)
|
||||
@@ -81,6 +86,7 @@ data class HACalendar(
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val username: String = "",
|
||||
@Json(name = "display_name") val displayName: String? = null,
|
||||
val email: String? = null,
|
||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
||||
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
|
||||
|
||||
@@ -22,6 +22,9 @@ data class AppSettings(
|
||||
@Json(name = "language") val language: String = "de",
|
||||
@Json(name = "month_divider_color") val monthDividerColor: 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,
|
||||
) {
|
||||
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import java.time.Instant
|
||||
* A unified calendar event, blended from all server sources
|
||||
* (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(
|
||||
val id: String,
|
||||
val url: String,
|
||||
@@ -22,13 +25,21 @@ data class CalEvent(
|
||||
val calendarName: String,
|
||||
val calendarColor: 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,
|
||||
) {
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
val effectiveColor: String
|
||||
get() = color?.takeIf { it.isNotBlank() }
|
||||
get() = displayColor?.takeIf { it.isNotBlank() }
|
||||
?: color?.takeIf { it.isNotBlank() }
|
||||
?: calendarColor.takeIf { it.isNotBlank() }
|
||||
?: fallbackColorFor("$source:$calendarId")
|
||||
|
||||
@@ -54,6 +65,16 @@ data class CalEvent(
|
||||
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. */
|
||||
fun fromJson(json: JSONObject): CalEvent? {
|
||||
val title = json.strOrNull("title") ?: return null
|
||||
@@ -91,6 +112,11 @@ data class CalEvent(
|
||||
calendarName = json.strOrNull("calendar_name") ?: "",
|
||||
calendarColor = json.strOrNull("calendarColor") ?: "",
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -87,7 +87,7 @@ object L10n {
|
||||
"twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren",
|
||||
"twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort",
|
||||
"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.description" to "Beschreibung", "event.calendar_section" to "Kalender",
|
||||
"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",
|
||||
"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",
|
||||
// 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.server_title" to "Server verbinden", "auth.server_url" to "Server-URL",
|
||||
"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.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password",
|
||||
"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.description" to "Description", "event.calendar_section" to "Calendar",
|
||||
"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",
|
||||
"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",
|
||||
// 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.server_title" to "Connect server", "auth.server_url" to "Server URL",
|
||||
"auth.server_hint" to "Enter the address of your Calendarr server.",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
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.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.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -37,15 +49,33 @@ 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.platform.LocalContext
|
||||
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.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.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
|
||||
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)
|
||||
@Composable
|
||||
fun AccountsScreen(
|
||||
@@ -53,7 +83,38 @@ fun AccountsScreen(
|
||||
onChanged: () -> Unit,
|
||||
vm: AccountsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lang = LocalLang.current
|
||||
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) {
|
||||
Scaffold(
|
||||
@@ -71,39 +132,69 @@ fun AccountsScreen(
|
||||
return@Scaffold
|
||||
}
|
||||
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 } }
|
||||
if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) }
|
||||
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
|
||||
item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } }
|
||||
if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) }
|
||||
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
|
||||
item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } }
|
||||
if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) }
|
||||
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
|
||||
item { SectionHeaderNoAdd(tr("accounts.google.header")) }
|
||||
if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) }
|
||||
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
|
||||
item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } }
|
||||
if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) }
|
||||
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)) } }
|
||||
@@ -127,6 +218,34 @@ fun AccountsScreen(
|
||||
}
|
||||
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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/** Tappable colour swatch. */
|
||||
@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(
|
||||
Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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)
|
||||
IconButton(onClick = onDelete) {
|
||||
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 ----
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.scarriffle.calendarr.data.CalendarRepository
|
||||
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.HomeAssistantAccount
|
||||
import com.scarriffle.calendarr.domain.model.ICalSubscription
|
||||
@@ -85,4 +87,69 @@ class AccountsViewModel @Inject constructor(
|
||||
|
||||
fun deleteGoogle(id: Int, onChanged: () -> Unit) =
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ fun CalendarScreen(
|
||||
if (ev != null) {
|
||||
EventDetailScreen(
|
||||
event = ev,
|
||||
currentUserId = vm.currentUserId,
|
||||
onClose = { detailEvent = null },
|
||||
onEdit = {
|
||||
detailEvent = null
|
||||
@@ -210,8 +211,8 @@ fun CalendarScreen(
|
||||
request = req,
|
||||
writableCalendars = state.writableCalendars,
|
||||
onDismiss = { editor = null },
|
||||
onSave = { cal, title, start, end, allDay, location, desc, color ->
|
||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error ->
|
||||
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
|
||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
|
||||
if (error == null) editor = null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -48,6 +48,9 @@ class CalendarViewModel @Inject constructor(
|
||||
|
||||
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. */
|
||||
private val loadMutex = Mutex()
|
||||
|
||||
@@ -298,20 +301,21 @@ class CalendarViewModel @Inject constructor(
|
||||
location: String,
|
||||
description: String,
|
||||
color: String?,
|
||||
isPrivate: Boolean,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
if (existing != null && existing.source == calendar.source) {
|
||||
when (existing.source) {
|
||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color)
|
||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"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)
|
||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
}
|
||||
} else {
|
||||
createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
||||
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
}
|
||||
}
|
||||
result.onSuccess { afterMutation(); onResult(null) }
|
||||
@@ -321,10 +325,10 @@ class CalendarViewModel @Inject constructor(
|
||||
|
||||
private suspend fun createForSource(
|
||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
||||
) {
|
||||
when (calendar.source) {
|
||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
|
||||
@@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
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.Person
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -60,6 +62,7 @@ import com.scarriffle.calendarr.util.colorFromHex
|
||||
@Composable
|
||||
fun EventDetailScreen(
|
||||
event: CalEvent,
|
||||
currentUserId: Int = 0,
|
||||
onClose: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
@@ -109,6 +112,12 @@ fun EventDetailScreen(
|
||||
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
|
||||
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
|
||||
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))
|
||||
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -69,7 +69,7 @@ fun EventEditorSheet(
|
||||
request: EditorRequest,
|
||||
writableCalendars: List<WritableCalendar>,
|
||||
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 context = LocalContext.current
|
||||
@@ -85,6 +85,7 @@ fun EventEditorSheet(
|
||||
var location by remember { mutableStateOf(template?.location ?: "") }
|
||||
var description by remember { mutableStateOf(template?.notes ?: "") }
|
||||
var color by remember { mutableStateOf(template?.color) }
|
||||
var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) }
|
||||
|
||||
val initialStart = template?.startDate
|
||||
val initialEnd = template?.endDate
|
||||
@@ -230,6 +231,15 @@ fun EventEditorSheet(
|
||||
}
|
||||
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
|
||||
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
@@ -270,7 +280,7 @@ fun EventEditorSheet(
|
||||
start = startDate.atTime(startTime).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(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -21,12 +21,17 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -86,6 +91,9 @@ fun SettingsScreen(
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
|
||||
ProfileChapter(vm)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.calview"))
|
||||
ChipRow(
|
||||
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))
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@Composable
|
||||
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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.viewModelScope
|
||||
import com.scarriffle.calendarr.data.CalendarRepository
|
||||
import com.scarriffle.calendarr.data.SettingsStore
|
||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -27,4 +31,65 @@ class SettingsViewModel @Inject constructor(
|
||||
var cacheMonths: Int
|
||||
get() = settingsStore.cacheMonths
|
||||
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 }) } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user