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.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,7 @@ 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,
|
||||||
)
|
)
|
||||||
).ensureSuccess()
|
).ensureSuccess()
|
||||||
}
|
}
|
||||||
@@ -200,6 +212,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 +298,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,
|
||||||
) = 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))
|
||||||
.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,
|
||||||
) = 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))
|
||||||
.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 +555,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,
|
||||||
) = jsonBody(
|
) = jsonBody(
|
||||||
buildMap {
|
buildMap {
|
||||||
calendarId?.let { put("calendar_id", it) }
|
calendarId?.let { put("calendar_id", it) }
|
||||||
@@ -368,6 +566,7 @@ 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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ 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,
|
||||||
) {
|
) {
|
||||||
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 @@ 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,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 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 +65,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 +112,11 @@ 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"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ fun CalendarScreen(
|
|||||||
if (ev != null) {
|
if (ev != null) {
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
event = ev,
|
event = ev,
|
||||||
|
currentUserId = vm.currentUserId,
|
||||||
onClose = { detailEvent = null },
|
onClose = { detailEvent = null },
|
||||||
onEdit = {
|
onEdit = {
|
||||||
detailEvent = null
|
detailEvent = null
|
||||||
@@ -210,8 +211,8 @@ fun CalendarScreen(
|
|||||||
request = req,
|
request = req,
|
||||||
writableCalendars = state.writableCalendars,
|
writableCalendars = state.writableCalendars,
|
||||||
onDismiss = { editor = null },
|
onDismiss = { editor = null },
|
||||||
onSave = { cal, title, start, end, allDay, location, desc, color ->
|
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
|
||||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error ->
|
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
|
||||||
if (error == null) editor = null
|
if (error == null) editor = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val zone: ZoneId = ZoneId.systemDefault()
|
private val zone: ZoneId = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
/** Current user id (for creator/owner comparisons in the UI). */
|
||||||
|
val currentUserId: Int get() = repository.currentUserId
|
||||||
|
|
||||||
/** Serializes network loads so overlapping fetches don't thrash the UI. */
|
/** Serializes network loads so overlapping fetches don't thrash the UI. */
|
||||||
private val loadMutex = Mutex()
|
private val loadMutex = Mutex()
|
||||||
|
|
||||||
@@ -298,20 +301,21 @@ class CalendarViewModel @Inject constructor(
|
|||||||
location: String,
|
location: String,
|
||||||
description: String,
|
description: String,
|
||||||
color: String?,
|
color: String?,
|
||||||
|
isPrivate: Boolean,
|
||||||
onResult: (String?) -> Unit,
|
onResult: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
if (existing != null && existing.source == calendar.source) {
|
if (existing != null && existing.source == calendar.source) {
|
||||||
when (existing.source) {
|
when (existing.source) {
|
||||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color)
|
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||||
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||||
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||||
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.onSuccess { afterMutation(); onResult(null) }
|
result.onSuccess { afterMutation(); onResult(null) }
|
||||||
@@ -321,10 +325,10 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun createForSource(
|
private suspend fun createForSource(
|
||||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
||||||
) {
|
) {
|
||||||
when (calendar.source) {
|
when (calendar.source) {
|
||||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||||
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||||
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Delete
|
|||||||
import androidx.compose.material.icons.filled.Dns
|
import androidx.compose.material.icons.filled.Dns
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notes
|
import androidx.compose.material.icons.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -60,6 +62,7 @@ import com.scarriffle.calendarr.util.colorFromHex
|
|||||||
@Composable
|
@Composable
|
||||||
fun EventDetailScreen(
|
fun EventDetailScreen(
|
||||||
event: CalEvent,
|
event: CalEvent,
|
||||||
|
currentUserId: Int = 0,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onCopy: () -> Unit,
|
onCopy: () -> Unit,
|
||||||
@@ -109,6 +112,12 @@ fun EventDetailScreen(
|
|||||||
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
|
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
|
||||||
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
|
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
|
||||||
DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() })
|
DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() })
|
||||||
|
event.creator?.let { c ->
|
||||||
|
if (c.id != currentUserId) {
|
||||||
|
DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fun EventEditorSheet(
|
|||||||
request: EditorRequest,
|
request: EditorRequest,
|
||||||
writableCalendars: List<WritableCalendar>,
|
writableCalendars: List<WritableCalendar>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit,
|
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -85,6 +85,7 @@ fun EventEditorSheet(
|
|||||||
var location by remember { mutableStateOf(template?.location ?: "") }
|
var location by remember { mutableStateOf(template?.location ?: "") }
|
||||||
var description by remember { mutableStateOf(template?.notes ?: "") }
|
var description by remember { mutableStateOf(template?.notes ?: "") }
|
||||||
var color by remember { mutableStateOf(template?.color) }
|
var color by remember { mutableStateOf(template?.color) }
|
||||||
|
var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) }
|
||||||
|
|
||||||
val initialStart = template?.startDate
|
val initialStart = template?.startDate
|
||||||
val initialEnd = template?.endDate
|
val initialEnd = template?.endDate
|
||||||
@@ -230,6 +231,15 @@ fun EventEditorSheet(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
|
|
||||||
|
// Private (local calendars only)
|
||||||
|
if (calendar?.source == "local") {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
// Color
|
// Color
|
||||||
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
@@ -270,7 +280,7 @@ fun EventEditorSheet(
|
|||||||
start = startDate.atTime(startTime).atZone(zone).toInstant()
|
start = startDate.atTime(startTime).atZone(zone).toInstant()
|
||||||
end = endDate.atTime(endTime).atZone(zone).toInstant()
|
end = endDate.atTime(endTime).atZone(zone).toInstant()
|
||||||
}
|
}
|
||||||
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color)
|
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local")
|
||||||
},
|
},
|
||||||
enabled = writableCalendars.isNotEmpty(),
|
enabled = writableCalendars.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user