chore(repo): Android-App als android/ ins Hauptrepo uebernehmen

Calendarr-Android wird mit vollstaendiger Historie als Unterverzeichnis
android/ eingebunden, damit Server, Web und die mobilen Apps in einem
Repo liegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 09:19:55 +02:00
co-authored by Claude Opus 5
108 changed files with 11709 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".CalendarrApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Calendarr"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Calendarr.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".notifications.ReminderReceiver"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,7 @@
package com.scarriffle.calendarr
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CalendarrApplication : Application()
@@ -0,0 +1,26 @@
package com.scarriffle.calendarr
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.scarriffle.calendarr.ui.CalendarrRoot
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Covers the window from the first frame (incl. warm-start), then hands
// off to the in-app branded splash which stays until data is loaded.
installSplashScreen()
// Draw edge-to-edge so system-bar insets reach every window, including
// ModalBottomSheets — otherwise navigationBarsPadding() inside the sheets
// resolves to 0 and the bottom menu rows hide behind the nav bar.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
CalendarrRoot()
}
}
}
@@ -0,0 +1,731 @@
package com.scarriffle.calendarr.data
import com.scarriffle.calendarr.data.remote.ApiException
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.data.remote.UnauthorizedException
import com.scarriffle.calendarr.data.remote.ensureSuccess
import com.scarriffle.calendarr.data.remote.errorDetail
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
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.OidcProvider
import com.scarriffle.calendarr.domain.model.UserProfile
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
import javax.inject.Inject
import javax.inject.Singleton
data class LoginResult(val token: String, val username: String, val isAdmin: Boolean)
data class TotpSetup(val secret: String, val qrUrl: String)
/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch.
* [calendarId] is null for account-wide failures (no single calendar is at fault). */
data class SyncError(val source: String, val name: String, val calendarId: Int?, val message: String)
/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */
data class EventsResult(val events: List<CalEvent>, val errors: List<SyncError>)
/**
* Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi],
* converts HTTP failures into [ApiException]s carrying the server's `detail`
* message, and parses the mixed-type event payloads with org.json.
*/
@Singleton
class CalendarRepository @Inject constructor(
private val apiProvider: ApiProvider,
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
) {
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()
} catch (e: HttpException) {
if (e.code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(e.response()?.errorBody(), e.code()))
}
}
// ---- Auth ----
suspend fun setupRequired(baseUrl: String): Boolean = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).setupRequired()
if (!resp.isSuccessful) return@withContext false
val raw = resp.body()?.string() ?: return@withContext false
runCatching { JSONObject(raw).optBoolean("required", false) }.getOrDefault(false)
}
suspend fun login(
baseUrl: String,
username: String,
password: String,
totpCode: String?,
rememberMe: Boolean,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"username" to username,
"password" to password,
"remember_me" to rememberMe,
"totp_code" to totpCode,
)
val resp = apiProvider.apiFor(baseUrl).login(body)
if (resp.code() == 401) {
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull()
if (detail == "2fa_required") throw TwoFactorRequiredException()
throw UnauthorizedException(detail?.takeIf { it.isNotBlank() } ?: "Benutzername oder Passwort falsch")
}
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = username)
}
// ---- Single sign-on (OpenID Connect) ----
/** Providers configured on the server. Empty list when SSO is off. */
suspend fun oidcProviders(baseUrl: String): List<OidcProvider> = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).oidcProviders()
if (!resp.isSuccessful) return@withContext emptyList()
val raw = resp.body()?.string() ?: return@withContext emptyList()
runCatching {
val json = JSONObject(raw)
if (!json.optBoolean("enabled", false)) return@runCatching emptyList<OidcProvider>()
val arr = json.optJSONArray("providers") ?: return@runCatching emptyList<OidcProvider>()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
// Without a mobile client id the app cannot start a flow.
val clientId = o.optString("mobile_client_id").takeIf { it.isNotBlank() }
?: return@mapNotNull null
// Without an issuer the flow can only fail later with a
// misleading "unreachable" — drop the provider here instead.
val issuer = o.optString("issuer").takeIf { it.isNotBlank() }
?: return@mapNotNull null
OidcProvider(
key = o.optString("key"),
name = o.optString("name").takeIf { it.isNotBlank() } ?: o.optString("key"),
issuer = issuer,
// The server decides the scopes (it knows whether the
// provider grants offline_access); the app never adds any.
scopes = o.optString("mobile_scopes").takeIf { it.isNotBlank() }
?: o.optString("scopes").takeIf { it.isNotBlank() }
?: "openid profile email",
mobileClientId = clientId,
)
}
}.getOrDefault(emptyList())
}
/**
* Trade a verified provider ID token for a Calendarr token.
*
* The app is a public client: the request carries no secret, only the id
* token the provider issued for [clientId] plus the [nonce] AppAuth bound
* into the flow.
*/
suspend fun exchangeOidc(
baseUrl: String,
provider: String,
clientId: String,
idToken: String,
accessToken: String?,
nonce: String?,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"provider" to provider,
"client_id" to clientId,
"id_token" to idToken,
"access_token" to accessToken,
"nonce" to nonce,
)
val resp = apiProvider.apiFor(baseUrl).oidcExchange(body)
if (!resp.isSuccessful) {
// The server answers with a stable slug in `detail`; map the ones a
// user can act on, and fall back to the raw detail otherwise.
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull().orEmpty()
throw OidcException(detail.takeIf { it.isNotBlank() } ?: "oidc_failed")
}
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = null)
}
/** Shared tail of [login] and [exchangeOidc]: read the user, persist, reset. */
private fun persistLogin(baseUrl: String, json: JSONObject, fallbackUsername: String?): LoginResult {
val token = json.optString("access_token").takeIf { it.isNotBlank() }
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val user = json.optJSONObject("user")
// A blank username would be persisted as a blank display name too, so
// refuse rather than store a nameless identity.
val uname = user?.optString("username")?.takeIf { it.isNotBlank() }
?: fallbackUsername
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
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, uid, displayName)
apiProvider.invalidate()
return LoginResult(token, uname, isAdmin)
}
// ---- Settings / profile ----
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
/** Push only the values whose sync flag is on (partial update; the server
* leaves unsynced columns untouched), plus the account-wide flag map. */
suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
val body = mutableMapOf<String, Any?>()
fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
addIf("default_view", s.defaultView)
addIf("week_start_day", s.weekStartDay)
addIf("dim_past_events", s.dimPastEvents)
addIf("hour_height", s.hourHeight)
addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
addIf("primary_color", s.primaryColor)
addIf("accent_color", s.accentColor)
addIf("today_color", s.todayColor)
addIf("text_color", s.textColor)
addIf("bg_color", s.backgroundColor)
addIf("line_color", s.lineColor)
addIf("month_divider_color", s.monthDividerColor)
addIf("month_label_color", s.monthLabelColor)
addIf("cache_months", s.cacheMonths)
addIf("month_view_paged", s.monthViewPaged)
// The flag map is always account-wide; send it every push.
body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>)
api.updateSettings(jsonBody(body)).ensureSuccess()
}
suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
suspend fun updateEmail(email: String) = guarded {
api.updateEmail(jsonBody("email" to email)).ensureSuccess()
}
suspend fun changePassword(current: String, new: String) = guarded {
api.changePassword(
jsonBody("current_password" to current, "new_password" to new)
).ensureSuccess()
}
suspend fun setup2fa(): TotpSetup = guarded {
val resp = api.setup2fa()
resp.ensureSuccess()
val json = JSONObject(resp.body()?.string() ?: "{}")
TotpSetup(json.optString("secret"), json.optString("qr_url"))
}
suspend fun enable2fa(code: String) = guarded {
api.enable2fa(jsonBody("code" to code)).ensureSuccess()
}
suspend fun disable2fa(password: String) = guarded {
api.disable2fa(jsonBody("password" to password)).ensureSuccess()
}
// ---- Accounts ----
suspend fun getCalDAVAccounts(): List<CalDAVAccount> = guarded { api.getCalDAVAccounts() }
suspend fun addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) =
guarded {
api.addCalDAVAccount(
jsonBody(
"name" to name, "url" to url, "username" to username,
"password" to password, "color" to color,
)
)
}
suspend fun deleteCalDAVAccount(id: Int) = guarded { api.deleteCalDAVAccount(id).ensureSuccess() }
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
suspend fun addLocalCalendar(
name: String, color: String,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = guarded {
api.addLocalCalendar(jsonBody(buildMap {
put("name", name)
put("color", color)
if (isBirthday) put("is_birthday", true)
if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore)
}))
}
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
suspend fun getICalSubscriptions(): List<ICalSubscription> = guarded { api.getICalSubscriptions() }
suspend fun addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) =
guarded {
api.addICalSubscription(
jsonBody(
"name" to name, "url" to url, "color" to color,
"refresh_minutes" to refreshMinutes,
)
)
}
suspend fun deleteICalSubscription(id: Int) = guarded { api.deleteICalSubscription(id).ensureSuccess() }
suspend fun getGoogleAccounts(): List<GoogleAccount> = guarded { api.getGoogleAccounts() }
suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() }
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount> =
guarded { api.getHomeAssistantAccounts() }
suspend fun addHomeAssistantAccount(name: String, url: String, token: String) =
guarded {
api.addHomeAssistantAccount(
jsonBody("name" to name, "url" to url, "token" to token, "auth_method" to "token")
)
}
suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** Change a local calendar's colour. Uses the colour-only endpoint so share
* recipients can recolour their view (own per-user colour) without needing
* write access or being able to rename the calendar. */
suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded {
api.setLocalCalendarColor(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)
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 `reminders_enabled` flag (all sources). */
suspend fun setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Boolean) = guarded {
val body = jsonBody("reminders_enabled" to enabled)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Resolve all calendars the user can create events in. */
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>()
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList())
// Exclude read-only shared calendars — offering them in the event
// editor only leads to a 403 on save. Own + read_write (incl. group)
// calendars stay.
.filter { it.owned || it.permission == "read_write" }
.forEach { cal ->
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
}
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
.filter { it.enabled }
.forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"caldav-${cal.id}", "${acc.name} – ${cal.name}",
cal.color ?: acc.color, "caldav", cal.id,
)
}
}
runCatching { api.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"google-${cal.id}", "${acc.email} – ${cal.name}",
cal.color ?: "#4285f4", "google", cal.id,
)
}
}
runCatching { api.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"ha-${cal.id}", "${acc.name} – ${cal.name}",
cal.color ?: "#46bdc6", "homeassistant", cal.id,
)
}
}
result
}
// ---- Events ----
suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) {
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList())
val root = JSONObject(raw)
val arr = root.optJSONArray("events")
val events = buildList {
if (arr != null) for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
val errArr = root.optJSONArray("errors")
val errors = buildList {
if (errArr != null) for (i in 0 until errArr.length()) {
val obj = errArr.optJSONObject(i) ?: continue
add(SyncError(
source = obj.optString("source"),
name = obj.optString("name"),
calendarId = if (obj.has("calendar_id")) obj.optInt("calendar_id") else null,
message = obj.optString("message"),
))
}
}
EventsResult(events, errors)
}
suspend fun createLocalEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
}
suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.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?, directoryHidden: Boolean? = null): String? = guarded {
val resp = api.updateProfile(jsonBody(
"display_name" to displayName,
"username" to username,
"email" to email,
"directory_hidden" to directoryHidden,
))
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(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.createCalDAVEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
.ensureSuccess()
}
suspend fun updateCalDAVEvent(
uid: String, url: String, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.updateCalDAVEvent(
uid, url, calendarId,
eventBody(null, title, start, end, isAllDay, location, description, color),
).ensureSuccess()
}
suspend fun deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) =
guarded { api.deleteCalDAVEvent(uid, url, calendarId).ensureSuccess() }
suspend fun createGoogleEvent(
calendarDbId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun updateGoogleEvent(
gcalDbId: Int, eventId: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.updateGoogleEvent(gcalDbId, eventId, simpleEventBody(null, null, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun deleteGoogleEvent(gcalDbId: Int, eventId: String) =
guarded { api.deleteGoogleEvent(gcalDbId, eventId).ensureSuccess() }
suspend fun createHAEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun updateHAEvent(
calendarId: Int, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.updateHAEvent(calendarId, uid, simpleEventBody(null, null, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun deleteHAEvent(calendarId: Int, uid: String) =
guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() }
/** Body for Google/HA events (no per-event colour). */
private fun simpleEventBody(
calKey: String?, calId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = jsonBody(
buildMap {
if (calKey != null && calId != null) put(calKey, calId)
put("title", title)
put("start", Dates.format(start, isAllDay))
put("end", Dates.format(end, isAllDay))
put("allDay", isAllDay)
put("location", location)
put("description", description)
}
)
private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = jsonBody(
buildMap {
calendarId?.let { put("calendar_id", it) }
put("title", title)
put("start", Dates.format(start, isAllDay))
put("end", Dates.format(end, isAllDay))
put("allDay", isAllDay)
put("location", location)
put("description", description)
if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
if (!rrule.isNullOrBlank()) put("rrule", rrule)
if (birthYear != null) put("birth_year", birthYear)
if (!externalUid.isNullOrBlank()) put("external_uid", externalUid)
}
)
// ---- Birthdays (Contacts import) ----
suspend fun getBirthdayEntries(calendarId: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry> =
guarded { api.getBirthdays(calendarId) }
suspend fun reportBirthdaySync(deviceId: String, deviceName: String, count: Int) = guarded {
api.reportBirthdaySync(jsonBody("device_id" to deviceId, "device_name" to deviceName, "count" to count))
.ensureSuccess()
}
}
@@ -0,0 +1,66 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.provider.ContactsContract
/** One birthday read from the address book. */
data class ContactBirthday(
val contactId: String,
val name: String,
val month: Int,
val day: Int,
val year: Int?,
)
/** Reads birthdays from the system Contacts (requires READ_CONTACTS). */
object ContactsReader {
fun readBirthdays(context: Context): List<ContactBirthday> {
val out = mutableListOf<ContactBirthday>()
val projection = arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.CommonDataKinds.Event.START_DATE,
ContactsContract.Data.DISPLAY_NAME,
)
val selection = "${ContactsContract.Data.MIMETYPE} = ? AND ${ContactsContract.CommonDataKinds.Event.TYPE} = ?"
val args = arrayOf(
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY.toString(),
)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)
?.use { c ->
val idIdx = c.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
val dateIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.START_DATE)
val nameIdx = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME)
while (c.moveToNext()) {
val raw = c.getString(dateIdx) ?: continue
val name = c.getString(nameIdx)?.takeIf { it.isNotBlank() } ?: continue
val id = c.getString(idIdx) ?: continue
val ymd = parseDate(raw) ?: continue
out.add(ContactBirthday(id, name, ymd.second, ymd.third, ymd.first))
}
}
// A contact could carry more than one birthday row — keep the first.
return out.distinctBy { it.contactId }
}
/** Returns (year?, month, day). Handles "yyyy-MM-dd", "--MM-dd" (no year),
* and "yyyyMMdd". */
private fun parseDate(raw: String): Triple<Int?, Int, Int>? = try {
val s = raw.trim()
when {
s.startsWith("--") -> {
val p = s.removePrefix("--").split("-")
Triple(null, p[0].toInt(), p[1].toInt())
}
s.contains("-") -> {
val p = s.split("-")
if (p.size == 3) Triple(p[0].toIntOrNull()?.takeIf { it > 0 }, p[1].toInt(), p[2].toInt()) else null
}
s.length == 8 -> Triple(s.substring(0, 4).toInt().takeIf { it > 0 }, s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
else -> null
}
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,128 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Securely stores the server URL and auth token in EncryptedSharedPreferences
* (Android's equivalent of the iOS Keychain). The iOS app keeps these in
* UserDefaults; on Android we encrypt them at rest.
*/
@Singleton
class CredentialStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"calendarr_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
var serverUrl: String?
get() = prefs.getString(KEY_SERVER_URL, null)
set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply()
var token: String?
get() = prefs.getString(KEY_TOKEN, null)
set(value) = prefs.edit().putString(KEY_TOKEN, value).apply()
var username: String?
get() = prefs.getString(KEY_USERNAME, null)
set(value) = prefs.edit().putString(KEY_USERNAME, value).apply()
var isAdmin: Boolean
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()
/**
* Serialised AppAuth [net.openid.appauth.AuthState] for the SSO session —
* holds the provider's refresh token, so it must stay in the encrypted
* store and never in plain preferences.
*/
var oidcState: String?
get() = prefs.getString(KEY_OIDC_STATE, null)
set(value) = prefs.edit().putString(KEY_OIDC_STATE, value).apply()
/** Provider key and client id the current SSO session was obtained with. */
var oidcProvider: String?
get() = prefs.getString(KEY_OIDC_PROVIDER, null)
set(value) = prefs.edit().putString(KEY_OIDC_PROVIDER, value).apply()
var oidcClientId: String?
get() = prefs.getString(KEY_OIDC_CLIENT_ID, null)
set(value) = prefs.edit().putString(KEY_OIDC_CLIENT_ID, 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, 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()
}
/** Persist the SSO session alongside the Calendarr token. */
fun saveOidcSession(state: String, provider: String, clientId: String) {
prefs.edit()
.putString(KEY_OIDC_STATE, state)
.putString(KEY_OIDC_PROVIDER, provider)
.putString(KEY_OIDC_CLIENT_ID, clientId)
.apply()
}
/** Clear the token (logout) but keep the server URL. */
fun clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_IS_ADMIN)
.remove(KEY_OIDC_STATE)
.remove(KEY_OIDC_PROVIDER)
.remove(KEY_OIDC_CLIENT_ID)
.apply()
}
/** Full reset, including the server URL ("switch server"). */
fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val KEY_SERVER_URL = "server_url"
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"
const val KEY_OIDC_STATE = "oidc_state"
const val KEY_OIDC_PROVIDER = "oidc_provider"
const val KEY_OIDC_CLIENT_ID = "oidc_client_id"
}
}
@@ -0,0 +1,160 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.Intent
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** The app is registered as a *public* client — no secret is ever sent. */
private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect"
/** Outcome of a silent renewal against the identity provider. */
data class RenewedTokens(
val idToken: String,
val accessToken: String?,
val nonce: String?,
val serialisedState: String,
)
/**
* Drives the OpenID Connect Authorization Code Flow with PKCE against the
* identity provider, using AppAuth and a Chrome Custom Tab.
*
* Deliberately never uses a WebView: a custom tab keeps the credentials in the
* browser's own security context and lets the provider's session cookies work.
*/
@Singleton
class OidcAuthenticator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Volatile
private var service: AuthorizationService? = null
private fun service(): AuthorizationService =
service ?: synchronized(this) {
service ?: AuthorizationService(context).also { service = it }
}
/** Fetch the provider's OpenID configuration from its issuer URL. */
suspend fun discover(provider: OidcProvider): AuthorizationServiceConfiguration =
suspendCancellableCoroutine { cont ->
AuthorizationServiceConfiguration.fetchFromIssuer(
android.net.Uri.parse(provider.issuer)
) { config, ex ->
when {
config != null -> cont.resume(config)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
/**
* Build the authorization request. AppAuth generates the PKCE verifier and
* the nonce itself; both are carried on the returned request, and the nonce
* has to travel to our server later so it can verify the ID token.
*/
fun buildRequest(
config: AuthorizationServiceConfiguration,
provider: OidcProvider,
): AuthorizationRequest {
// Exactly what the server asked for — including offline_access when it
// configured that. A scope the provider does not know would make it
// reject the whole authorization request.
val scopes = buildSet {
addAll(provider.scopes.split(" ").filter { it.isNotBlank() })
add("openid")
}
return AuthorizationRequest.Builder(
config,
requireNotNull(provider.mobileClientId) { "provider has no mobile client id" },
ResponseTypeValues.CODE,
android.net.Uri.parse(REDIRECT_URI),
)
.setScopes(scopes)
.build()
}
/** Intent that opens the provider's login page in a Custom Tab. */
fun authorizationIntent(request: AuthorizationRequest): Intent =
service().getAuthorizationRequestIntent(request)
/**
* Complete the code exchange with the provider.
*
* PKCE means no client secret is involved: the verifier AppAuth kept from
* [buildRequest] is what proves this is the same app that started the flow.
*/
suspend fun exchangeCode(data: Intent): Pair<AuthorizationResponse, TokenResponse> {
val response = AuthorizationResponse.fromIntent(data)
val error = AuthorizationException.fromIntent(data)
if (response == null) throw error ?: AuthorizationException.GeneralErrors.NETWORK_ERROR
val tokens: TokenResponse = suspendCancellableCoroutine { cont ->
service().performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex ->
when {
tokens != null -> cont.resume(tokens)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
return response to tokens
}
/**
* Ask the provider for a fresh ID token using a stored [AuthState].
*
* Returns null when there is no session to renew. The refreshed state is
* handed back too, because the provider may have rotated the refresh token.
*/
suspend fun renewIdToken(serialisedState: String): RenewedTokens? {
val state = runCatching { AuthState.jsonDeserialize(serialisedState) }.getOrNull()
?: return null
val tokens: Pair<String?, String?> = suspendCancellableCoroutine { cont ->
state.performActionWithFreshTokens(service()) { accessToken, idToken, ex ->
if (ex != null) {
cont.resumeWithException(ex)
} else {
cont.resume(idToken to accessToken)
}
}
}
val idToken = tokens.first ?: return null
return RenewedTokens(
idToken = idToken,
accessToken = tokens.second,
// The original request rides along inside the state, so the nonce
// survives. A refreshed token often drops the claim, which the
// server tolerates — but when it keeps it, it has to match.
nonce = state.lastAuthorizationResponse?.request?.nonce,
serialisedState = state.jsonSerializeString(),
)
}
/**
* Serialised AuthState (including the refresh token) for secure storage.
*
* Both arguments are non-null on purpose: `AuthState.update` asserts that
* exactly one of (response, exception) is set and throws otherwise.
*/
fun stateFor(response: AuthorizationResponse, tokens: TokenResponse): String =
AuthState().apply {
update(response, null)
update(tokens, null)
}.jsonSerializeString()
}
@@ -0,0 +1,243 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local persistence for cached appearance settings and the per-device
* calendar visibility sets (hidden / banished). Mirrors the UserDefaults
* usage in the iOS `CalendarStore`.
*/
@Singleton
class SettingsStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("calendarr_prefs", Context.MODE_PRIVATE)
// --- Appearance settings cache (mirror of the server) ---
fun loadSettings(): AppSettings = AppSettings(
defaultView = prefs.getString(K_DEFAULT_VIEW, null) ?: "month",
weekStartDay = prefs.getString(K_WEEK_START, null) ?: "monday",
primaryColor = prefs.getString(K_PRIMARY, null) ?: "#4285f4",
accentColor = prefs.getString(K_ACCENT, null) ?: "#ea4335",
todayColor = prefs.getString(K_TODAY, null) ?: "#4285f4",
dimPastEvents = prefs.getBoolean(K_DIM_PAST, false),
textContrast = prefs.getInt(K_TEXT_CONTRAST, 3),
lineContrast = prefs.getInt(K_LINE_CONTRAST, 3),
hourHeight = prefs.getInt(K_HOUR_HEIGHT, 60),
language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF",
backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000",
lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3),
monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false),
)
fun saveSettings(s: AppSettings) {
prefs.edit()
.putString(K_DEFAULT_VIEW, s.defaultView)
.putString(K_WEEK_START, s.weekStartDay)
.putString(K_PRIMARY, s.primaryColor)
.putString(K_ACCENT, s.accentColor)
.putString(K_TODAY, s.todayColor)
.putBoolean(K_DIM_PAST, s.dimPastEvents)
.putInt(K_TEXT_CONTRAST, s.textContrast)
.putInt(K_LINE_CONTRAST, s.lineContrast)
.putInt(K_HOUR_HEIGHT, s.hourHeight)
.putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor)
.putString(K_TEXT_COLOR, s.textColor)
.putString(K_BG_COLOR, s.backgroundColor)
.putString(K_LINE_COLOR, s.lineColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
.putInt(K_CACHE_MONTHS, s.cacheMonths)
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
.apply()
}
// --- Per-setting cross-device sync flags (account-wide; server is authority) ---
/** Keys this Android client can sync. Excludes language (device "system" has
* no server value) and text/line contrast (iOS-style opacity, device-local). */
val syncableKeys: List<String> = listOf(
"default_view", "week_start_day", "dim_past_events", "hour_height",
"default_event_duration_minutes", "default_reminder_minutes",
"primary_color", "accent_color", "today_color",
"text_color", "bg_color", "line_color",
"month_divider_color", "month_label_color",
"cache_months", "month_view_paged",
)
private val defaultSync: Map<String, Boolean> = syncableKeys.associateWith { key ->
key != "cache_months" && key != "month_view_paged"
}
fun loadSyncFlags(): Map<String, Boolean> {
val result = defaultSync.toMutableMap()
val raw = prefs.getString(K_SYNC_FLAGS, null)
if (!raw.isNullOrBlank()) {
runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj ->
for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key)
}
}
return result
}
private fun writeSyncFlags(f: Map<String, Boolean>) {
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
}
fun storeServerFlags(server: Map<String, Boolean>?) {
if (server == null) return
val f = loadSyncFlags().toMutableMap()
for (key in syncableKeys) server[key]?.let { f[key] = it }
writeSyncFlags(f)
}
fun setSyncFlag(key: String, on: Boolean) {
val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f)
}
fun setAllSyncFlags(on: Boolean) {
val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f)
}
/** Merge a server snapshot with local values honouring the (refreshed) flags:
* synced keys take the server value, others keep the local value. Persists
* the result and returns the effective settings. */
fun applyServerPull(server: AppSettings): AppSettings {
storeServerFlags(server.syncFlags)
val flags = loadSyncFlags()
val local = loadSettings()
fun on(key: String) = flags[key] == true
val eff = local.copy(
defaultView = if (on("default_view")) server.defaultView else local.defaultView,
weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay,
dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents,
hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight,
defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes,
defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes,
primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor,
accentColor = if (on("accent_color")) server.accentColor else local.accentColor,
todayColor = if (on("today_color")) server.todayColor else local.todayColor,
textColor = if (on("text_color")) server.textColor else local.textColor,
backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor,
lineColor = if (on("line_color")) server.lineColor else local.lineColor,
monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor,
monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor,
cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths,
monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged,
// Device-local (never synced): language + contrast levels.
language = local.language,
textContrast = local.textContrast,
lineContrast = local.lineContrast,
)
saveSettings(eff)
return eff
}
/** Device-local cache range in months around today (default 3). */
var cacheMonths: Int
get() = prefs.getInt(K_CACHE_MONTHS, 3)
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
/** Device-local: month view as horizontal paged (swipe) instead of the
* continuous vertical scroll feed (default false = scroll). */
var monthViewPaged: Boolean
get() = prefs.getBoolean(K_MONTH_PAGED, false)
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = prefs.getBoolean(K_HIDE_MENU, false)
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
var birthdaysSyncEnabled: Boolean
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
/** Stable per-install id used to scope this device's contact-birthday rows. */
val birthdaysDeviceId: String
get() {
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
return id
}
// --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String>
get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply()
// --- Banished calendars ("source:id") ---
var banishedCalendarKeys: Set<String>
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply()
// --- Reminder-disabled calendars ("source:id") ---
// Mirrors the server's per-calendar `reminders_enabled` flag so the
// notification scheduler can skip muted calendars without deleting any
// event reminders.
var reminderDisabledCalendarKeys: Set<String>
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
var calendarOrder: List<String>
get() {
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
return runCatching {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList())
}
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
private companion object {
const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day"
const val K_PRIMARY = "primary_color"
const val K_ACCENT = "accent_color"
const val K_TODAY = "today_color"
const val K_DIM_PAST = "dim_past_events"
const val K_TEXT_CONTRAST = "text_contrast"
const val K_LINE_CONTRAST = "line_contrast"
const val K_HOUR_HEIGHT = "hour_height"
const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color"
const val K_TEXT_COLOR = "text_color"
const val K_BG_COLOR = "bg_color"
const val K_LINE_COLOR = "line_color"
const val K_SYNC_FLAGS = "sync_flags"
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDE_MENU = "hide_menu_button"
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
const val K_BDAY_DEVICE = "birthdays_device_id"
const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
const val K_CAL_ORDER = "calendar_order"
}
}
@@ -0,0 +1,42 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.ResponseBody
import org.json.JSONObject
import retrofit2.Response
/** Generic server error carrying the `detail` message when available. */
open class ApiException(message: String) : Exception(message)
/** Credentials rejected (HTTP 401, not a 2FA prompt). Carries the server detail. */
class UnauthorizedException(message: String = "Benutzername oder Passwort falsch") : ApiException(message)
/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */
class TwoFactorRequiredException : ApiException("2FA-Code erforderlich")
/** Extract the `detail` field from an error response body, with a fallback. */
fun errorDetail(errorBody: ResponseBody?, status: Int): String {
val raw = runCatching { errorBody?.string() }.getOrNull()
if (!raw.isNullOrBlank()) {
runCatching {
val detail = JSONObject(raw).optString("detail")
if (detail.isNotBlank()) return detail
}
}
return "Fehler $status"
}
/** Throw an [ApiException] if the response is unsuccessful. */
fun Response<*>.ensureSuccess() {
if (!isSuccessful) {
if (code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(errorBody(), code()))
}
}
/**
* An SSO exchange the server refused. [slug] is the stable machine-readable
* reason (e.g. `oidc_account_not_linked`), which the UI maps to a translated
* message; unknown slugs fall back to a generic one.
*/
class OidcException(val slug: String) : Exception(slug)
@@ -0,0 +1,71 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds [CalendarrApi] instances. The base URL is dynamic (the user's server),
* so the cached instance is rebuilt whenever the stored server URL changes.
*/
@Singleton
class ApiProvider @Inject constructor(
private val credentialStore: CredentialStore,
private val moshi: Moshi,
private val authInterceptor: AuthInterceptor,
) {
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
}
@Volatile private var cached: Pair<String, CalendarrApi>? = null
/** API bound to the currently stored server URL. Requires a configured server. */
fun api(): CalendarrApi {
val base = normalize(credentialStore.serverUrl ?: error("No server URL configured"))
cached?.let { (url, api) -> if (url == base) return api }
val api = build(base)
cached = base to api
return api
}
/** API for an explicit base URL (used during server setup / login). */
fun apiFor(baseUrl: String): CalendarrApi = build(normalize(baseUrl))
/** Force the cached instance to be rebuilt (e.g. after switching servers). */
fun invalidate() { cached = null }
private fun build(baseUrl: String): CalendarrApi =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(CalendarrApi::class.java)
companion object {
/** Ensure https:// prefix, strip trailing slashes, then add exactly one. */
fun normalize(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
url = url.trimEnd('/')
return "$url/"
}
}
}
@@ -0,0 +1,27 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
/**
* Attaches the current bearer token (read dynamically from the
* [CredentialStore]) to every request. Requests made before login simply
* carry no Authorization header.
*/
class AuthInterceptor @Inject constructor(
private val credentialStore: CredentialStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = credentialStore.token
val request = if (!token.isNullOrBlank()) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
@@ -0,0 +1,289 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.GoogleAccount
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
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
/**
* Retrofit definition of the Calendarr server HTTP API.
*
* Endpoints whose responses contain mixed-type fields (event `id` /
* `calendar_id` arrive as either String or Int) return a raw [ResponseBody]
* and are parsed manually with org.json, mirroring the iOS client.
*/
interface CalendarrApi {
// ---- Auth ----
@GET("api/auth/setup-required")
suspend fun setupRequired(): Response<ResponseBody>
/** Configured OIDC providers. Public — used before login. */
@GET("api/auth/oidc/providers")
suspend fun oidcProviders(): Response<ResponseBody>
/** Trade a provider ID token for a Calendarr token. Same response shape as
* [login]. Public client: never send a client secret. */
@POST("api/auth/oidc/exchange")
suspend fun oidcExchange(@Body body: RequestBody): Response<ResponseBody>
@POST("api/auth/login")
suspend fun login(@Body body: RequestBody): Response<ResponseBody>
@GET("api/auth/me")
suspend fun getProfile(): UserProfile
// ---- Settings ----
@GET("api/settings/")
suspend fun getSettings(): AppSettings
@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/")
suspend fun updateEmail(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/password")
suspend fun changePassword(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/setup")
suspend fun setup2fa(): Response<ResponseBody>
@POST("api/profile/2fa/enable")
suspend fun enable2fa(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/disable")
suspend fun disable2fa(@Body body: RequestBody): Response<ResponseBody>
// ---- CalDAV ----
@GET("api/caldav/accounts")
suspend fun getCalDAVAccounts(): List<CalDAVAccount>
@POST("api/caldav/accounts")
suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount
@DELETE("api/caldav/accounts/{id}")
suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@POST("api/caldav/accounts/{id}/sync")
suspend fun syncCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/caldav/calendars/{id}")
suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Local ----
@GET("api/local/calendars")
suspend fun getLocalCalendars(): List<LocalCalendar>
@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>
// Colour-only update that works for owners AND share recipients (recipients
// get their own per-user colour; owners change the calendar's colour).
@PUT("api/local/calendars/{id}/color")
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- iCal subscriptions ----
@GET("api/ical/subscriptions")
suspend fun getICalSubscriptions(): List<ICalSubscription>
@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>
@POST("api/ical/subscriptions/{id}/refresh")
suspend fun refreshICalSubscription(@Path("id") id: Int): Response<ResponseBody>
// ---- Google ----
@GET("api/google/accounts")
suspend fun getGoogleAccounts(): List<GoogleAccount>
@DELETE("api/google/accounts/{id}")
suspend fun deleteGoogleAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/google/calendars/{id}")
suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Home Assistant ----
@GET("api/homeassistant/accounts")
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount>
@POST("api/homeassistant/accounts")
suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount
@DELETE("api/homeassistant/accounts/{id}")
suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/homeassistant/calendars/{id}")
suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Events ----
@GET("api/caldav/events")
suspend fun fetchEvents(
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
@POST("api/local/events")
suspend fun createLocalEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/local/events/{uid}")
suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
@GET("api/local/calendars/{id}/birthdays")
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
@POST("api/birthdays/sync-report")
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
@POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/caldav/events/{uid}")
suspend fun updateCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/caldav/events/{uid}", hasBody = false)
suspend fun deleteCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
): Response<ResponseBody>
@POST("api/google/events")
suspend fun createGoogleEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/google/events/{gcalDbId}/{eventId}")
suspend fun updateGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false)
suspend fun deleteGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
): Response<ResponseBody>
@POST("api/homeassistant/events")
suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/homeassistant/events/{calendarId}/{uid}")
suspend fun updateHAEvent(
@Path("calendarId") calendarId: Int,
@Path("uid") uid: String,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false)
suspend fun deleteHAEvent(
@Path("calendarId") calendarId: Int,
@Path("uid") uid: String,
): Response<ResponseBody>
}
@@ -0,0 +1,26 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val JSON = "application/json; charset=utf-8".toMediaType()
/** Build a JSON request body from key/value pairs, dropping null values. */
fun jsonBody(vararg pairs: Pair<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in pairs) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}
/** Build a JSON request body from a map, dropping null values. */
fun jsonBody(map: Map<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in map) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}
@@ -0,0 +1,21 @@
package com.scarriffle.calendarr.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideMoshi(): Moshi =
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
@@ -0,0 +1,123 @@
package com.scarriffle.calendarr.domain.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
data class CalDAVAccount(
val id: Int,
val name: String = "",
val url: String = "",
val username: String = "",
val color: String = "#4285f4",
val enabled: Boolean = true,
val calendars: List<CalDAVCalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class CalDAVCalendar(
val id: Int,
val name: String = "",
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
data class LocalCalendar(
val id: Int,
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,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
@Json(name = "is_birthday") val isBirthday: Boolean = false,
@Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null,
)
@JsonClass(generateAdapter = false)
data class ICalSubscription(
val id: Int,
val name: String = "",
val url: String = "",
val color: String = "#46bdc6",
val enabled: Boolean = true,
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
@Json(name = "last_fetched") val lastFetched: String? = null,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
data class GoogleAccount(
val id: Int,
val email: String = "",
val calendars: List<GoogleCalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class GoogleCalendar(
val id: Int,
val name: String = "",
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
data class HomeAssistantAccount(
val id: Int,
val name: String = "",
val url: String = "",
@Json(name = "auth_method") val authMethod: String = "token",
val calendars: List<HACalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class HACalendar(
val id: Int,
val name: String = "",
@Json(name = "entity_id") val entityId: String = "",
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
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,
@Json(name = "totp_enabled") val totpEnabled: Boolean = false,
@Json(name = "directory_hidden") val directoryHidden: Boolean = false,
)
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
@JsonClass(generateAdapter = false)
data class BirthdayEntry(
val uid: String,
@Json(name = "external_uid") val externalUid: String? = null,
val title: String = "",
val month: Int? = null,
val day: Int? = null,
@Json(name = "birth_year") val birthYear: Int? = null,
)
/** A calendar the user can create events in (resolved from all writable sources). */
data class WritableCalendar(
val id: String,
val name: String,
val color: String,
val source: String,
val numericId: Int,
)
@@ -0,0 +1,44 @@
package com.scarriffle.calendarr.domain.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* User appearance / behaviour settings, synced with the server.
* Mirrors iOS `AppSettings`. Missing keys fall back to the defaults below
* (the server only persists a subset).
*/
@JsonClass(generateAdapter = false)
data class AppSettings(
@Json(name = "default_view") val defaultView: String = "month",
@Json(name = "week_start_day") val weekStartDay: String = "monday",
@Json(name = "primary_color") val primaryColor: String = "#4285f4",
@Json(name = "accent_color") val accentColor: String = "#ea4335",
@Json(name = "today_color") val todayColor: String = "#4285f4",
@Json(name = "dim_past_events") val dimPastEvents: Boolean = false,
@Json(name = "text_contrast") val textContrast: Int = 3,
@Json(name = "line_contrast") val lineContrast: Int = 3,
@Json(name = "hour_height") val hourHeight: Int = 60,
@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",
// Text / background / line colours drive the Material theme (onBackground,
// background, outline) so the whole app follows them, matching web/iOS.
@Json(name = "text_color") val textColor: String = "#FFFFFF",
@Json(name = "bg_color") val backgroundColor: String = "#000000",
@Json(name = "line_color") val lineColor: String = "#3A3A52",
// How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
// Minutes-before-start applied to all events client-side; null = off.
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
// Duration (minutes) applied to a newly created event's end time.
@Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60,
// Preload range in months (device-local by default) and month-view paging.
@Json(name = "cache_months") val cacheMonths: Int = 3,
@Json(name = "month_view_paged") val monthViewPaged: Boolean = false,
// Account-wide per-setting sync flags, fully resolved by the server.
@Json(name = "sync_flags") val syncFlags: Map<String, Boolean>? = null,
) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
}
@@ -0,0 +1,145 @@
package com.scarriffle.calendarr.domain.model
import com.scarriffle.calendarr.util.Dates
import org.json.JSONObject
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,
val title: String,
val startDate: Instant,
val endDate: Instant,
val isAllDay: Boolean,
val location: String,
val notes: String,
val color: String?,
val calendarId: String,
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,
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
val displayTitle: String? = null,
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
val reminders: List<Int> = emptyList(),
// True for events from a calendar shared with the user read-only.
val readOnly: Boolean = false,
// True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`.
val isBirthday: Boolean = false,
) {
/**
* Title to render: the server-decorated one (birthday age, group prefix)
* wins over the raw title, which is kept for editing.
*/
val renderTitle: String
get() = displayTitle?.takeIf { it.isNotBlank() } ?: title
/**
* 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() = displayColor?.takeIf { it.isNotBlank() }
?: color?.takeIf { it.isNotBlank() }
?: calendarColor.takeIf { it.isNotBlank() }
?: fallbackColorFor("$source:$calendarId")
companion object {
private val FALLBACK_PALETTE = listOf(
"#34a853", "#4285f4", "#ea4335", "#fbbc05",
"#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
)
private fun fallbackColorFor(key: String): String {
val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size
return FALLBACK_PALETTE[idx]
}
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON
* null value, which previously made every event blue (color "null" →
* unparseable → fallback) and showed "null" for empty location/notes.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return 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. */
fun fromJson(json: JSONObject): CalEvent? {
val title = json.strOrNull("title") ?: return null
val startStr = json.strOrNull("start") ?: return null
val endStr = json.strOrNull("end") ?: return null
// id may be a String (local UUID) or an Int (CalDAV numeric)
val id: String = when (val raw = json.opt("id")) {
is String -> raw
is Number -> raw.toString()
else -> return null
}
val isAllDay = json.optBoolean("allDay", false)
val start = Dates.parse(startStr, isAllDay) ?: return null
val end = Dates.parse(endStr, isAllDay) ?: return null
// calendar_id arrives as raw numeric (caldav) or "<source>-<id>" string
val calendarId = when (val raw = json.opt("calendar_id")) {
null, JSONObject.NULL -> ""
else -> raw.toString()
}
return CalEvent(
id = id,
url = json.strOrNull("url") ?: "",
title = title,
startDate = start,
endDate = end,
isAllDay = isAllDay,
location = json.strOrNull("location") ?: "",
notes = json.strOrNull("description") ?: "",
color = json.strOrNull("color"),
calendarId = calendarId,
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"),
displayTitle = json.strOrNull("display_title"),
reminders = json.optJSONArray("reminders")?.let { arr ->
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(),
readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false),
)
}
}
}
@@ -0,0 +1,31 @@
package com.scarriffle.calendarr.domain.model
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
enum class CalViewType(val key: String) {
MONTH("month"),
WEEK("week"),
DAY("day"),
QUARTER("quarter"),
AGENDA("agenda");
val icon: ImageVector
get() = when (this) {
MONTH -> Icons.Filled.CalendarMonth
WEEK -> Icons.Filled.CalendarViewWeek
DAY -> Icons.Filled.WbSunny
QUARTER -> Icons.Filled.DateRange
AGENDA -> Icons.Filled.ViewList
}
companion object {
fun fromKey(key: String?): CalViewType =
entries.firstOrNull { it.key == key } ?: MONTH
}
}
@@ -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>,
)
@@ -0,0 +1,13 @@
package com.scarriffle.calendarr.domain.model
/**
* One identity provider configured on the Calendarr server, as returned by
* `GET /api/auth/oidc/providers`.
*/
data class OidcProvider(
val key: String,
val name: String,
val issuer: String,
val scopes: String,
val mobileClientId: String?,
)
@@ -0,0 +1,29 @@
package com.scarriffle.calendarr.domain.model
/**
* Reminder offsets are stored as minutes-before-start integers (0 = at start).
* A few quick presets are offered; anything else is entered as a custom
* number + unit. Mirrors the iOS `ReminderOptions`.
*/
object ReminderOptions {
/** Quick presets: at start, 30 min, 1 day. */
val presets = listOf(0, 30, 1440)
/** Default for a freshly-switched custom row (deliberately not a preset). */
const val customDefault = 120
enum class Unit(val mult: Int, val labelKey: String) {
MINUTES(1, "event.reminder_unit.minutes"),
HOURS(60, "event.reminder_unit.hours"),
DAYS(1440, "event.reminder_unit.days"),
WEEKS(10080, "event.reminder_unit.weeks"),
}
/** Split a minutes value into the largest exact {value, unit} for the custom picker. */
fun split(minutes: Int): Pair<Int, Unit> {
for (u in Unit.values().reversed()) {
if (minutes > 0 && minutes % u.mult == 0) return (minutes / u.mult) to u
}
return maxOf(1, minutes) to Unit.MINUTES
}
}
@@ -0,0 +1,111 @@
package com.scarriffle.calendarr.notifications
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.calendar.calendarKey
/**
* Schedules OS reminder notifications for upcoming events via AlarmManager.
* Per-event reminders take precedence; otherwise the user's default reminder
* applies. Calendars the user muted (`disabledKeys`) are skipped — their
* reminders are kept on the events, just never fired. Mirrors the iOS
* `NotificationScheduler`.
*/
object NotificationScheduler {
const val CHANNEL_ID = "calendarr_reminders"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
const val EXTRA_ID = "id"
private const val PREFS = "calendarr_reminders_sched"
private const val KEY_COUNT = "scheduled_count"
private const val MAX = 50 // keep the alarm count bounded
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = context.getSystemService(NotificationManager::class.java)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Reminders", NotificationManager.IMPORTANCE_HIGH)
)
}
}
}
private data class Pending(val fire: Long, val title: String, val body: String)
fun reschedule(
context: Context,
events: List<CalEvent>,
disabledKeys: Set<String>,
defaultMinutes: Int,
) {
ensureChannel(context)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val now = System.currentTimeMillis()
val pending = mutableListOf<Pending>()
for (ev in events) {
if (disabledKeys.contains(calendarKey(ev.source, ev.calendarId))) continue
val offsets = if (ev.reminders.isEmpty()) {
if (defaultMinutes >= 0) listOf(defaultMinutes) else emptyList()
} else ev.reminders
for (m in offsets) {
val fire = ev.startDate.toEpochMilli() - m * 60_000L
if (fire > now) {
val rel = relativeText(m)
val body = if (ev.location.isNotBlank()) "$rel · ${ev.location}" else rel
pending.add(Pending(fire, ev.title, body))
}
}
}
pending.sortBy { it.fire }
val limited = pending.take(MAX)
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val lastCount = prefs.getInt(KEY_COUNT, 0)
// Cancel every alarm from the previous run (extras are ignored when
// matching a PendingIntent, so a bare intent with the same code cancels).
for (i in 0 until maxOf(lastCount, limited.size)) {
am.cancel(intentFor(context, i, null))
}
limited.forEachIndexed { i, p ->
val pi = intentFor(context, i, p)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, p.fire, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, p.fire, pi)
}
}
prefs.edit().putInt(KEY_COUNT, limited.size).apply()
}
private fun relativeText(minutes: Int): String = when {
minutes < 60 -> "in $minutes Min."
minutes == 60 -> "in 1 Std."
minutes < 1440 -> "in ${minutes / 60} Std."
minutes == 1440 -> "morgen"
else -> "in ${minutes / 1440} Tagen"
}
private fun intentFor(context: Context, code: Int, p: Pending?): PendingIntent {
val intent = Intent(context, ReminderReceiver::class.java).apply {
// Distinct action per code so PendingIntents don't collapse together.
action = "com.scarriffle.calendarr.REMINDER_$code"
if (p != null) {
putExtra(EXTRA_ID, code)
putExtra(EXTRA_TITLE, p.title)
putExtra(EXTRA_BODY, p.body)
}
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, code, intent, flags)
}
}
@@ -0,0 +1,33 @@
package com.scarriffle.calendarr.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.scarriffle.calendarr.R
/** Posts the reminder notification when an AlarmManager alarm fires. */
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
NotificationScheduler.ensureChannel(context)
val title = intent.getStringExtra(NotificationScheduler.EXTRA_TITLE) ?: return
val body = intent.getStringExtra(NotificationScheduler.EXTRA_BODY) ?: ""
val id = intent.getIntExtra(NotificationScheduler.EXTRA_ID, 0)
val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
// notify() is a no-op (and may throw on some OEMs) without the runtime
// POST_NOTIFICATIONS permission; ignore that case.
try {
NotificationManagerCompat.from(context).notify(id, notification)
} catch (_: SecurityException) {
}
}
}
@@ -0,0 +1,80 @@
package com.scarriffle.calendarr.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.auth.LoginScreen
import com.scarriffle.calendarr.ui.auth.ServerSetupScreen
import com.scarriffle.calendarr.ui.calendar.CalendarScreen
import com.scarriffle.calendarr.ui.calendar.CalendarViewModel
import com.scarriffle.calendarr.ui.theme.CalendarrTheme
import kotlinx.coroutines.delay
@Composable
fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
val route by vm.route.collectAsState()
val settings by vm.settings.collectAsState()
CalendarrTheme(settings) {
CompositionLocalProvider(
LocalLang provides L10n.resolved(settings.language),
LocalAppSettings provides settings,
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// Obtain the calendar VM early when logged in so events load
// *behind* the splash; the branded splash stays until ready.
val calendarVm: CalendarViewModel? =
if (route == AppRoute.MAIN) hiltViewModel() else null
val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true
var minElapsed by remember { mutableStateOf(false) }
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(500); minElapsed = true }
LaunchedEffect(Unit) { delay(6000); timedOut = true }
if (!minElapsed || (!dataReady && !timedOut)) {
SplashScreen()
return@Surface
}
when (route) {
// Auth screens draw full-screen; inset them from the system
// bars (edge-to-edge) so their content isn't under status/nav.
AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) {
ServerSetupScreen(onConfigured = vm::onServerConfigured)
}
AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) {
LoginScreen(
serverUrl = vm.serverUrl,
onLoggedIn = vm::onLoggedIn,
onBack = vm::switchServer,
)
}
AppRoute.MAIN -> calendarVm?.let { cvm ->
CalendarScreen(
vm = cvm,
onLogout = vm::logout,
onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings,
onSettingsSynced = vm::refreshSettings,
username = vm.username,
serverUrl = vm.serverUrl,
)
} ?: SplashScreen()
}
}
}
}
}
@@ -0,0 +1,426 @@
package com.scarriffle.calendarr.ui
import java.util.Locale
/**
* In-app localization mirroring the iOS `L10n`. The stored language may be
* "de", "en" or "system" (resolve to device language).
*/
object L10n {
fun resolved(stored: String): String {
if (stored == "de" || stored == "en") return stored
val pref = Locale.getDefault().language.lowercase()
return if (pref.startsWith("de")) "de" else "en"
}
fun locale(stored: String): Locale = Locale(resolved(stored))
fun t(key: String, stored: String): String {
val lang = resolved(stored)
return strings[lang]?.get(key) ?: strings["en"]?.get(key) ?: key
}
fun t(key: String, stored: String, vararg args: Any): String {
val template = t(key, stored)
// Support both %@ (iOS) and %s / %d (Java) placeholders.
val javaTemplate = template.replace("%@", "%s")
return runCatching { String.format(locale(stored), javaTemplate, *args) }
.getOrDefault(template)
}
private val strings: Map<String, Map<String, String>> = mapOf(
"de" to mapOf(
"nav.today" to "Heute", "nav.menu" to "Menü", "nav.done" to "Fertig",
"view.month" to "Monat", "view.week" to "Woche", "view.day" to "Tag",
"view.quarter" to "Quartal", "view.agenda" to "Termine", "view.change" to "Ansicht",
"cal.cw" to "KW", "cal.allday" to "Ganztägig",
"cal.no_events_title" to "Keine Termine",
"cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.",
"cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis",
"cal.show_in_day_view" to "In Tagesansicht öffnen",
"cal.show_in_week_view" to "In Wochenansicht öffnen",
"cal.no_events_day" to "Keine Termine an diesem Tag",
"menu.section.settings" to "Einstellungen", "menu.profile" to "Profil",
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
"menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin",
"menu.sync" to "Mit Server synchronisieren", "menu.sync.section" to "Synchronisierung",
"settings.title" to "Darstellung", "settings.loading" to "Lade Einstellungen…",
"settings.save" to "Speichern", "settings.saved" to "Gespeichert",
"settings.cache.title" to "Vorladen", "settings.cache.range" to "Zeitraum",
"settings.cache.1m" to "±1 Monat", "settings.cache.3m" to "±3 Monate",
"settings.cache.6m" to "±6 Monate", "settings.cache.1y" to "±1 Jahr",
"settings.language" to "Sprache", "lang.system" to "Systemstandard",
"lang.german" to "Deutsch", "lang.english" to "English",
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
"settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe",
"settings.color.line" to "Linienfarbe",
"settings.sync_all" to "Alle synchronisieren",
"settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen",
"settings.sync_this" to "Zwischen Geräten synchronisieren",
"settings.reset" to "Zurücksetzen",
"settings.appearance" to "Ansicht",
"settings.device" to "Nur auf diesem Gerät",
"settings.more" to "Mehr",
"settings.hide_menu_button" to "Menü-Button ausblenden",
"settings.month_mode" to "Monatswechsel",
"settings.month_mode.scroll" to "Fortlaufend scrollen",
"settings.month_mode.paged" to "Seitenweise blättern",
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
"settings.directory_hidden" to "Profil verbergen",
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
"settings.defaultreminder" to "Standard-Erinnerung",
"reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit",
"reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher",
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
"settings.linecontrast.barely" to "Kaum", "settings.linecontrast.subtle" to "Subtil",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Stark",
"settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
"settings.month_paged" to "Monatsansicht seitenweise wischen",
"settings.hourheight" to "Stundenhöhe",
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
"common.cancel" to "Abbrechen", "common.close" to "Schliessen",
"common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen",
"common.save" to "Sichern", "common.retry" to "Erneut versuchen",
"server.title" to "Server", "server.connected" to "Verbundener Server",
"server.switch" to "Server wechseln", "server.logout_title" to "Abmelden",
"server.version" to "Version",
"profile.title" to "Profil", "profile.loading" to "Lade Profil…",
"profile.account" to "Konto", "profile.username" to "Benutzername",
"profile.role" to "Rolle", "profile.role.admin" to "Administrator",
"profile.role.user" to "Benutzer", "profile.email" to "E-Mail",
"profile.no_email" to "Keine E-Mail", "profile.save_email" to "E-Mail speichern",
"profile.email_saved" to "E-Mail gespeichert", "profile.change_password" to "Passwort ändern",
"profile.current_password" to "Aktuelles Passwort", "profile.new_password" to "Neues Passwort",
"profile.new_password_repeat" to "Neues Passwort wiederholen",
"profile.password_mismatch" to "Passwörter stimmen nicht überein",
"profile.password_changed" to "Passwort geändert",
"profile.twofa" to "Zwei-Faktor-Authentifizierung",
"profile.twofa.active" to "2FA ist aktiviert", "profile.twofa.inactive" to "2FA ist deaktiviert",
"profile.twofa.enable" to "2FA einrichten", "profile.twofa.disable" to "2FA deaktivieren",
"twofa.setup_title" to "2FA einrichten",
"twofa.scan_hint" to "Scanne den QR-Code mit deiner Authenticator-App.",
"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.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",
"event.calendar_picker" to "Kalender", "event.color_section" to "Farbe",
"event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen",
"event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin",
"event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren",
"event.detail_title" to "Termin", "event.source" to "Quelle",
"event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?",
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
"event.reminder_before" to "vorher",
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
"event.reminder_unit.days" to "Tage", "event.reminder_unit.weeks" to "Wochen",
"event.reminders_disabled" to "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.",
"settings.default_duration" to "Standard-Termindauer",
"filter.reminders_on" to "Benachrichtigungen aktivieren", "filter.reminders_off" to "Benachrichtigungen deaktivieren",
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…",
"accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten",
"accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender",
"accounts.local.empty" to "Keine lokalen Kalender", "accounts.local.add" to "Lokalen Kalender erstellen",
"accounts.ical.header" to "iCal-Abonnements", "accounts.ical.empty" to "Keine Abonnements",
"accounts.ical.add" to "iCal-URL abonnieren", "accounts.google.header" to "Google-Konten",
"accounts.google.empty" to "Keine Google-Konten",
"accounts.google.hint" to "Google-Konten werden über den Browser verknüpft",
"accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "Keine Home Assistant-Konten",
"accounts.ha.add" to "Home Assistant hinzufügen",
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
"filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
"filter.banish" to "Dauerhaft ausblenden",
"filter.read_only" to "Nur lesen",
"filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
"accounts.banished_header" to "Ausgeblendete Kalender",
"accounts.banished_unhide" to "Wieder einblenden",
"accounts.banished_unknown" to "Unbekannter Kalender",
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
"local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
"local.create" to "Erstellen",
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
"birthday.calendar_name" to "Geburtstage",
"birthday.activate" to "Geburtstagskalender aktivieren",
"birthday.import_contacts" to "Aus Kontakten importieren",
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.is_calendar" to "Geburtstagskalender",
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
"birthday.target" to "Geburtstagskalender",
"birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".",
"birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus",
"birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher",
"birthday.notify.days" to "%d Tage vorher",
"birthday.created" to "Geburtstag „%s\" hinzugefügt",
"ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL",
"ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren",
"ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.",
"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.",
"auth.continue" to "Weiter", "auth.checking" to "Verbinde…",
"auth.login_title" to "Anmelden", "auth.username" to "Benutzername",
"auth.password" to "Passwort", "auth.login" to "Anmelden",
"auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben",
"auth.back" to "Zurück",
// Single sign-on (OpenID Connect)
"sso.or" to "oder", "sso.login_with" to "Mit %s anmelden",
"sso.err.generic" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_failed" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_provider_unreachable" to "Anmeldedienst nicht erreichbar",
"sso.err.oidc_no_browser" to "Kein Browser installiert — für die Anmeldung wird einer benötigt",
"sso.err.oidc_account_not_linked" to "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.",
"sso.err.oidc_signup_disabled" to "Automatische Kontoerstellung ist deaktiviert",
"sso.err.oidc_domain_not_allowed" to "Diese E-Mail-Domain ist nicht zugelassen",
"sso.err.oidc_email_conflict" to "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto",
"sso.err.oidc_email_not_verified" to "E-Mail-Adresse beim Anbieter nicht bestätigt",
"sso.err.oidc_no_email" to "Der Anbieter hat keine E-Mail-Adresse übermittelt",
"sso.err.oidc_unknown_client" to "Diese App ist beim Anbieter nicht registriert",
"sso.err.oidc_invalid_token" to "Das Anmelde-Token ist ungültig",
"sso.err.oidc_no_id_token" to "Der Anbieter hat kein ID-Token geliefert",
),
"en" to mapOf(
"nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done",
"view.month" to "Month", "view.week" to "Week", "view.day" to "Day",
"view.quarter" to "Quarter", "view.agenda" to "Agenda", "view.change" to "View",
"cal.cw" to "W", "cal.allday" to "All-day",
"cal.no_events_title" to "No events",
"cal.no_events_body" to "No events in the next 90 days.",
"cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event",
"cal.show_in_day_view" to "Open in day view",
"cal.show_in_week_view" to "Open in week view",
"cal.no_events_day" to "No events on this day",
"menu.section.settings" to "Settings", "menu.profile" to "Profile",
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
"menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin",
"menu.sync" to "Sync with server", "menu.sync.section" to "Synchronization",
"settings.title" to "Appearance", "settings.loading" to "Loading settings…",
"settings.save" to "Save", "settings.saved" to "Saved",
"settings.cache.title" to "Preloading", "settings.cache.range" to "Range",
"settings.cache.1m" to "±1 month", "settings.cache.3m" to "±3 months",
"settings.cache.6m" to "±6 months", "settings.cache.1y" to "±1 year",
"settings.language" to "Language", "lang.system" to "System default",
"lang.german" to "Deutsch", "lang.english" to "English",
"settings.colors" to "Colors", "settings.color.primary" to "Primary color",
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
"settings.color.text" to "Text color", "settings.color.background" to "Background color",
"settings.color.line" to "Line color",
"settings.sync_all" to "Sync all",
"settings.sync_all.desc" to "Share these settings across your devices",
"settings.sync_this" to "Sync across devices",
"settings.reset" to "Reset",
"settings.appearance" to "View",
"settings.device" to "This device only",
"settings.more" to "More",
"settings.hide_menu_button" to "Hide menu button",
"settings.month_mode" to "Month switching",
"settings.month_mode.scroll" to "Continuous scroll",
"settings.month_mode.paged" to "Page by page",
"settings.device.footer" to "These settings apply to this device only and are not synced.",
"settings.directory_hidden" to "Hide profile",
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
"settings.defaultreminder" to "Default reminder",
"reminder.off" to "Off", "reminder.at_start" to "At start time",
"reminder.1d" to "1 day before", "reminder.1w" to "1 week before",
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
"settings.linecontrast.barely" to "Barely", "settings.linecontrast.subtle" to "Subtle",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Strong",
"settings.calview" to "Calendar view", "settings.defaultview" to "Default view",
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
"settings.month_paged" to "Swipe month view as pages",
"settings.hourheight" to "Hour height",
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",
"common.cancel" to "Cancel", "common.close" to "Close",
"common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete",
"common.save" to "Save", "common.retry" to "Retry",
"server.title" to "Server", "server.connected" to "Connected server",
"server.switch" to "Switch server", "server.logout_title" to "Sign out",
"server.version" to "Version",
"profile.title" to "Profile", "profile.loading" to "Loading profile…",
"profile.account" to "Account", "profile.username" to "Username",
"profile.role" to "Role", "profile.role.admin" to "Administrator",
"profile.role.user" to "User", "profile.email" to "Email",
"profile.no_email" to "No email", "profile.save_email" to "Save email",
"profile.email_saved" to "Email saved", "profile.change_password" to "Change password",
"profile.current_password" to "Current password", "profile.new_password" to "New password",
"profile.new_password_repeat" to "Repeat new password",
"profile.password_mismatch" to "Passwords don't match",
"profile.password_changed" to "Password changed",
"profile.twofa" to "Two-factor authentication",
"profile.twofa.active" to "2FA is enabled", "profile.twofa.inactive" to "2FA is disabled",
"profile.twofa.enable" to "Set up 2FA", "profile.twofa.disable" to "Disable 2FA",
"twofa.setup_title" to "Set up 2FA",
"twofa.scan_hint" to "Scan the QR code with your authenticator app.",
"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.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",
"event.calendar_picker" to "Calendar", "event.color_section" to "Color",
"event.color" to "Event color", "event.reset_color" to "Reset",
"event.edit_title" to "Edit event", "event.new_title" to "New event",
"event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar",
"event.detail_title" to "Event", "event.source" to "Source",
"event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?",
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
"event.reminder_before" to "before",
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
"event.reminder_unit.days" to "days", "event.reminder_unit.weeks" to "weeks",
"event.reminders_disabled" to "Reminders are disabled for this calendar – they will not fire.",
"settings.default_duration" to "Default event duration",
"filter.reminders_on" to "Enable reminders", "filter.reminders_off" to "Disable reminders",
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…",
"accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts",
"accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars",
"accounts.local.empty" to "No local calendars", "accounts.local.add" to "Create local calendar",
"accounts.ical.header" to "iCal subscriptions", "accounts.ical.empty" to "No subscriptions",
"accounts.ical.add" to "Subscribe to iCal URL", "accounts.google.header" to "Google accounts",
"accounts.google.empty" to "No Google accounts",
"accounts.google.hint" to "Google accounts are linked via the browser",
"accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "No Home Assistant accounts",
"accounts.ha.add" to "Add Home Assistant",
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.sort" to "Sort", "filter.done" to "Done",
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
"filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed",
"filter.banish" to "Hide permanently",
"filter.read_only" to "Read-only",
"filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
"accounts.banished_header" to "Hidden calendars",
"accounts.banished_unhide" to "Show again",
"accounts.banished_unknown" to "Unknown calendar",
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
"caldav.username" to "Username", "caldav.password" to "Password",
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
"local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
"local.create" to "Create",
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
"birthday.calendar_name" to "Birthdays",
"birthday.activate" to "Enable birthday calendar",
"birthday.import_contacts" to "Import from contacts",
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.is_calendar" to "Birthday calendar",
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
"birthday.target" to "Birthday calendar",
"birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.",
"birthday.notify" to "Reminder", "birthday.notify.off" to "Off",
"birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before",
"birthday.notify.days" to "%d days before",
"birthday.created" to "Birthday \"%s\" added",
"ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL",
"ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe",
"ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min",
"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.",
"auth.continue" to "Continue", "auth.checking" to "Connecting…",
"auth.login_title" to "Sign in", "auth.username" to "Username",
"auth.password" to "Password", "auth.login" to "Sign in",
"auth.totp" to "2FA code", "auth.remember" to "Stay signed in",
"auth.back" to "Back",
// Single sign-on (OpenID Connect)
"sso.or" to "or", "sso.login_with" to "Sign in with %s",
"sso.err.generic" to "Sign-in failed",
"sso.err.oidc_failed" to "Sign-in failed",
"sso.err.oidc_provider_unreachable" to "Sign-in service unreachable",
"sso.err.oidc_no_browser" to "No browser installed — signing in requires one",
"sso.err.oidc_account_not_linked" to "No linked Calendarr account. Sign in locally and link it in Settings.",
"sso.err.oidc_signup_disabled" to "Automatic account creation is disabled",
"sso.err.oidc_domain_not_allowed" to "This email domain is not allowed",
"sso.err.oidc_email_conflict" to "That email address already belongs to another account",
"sso.err.oidc_email_not_verified" to "Email address not verified at the provider",
"sso.err.oidc_no_email" to "The provider did not supply an email address",
"sso.err.oidc_unknown_client" to "This app is not registered with the provider",
"sso.err.oidc_invalid_token" to "The sign-in token is invalid",
"sso.err.oidc_no_id_token" to "The provider did not return an ID token",
),
)
}
@@ -0,0 +1,22 @@
package com.scarriffle.calendarr.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import com.scarriffle.calendarr.domain.model.AppSettings
/** Resolved UI language ("de" / "en"), provided at the root. */
val LocalLang = compositionLocalOf { "de" }
/** Current appearance settings, provided at the root. */
val LocalAppSettings = staticCompositionLocalOf { AppSettings() }
/** Convenience translation lookup that reads the ambient language. */
@Composable
@ReadOnlyComposable
fun tr(key: String): String = L10n.t(key, LocalLang.current)
@Composable
@ReadOnlyComposable
fun tr(key: String, vararg args: Any): String = L10n.t(key, LocalLang.current, *args)
@@ -0,0 +1,144 @@
package com.scarriffle.calendarr.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class AppRoute { SETUP, LOGIN, MAIN }
@HiltViewModel
class MainViewModel @Inject constructor(
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
private val repository: CalendarRepository,
private val apiProvider: ApiProvider,
private val oidc: OidcAuthenticator,
) : ViewModel() {
private val _route = MutableStateFlow(computeRoute())
val route: StateFlow<AppRoute> = _route.asStateFlow()
private val _settings = MutableStateFlow(settingsStore.loadSettings())
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
val serverUrl: String get() = credentialStore.serverUrl ?: ""
val username: String get() = credentialStore.username ?: ""
val isAdmin: Boolean get() = credentialStore.isAdmin
init {
if (_route.value == AppRoute.MAIN) {
refreshSettings()
renewSsoSessionIfNeeded()
}
}
/**
* Keep an SSO session alive without another trip through the browser.
*
* Calendarr's own token simply expires; what `offline_access` buys is a
* refresh token at the *provider*. So renewal means: fresh ID token from
* the provider, then trade it for a fresh Calendarr token.
*
* Best effort — on any failure the existing token stays in place and the
* user notices nothing until it genuinely expires.
*/
private fun renewSsoSessionIfNeeded() {
val state = credentialStore.oidcState ?: return
val providerKey = credentialStore.oidcProvider ?: return
val clientId = credentialStore.oidcClientId ?: return
val base = credentialStore.serverUrl ?: return
val token = credentialStore.token ?: return
if (!expiresSoon(token)) return
viewModelScope.launch {
runCatching {
val renewed = oidc.renewIdToken(state) ?: return@runCatching
repository.exchangeOidc(
baseUrl = base,
provider = providerKey,
clientId = clientId,
idToken = renewed.idToken,
accessToken = renewed.accessToken,
nonce = renewed.nonce,
)
credentialStore.saveOidcSession(renewed.serialisedState, providerKey, clientId)
}
}
}
private fun computeRoute(): AppRoute = when {
!credentialStore.isConfigured -> AppRoute.SETUP
!credentialStore.isLoggedIn -> AppRoute.LOGIN
else -> AppRoute.MAIN
}
fun onServerConfigured() { _route.value = computeRoute() }
fun onLoggedIn() {
_route.value = AppRoute.MAIN
refreshSettings()
}
fun logout() {
credentialStore.clearToken()
_route.value = computeRoute()
}
fun switchServer() {
credentialStore.clearAll()
apiProvider.invalidate()
_route.value = computeRoute()
}
/** Pull settings from the server and merge them with local values honouring
* each setting's sync flag (synced keys take the server value). */
fun refreshSettings() {
viewModelScope.launch {
runCatching { repository.getSettings() }.onSuccess { s ->
_settings.value = settingsStore.applyServerPull(s)
}
}
}
fun applyLocalSettings(s: AppSettings) {
settingsStore.saveSettings(s)
_settings.value = s
}
/**
* True when the Calendarr JWT expires within [RENEW_WITHIN_MS].
*
* The payload is read without verifying the signature — this only decides
* *whether* to renew, and the server validates the token on every request
* regardless. A token we cannot parse is left alone.
*/
private fun expiresSoon(jwt: String): Boolean {
val parts = jwt.split(".")
if (parts.size != 3) return false
return runCatching {
val payload = android.util.Base64.decode(
parts[1],
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or
android.util.Base64.NO_WRAP,
)
val exp = org.json.JSONObject(String(payload)).optLong("exp", 0L)
exp > 0L && (exp * 1000L) - System.currentTimeMillis() < RENEW_WITHIN_MS
}.getOrDefault(false)
}
private companion object {
/** Renew when less than a day of the Calendarr token's life is left. */
const val RENEW_WITHIN_MS = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.R
/**
* Branded startup screen shown right after the system splash hands off (same
* black background + same centred logo, so the logo doesn't jump); the app name
* and copyright fade in. Stays until the first events have loaded.
*/
@Composable
fun SplashScreen() {
// Fade the texts in so the handoff from the (text-less) system splash is smooth.
val textAlpha by animateFloatAsState(targetValue = 1f, animationSpec = tween(350), label = "splashText")
Box(Modifier.fillMaxSize().background(Color.Black)) {
Text(
"Calendarr",
modifier = Modifier.align(Alignment.TopCenter).padding(top = 110.dp).alpha(textAlpha),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
// Match the system splash icon size (~150dp) so it stays put across the handoff.
Image(
painter = painterResource(R.drawable.ic_splash_logo),
contentDescription = null,
modifier = Modifier.align(Alignment.Center).size(150.dp).clip(RoundedCornerShape(34.dp)),
)
Text(
"© Scarriffle",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).alpha(textAlpha),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
)
}
}
@@ -0,0 +1,558 @@
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
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
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(
onClose: () -> Unit,
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) } }
}
}
// Calendars permanently hidden ("banished") on the server — offered for
// re-enabling. Derived from the loaded account lists' sidebar_hidden flags.
val banishedCals: List<BanishedCalendar> = buildList {
vm.caldav.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("caldav", it.id, "${acc.name} – ${it.name}", it.color ?: acc.color))
}
}
vm.google.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("google", it.id, "${acc.email} – ${it.name}", it.color ?: "#4285f4"))
}
}
vm.homeAssistant.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("homeassistant", it.id, "${acc.name} – ${it.name}", it.color ?: "#46bdc6"))
}
}
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
androidx.compose.material3.TopAppBar(
title = { Text(tr("accounts.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
// 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 ->
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 ->
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 ->
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 ->
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 ->
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")
}
}
}
}
// Banished (permanently hidden) calendars — re-enable here.
if (banishedCals.isNotEmpty()) {
item { SectionHeaderNoAdd(tr("accounts.banished_header")) }
items(banishedCals, key = { "b${it.source}${it.id}" }) { cal ->
BanishedCalendarRow(cal.label, cal.color) { vm.unbanishCalendar(cal.source, cal.id, onChanged) }
}
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
item { Spacer(Modifier.size(40.dp)) }
}
}
}
when (addDialog) {
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color ->
vm.addLocal(name, color, onChanged); addDialog = null
}
AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c ->
vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null
}
AddType.ICAL -> ICalDialog(onDismiss = { addDialog = null }) { n, u, c, r ->
vm.addICal(n, u, c, r, onChanged); addDialog = null
}
AddType.HA -> HADialog(onDismiss = { addDialog = null }) { n, u, t ->
vm.addHA(n, u, t, onChanged); addDialog = null
}
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
private fun SectionHeader(title: String, addLabel: String, onAdd: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
TextButton(onClick = onAdd) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(4.dp))
Text(addLabel)
}
}
}
@Composable
private fun SectionHeaderNoAdd(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp, bottom = 6.dp))
}
@Composable
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 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(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 permanently-hidden ("banished") calendar with a "show again" button. */
private data class BanishedCalendar(val source: String, val id: Int, val label: String, val color: String)
@Composable
private fun BanishedCalendarRow(name: String, color: String, onUnbanish: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ColorDot(color, editable = false, onClick = {})
Text(
name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onUnbanish) { Text(tr("accounts.banished_unhide")) }
}
}
/** 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)
}
}
}
/** 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) {
// Recipients of a shared calendar may recolour it (their own per-user
// colour); only renaming/other management stays owner-only.
ColorDot(cal.color, editable = true, 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().navigationBarsPadding().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
private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }
val color = "#34a853"
FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
val color = "#4285f4"
FormDialog(tr("accounts.caldav.add"), onDismiss, confirmEnabled = url.isNotBlank() && user.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), user.trim(), pass, color) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("caldav.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("caldav.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(pass, { pass = it }, label = tr("caldav.password"), modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun ICalDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, Int) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
val color = "#46bdc6"
FormDialog(tr("accounts.ical.add"), onDismiss, confirmEnabled = url.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), color, 60) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("ical.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("ical.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun HADialog(onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var token by remember { mutableStateOf("") }
FormDialog(tr("accounts.ha.add"), onDismiss, confirmEnabled = url.isNotBlank() && token.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), token.trim()) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("ha.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("ha.url_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(token, { token = it }, label = { Text(tr("ha.token")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun FormDialog(
title: String,
onDismiss: () -> Unit,
confirmEnabled: Boolean,
onConfirm: () -> Unit,
content: @Composable () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Column { content() } },
confirmButton = { TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(tr("caldav.connect")) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
)
}
@@ -0,0 +1,166 @@
package com.scarriffle.calendarr.ui.accounts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.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
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var caldav by mutableStateOf<List<CalDAVAccount>>(emptyList())
private set
var local by mutableStateOf<List<LocalCalendar>>(emptyList())
private set
var ical by mutableStateOf<List<ICalSubscription>>(emptyList())
private set
var google by mutableStateOf<List<GoogleAccount>>(emptyList())
private set
var homeAssistant by mutableStateOf<List<HomeAssistantAccount>>(emptyList())
private set
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList())
google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
homeAssistant = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
loading = false
}
}
private fun mutate(onChanged: () -> Unit, block: suspend () -> Unit) {
viewModelScope.launch {
runCatching { block() }
.onSuccess { load(); onChanged() }
.onFailure { error = it.message }
}
}
fun addLocal(
name: String, color: String, onChanged: () -> Unit,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = mutate(onChanged) {
repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore)
}
fun deleteLocal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteLocalCalendar(id) }
fun addCalDAV(name: String, url: String, user: String, pass: String, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.addCalDAVAccount(name, url, user, pass, color) }
fun deleteCalDAV(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteCalDAVAccount(id) }
fun addICal(name: String, url: String, color: String, refreshMinutes: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.addICalSubscription(name, url, color, refreshMinutes) }
fun deleteICal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteICalSubscription(id) }
fun addHA(name: String, url: String, token: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.addHomeAssistantAccount(name, url, token) }
fun deleteHA(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteHomeAssistantAccount(id) }
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) }
// ---- Banished (permanently hidden) calendars ----
/** Lift the server-side sidebar_hidden flag so a banished calendar reappears.
* `onChanged` triggers the calendar screen's reconcile + refetch. */
fun unbanishCalendar(source: String, calendarId: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarSidebarHidden(source, calendarId, hidden = false) }
// ---- 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 }
}
}
}
@@ -0,0 +1,222 @@
package com.scarriffle.calendarr.ui.auth
import android.content.ActivityNotFoundException
import android.content.Intent
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.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationException
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: CalendarRepository,
private val credentialStore: CredentialStore,
private val oidc: OidcAuthenticator,
) : ViewModel() {
// Server setup
var serverUrl by mutableStateOf(credentialStore.serverUrl ?: "")
private set
var checking by mutableStateOf(false)
private set
var setupError by mutableStateOf<String?>(null)
private set
// Login
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var totpCode by mutableStateOf("")
private set
var showTotp by mutableStateOf(false)
private set
var rememberMe by mutableStateOf(true)
private set
var loggingIn by mutableStateOf(false)
private set
var loginError by mutableStateOf<String?>(null)
private set
// Single sign-on
var ssoProviders by mutableStateOf<List<OidcProvider>>(emptyList())
private set
/** Key of the provider whose flow is running, or null. Per-provider so a
* second button does not also show a spinner. */
var ssoBusyKey by mutableStateOf<String?>(null)
private set
/** Stable server error slug (e.g. `oidc_account_not_linked`). The screen
* turns it into a message; the ViewModel cannot call the translator. */
var ssoErrorSlug by mutableStateOf<String?>(null)
private set
private var loadingProviders = false
fun onServerUrlChange(v: String) { serverUrl = v; setupError = null }
fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null }
fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null }
fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null }
fun onRememberChange(v: Boolean) { rememberMe = v }
fun checkServer(onConfigured: () -> Unit) {
val url = serverUrl.trim()
if (url.isEmpty()) { setupError = "URL erforderlich"; return }
viewModelScope.launch {
checking = true
setupError = null
runCatching { repository.setupRequired(url) }
.onSuccess {
credentialStore.serverUrl = ApiProvider.normalize(url)
onConfigured()
}
.onFailure { setupError = it.message ?: "Verbindung fehlgeschlagen" }
checking = false
}
}
/** Load the configured providers. Silently yields an empty list when SSO
* is off or the server is older — the password form stays as it is. */
fun loadSsoProviders() {
val base = credentialStore.serverUrl ?: return
if (ssoProviders.isNotEmpty() || loadingProviders) return
loadingProviders = true
viewModelScope.launch {
runCatching { repository.oidcProviders(base) }
.onSuccess { ssoProviders = it }
loadingProviders = false
}
}
/**
* Start an SSO login: fetch the provider's configuration, build the PKCE
* request and open the Custom Tab.
*
* The provider key and client id are persisted before launching, because
* the browser runs in its own task and this process may well be killed
* while it is in the foreground.
*/
fun startSso(provider: OidcProvider, launch: (Intent) -> Unit) {
val clientId = provider.mobileClientId
if (clientId == null) { ssoErrorSlug = "oidc_unknown_client"; return }
viewModelScope.launch {
ssoBusyKey = provider.key
loginError = null
ssoErrorSlug = null
// `launch` has to stay inside runCatching: the result launcher is
// unregistered if the user leaves the screen while discovery is in
// flight, and the resulting IllegalStateException would otherwise
// escape viewModelScope and take the app down.
runCatching {
val config = oidc.discover(provider)
val request = oidc.buildRequest(config, provider)
credentialStore.oidcProvider = provider.key
credentialStore.oidcClientId = clientId
launch(oidc.authorizationIntent(request))
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = when (e) {
// No browser at all needs a different remedy than "the
// login service is down".
is ActivityNotFoundException -> "oidc_no_browser"
else -> "oidc_provider_unreachable"
}
}
}
}
/** Handle the browser's return: exchange the code, then trade the ID token. */
fun finishSso(data: Intent?, onSuccess: () -> Unit) {
if (data == null) { ssoBusyKey = null; return }
// AppAuth reports a user-cancelled flow as a normal result carrying
// USER_CANCELED_AUTH_FLOW — backing out of the browser is not an error.
val failure = AuthorizationException.fromIntent(data)
if (failure == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW) {
ssoBusyKey = null
return
}
val providerKey = credentialStore.oidcProvider
val clientId = credentialStore.oidcClientId
if (providerKey == null || clientId == null) {
ssoBusyKey = null
ssoErrorSlug = "oidc_failed"
return
}
viewModelScope.launch {
runCatching {
val (response, tokens) = oidc.exchangeCode(data)
val idToken = tokens.idToken ?: throw OidcException("oidc_no_id_token")
val result = repository.exchangeOidc(
baseUrl = credentialStore.serverUrl ?: error("no server"),
provider = providerKey,
clientId = clientId,
idToken = idToken,
accessToken = tokens.accessToken,
// AppAuth round-trips the original request through the
// redirect, so the nonce survives process death.
nonce = response.request.nonce,
)
// Keep the provider's refresh token so the session can be
// renewed later without another trip through the browser.
credentialStore.saveOidcSession(
state = oidc.stateFor(response, tokens),
provider = providerKey,
clientId = clientId,
)
result
}.onSuccess {
ssoBusyKey = null
onSuccess()
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = if (e is OidcException) e.slug else "oidc_failed"
}
}
}
fun login(onSuccess: () -> Unit) {
val base = credentialStore.serverUrl ?: return
if (username.isBlank() || password.isBlank()) {
loginError = "Benutzername und Passwort erforderlich"
return
}
viewModelScope.launch {
loggingIn = true
loginError = null
runCatching {
repository.login(
baseUrl = base,
username = username.trim(),
password = password,
totpCode = totpCode.trim().takeIf { it.isNotEmpty() },
rememberMe = rememberMe,
)
}.onSuccess { onSuccess() }
.onFailure { e ->
if (e is TwoFactorRequiredException) {
showTotp = true
loginError = null
} else {
loginError = e.message ?: "Anmeldung fehlgeschlagen"
}
}
loggingIn = false
}
}
}
@@ -0,0 +1,167 @@
package com.scarriffle.calendarr.ui.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Divider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
@Composable
fun LoginScreen(
serverUrl: String,
onLoggedIn: () -> Unit,
onBack: () -> Unit,
vm: AuthViewModel = hiltViewModel(),
) {
// Browser round-trip for SSO. AppAuth returns the authorization response
// as an Activity result, so nothing has to be wired into MainActivity.
val ssoLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result -> vm.finishSso(result.data, onLoggedIn) }
LaunchedEffect(Unit) { vm.loadSsoProviders() }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = tr("auth.back"))
}
Spacer(Modifier.height(0.dp))
Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(tr("auth.login_title"), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = vm.username,
onValueChange = vm::onUsernameChange,
label = { Text(tr("auth.username")) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
PasswordField(
value = vm.password,
onValueChange = vm::onPasswordChange,
label = tr("auth.password"),
modifier = Modifier.fillMaxWidth(),
)
AnimatedVisibility(vm.showTotp) {
Column {
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = vm.totpCode,
onValueChange = vm::onTotpChange,
label = { Text(tr("auth.totp")) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(tr("auth.remember"), style = MaterialTheme.typography.bodyMedium)
Switch(checked = vm.rememberMe, onCheckedChange = vm::onRememberChange)
}
vm.loginError?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
vm.ssoErrorSlug?.let { slug ->
// Unknown slugs fall through to the generic message rather than
// showing the raw key.
val key = "sso.err.$slug"
val msg = tr(key).takeIf { it != key } ?: tr("sso.err.generic")
Spacer(Modifier.height(8.dp))
Text(msg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { vm.login(onLoggedIn) },
enabled = !vm.loggingIn,
modifier = Modifier.fillMaxWidth(),
) {
if (vm.loggingIn) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.login"))
}
}
if (vm.ssoProviders.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Divider(modifier = Modifier.weight(1f))
Text(
tr("sso.or"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
vm.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = { vm.startSso(provider) { intent -> ssoLauncher.launch(intent) } },
enabled = vm.ssoBusyKey == null && !vm.loggingIn,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
if (vm.ssoBusyKey == provider.key) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("sso.login_with", provider.name))
}
}
}
}
TextButton(onClick = onBack) { Text(tr("server.switch")) }
}
}
}
/** Read the configured server URL for display. */
@Composable
fun rememberServerUrl(credentialStore: CredentialStore): String =
credentialStore.serverUrl ?: ""
@@ -0,0 +1,88 @@
package com.scarriffle.calendarr.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.tr
@Composable
fun ServerSetupScreen(
onConfigured: () -> Unit,
vm: AuthViewModel = hiltViewModel(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(64.dp),
)
Spacer(Modifier.height(16.dp))
Text("Calendarr", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
tr("auth.server_hint"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
OutlinedTextField(
value = vm.serverUrl,
onValueChange = vm::onServerUrlChange,
label = { Text(tr("auth.server_url")) },
placeholder = { Text("https://cal.example.com") },
singleLine = true,
isError = vm.setupError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
)
vm.setupError?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { vm.checkServer(onConfigured) },
enabled = !vm.checking && vm.serverUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (vm.checking) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.continue"))
}
}
}
}
@@ -0,0 +1,118 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun AgendaView(
state: CalendarUiState,
vm: CalendarViewModel,
onEventClick: (CalEvent) -> Unit,
) {
val lang = LocalLang.current
val dimPast = com.scarriffle.calendarr.ui.LocalAppSettings.current.dimPastEvents
val now = java.time.Instant.now()
val today = LocalDate.now()
val days = (0 until 90).map { today.plusDays(it.toLong()) }
.map { it to vm.eventsOn(it, state.events) }
.filter { it.second.isNotEmpty() }
if (days.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
tr("cal.no_events_body"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang))
LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
days.forEach { (date, events) ->
item(key = "h-$date") {
Text(
headerFmt.format(date).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp),
)
}
items(events, key = { "${date}-${it.id}" }) { ev ->
AgendaRow(ev, lang, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) })
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
@Composable
private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
EventLabel(
event = event,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Medium,
)
if (event.location.isNotBlank()) {
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(Modifier.width(8.dp))
Text(
eventTimeRange(event, lang),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,115 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.ui.tr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Minimal "new birthday" mask for the user's single birthday calendar: name +
* date (with an optional "year unknown"). Saves an all-day, yearly-recurring
* local event; the server adds the age suffix and cake icon. If no birthday
* calendar exists yet, offers to activate one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BirthdayDialog(
calendar: LocalCalendar?,
onDismiss: () -> Unit,
onActivate: () -> Unit,
onImportContacts: () -> Unit,
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
) {
var name by remember { mutableStateOf("") }
var yearUnknown by remember { mutableStateOf(false) }
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
var showPicker by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(tr("birthday.new_title")) },
text = {
Column {
if (calendar == null) {
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
OutlinedTextField(
name, { name = it },
label = { Text(tr("birthday.person")) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = true, onClick = { showPicker = true },
label = { Text("${tr("birthday.date")}: ${formatBirthday(pickedDate, yearUnknown)}") },
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
label = { Text(tr("birthday.year_unknown")) },
)
Spacer(Modifier.size(4.dp))
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
}
}
},
confirmButton = {
if (calendar == null) {
TextButton(onClick = onActivate) { Text(tr("birthday.activate")) }
} else {
TextButton(
enabled = name.isNotBlank(),
onClick = { onSave(name.trim(), pickedDate, !yearUnknown) },
) { Text(tr("common.save")) }
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
)
if (showPicker) {
val dpState = rememberDatePickerState(
initialSelectedDateMillis = pickedDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
dpState.selectedDateMillis?.let { millis ->
pickedDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
}
showPicker = false
}) { Text(tr("common.save")) }
},
dismissButton = { TextButton(onClick = { showPicker = false }) { Text(tr("common.cancel")) } },
) { DatePicker(state = dpState) }
}
}
private val DAY_MONTH = DateTimeFormatter.ofPattern("dd.MM.")
private val DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private fun formatBirthday(date: LocalDate, yearUnknown: Boolean): String =
date.format(if (yearUnknown) DAY_MONTH else DAY_MONTH_YEAR)
@@ -0,0 +1,293 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* The left navigation drawer: calendar visibility (flat, reorderable) + view /
* group switching + a header with manual sync, the full menu, and close. Mirrors
* the iOS side drawer. Order is device-local (CalendarViewModel.calendarOrder).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CalendarDrawerContent(
state: CalendarUiState,
vm: CalendarViewModel,
username: String,
serverUrl: String,
calendars: List<CalendarFilterEntry>,
onOpenMenu: () -> Unit,
onSync: () -> Unit,
onClose: () -> Unit,
) {
var isSorting by remember { mutableStateOf(false) }
// Working order: entries sorted by the stored order (unknown → end, by name).
var order by remember(calendars, vm.calendarOrder) {
mutableStateOf(
calendars.sortedWith(
compareBy(
{ vm.calendarOrder.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } },
{ it.name.lowercase() },
)
).map { it.key }
)
}
val entries = order.mapNotNull { key -> calendars.firstOrNull { it.key == key } }
fun move(index: Int, delta: Int) {
val to = index + delta
if (to < 0 || to >= order.size) return
val m = order.toMutableList()
val x = m.removeAt(index); m.add(to, x)
order = m
vm.setCalendarOrder(m)
}
ModalDrawerSheet(Modifier.width(320.dp).fillMaxHeight()) {
Column(Modifier.fillMaxHeight()) {
header(username, serverUrl, onSync, onOpenMenu, onClose)
Divider()
viewSwitcher(state.viewType) { vm.setViewType(it) }
Divider()
if (state.groups.isNotEmpty()) {
groupSwitcher(state.groups, state.activeGroup) { vm.switchGroup(it) }
Divider()
}
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
TextButton(onClick = { isSorting = !isSorting }) {
Text(tr(if (isSorting) "filter.done" else "filter.sort"))
}
}
LazyColumn(Modifier.fillMaxWidth().weight(1f).navigationBarsPadding()) {
items(entries, key = { it.key }) { entry ->
CalendarRow(
entry = entry,
visible = entry.key !in state.hiddenKeys,
reminderDisabled = entry.key in state.reminderDisabledKeys,
sorting = isSorting,
onToggle = { vm.setCalendarHidden(entry.key, !it) },
onBanish = { vm.setCalendarBanished(entry.key, banished = true) },
onToggleReminders = { vm.setCalendarRemindersDisabled(entry.key, disabled = it) },
onMoveUp = { move(order.indexOf(entry.key), -1) },
onMoveDown = { move(order.indexOf(entry.key), 1) },
)
}
}
}
}
}
@Composable
private fun header(
username: String,
serverUrl: String,
onSync: () -> Unit,
onOpenMenu: () -> Unit,
onClose: () -> Unit,
) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(username.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
IconButton(onClick = onSync, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = tr("menu.sync"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onOpenMenu, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Settings, contentDescription = tr("menu.appearance"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onClose, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Close, contentDescription = tr("common.close"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun viewSwitcher(current: CalViewType, onSelect: (CalViewType) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalViewType.entries.forEach { type ->
FilterChip(
selected = current == type,
onClick = { onSelect(type) },
label = { Text(tr("view.${type.key}")) },
leadingIcon = { Icon(type.icon, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun groupSwitcher(groups: List<Group>, active: Group?, onSwitch: (Group?) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = active == null,
onClick = { onSwitch(null) },
label = { Text(tr("group.switch.personal")) },
)
groups.forEach { g ->
FilterChip(
selected = active?.id == g.id,
onClick = { onSwitch(g) },
label = { Text(g.name) },
leadingIcon = { GroupIcon(g.icon) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CalendarRow(
entry: CalendarFilterEntry,
visible: Boolean,
reminderDisabled: Boolean,
sorting: Boolean,
onToggle: (Boolean) -> Unit,
onBanish: () -> Unit,
onToggleReminders: (Boolean) -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Box {
Row(
Modifier.fillMaxWidth()
.combinedClickable(
onClick = { if (!sorting) onToggle(visible) },
onLongClick = { if (!sorting) menuOpen = true },
)
.padding(start = 16.dp, end = 4.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(entry.color)))
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
color = if (visible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.readOnly) {
Icon(Icons.Filled.Lock, contentDescription = tr("filter.read_only"),
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp).size(14.dp))
}
if (sorting) {
IconButton(onClick = onMoveUp, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = tr("filter.move_up"))
}
IconButton(onClick = onMoveDown, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = tr("filter.move_down"))
}
} else {
if (reminderDisabled) {
Icon(Icons.Filled.NotificationsOff, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 2.dp).size(15.dp))
}
IconButton(onClick = { onToggle(visible) }, modifier = Modifier.size(40.dp)) {
Icon(
if (visible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (visible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(tr(if (reminderDisabled) "filter.reminders_on" else "filter.reminders_off")) },
leadingIcon = { Icon(if (reminderDisabled) Icons.Filled.Notifications else Icons.Filled.NotificationsOff, contentDescription = null) },
onClick = { menuOpen = false; onToggleReminders(!reminderDisabled) },
)
DropdownMenuItem(
text = { Text(tr("filter.banish")) },
leadingIcon = { Icon(Icons.Filled.Archive, contentDescription = null) },
onClick = { menuOpen = false; onBanish() },
)
}
}
}
@@ -0,0 +1,168 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "", val readOnly: Boolean = false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarFilterSheet(
events: List<CalendarFilterEntry>,
vm: CalendarViewModel,
onDismiss: () -> Unit,
) {
val state by vm.state.collectAsState()
val groupMode = state.activeGroup != null
// In group mode the filter lists members (+ the group calendar) so they can
// be hidden individually, Outlook-style; otherwise the normal calendars.
val groupEntries: List<CalendarFilterEntry> = if (groupMode) {
buildList {
state.activeGroupMembers.forEach { m ->
add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4"))
}
add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4"))
}
} else emptyList()
val rows = if (groupMode) groupEntries else events
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Row {
TextButton(onClick = {
if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet())
}) { Text(tr("filter.show_all")) }
TextButton(onClick = {
if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet())
else vm.setHiddenCalendars(rows.map { it.key }.toSet())
}) { Text(tr("filter.hide_all")) }
}
}
if (rows.isEmpty()) {
Text(
tr("filter.empty"),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
rows.forEach { entry ->
val visible = entry.key !in hiddenSet
// entry.key is "source:id" (see calendarKey()); match sync errors on the
// same (source, calendarId) pair the server already attaches to events.
val entryId = entry.key.substringAfter(":")
val hasSyncError = !groupMode && state.syncErrors.any { err ->
err.source == entry.source && err.calendarId?.toString() == entryId
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
if (hasSyncError) {
Icon(
Icons.Filled.WarningAmber,
contentDescription = tr("filter.sync_error"),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp).size(16.dp),
)
}
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge,
)
if (entry.readOnly) {
Icon(
Icons.Filled.Lock,
contentDescription = tr("filter.read_only"),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 4.dp).size(15.dp),
)
}
if (!groupMode) {
val remDisabled = entry.key in state.reminderDisabledKeys
IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) {
Icon(
if (remDisabled) Icons.Filled.NotificationsOff else Icons.Filled.Notifications,
contentDescription = tr(if (remDisabled) "filter.reminders_on" else "filter.reminders_off"),
tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
)
}
// Banish = permanently hide (syncs to the server); moves the
// calendar into Settings, distinct from the local quick-hide.
IconButton(onClick = { vm.setCalendarBanished(entry.key, banished = true) }) {
Icon(
Icons.Filled.Archive,
contentDescription = tr("filter.banish"),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = visible,
onCheckedChange = {
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it)
else vm.setCalendarHidden(entry.key, !it)
},
)
}
}
if (!groupMode && state.banishedKeys.isNotEmpty()) {
Text(
tr("filter.banished_footer"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Box(Modifier.padding(bottom = 24.dp))
}
}
}
@@ -0,0 +1,122 @@
package com.scarriffle.calendarr.ui.calendar
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.L10n
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
private val zone: ZoneId = ZoneId.systemDefault()
/** Localized month name with a hard fallback (guards against empty/numeric results). */
private fun monthName(date: LocalDate, style: TextStyle, loc: java.util.Locale): String {
val name = date.month.getDisplayName(style, loc)
val safe = if (name.isBlank() || name.all { it.isDigit() }) {
date.month.getDisplayName(TextStyle.FULL, java.util.Locale.ENGLISH)
} else {
name
}
return safe.replaceFirstChar { it.uppercase(loc) }
}
fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
val loc = L10n.locale(lang)
return when (viewType) {
CalViewType.MONTH -> {
// Use the FORMAT month style (TextStyle.FULL). The standalone style
// (FULL_STANDALONE / the "LLLL" pattern) drops the name for some
// months under desugared java.time (e.g. December showed only "2026").
val month = monthName(date, TextStyle.FULL, loc)
"$month ${date.year}"
}
CalViewType.QUARTER -> {
val endM = date.plusMonths(2)
"${monthName(date, TextStyle.SHORT, loc)} ${date.year} – ${monthName(endM, TextStyle.SHORT, loc)} ${endM.year}"
}
CalViewType.WEEK -> {
val start = date
val fmt = DateTimeFormatter.ofPattern("d. MMM", loc)
val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc)
// Two lines for the compact top bar.
"${fmt.format(start)} –\n${endFmt.format(start.plusDays(6))}"
}
CalViewType.DAY -> {
val weekday = DateTimeFormatter.ofPattern("EEEE", loc).format(date)
val rest = DateTimeFormatter.ofPattern("d. MMMM yyyy", loc).format(date)
"$weekday\n$rest"
}
CalViewType.AGENDA -> L10n.t("view.agenda", lang)
}
}
fun weekdayLabels(mondayFirst: Boolean, lang: String): List<String> {
val loc = L10n.locale(lang)
val order = if (mondayFirst) {
listOf(1, 2, 3, 4, 5, 6, 7)
} else {
listOf(7, 1, 2, 3, 4, 5, 6)
}
return order.map {
java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2)
}
}
fun timeLabel(instant: Instant, lang: String): String =
DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant)
/** Opacity for secondary text (week number, overflow count) per text_contrast 1..4. */
fun secondaryTextOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) {
1 -> 0.40f
2 -> 0.55f
3 -> 0.72f
else -> 0.92f
}
/** Opacity for regular grid lines per line_contrast 1..4. */
fun gridLineOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) {
1 -> 0.15f
2 -> 0.30f
3 -> 0.50f
else -> 0.80f
}
fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone)
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
/** Minutes-from-midnight of the instant in the local zone. */
fun minutesOfDay(instant: Instant): Int {
val t = localTime(instant)
return t.hour * 60 + t.minute
}
fun eventTimeRange(event: CalEvent, lang: String): String {
if (event.isAllDay) return L10n.t("cal.allday", lang)
return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
}
/** Full human-readable date (+time) range for the detail sheet. */
fun eventDateRange(event: CalEvent, lang: String): String {
val loc = L10n.locale(lang)
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc)
val startDate = localDate(event.startDate)
val endDate = localDate(event.endDate)
if (event.isAllDay) {
// All-day end is exclusive on the server; show the inclusive last day.
val lastDay = endDate.minusDays(1)
return if (lastDay.isAfter(startDate)) {
"${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}"
} else {
"${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}"
}
}
return if (startDate == endDate) {
"${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
} else {
"${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}"
}
}
@@ -0,0 +1,590 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.accounts.AccountsScreen
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.event.EventDetailScreen
import com.scarriffle.calendarr.ui.event.EventEditorSheet
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.groups.GroupsScreen
import com.scarriffle.calendarr.ui.menu.MenuScreen
import com.scarriffle.calendarr.ui.profile.ProfileScreen
import com.scarriffle.calendarr.ui.settings.SettingsScreen
import com.scarriffle.calendarr.ui.tr
import kotlinx.coroutines.launch
import java.time.LocalDate
private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CalendarScreen(
onLogout: () -> Unit,
onSwitchServer: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit,
username: String = "",
serverUrl: String = "",
vm: CalendarViewModel = hiltViewModel(),
) {
val state by vm.state.collectAsState()
val lang = LocalLang.current
val context = androidx.compose.ui.platform.LocalContext.current
// Ask once for notification permission (Android 13+), then keep the OS
// reminder alarms in sync with the visible events / muted-calendar set.
val notifPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) {}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
androidx.compose.runtime.LaunchedEffect(state.events, state.reminderDisabledKeys) {
com.scarriffle.calendarr.notifications.NotificationScheduler.reschedule(
context, state.events, state.reminderDisabledKeys, vm.defaultReminderMinutes
)
}
// Re-check server-side calendar visibility whenever the app returns to the
// foreground (e.g. after hiding/showing a calendar on the web or another
// device); reloads only if something changed.
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) vm.onAppResumed()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Contacts birthday import (mirrors iOS): request permission on demand; sync
// this device's contact birthdays into the birthday calendar.
val deviceName = android.os.Build.MODEL ?: "Android"
fun runBirthdayImport() {
vm.birthdaysSyncEnabled = true
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
}
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { granted -> if (granted) runBirthdayImport() }
fun startBirthdayImport() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (vm.birthdaysSyncEnabled &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport()
}
var viewMenuOpen by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
var fabMenuOpen by remember { mutableStateOf(false) }
var showBirthday by remember { mutableStateOf(false) }
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
val birthdayScope = rememberCoroutineScope()
val birthdayCalName = tr("birthday.calendar_name")
// Side navigation drawer (calendars + groups + view + menu).
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
if (drawerState.isOpen) vm.loadAllCalendars()
}
// Continuous month scrolling
val monthListState = rememberLazyListState()
var todaySignal by remember { mutableIntStateOf(0) }
var monthJumpSignal by remember { mutableIntStateOf(0) }
var monthJumpTarget by remember { mutableStateOf<LocalDate?>(null) }
var visibleMonth by remember { mutableStateOf(state.currentDate) }
val isMonth = state.viewType == CalViewType.MONTH
val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang)
else titleForView(state.viewType, state.currentDate, lang)
fun goPrev() {
if (isMonth) { monthJumpTarget = visibleMonth.minusMonths(1); monthJumpSignal++ } else vm.navigatePrev()
}
fun goNext() {
if (isMonth) { monthJumpTarget = visibleMonth.plusMonths(1); monthJumpSignal++ } else vm.navigateNext()
}
fun goToday() {
if (isMonth) todaySignal++ else vm.moveToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawerContent(
state = state, vm = vm, username = username, serverUrl = serverUrl,
calendars = drawerCalendars,
onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU },
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
onClose = { drawerScope.launch { drawerState.close() } },
)
},
) {
Scaffold(
topBar = {
CompactTopBar(
title = barTitle,
viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen,
showMenuButton = !state.hideMenuButton,
onMenu = { drawerScope.launch { drawerState.open() } },
onPrev = { goPrev() },
onToday = { goToday() },
onNext = { goNext() },
onViewMenuToggle = { viewMenuOpen = it },
onSelectView = { vm.setViewType(it) },
)
},
floatingActionButton = {
// Tap = new event; long-press opens a small menu (new event / new
// birthday). A transparent overlay carries the combined click so the
// long-press is reliable on the Material FAB.
Box(Modifier.navigationBarsPadding()) {
FloatingActionButton(
onClick = {},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
}
Box(
Modifier
.matchParentSize()
.clip(CircleShape)
.combinedClickable(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
onLongClick = { fabMenuOpen = true },
),
)
DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) {
DropdownMenuItem(
text = { Text(tr("cal.new_event")) },
onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
)
DropdownMenuItem(
text = { Text(tr("birthday.new")) },
onClick = { fabMenuOpen = false; showBirthday = true },
)
}
}
},
// Edge-to-edge: we place the system-bar insets ourselves (top bar gets
// statusBarsPadding, the content column gets navigationBarsPadding) so
// nothing hides behind the status/nav bars.
contentWindowInsets = WindowInsets(0),
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) {
state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
}
if (state.syncErrors.isNotEmpty()) {
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
}
state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
Box(Modifier.fillMaxSize()) {
// remember: stable callbacks keep the lazily composed week rows
// skippable during scroll (fresh lambdas would recompose them all).
CalendarBody(
state = state,
vm = vm,
monthListState = monthListState,
scrollToTodaySignal = todaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = remember { { visibleMonth = it } },
onEventClick = remember { { detailEvent = it } },
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
onDayLongPress = remember { { date -> dayPreview = date } },
)
}
}
}
}
// ---- Sheets ----
dayPreview?.let { date ->
DayPreviewDialog(
date = date,
events = remember(state.events, date) { vm.eventsOn(date, state.events) },
onDismiss = { dayPreview = null },
onEventClick = { ev -> dayPreview = null; detailEvent = ev },
onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) },
onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) },
onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) },
)
}
if (showBirthday) {
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
BirthdayDialog(
calendar = birthdayCal,
onDismiss = { showBirthday = false },
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
onImportContacts = { showBirthday = false; startBirthdayImport() },
onSave = { name, date, yearKnown ->
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
showBirthday = false
},
)
}
// Keep the last event during the close animation.
var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
detailEvent?.let { lastDetail = it }
AnimatedVisibility(
visible = detailEvent != null,
enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(),
) {
val ev = lastDetail
if (ev != null) {
EventDetailScreen(
event = ev,
currentUserId = vm.currentUserId,
onClose = { detailEvent = null },
onEdit = {
detailEvent = null
editor = EditorRequest(ev, localDate(ev.startDate))
},
onCopy = {
detailEvent = null
editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev)
},
onDelete = {
vm.deleteEvent(ev) {}
detailEvent = null
},
)
}
}
editor?.let { req ->
EventEditorSheet(
request = req,
writableCalendars = state.writableCalendars,
onDismiss = { editor = null },
defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
if (error == null) editor = null
}
},
)
}
when (overlay) {
Overlay.MENU -> MenuScreen(
username = username,
serverUrl = serverUrl,
onClose = { overlay = Overlay.NONE },
onProfile = { overlay = Overlay.PROFILE },
onAppearance = { overlay = Overlay.SETTINGS },
onAccounts = { overlay = Overlay.ACCOUNTS },
onGroups = { overlay = Overlay.GROUPS },
onSync = { overlay = Overlay.NONE; vm.syncWithServer() },
onSwitchServer = onSwitchServer,
onLogout = onLogout,
)
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU })
Overlay.SETTINGS -> SettingsScreen(
onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() },
onSettingsChanged = onSettingsChanged,
onSettingsSynced = onSettingsSynced,
)
Overlay.ACCOUNTS -> AccountsScreen(
onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
)
Overlay.GROUPS -> GroupsScreen(
onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
)
Overlay.NONE -> Unit
}
}
@Composable
private fun CalendarBody(
state: CalendarUiState,
vm: CalendarViewModel,
monthListState: androidx.compose.foundation.lazy.LazyListState,
scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
) {
when (state.viewType) {
CalViewType.MONTH -> MonthView(
state = state,
vm = vm,
listState = monthListState,
scrollToTodaySignal = scrollToTodaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = onVisibleMonthChange,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
CalViewType.WEEK -> WeekView(state, vm, onEventClick)
CalViewType.DAY -> DayView(state, vm, onEventClick)
CalViewType.QUARTER -> QuarterView(state, onDayClick)
CalViewType.AGENDA -> AgendaView(state, vm, onEventClick)
}
}
@Composable
private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall)
androidx.compose.foundation.layout.Row {
TextButton(onClick = onRetry) { Text(tr("common.retry")) }
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
}
}
}
}
/**
* Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more
* enabled calendars failed to sync (e.g. expired credentials) and are showing
* zero events with no other indication. Same visual language, no retry button
* (retrying the whole range wouldn't target just the broken calendar).
*/
@Composable
private fun SyncErrorBanner(errors: List<com.scarriffle.calendarr.data.SyncError>, onDismiss: () -> Unit) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
errors.forEach { err ->
Text(
"${err.name}: ${err.message}",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
androidx.compose.foundation.layout.Row {
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
}
}
}
}
@Composable
private fun loadingPlaceholder() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun CompactTopBar(
title: String,
viewType: CalViewType,
loading: Boolean,
viewMenuOpen: Boolean,
showMenuButton: Boolean,
onMenu: () -> Unit,
onPrev: () -> Unit,
onToday: () -> Unit,
onNext: () -> Unit,
onViewMenuToggle: (Boolean) -> Unit,
onSelectView: (CalViewType) -> Unit,
) {
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
// Surface fills behind the status bar; the content column is inset below it.
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.statusBarsPadding()) {
Row(
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Menu (hamburger) on the left — the drawer opens from the left.
if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
CompactIcon(Icons.Filled.ChevronLeft, onPrev)
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
Text(tr("nav.today"), fontSize = 13.sp)
}
CompactIcon(Icons.Filled.ChevronRight, onNext)
Text(
title,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = if (twoLine) 13.sp else 16.sp,
lineHeight = if (twoLine) 15.sp else 18.sp,
fontWeight = FontWeight.Medium,
)
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp).padding(end = 2.dp),
strokeWidth = 2.dp,
)
}
Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
CalViewType.entries.forEach { type ->
DropdownMenuItem(
text = { Text(tr("view.${type.key}")) },
leadingIcon = { Icon(type.icon, contentDescription = null) },
onClick = { onViewMenuToggle(false); onSelectView(type) },
)
}
}
}
}
Divider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun CompactIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
contentDescription: String? = null,
) {
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
Icon(icon, contentDescription = contentDescription, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun GroupBanner(group: Group, onExit: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp))
Text(
"${tr("groups.view")}: ${group.name}",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onExit) { Text(tr("group.switch.personal")) }
}
}
}
/** Distinct calendars in the cache for the filter sheet — from the UNFILTERED
* cache (minus banished) so a locally quick-hidden calendar still shows up and
* can be toggled back on. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val fromEvents = vm.knownCalendars()
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) }
// Event-derived entries first (current server colour / owner name), then the
// full source list so calendars WITHOUT events in range still appear;
// distinctBy keeps the event-derived entry when a calendar has both.
return (fromEvents + vm.state.value.allCalendars)
.distinctBy { it.key }
.sortedBy { it.name.lowercase() }
}
@@ -0,0 +1,803 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.SyncError
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
/** Build the "source:id" visibility key, stripping any "<source>-" prefix. */
fun calendarKey(source: String, calendarId: String): String {
val prefix = "$source-"
val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId
return "$source:$id"
}
data class CalendarUiState(
val viewType: CalViewType = CalViewType.MONTH,
val currentDate: LocalDate = LocalDate.now(),
val events: List<CalEvent> = emptyList(),
val isLoading: Boolean = false,
val isBackgroundCaching: Boolean = false,
val error: String? = null,
// Per-calendar sync failures from the last successful /events fetch (e.g.
// expired CalDAV credentials) — the fetch as a whole succeeded, but one or
// more enabled calendars silently returned nothing. Additive to `error`.
val syncErrors: List<SyncError> = emptyList(),
val weekStartsOnMonday: Boolean = true,
val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: Set<String> = emptySet(),
// Calendars ("source:id") the user muted for reminders — events keep their
// reminders but the scheduler skips them.
val reminderDisabledKeys: Set<String> = emptySet(),
// Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = emptyList(),
val activeGroup: Group? = null,
// Group overlay: full member list (for the filter) + per-member / group-cal
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(),
val hiddenGroupKeys: Set<String> = emptySet(),
// Full calendar list across all sources (loaded on demand) so the filter
// shows every calendar, including ones with no events in the loaded range.
val allCalendars: List<CalendarFilterEntry> = emptyList(),
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
val monthViewPaged: Boolean = false,
// Device-local: hide the top-bar menu button (drawer opens via edge-swipe).
val hideMenuButton: Boolean = false,
)
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
const val GROUP_CALENDAR_KEY = "gc"
@HiltViewModel
class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository,
private val settingsStore: SettingsStore,
) : ViewModel() {
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()
private val _state = MutableStateFlow(initialState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
/** True once the first event load has completed — used to gate the splash. */
private val _ready = MutableStateFlow(false)
val ready: StateFlow<Boolean> = _ready.asStateFlow()
// Cache bookkeeping
private var cachedStart: Instant? = null
private var cachedEnd: Instant? = null
private var allCachedEvents: List<CalEvent> = emptyList()
init {
loadWritableCalendars()
loadGroups()
initialLoad(reconcile = true)
}
/**
* Load the full ±cacheMonths window once, behind the splash, and only then
* mark ready. Entering the app fully-loaded avoids the first-open jank of
* loading a big batch while the user is already scrolling.
*/
private fun initialLoad(reconcile: Boolean = false) {
val months = settingsStore.cacheMonths.toLong()
val today = LocalDate.now().withDayOfMonth(1)
val start = instant(today.minusMonths(months))
val end = instant(today.plusMonths(months + 1))
viewModelScope.launch {
// Honour a calendar hidden/shown on the web BEFORE the first load so
// the visibility filter is correct before events are rendered.
if (reconcile) reconcileCalendarVisibility()
loadRange(start, end, background = false)
markReady()
}
}
private fun initialState(): CalendarUiState {
val s = settingsStore.loadSettings()
return CalendarUiState(
viewType = CalViewType.fromKey(s.defaultView),
weekStartsOnMonday = s.weekStartsOnMonday,
hiddenKeys = settingsStore.hiddenCalendarKeys,
banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
)
}
/** Re-read the device-local view prefs (called when settings close). */
fun refreshMonthViewMode() {
_state.update { it.copy(
monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
) }
}
// Device-local calendar order ("source:id"), mirrors the web cal_order.
val calendarOrder: List<String> get() = settingsStore.calendarOrder
fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys }
// ---- Contacts birthday import (mirrors the iOS BirthdaysImporter) ----
var birthdaysSyncEnabled: Boolean
get() = settingsStore.birthdaysSyncEnabled
set(value) { settingsStore.birthdaysSyncEnabled = value }
/** Mirror this device's contact birthdays into the (existing) birthday
* calendar: reconcile rows scoped to this device's external_uid prefix
* (add / update / delete), then report the device. No-op if disabled or
* the birthday calendar hasn't been created. */
fun syncContactBirthdays(contacts: List<com.scarriffle.calendarr.data.ContactBirthday>, deviceName: String) {
if (!settingsStore.birthdaysSyncEnabled) return
viewModelScope.launch {
runCatching {
val cal = repository.getLocalCalendars().firstOrNull { it.isBirthday && it.owned }
?: return@runCatching
val existing = repository.getBirthdayEntries(cal.id)
val deviceId = settingsStore.birthdaysDeviceId
val prefix = "contact:$deviceId:"
val byExt = existing.filter { it.externalUid?.startsWith(prefix) == true }
.associateBy { it.externalUid!! }
val seen = mutableSetOf<String>()
for (c in contacts) {
val ext = prefix + c.contactId
seen.add(ext)
val (start, end) = birthdayRange(c.month, c.day, c.year)
val match = byExt[ext]
if (match != null) {
val changed = match.title != c.name || match.month != c.month ||
match.day != c.day || match.birthYear != c.year
if (changed) repository.updateLocalEvent(
uid = match.uid, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
} else {
repository.createLocalEvent(
calendarId = cal.id, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
}
}
byExt.filterKeys { it !in seen }.values.forEach { repository.deleteLocalEvent(it.uid) }
repository.reportBirthdaySync(deviceId, deviceName, contacts.size)
}
loadVisible(force = true)
}
}
private fun birthdayRange(month: Int, day: Int, year: Int?): Pair<Instant, Instant> {
val anchor = year ?: 2000 // leap-safe anchor for Feb 29 with unknown year
val date = LocalDate.of(anchor, month, day)
val start = date.atTime(12, 0).atZone(zone).toInstant()
val end = date.plusDays(1).atTime(12, 0).atZone(zone).toInstant()
return start to end
}
/** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes
/** Default reminder offset (minutes before start), or -1 when off. */
val defaultReminderMinutes: Int get() = settingsStore.loadSettings().defaultReminderMinutes ?: -1
/** Toggle a calendar's reminders without deleting any event reminders. */
fun setCalendarRemindersDisabled(key: String, disabled: Boolean) {
val keys = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
if (disabled) keys.add(key) else keys.remove(key)
settingsStore.reminderDisabledCalendarKeys = keys
_state.update { it.copy(reminderDisabledKeys = keys) }
val parts = key.split(":")
val source = parts.getOrNull(0) ?: return
val id = parts.getOrNull(1)?.toIntOrNull() ?: return
viewModelScope.launch { runCatching { repository.setCalendarRemindersEnabled(source, id, enabled = !disabled) } }
}
// ---- Navigation ----
fun setViewType(type: CalViewType) {
_state.update { it.copy(viewType = type) }
loadVisible()
}
fun moveToToday() {
_state.update { it.copy(currentDate = LocalDate.now()) }
loadVisible()
}
fun navigatePrev() = navigate(-1)
fun navigateNext() = navigate(+1)
private fun navigate(direction: Int) {
val st = _state.value
val date = st.currentDate
val newDate = when (st.viewType) {
CalViewType.WEEK -> date.plusWeeks(direction.toLong())
CalViewType.DAY -> date.plusDays(direction.toLong())
CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong())
else -> date.plusMonths(direction.toLong())
}
_state.update { it.copy(currentDate = newDate) }
loadVisible()
}
fun goToDate(date: LocalDate, viewType: CalViewType? = null) {
_state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) }
loadVisible()
}
// ---- Range ----
fun rangeForCurrentView(): Pair<Instant, Instant> {
val st = _state.value
val date = st.currentDate
return when (st.viewType) {
CalViewType.MONTH -> {
val start = date.withDayOfMonth(1)
instant(start.minusMonths(1)) to instant(start.plusMonths(2))
}
CalViewType.QUARTER -> {
val start = date.withDayOfMonth(1)
instant(start) to instant(start.plusMonths(4))
}
CalViewType.WEEK -> {
val weekStart = startOfWeek(date, st.weekStartsOnMonday)
instant(weekStart) to instant(weekStart.plusDays(8))
}
CalViewType.DAY -> instant(date) to instant(date.plusDays(1))
CalViewType.AGENDA -> {
val today = LocalDate.now()
instant(today) to instant(today.plusDays(90))
}
}
}
private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val dow = date.dayOfWeek.value // Mon=1 .. Sun=7
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
// ---- Loading ----
fun loadVisible(force: Boolean = false) {
val (start, end) = rangeForCurrentView()
if (!force && isCached(start, end)) {
refreshFromCache()
markReady()
return
}
viewModelScope.launch {
loadRange(start, end, background = false, force = force)
markReady()
}
}
/** On-demand load for a month the user scrolled to in the continuous calendar. */
fun ensureMonthLoaded(monthAnchor: LocalDate) {
val first = monthAnchor.withDayOfMonth(1)
val start = instant(first.minusMonths(1))
val end = instant(first.plusMonths(2))
if (isCached(start, end)) return
viewModelScope.launch { loadRange(start, end, background = false) }
}
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean, force: Boolean = false) {
loadMutex.withLock {
// Another load (e.g. the background prefetch) may have covered this range.
// `force` bypasses this so callers can explicitly re-fetch an already-cached
// range (e.g. to pick up a mutation) without wiping the whole cache first.
if (!force && isCached(start, end)) return
val group = _state.value.activeGroup
val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
else repository.fetchEvents(start, end).let { it.events to it.errors }
}
.onSuccess { (events, errors) ->
val (keepKeys, keepSources) = failedCalendarKeys(errors)
mergeIntoCache(events, start, end, keepKeys, keepSources)
refreshFromCache()
_state.update { it.copy(syncErrors = errors) }
}
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
}
}
/** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */
private fun decorateGroup(events: List<CalEvent>): List<CalEvent> {
val me = currentUserId
return events.map { ev ->
// Prefer the server-decorated title (group icon + owner prefix) so
// web, iOS and Android render identically; fall back for old servers.
val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() }
if (serverTitle != null) return@map ev.copy(title = serverTitle)
val prefix = when {
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: "
ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: "
else -> ""
}
if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title)
}
}
private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s }
private fun markReady() {
_ready.value = true
}
private fun isCached(start: Instant, end: Instant): Boolean {
val cs = cachedStart ?: return false
val ce = cachedEnd ?: return false
return !cs.isAfter(start) && !ce.isBefore(end)
}
/**
* Split sync errors into per-calendar keys and whole sources whose cached
* events must NOT be evicted on a partial sync — stale data beats an empty
* calendar. An error carrying a calendar_id protects just that calendar; an
* account-level error without one (e.g. an HA / Google token-refresh
* failure, which aborts the whole account fetch) protects every cached
* calendar of that source.
*/
private fun failedCalendarKeys(errors: List<SyncError>): Pair<Set<String>, Set<String>> {
val keys = mutableSetOf<String>()
val sources = mutableSetOf<String>()
for (err in errors) {
val cid = err.calendarId
if (cid != null) keys.add(calendarKey(err.source, cid.toString()))
else sources.add(err.source)
}
return keys to sources
}
private fun mergeIntoCache(
newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant,
keepKeysInRange: Set<String> = emptySet(),
keepSourcesInRange: Set<String> = emptySet(),
) {
// Remove old events in the fetched range to avoid duplicates — but
// PRESERVE events from calendars / sources that had sync errors so a
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
val retained = allCachedEvents.filter { ev ->
val outsideRange = !ev.startDate.isBefore(rangeEnd) || !ev.endDate.isAfter(rangeStart)
when {
outsideRange -> true
ev.source in keepSourcesInRange -> true
keepKeysInRange.isEmpty() -> false
else -> calendarKey(ev.source, ev.calendarId) in keepKeysInRange
}
}
allCachedEvents = retained + newEvents
// Only extend the cached range on a fully clean fetch. When some
// calendars/sources failed, leave cachedStart/End unchanged so
// isCached() stays false and they're retried on the next load rather
// than being silently treated as "done" with empty data.
if (keepKeysInRange.isEmpty() && keepSourcesInRange.isEmpty()) {
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
}
}
private fun refreshFromCache() {
val st = _state.value
// In group mode: server scopes/filters by privacy; locally honour the
// per-member / group-calendar hide toggles (hiddenGroupKeys).
val visible = if (st.activeGroup != null) {
val hg = st.hiddenGroupKeys
if (hg.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
when {
ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg
ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg
else -> true
}
}
} else {
val hidden = st.hiddenKeys
val banished = st.banishedKeys
if (hidden.isEmpty() && banished.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished
}
}
// Skip the state write (and resulting recomposition) when nothing changed.
_state.update { if (it.events == visible) it else it.copy(events = visible) }
}
private fun invalidateCache() {
cachedStart = null
cachedEnd = null
allCachedEvents = emptyList()
}
fun syncWithServer() {
invalidateCache()
loadGroups()
initialLoad(reconcile = true)
}
/**
* Called when the app returns to the foreground. Re-checks the server's
* per-calendar visibility (a calendar may have been hidden/shown on the web
* or another device meanwhile) and reloads only if something changed.
*/
fun onAppResumed() {
viewModelScope.launch {
if (reconcileCalendarVisibility()) {
invalidateCache()
loadGroups()
initialLoad(reconcile = false) // just reconciled above
}
}
}
/**
* Reconcile the local **banished** set with the server's per-calendar
* `sidebar_hidden` flags for external calendars (CalDAV / Google / HA).
* Returns `true` if the set changed, so the caller can force a refetch — a
* calendar re-enabled on the web has NO events in the cache (the server
* excludes a hidden calendar's events entirely).
*
* NOTE: This deliberately drives `banishedKeys`, NOT `hiddenKeys`. The
* quick-hide (`hiddenKeys`) is a device-local filter and must never be
* overwritten from the server; only the "banish / permanently hide" state
* maps to the server's `sidebar_hidden` (mirrors iOS).
*/
private suspend fun reconcileCalendarVisibility(): Boolean {
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
fun apply(source: String, id: Int, serverHidden: Boolean) {
val key = calendarKey(source, id.toString())
if (serverHidden) banished.add(key) else banished.remove(key)
}
caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } }
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
if (banished == settingsStore.banishedCalendarKeys) return false
settingsStore.banishedCalendarKeys = banished
_state.update { it.copy(banishedKeys = banished) }
return true
}
fun clearError() = _state.update { it.copy(error = null) }
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
// ---- Visibility filters ----
fun setCalendarHidden(key: String, hidden: Boolean) {
val next = _state.value.hiddenKeys.toMutableSet().apply {
if (hidden) add(key) else remove(key)
}
settingsStore.hiddenCalendarKeys = next
_state.update { it.copy(hiddenKeys = next) }
refreshFromCache()
}
fun setHiddenCalendars(keys: Set<String>) {
settingsStore.hiddenCalendarKeys = keys
_state.update { it.copy(hiddenKeys = keys) }
refreshFromCache()
}
/** Distinct calendars present in the full cache, IGNORING the device-local
* quick-hide filter but excluding banished ones — for the filter sheet, so
* a locally hidden calendar still appears there and can be toggled back on. */
fun knownCalendars(): List<CalEvent> {
val banished = _state.value.banishedKeys
return allCachedEvents
.distinctBy { calendarKey(it.source, it.calendarId) }
.filter { calendarKey(it.source, it.calendarId) !in banished }
}
/**
* Load the full calendar list across all sources into [CalendarUiState.allCalendars]
* so the filter shows every calendar, even ones with no events in the loaded
* range. Called when the filter sheet opens. Read-only flag for shared local
* calendars from owned/permission; banished calendars are dropped.
*/
fun loadAllCalendars() {
viewModelScope.launch {
val banished = _state.value.banishedKeys
val entries = mutableListOf<CalendarFilterEntry>()
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()).forEach { c ->
entries += CalendarFilterEntry(
key = calendarKey("local", c.id.toString()),
name = if (c.owned) c.name else (c.sharedBy ?: c.name),
color = c.color,
source = "local",
readOnly = !c.owned && c.permission != "read_write",
)
}
runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("caldav", c.id.toString()), c.name, c.color ?: acc.color, "caldav")
}
}
runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("google", c.id.toString()), c.name, c.color ?: "#4285f4", "google")
}
}
runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("homeassistant", c.id.toString()), c.name, c.color ?: "#46bdc6", "homeassistant")
}
}
runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()).forEach { s ->
entries += CalendarFilterEntry(calendarKey("ical", s.id.toString()), s.name, s.color, "ical")
}
_state.update { st -> st.copy(allCalendars = entries.filter { it.key !in banished }) }
}
}
/** The user's single birthday calendar, or null if not activated yet. */
suspend fun birthdayCalendar(): LocalCalendar? =
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
.firstOrNull { it.isBirthday && it.owned }
/** The birthday calendar, creating the single one (named [name]) if none. */
suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? {
birthdayCalendar()?.let { return it }
return runCatching {
repository.addLocalCalendar(name, "#E0407F", isBirthday = true)
}.getOrNull()
}
/**
* Create a manual birthday: an all-day, yearly-recurring local event whose
* age suffix and cake icon are added server-side. When the year is unknown
* we anchor to 1970 and send no birth_year (so no age is shown).
*/
fun createBirthday(
calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit,
) {
viewModelScope.launch {
val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth)
val start = Dates.startOfDay(anchor)
val end = Dates.startOfDay(anchor.plusDays(1))
runCatching {
repository.createLocalEvent(
calendarId, name, start, end, isAllDay = true,
location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null,
)
}.onSuccess { loadVisible(force = true); onDone() }
}
}
/**
* Banish ("permanently hide") a calendar, or lift the banish. Unlike the
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for
* external calendars, matching iOS. Banishing also clears any local
* quick-hide flag for the same key (redundant once banished).
*/
fun setCalendarBanished(key: String, banished: Boolean) {
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
if (banished) add(key) else remove(key)
}
val nextHidden = _state.value.hiddenKeys.toMutableSet().apply {
if (banished) remove(key)
}
settingsStore.banishedCalendarKeys = nextBanished
settingsStore.hiddenCalendarKeys = nextHidden
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
refreshFromCache()
val parts = key.split(":")
val source = parts.getOrNull(0)
val id = parts.getOrNull(1)?.toIntOrNull()
if (source != null && id != null && source in listOf("caldav", "google", "homeassistant")) {
viewModelScope.launch {
runCatching { repository.setCalendarSidebarHidden(source, id, banished) }
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } }
// Un-banishing re-enables the calendar on the server, but its
// events were excluded while hidden — force a refetch so they
// reappear without a manual sync.
if (!banished) {
invalidateCache()
initialLoad(reconcile = false)
}
}
}
}
// ---- Groups ----
fun loadGroups() {
viewModelScope.launch {
runCatching { repository.getGroups() }
.onSuccess { gs ->
_state.update { st ->
// If the active group was deleted elsewhere, drop back to personal.
val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } }
st.copy(groups = gs, activeGroup = stillActive)
}
}
}
}
/** Flip between personal and a group's combined overlay; reloads the wide window. */
fun switchGroup(group: Group?) {
if (_state.value.activeGroup?.id == group?.id) return
_state.update {
it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList())
}
invalidateCache()
initialLoad()
// Load the full member list (with server colours) for the filter sheet.
if (group != null) {
viewModelScope.launch {
runCatching { repository.getGroup(group.id) }
.onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } }
}
}
}
/** Toggle a single member's calendar / the group calendar in the overlay. */
fun setGroupKeyHidden(key: String, hidden: Boolean) {
_state.update {
val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) }
it.copy(hiddenGroupKeys = next)
}
refreshFromCache()
}
/** Replace the group-overlay hidden set (bulk show/hide all). */
fun setHiddenGroupKeys(keys: Set<String>) {
_state.update { it.copy(hiddenGroupKeys = keys) }
refreshFromCache()
}
// ---- Writable calendars ----
fun loadWritableCalendars() {
viewModelScope.launch {
runCatching { repository.getWritableCalendars() }
.onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } }
}
}
// ---- Event mutations ----
/**
* Re-fetch just the already-cached range after a create/update so the edit is
* reflected everywhere it's currently loaded, without nuking and reloading the
* whole ±cacheMonths window (which caused a full-screen freeze on every save).
* Falls back to [initialLoad] if nothing was cached yet.
*/
private fun afterMutation() {
val start = cachedStart
val end = cachedEnd
if (start == null || end == null) {
initialLoad()
return
}
viewModelScope.launch {
loadRange(start, end, background = false, force = true)
markReady()
}
}
fun saveEvent(
calendar: WritableCalendar,
existing: CalEvent?,
title: String,
start: Instant,
end: Instant,
isAllDay: Boolean,
location: String,
description: String,
color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
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, isPrivate, reminders)
"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, isPrivate, reminders)
}
} else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
}
}
result.onSuccess { afterMutation(); onResult(null) }
.onFailure { onResult(it.message ?: "Fehler") }
}
}
private suspend fun createForSource(
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
) {
when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
}
}
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
viewModelScope.launch {
val result = runCatching {
when (event.source) {
"local" -> repository.deleteLocalEvent(event.id)
"caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event))
"homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id)
"google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id)
else -> Unit
}
}
result.onSuccess {
removeCachedEvent(event.id)
onResult(null)
}.onFailure { onResult(it.message ?: "Fehler") }
}
}
private fun calendarNumericId(event: CalEvent): Int? {
val key = calendarKey(event.source, event.calendarId)
return key.substringAfter(":").toIntOrNull()
}
private fun removeCachedEvent(id: String) {
allCachedEvents = allCachedEvents.filterNot { it.id == id }
refreshFromCache()
}
/** Events overlapping a single day, sorted by start. */
fun eventsOn(date: LocalDate, events: List<CalEvent>): List<CalEvent> {
val dayStart = instant(date)
val dayEnd = instant(date.plusDays(1))
return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) }
.sortedBy { it.startDate }
}
}
@@ -0,0 +1,255 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.TextStyle
/**
* Long-press day preview (mirrors the iOS DayContextPreviewView): all-day
* events as colored bars with tapered ends when they continue beyond this day,
* timed events as dot + time + title rows, followed by quick actions.
*/
@Composable
fun DayPreviewDialog(
date: LocalDate,
events: List<CalEvent>,
onDismiss: () -> Unit,
onEventClick: (CalEvent) -> Unit,
onCreateEvent: () -> Unit,
onOpenDay: () -> Unit,
onOpenWeek: () -> Unit,
) {
val lang = LocalLang.current
val sorted = remember(events) {
events.sortedWith(compareByDescending<CalEvent> { it.isAllDay }.thenBy { it.startDate })
}
val weekdayAbbr = remember(date, lang) {
date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase()
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(
Modifier
.width(290.dp)
.padding(vertical = 14.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.Bottom,
) {
Text(
weekdayAbbr,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp, bottom = 3.dp),
)
Text(
"${date.dayOfMonth}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
if (sorted.isEmpty()) {
Text(
tr("cal.no_events_day"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
} else {
sorted.forEach { ev ->
if (ev.isAllDay) {
AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) })
} else {
TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) })
}
}
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent)
PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek)
PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay)
}
}
}
}
@Composable
private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor)
val startDay = localDate(event.startDate)
val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive
val cLeft = startDay.isBefore(date)
val cRight = lastDay.isAfter(date)
val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) }
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp)
.clip(shape)
.background(color)
.clickable(onClick = onClick)
.padding(
start = if (cLeft) 14.dp else 8.dp,
end = if (cRight) 14.dp else 8.dp,
top = 4.dp,
bottom = 4.dp,
),
) {
EventLabel(
event = event,
color = color.contrastingTextColor(),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
@Composable
private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(colorFromHex(event.effectiveColor)),
)
Text(
timeLabel(event.startDate, lang),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).width(42.dp),
)
EventLabel(
event = event,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
/**
* Bar shape with tapered (pointed) ends on the sides where the event continues
* beyond the shown day — port of the iOS ChevronBarShape.
*/
private class ChevronBarShape(
private val continuesLeft: Boolean,
private val continuesRight: Boolean,
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val r = with(density) { 4.dp.toPx() }
if (!continuesLeft && !continuesRight) {
return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r)))
}
val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2)
val p = Path()
if (continuesLeft && continuesRight) {
p.moveTo(t, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else if (continuesLeft) {
p.moveTo(t, 0f)
p.lineTo(size.width, 0f)
p.lineTo(size.width, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else {
p.moveTo(0f, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(0f, size.height)
}
p.close()
return Outline.Generic(p)
}
}
@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
/**
* Event label for the calendar grids: an optional leading cake icon for
* birthday events, followed by the render title (which carries the
* server-computed age). The icon inherits the text colour/size so the label
* matches whatever bar it's dropped into.
*/
@Composable
fun EventLabel(
event: CalEvent,
color: Color,
fontSize: TextUnit = 10.sp,
fontWeight: FontWeight = FontWeight.Medium,
maxLines: Int = 1,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
if (event.isBirthday) {
Icon(
Icons.Filled.Cake,
contentDescription = null,
tint = color,
modifier = Modifier
.size(fontSize.value.dp)
.padding(end = 2.dp),
)
}
Text(
event.renderTitle,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
fontSize = fontSize,
fontWeight = fontWeight,
color = color,
modifier = Modifier.weight(1f, fill = false),
)
}
}
@@ -0,0 +1,512 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.time.temporal.IsoFields
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private const val MONTHS_BACK = 120L // 10 years back
private const val MONTHS_AHEAD = 600L // 50 years ahead
private const val MAX_LANES = 4
private val DAY_NUM_H = 22.dp
private val LANE_H = 15.dp
private val LANE_SPACE = 2.dp
// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them.
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp
private enum class DividerEdge { NONE, TOP, BOTTOM }
/** A placed event bar within a week (with colours resolved once, not per frame). */
private data class PlacedBar(
val event: CalEvent,
val lane: Int,
val startCol: Int,
val span: Int,
val color: Color,
val textColor: Color,
)
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** Month calendar with multi-day event bars (iOS-style). Two modes: a
* continuous vertical scroll feed, or a horizontally-paged one-month-per-screen
* grid (swipe left/right), toggled by [CalendarUiState.monthViewPaged]. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MonthView(
state: CalendarUiState,
vm: CalendarViewModel,
listState: LazyListState,
scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
) {
val lang = LocalLang.current
val settings = LocalAppSettings.current
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant
val outline = MaterialTheme.colorScheme.outline
val onBackground = MaterialTheme.colorScheme.onBackground
val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) }
val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) }
val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) }
val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) }
val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) }
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
val density = LocalDensity.current
val fallbackCellW = LocalConfiguration.current.screenWidthDp.dp / 7
var gridWidthPx by remember { mutableIntStateOf(0) }
val cellW: Dp = if (gridWidthPx > 0) with(density) { (gridWidthPx / 7f).toDp() } else fallbackCellW
val firstVisible = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
}
val end = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst)
}
val weekCount = remember(firstVisible, end) {
(ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1)
}
fun weekIndexOf(date: LocalDate): Int =
ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1)
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
// Month-paged mode bookkeeping (one page per calendar month).
val firstMonth = remember { today.withDayOfMonth(1).minusMonths(MONTHS_BACK) }
val monthCount = remember { (MONTHS_BACK + MONTHS_AHEAD + 1).toInt() }
fun monthIndexOf(date: LocalDate): Int =
ChronoUnit.MONTHS.between(firstMonth, date.withDayOfMonth(1)).toInt().coerceIn(0, monthCount - 1)
val todayMonthIndex = remember(firstMonth) { monthIndexOf(today) }
val pagerState = rememberPagerState(initialPage = todayMonthIndex) { monthCount }
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
val dimPast = settings.dimPastEvents
val cwLabel = tr("cal.cw")
// remember: a fresh Instant per recomposition would invalidate every visible
// WeekRow (the main source of scroll jank); minute precision is plenty here.
val now = remember { java.time.Instant.now() }
// Route the title / prev-next / today signals to the active surface (pager or list).
if (state.monthViewPaged) {
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) pagerState.animateScrollToPage(todayMonthIndex)
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) pagerState.animateScrollToPage(monthIndexOf(monthJumpTarget))
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.map { firstMonth.plusMonths(it.toLong()) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
} else {
LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) {
listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1)))
}
}
LaunchedEffect(listState, weekCount) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
}
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
weekdayLabels(mondayFirst, lang).forEach { label ->
Text(
label,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.monthViewPaged) {
// One month per page, six height-filling week rows, swipe to change month.
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) { page ->
val month = firstMonth.plusMonths(page.toLong())
val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
Column(Modifier.fillMaxSize()) {
repeat(6) { w ->
val weekStart = firstWeek.plusWeeks(w.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().weight(1f),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount, key = { it }, contentType = { "week" }) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
}
}
}
}
}
@Composable
private fun WeekRow(
rowModifier: Modifier,
weekStart: LocalDate,
today: LocalDate,
weekEvents: List<CalEvent>,
cellW: Dp,
dimPast: Boolean,
now: java.time.Instant,
lang: String,
cwLabel: String,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
) {
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
val rowStartsNewMonth = days[0].dayOfMonth == 1
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
// Scroll mode passes a fixed ROW_HEIGHT; paged mode passes weight(1f) so six
// rows fill the screen. Event bars anchor to the top; the day cells fill height.
Box(rowModifier) {
Row(Modifier.fillMaxSize()) {
days.forEachIndexed { idx, day ->
val edge = when {
boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP
rowStartsNewMonth -> DividerEdge.TOP
else -> DividerEdge.NONE
}
DayCellBackground(
day = day,
isToday = day == today,
isMonday = day.dayOfWeek == DayOfWeek.MONDAY,
weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR),
cwLabel = cwLabel,
overflow = packed.overflowPerCol[idx],
lang = lang,
edge = edge,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onClick = { onDayClick(day) },
onLongClick = { onDayLongPress(day) },
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
packed.bars.forEach { bar ->
val past = dimPast && bar.event.endDate.isBefore(now)
EventBar(bar = bar, cellW = cellW, dimmed = past, onClick = { onEventClick(bar.event) })
}
if (boundaryCol != null) {
Box(
Modifier
.offset(x = cellW * boundaryCol)
.width(1.5.dp)
.fillMaxHeight()
.background(dividerColor),
)
}
}
}
@Composable
private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () -> Unit) {
Box(
Modifier
.offset(
x = cellW * bar.startCol + 1.dp,
y = DAY_NUM_H + (LANE_H + LANE_SPACE) * bar.lane,
)
.width(cellW * bar.span - 2.dp)
.height(LANE_H)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(3.dp))
.background(bar.color)
.clickable(onClick = onClick)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
EventLabel(
event = bar.event,
color = bar.textColor,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DayCellBackground(
day: LocalDate,
isToday: Boolean,
isMonday: Boolean,
weekNumber: Int,
cwLabel: String,
overflow: Int,
lang: String,
edge: DividerEdge,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isFirst = day.dayOfMonth == 1
val onBg = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
.drawBehind {
val gridW = 0.5.dp.toPx()
val divW = 1.5.dp.toPx()
if (edge == DividerEdge.TOP) {
drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW)
} else {
drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW)
}
if (edge == DividerEdge.BOTTOM) {
drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW)
}
drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW)
}
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 1.dp),
) {
Row(
Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isFirst) {
Text(
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(),
fontSize = 8.sp,
fontWeight = FontWeight.SemiBold,
color = labelColor,
modifier = Modifier.padding(end = 2.dp),
)
}
Box(contentAlignment = Alignment.Center) {
if (isToday) {
Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor))
}
Text(
"${day.dayOfMonth}",
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color = if (isToday) todayColor.contrastingTextColor() else onBg,
)
}
}
if (overflow > 0) {
Text(
"+$overflow",
fontSize = 8.sp,
color = secondaryText,
modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp),
)
}
if (isMonday) {
Text(
"$cwLabel $weekNumber",
fontSize = 8.sp,
color = secondaryText,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 1.dp),
)
}
}
}
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val dow = date.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
/** Columns [startCol, startCol+span-1] this event occupies within the given week. */
private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair<Int, Int> {
val weekEnd = weekStart.plusDays(7)
val evStartDay = maxOf(localDate(ev.startDate), weekStart)
val lastDay = localDate(ev.endDate.minusSeconds(1))
val evEndDay = minOf(lastDay, weekEnd.minusDays(1))
val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6)
val ec = ChronoUnit.DAYS.between(weekStart, evEndDay).toInt().coerceIn(0, 6)
return sc to (ec - sc + 1).coerceAtLeast(1)
}
/** Greedy first-fit lane packing (mirrors iOS packEvents); colours resolved here. */
private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLayout {
val sorted = weekEvents.sortedWith(compareBy<CalEvent> { it.startDate }.thenByDescending { it.endDate })
val laneLastEnd = ArrayList<Int>()
val bars = ArrayList<PlacedBar>()
val overflow = IntArray(7)
for (ev in sorted) {
val (sc, span) = columnRange(weekStart, ev)
val lastCol = (sc + span - 1).coerceAtMost(6)
var assigned = -1
for (i in laneLastEnd.indices) {
if (laneLastEnd[i] < sc) { laneLastEnd[i] = lastCol; assigned = i; break }
}
if (assigned == -1 && laneLastEnd.size < MAX_LANES) {
laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1
}
if (assigned >= 0) {
val color = colorFromHex(ev.effectiveColor)
bars.add(PlacedBar(ev, assigned, sc, span, color, color.contrastingTextColor()))
} else {
for (c in sc..lastCol) overflow[c]++
}
}
return WeekLayout(bars, overflow)
}
/** Bucket events into every week (week-start key) they overlap. */
private fun buildEventsByWeek(events: List<CalEvent>, mondayFirst: Boolean): Map<LocalDate, List<CalEvent>> {
val map = HashMap<LocalDate, MutableList<CalEvent>>()
for (ev in events) {
val startD = localDate(ev.startDate)
val firstWeek = startOfWeek(startD, mondayFirst)
val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(startD)) startD else it }
val lastWeek = startOfWeek(lastDay, mondayFirst)
var w = firstWeek
var guard = 0
while (!w.isAfter(lastWeek) && guard < 120) {
map.getOrPut(w) { mutableListOf() }.add(ev)
w = w.plusWeeks(1)
guard++
}
}
return map
}
@@ -0,0 +1,104 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) {
val lang = LocalLang.current
val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
months.forEach { month ->
MiniMonth(month, state, lang, onDayClick)
}
}
}
@Composable
private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) {
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang))
val dow = month.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
val firstVisible = month.minusDays(offset.toLong())
val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) }
Column(Modifier.padding(bottom = 16.dp)) {
Text(
titleFmt.format(month).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp),
)
Row(Modifier.fillMaxWidth()) {
weekdayLabels(mondayFirst, lang).forEach {
Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline)
}
}
cells.chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth()) {
week.forEach { day ->
val hasEvents = state.events.any {
localDate(it.startDate) <= day && localDate(it.endDate) >= day
}
val firstColor = state.events.firstOrNull {
localDate(it.startDate) <= day && localDate(it.endDate) >= day
}?.effectiveColor
Box(
Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val isToday = day == today
Box(contentAlignment = Alignment.Center) {
if (isToday) {
Box(
Modifier.clip(CircleShape)
.background(colorFromHex(LocalAppSettings.current.todayColor))
.padding(horizontal = 5.dp, vertical = 2.dp),
) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) }
} else {
Text(
"${day.dayOfMonth}",
fontSize = 10.sp,
color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline,
)
}
}
if (hasEvents) {
Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp))
}
}
}
}
}
}
}
}
@@ -0,0 +1,267 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.DateTimeFormatter
private val GUTTER = 48.dp
/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */
private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int)
@Composable
fun TimeGridView(
days: List<LocalDate>,
state: CalendarUiState,
vm: CalendarViewModel,
onEventClick: (CalEvent) -> Unit,
) {
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
val dimPast = LocalAppSettings.current.dimPastEvents
val today = LocalDate.now()
// Recomputed once per day (not on every recomposition/frame) — Instant.now()
// was previously called fresh each recomposition, causing visible flicker in
// the "is this now" past-dimming and wasted allocation.
val now = remember(today) { java.time.Instant.now() }
val lang = LocalLang.current
Column(Modifier.fillMaxSize()) {
// Day headers (only for multi-day / week view)
if (days.size > 1) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(GUTTER))
val dayFmt = remember(lang) {
DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
}
days.forEach { day ->
Text(
dayFmt.format(day),
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal,
color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// All-day row: continuous bars spanning an event's start→end columns and
// packed into lanes, instead of repeating a multi-day event once per day.
val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
if (perDay.any { it.isNotEmpty() }) {
// event id -> [minCol, maxCol] within this week
val range = LinkedHashMap<String, IntArray>()
val evById = HashMap<String, CalEvent>()
perDay.forEachIndexed { i, evs ->
evs.forEach { ev ->
evById[ev.id] = ev
val r = range[ev.id]
if (r == null) range[ev.id] = intArrayOf(i, i) else r[1] = i
}
}
// earlier / longer first, then greedily pack into lanes
val spans = range.entries
.map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) }
.sortedWith(compareBy({ it.second }, { -(it.third - it.second) }))
val laneEnd = ArrayList<Int>()
val bars = ArrayList<AllDayBar>()
for ((ev, s, e) in spans) {
var lane = 0
while (lane < laneEnd.size && laneEnd[lane] >= s) lane++
if (lane == laneEnd.size) laneEnd.add(e) else laneEnd[lane] = e
bars.add(AllDayBar(ev, s, e, lane))
}
val laneCount = (bars.maxOfOrNull { it.lane } ?: -1) + 1
Column(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
for (lane in 0 until laneCount) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
if (lane == 0) Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
}
var col = 0
bars.filter { it.lane == lane }.sortedBy { it.start }.forEach { bar ->
if (bar.start > col) { Spacer(Modifier.weight((bar.start - col).toFloat())); col = bar.start }
Box(Modifier.weight((bar.end - bar.start + 1).toFloat()).padding(horizontal = 1.dp)) {
AllDayChip(bar.ev, dimmed = dimPast && bar.ev.endDate.isBefore(now), onClick = { onEventClick(bar.ev) })
}
col = bar.end + 1
}
if (col < days.size) Spacer(Modifier.weight((days.size - col).toFloat()))
}
}
}
Divider()
}
// Scrollable hour grid
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Box(Modifier.fillMaxWidth().height(hourHeight * 24)) {
// Hour lines + labels
for (h in 0..24) {
val y = hourHeight * h.toFloat()
Divider(
modifier = Modifier
.fillMaxWidth()
.padding(start = GUTTER)
.offset(y = y),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
if (h < 24) {
Text(
"%02d:00".format(h),
modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp),
textAlign = TextAlign.End,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.outline,
)
}
}
// Timed events per day column
days.forEachIndexed { index, day ->
val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay }
timed.forEach { ev ->
TimedEvent(
event = ev,
day = day,
index = index,
dayCount = days.size,
hourHeight = hourHeight,
dimmed = dimPast && ev.endDate.isBefore(now),
onClick = { onEventClick(ev) },
)
}
}
}
}
}
}
@Composable
private fun TimedEvent(
event: CalEvent,
day: LocalDate,
index: Int,
dayCount: Int,
hourHeight: androidx.compose.ui.unit.Dp,
dimmed: Boolean,
onClick: () -> Unit,
) {
val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate)
val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate)
val duration = (endMin - startMin).coerceAtLeast(30)
val top = hourHeight * (startMin / 60f)
val height = (hourHeight * (duration / 60f))
val color = colorFromHex(event.effectiveColor)
val lang = LocalLang.current
Box(
modifier = Modifier
.offset(y = top)
.columnSlot(index, dayCount)
.height(height)
.padding(horizontal = 1.dp, vertical = 0.5.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
EventLabel(
event = event,
color = color.contrastingTextColor(),
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
)
if (height > 36.dp) {
Text(
eventTimeRange(event, lang),
fontSize = 8.sp,
color = color.contrastingTextColor().copy(alpha = 0.85f),
)
}
}
}
}
/** Position a child in column [index] of [dayCount] columns, after the gutter. */
private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints ->
val gutterPx = GUTTER.roundToPx()
val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0)
val colWidth = available / dayCount
val placeable = measurable.measure(
constraints.copy(minWidth = colWidth, maxWidth = colWidth)
)
layout(constraints.maxWidth, placeable.height) {
placeable.place(gutterPx + colWidth * index, 0)
}
}
@Composable
private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor)
Box(
Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(3.dp))
.background(color)
.clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp),
) {
EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp)
}
}
@Composable
fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
TimeGridView(listOf(state.currentDate), state, vm, onEventClick)
}
@Composable
fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
val mondayFirst = state.weekStartsOnMonday
val dow = state.currentDate.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
val weekStart = state.currentDate.minusDays(offset.toLong())
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
TimeGridView(days, state, vm, onEventClick)
}
@@ -0,0 +1,152 @@
package com.scarriffle.calendarr.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex
/**
* Full HSV colour picker: saturation/value field + hue slider + hex input.
* Returns a "#RRGGBB" string via [onConfirm].
*/
@Composable
fun ColorPickerDialog(
initial: String,
title: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
val initHsv = remember(initial) {
FloatArray(3).also { android.graphics.Color.colorToHSV(colorFromHex(initial).toArgb(), it) }
}
var hue by remember { mutableFloatStateOf(initHsv[0]) }
var sat by remember { mutableFloatStateOf(initHsv[1]) }
var value by remember { mutableFloatStateOf(initHsv[2]) }
var hexText by remember { mutableStateOf(colorFromHex(initial).toHex()) }
val current = Color.hsv(hue, sat, value)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(Modifier.fillMaxWidth()) {
// Saturation / Value field
Box(
Modifier
.fillMaxWidth()
.height(170.dp)
.clip(RoundedCornerShape(10.dp))
.background(Brush.horizontalGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f))))
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black)))
.pointerInput(Unit) {
detectTapGestures { o ->
sat = (o.x / size.width).coerceIn(0f, 1f)
value = (1f - o.y / size.height).coerceIn(0f, 1f)
hexText = Color.hsv(hue, sat, value).toHex()
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
sat = (change.position.x / size.width).coerceIn(0f, 1f)
value = (1f - change.position.y / size.height).coerceIn(0f, 1f)
hexText = Color.hsv(hue, sat, value).toHex()
}
},
) {
Canvas(Modifier.matchParentSize()) {
val cx = sat * size.width
val cy = (1f - value) * size.height
drawCircle(Color.White, radius = 9f, center = Offset(cx, cy), style = Stroke(width = 3f))
drawCircle(Color.Black, radius = 12f, center = Offset(cx, cy), style = Stroke(width = 1f))
}
}
Spacer(Modifier.height(14.dp))
// Hue slider
Box(
Modifier
.fillMaxWidth()
.height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(Brush.horizontalGradient((0..6).map { Color.hsv(it * 60f, 1f, 1f) }))
.pointerInput(Unit) {
detectTapGestures { o ->
hue = (o.x / size.width * 360f).coerceIn(0f, 360f)
hexText = Color.hsv(hue, sat, value).toHex()
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
hue = (change.position.x / size.width * 360f).coerceIn(0f, 360f)
hexText = Color.hsv(hue, sat, value).toHex()
}
},
) {
Canvas(Modifier.matchParentSize()) {
val cx = (hue / 360f) * size.width
drawCircle(Color.White, radius = 10f, center = Offset(cx, size.height / 2f), style = Stroke(width = 3f))
}
}
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(40.dp).clip(CircleShape).background(current).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape))
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = hexText,
onValueChange = { raw ->
hexText = raw
val parsed = colorFromHex(raw, Color.Unspecified)
if (parsed != Color.Unspecified) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(parsed.toArgb(), hsv)
hue = hsv[0]; sat = hsv[1]; value = hsv[2]
}
},
label = { Text("Hex") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(current.toHex()) }) { Text("OK") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
)
}
@@ -0,0 +1,88 @@
package com.scarriffle.calendarr.ui.components
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import kotlinx.coroutines.delay
/**
* Password input with two affordances the plain field lacks:
* - an eye toggle to reveal the full value, and
* - a brief reveal of the most recently typed character (~1.2s) so the user
* can tell whether a keystroke landed as intended.
*/
@Composable
fun PasswordField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
imeAction: ImeAction = ImeAction.Done,
) {
var fullyVisible by remember { mutableStateOf(false) }
var revealLast by remember { mutableStateOf(false) }
// Reveal the last character briefly after each change.
LaunchedEffect(value) {
if (value.isNotEmpty() && !fullyVisible) {
revealLast = true
delay(1200)
revealLast = false
} else {
revealLast = false
}
}
val transformation = when {
fullyVisible -> VisualTransformation.None
revealLast -> LastCharVisibleTransformation
else -> PasswordVisualTransformation()
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
singleLine = true,
visualTransformation = transformation,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
trailingIcon = {
IconButton(onClick = { fullyVisible = !fullyVisible }) {
Icon(
imageVector = if (fullyVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = null,
)
}
},
modifier = modifier,
)
}
/** Masks every character except the last with a bullet. 1:1 char mapping → identity offsets. */
private val LastCharVisibleTransformation = object : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val masked = buildString {
for (i in text.indices) append(if (i == text.lastIndex) text[i] else '•')
}
return TransformedText(AnnotatedString(masked), OffsetMapping.Identity)
}
}
@@ -0,0 +1,168 @@
package com.scarriffle.calendarr.ui.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/** Full-screen event detail (mirrors the iOS detail page). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
event: CalEvent,
currentUserId: Int = 0,
onClose: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
) {
val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) }
val canEdit = event.source in setOf("local", "caldav", "homeassistant", "google")
val canDelete = canEdit
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("event.detail_title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
if (canEdit) {
TextButton(onClick = onEdit) { Text(tr("event.edit_title")) }
}
},
)
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Box(
Modifier.width(6.dp).height(46.dp).clip(RoundedCornerShape(3.dp))
.background(colorFromHex(event.effectiveColor)),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
if (event.calendarName.isNotBlank()) {
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Spacer(Modifier.height(20.dp))
DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang))
if (event.location.isNotBlank()) DetailRow(Icons.Filled.LocationOn, event.location)
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()) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(tr("event.copy_title"))
}
if (canDelete) {
Spacer(Modifier.height(4.dp))
// Deliberately low-key (plain text button): destructive action
// should be findable, not the loudest element on the screen.
TextButton(
onClick = { confirmDelete = true },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(tr("common.delete"))
}
}
Spacer(Modifier.height(24.dp))
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("common.delete")) },
text = { Text(tr("event.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) {
Text(tr("common.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}
@Composable
private fun DetailRow(icon: ImageVector, text: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.Top) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(14.dp))
Text(text, style = MaterialTheme.typography.bodyLarge)
}
}
@@ -0,0 +1,407 @@
package com.scarriffle.calendarr.ui.event
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.ReminderOptions
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.EditorRequest
import com.scarriffle.calendarr.ui.calendar.calendarKey
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val PRESET_COLORS = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05",
"#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditorSheet(
request: EditorRequest,
writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit,
defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit,
) {
val zone = ZoneId.systemDefault()
val context = LocalContext.current
val lang = LocalLang.current
val existing = request.existing
// Fields are prefilled from the event being edited OR copied.
val template = existing ?: request.prefill
val isCopy = existing == null && request.prefill != null
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var title by remember { mutableStateOf(template?.title ?: "") }
var allDay by remember { mutableStateOf(template?.isAllDay ?: false) }
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
var startDate by remember {
mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date)
}
var startTime by remember {
mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0))
}
// New events default to start (09:00) + the user's default duration.
val defaultEnd = request.date.atTime(9, 0).plusMinutes(defaultDurationMinutes.toLong())
var endDate by remember {
mutableStateOf(
initialEnd?.let {
val d = LocalDate.ofInstant(it, zone)
if (template?.isAllDay == true) d.minusDays(1) else d
} ?: defaultEnd.toLocalDate()
)
}
var endTime by remember {
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: defaultEnd.toLocalTime())
}
var reminders by remember { mutableStateOf(template?.reminders ?: emptyList<Int>()) }
val preselected = template?.let { ev ->
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id }
}
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
var calMenuOpen by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
fun pickDate(current: LocalDate, onPicked: (LocalDate) -> Unit) {
DatePickerDialog(
context,
{ _, y, m, d -> onPicked(LocalDate.of(y, m + 1, d)) },
current.year, current.monthValue - 1, current.dayOfMonth,
).show()
}
fun pickTime(current: LocalTime, onPicked: (LocalTime) -> Unit) {
TimePickerDialog(
context,
{ _, h, min -> onPicked(LocalTime.of(h, min)) },
current.hour, current.minute, true,
).show()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
) {
Text(
when {
existing != null -> tr("event.edit_title")
isCopy -> tr("event.copy_title")
else -> tr("event.new_title")
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp),
)
OutlinedTextField(
value = title,
onValueChange = { title = it; error = null },
label = { Text(tr("event.title_placeholder")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.allday"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = allDay, onCheckedChange = { allDay = it })
}
Spacer(Modifier.size(8.dp))
// Start
Text(tr("event.start"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { pickDate(startDate) { startDate = it; if (endDate.isBefore(it)) endDate = it } }) {
Text(dateFmt.format(startDate))
}
if (!allDay) {
OutlinedButton(onClick = { pickTime(startTime) { startTime = it } }) { Text(timeFmt.format(startTime)) }
}
}
Spacer(Modifier.size(8.dp))
// End
Text(tr("event.end"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { pickDate(endDate) { endDate = it } }) { Text(dateFmt.format(endDate)) }
if (!allDay) {
OutlinedButton(onClick = { pickTime(endTime) { endTime = it } }) { Text(timeFmt.format(endTime)) }
}
}
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = location,
onValueChange = { location = it },
label = { Text(tr("event.location")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(tr("event.description")) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
// Calendar picker
Text(tr("event.calendar_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (writableCalendars.isEmpty()) {
Text(tr("event.no_writable"), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} else {
Box {
OutlinedButton(onClick = { calMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(calendar?.color)))
Spacer(Modifier.size(8.dp))
Text(calendar?.name ?: tr("event.calendar_picker"), modifier = Modifier.weight(1f))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = calMenuOpen, onDismissRequest = { calMenuOpen = false }) {
writableCalendars.forEach { c ->
DropdownMenuItem(
text = { Text(c.name) },
leadingIcon = { Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(c.color))) },
onClick = { calendar = c; calMenuOpen = false },
)
}
}
}
}
Spacer(Modifier.size(12.dp))
// Private + reminders (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))
val remindersDisabled = calendar?.let {
reminderDisabledKeys.contains(calendarKey(it.source, it.numericId.toString()))
} ?: false
Text(tr("event.reminders"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (remindersDisabled) {
Text(
tr("event.reminders_disabled"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(top = 2.dp),
)
}
reminders.forEachIndexed { idx, min ->
ReminderRow(
minutes = min,
enabled = !remindersDisabled,
onChange = { v -> reminders = reminders.toMutableList().also { it[idx] = v } },
onRemove = { reminders = reminders.toMutableList().also { it.removeAt(idx) } },
)
}
androidx.compose.material3.TextButton(
enabled = !remindersDisabled,
onClick = {
val next = ReminderOptions.presets.firstOrNull { it !in reminders } ?: ReminderOptions.customDefault
reminders = reminders + next
},
) { Text(tr("event.reminder_add")) }
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)) {
PRESET_COLORS.forEach { hex ->
val selected = color?.equals(hex, ignoreCase = true) == true
Box(
Modifier
.size(28.dp)
.clip(CircleShape)
.background(colorFromHex(hex))
.then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
.clickable { color = hex },
contentAlignment = Alignment.Center,
) {
if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
if (color != null) {
androidx.compose.material3.TextButton(onClick = { color = null }) { Text(tr("event.reset_color")) }
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
}
Spacer(Modifier.size(16.dp))
Button(
onClick = {
val cal = calendar
if (title.isBlank()) { error = L10n.t("event.title_placeholder", lang); return@Button }
if (cal == null) { error = L10n.t("event.no_writable", lang); return@Button }
val start: Instant
val end: Instant
if (allDay) {
start = startDate.atStartOfDay(zone).toInstant()
end = endDate.plusDays(1).atStartOfDay(zone).toInstant()
} else {
start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
val rem = if (cal.source == "local") reminders else emptyList()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem)
},
enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (existing == null) tr("event.add") else tr("event.save"))
}
}
}
}
/** One reminder row: a preset dropdown plus, in custom mode, a number field +
* unit dropdown. The value is always emitted as minutes-before-start. */
@Composable
private fun ReminderRow(
minutes: Int,
enabled: Boolean,
onChange: (Int) -> Unit,
onRemove: () -> Unit,
) {
val isPreset = minutes in ReminderOptions.presets
var presetMenu by remember { mutableStateOf(false) }
var unitMenu by remember { mutableStateOf(false) }
@Composable
fun label(min: Int): String =
if (min == 0) tr("event.reminder_at_start")
else ReminderOptions.split(min).let { (v, u) -> "$v ${tr(u.labelKey)} ${tr("event.reminder_before")}" }
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { presetMenu = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(if (isPreset) label(minutes) else tr("event.reminder_custom"), modifier = Modifier.weight(1f))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = presetMenu, onDismissRequest = { presetMenu = false }) {
ReminderOptions.presets.forEach { p ->
DropdownMenuItem(text = { Text(label(p)) }, onClick = { onChange(p); presetMenu = false })
}
DropdownMenuItem(text = { Text(tr("event.reminder_custom")) }, onClick = {
if (minutes in ReminderOptions.presets) onChange(ReminderOptions.customDefault)
presetMenu = false
})
}
}
IconButton(onClick = onRemove, enabled = enabled) {
Icon(Icons.Filled.Close, contentDescription = null)
}
}
if (!isPreset) {
val (value, unit) = ReminderOptions.split(minutes)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = value.toString(),
onValueChange = { txt ->
val n = txt.filter { it.isDigit() }.toIntOrNull()?.coerceAtLeast(1) ?: 1
onChange(n * unit.mult)
},
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(96.dp),
)
Box {
OutlinedButton(onClick = { unitMenu = true }, enabled = enabled) {
Text(tr(unit.labelKey))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = unitMenu, onDismissRequest = { unitMenu = false }) {
ReminderOptions.Unit.values().forEach { u ->
DropdownMenuItem(text = { Text(tr(u.labelKey)) }, onClick = { onChange(value * u.mult); unitMenu = false })
}
}
}
Text(tr("event.reminder_before"), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -0,0 +1,365 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Work
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* Cross-platform group-icon keys (stored server-side) rendered as native
* Material icons — consistent everywhere instead of OS-emoji that vary by
* platform. Mirrors iOS GroupIcons / the web SVG set.
*/
object GroupIcons {
val keys = listOf(
"people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star",
)
fun vector(key: String?): ImageVector = when (key) {
"people" -> Icons.Filled.People
"home" -> Icons.Filled.Home
"heart" -> Icons.Filled.Favorite
"work" -> Icons.Filled.Work
"school" -> Icons.Filled.School
"sports" -> Icons.Filled.DirectionsRun
"party" -> Icons.Filled.Celebration
"pet" -> Icons.Filled.Pets
"travel" -> Icons.Filled.Flight
"music" -> Icons.Filled.MusicNote
"food" -> Icons.Filled.Restaurant
"star" -> Icons.Filled.Star
else -> Icons.Filled.People
}
fun isKey(s: String?): Boolean = s != null && s in keys
}
/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */
@Composable
fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) {
if (GroupIcons.isKey(icon)) {
Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
} else if (!icon.isNullOrEmpty()) {
Text(icon, modifier = modifier)
} else {
Icon(Icons.Filled.People, contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
onOpenGroupView: (Group) -> Unit = {},
vm: GroupsViewModel = hiltViewModel(),
) {
var createOpen by remember { mutableStateOf(false) }
var manageId by remember { mutableStateOf<Int?>(null) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("groups.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
if (vm.groups.isEmpty()) {
item {
Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp))
}
}
items(vm.groups, key = { it.id }) { g ->
Row(
Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(g.name, style = MaterialTheme.typography.bodyLarge)
Text(
tr("groups.member_count", g.memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { manageId = g.id }) {
Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Divider()
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
}
}
}
if (createOpen) {
GroupEditSheet(
vm = vm,
existing = null,
onDismiss = { createOpen = false },
onSaved = { createOpen = false; onChanged() },
)
}
manageId?.let { id ->
val existing = vm.groups.firstOrNull { it.id == id }
GroupEditSheet(
vm = vm,
existing = existing,
onDismiss = { manageId = null },
onSaved = { manageId = null; onChanged() },
)
}
}
/** Create (existing == null) or manage (existing != null) a group. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun GroupEditSheet(
vm: GroupsViewModel,
existing: Group?,
onDismiss: () -> Unit,
onSaved: () -> Unit,
) {
val me = vm.currentUserId
var name by remember { mutableStateOf(existing?.name ?: "") }
var icon by remember { mutableStateOf(existing?.icon?.takeIf { GroupIcons.isKey(it) } ?: "people") }
var selected by remember { mutableStateOf(setOf<Int>()) }
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
var detail by remember { mutableStateOf<Group?>(null) }
var loaded by remember { mutableStateOf(existing == null) }
var confirmDelete by remember { mutableStateOf(false) }
var memberColorTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } // userId, current hex
// Manage: load full details (members + colours) and pre-fill selection.
LaunchedEffect(existing?.id) {
val id = existing?.id ?: return@LaunchedEffect
val g = vm.groupDetail(id)
detail = g
if (g != null) {
name = g.name
icon = g.icon?.takeIf { GroupIcons.isKey(it) } ?: "people"
val members = g.members.map { it.id }.filter { it != me }.toSet()
existingMembers = members
selected = members
}
loaded = true
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) {
Text(
if (existing == null) tr("groups.create") else tr("groups.manage"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(tr("groups.name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(16.dp))
Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
GroupIcons.keys.forEach { ic ->
val sel = ic == icon
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(8.dp))
.background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant)
.clickable { icon = ic },
contentAlignment = Alignment.Center,
) {
Icon(
GroupIcons.vector(ic),
contentDescription = ic,
tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.size(16.dp))
Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (!loaded) {
Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
} else {
vm.directory.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable {
selected = if (u.id in selected) selected - u.id else selected + u.id
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = u.id in selected, onCheckedChange = {
selected = if (u.id in selected) selected - u.id else selected + u.id
})
Text(u.displayName, modifier = Modifier.padding(start = 4.dp))
}
}
}
// Member colours (manage only)
detail?.members?.takeIf { it.isNotEmpty() }?.let { members ->
Spacer(Modifier.size(8.dp))
Divider()
Spacer(Modifier.size(8.dp))
Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
members.forEach { m ->
val hex = m.color ?: "#4285f4"
Row(
Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex)))
Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp))
}
}
}
Spacer(Modifier.size(20.dp))
Button(onClick = {
val n = name.trim()
if (n.isEmpty()) return@Button
if (existing == null) {
vm.createGroup(n, icon, selected.toList()) { onSaved() }
} else {
vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() }
}
}, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(tr("event.save"))
}
if (existing != null) {
Spacer(Modifier.size(8.dp))
OutlinedButton(
onClick = { confirmDelete = true },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.size(8.dp))
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
}
}
}
memberColorTarget?.let { (userId, hex) ->
ColorPickerDialog(
initial = hex,
title = tr("groups.member_color"),
onDismiss = { memberColorTarget = null },
onConfirm = { picked ->
existing?.let { vm.setMemberColor(it.id, userId, picked) }
// reflect locally
detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) }
memberColorTarget = null
},
)
}
if (confirmDelete && existing != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("groups.delete")) },
text = { Text(tr("groups.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) {
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}
@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GroupsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var groups by mutableStateOf<List<Group>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
var error by mutableStateOf<String?>(null)
private set
val currentUserId: Int get() = repository.currentUserId
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
groups = runCatching { repository.getGroups() }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
loading = false
}
}
suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull()
fun createGroup(name: String, icon: String, memberIds: List<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.createGroup(name, memberIds, icon) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
/** Save name/icon, then reconcile membership against [existingMemberIds]. */
fun saveGroup(id: Int, name: String, icon: String, desired: Set<Int>, existing: Set<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching {
repository.updateGroup(id, name, icon)
for (uid in desired - existing) repository.addGroupMember(id, uid)
for (uid in existing - desired) repository.removeGroupMember(id, uid)
}
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
fun setMemberColor(groupId: Int, userId: Int, color: String) {
viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } }
}
fun deleteGroup(id: Int, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.deleteGroup(id) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
}
@@ -0,0 +1,124 @@
package com.scarriffle.calendarr.ui.menu
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.ui.tr
/** Full-screen menu / landing page reached from the drawer's gear: the hub for
* Profile / Appearance / Accounts / Groups / Sync / Server / Logout. Each entry
* opens its own full-screen page. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuScreen(
username: String,
serverUrl: String,
onClose: () -> Unit,
onProfile: () -> Unit,
onAppearance: () -> Unit,
onAccounts: () -> Unit,
onGroups: () -> Unit,
onSync: () -> Unit,
onSwitchServer: () -> Unit,
onLogout: () -> Unit,
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("nav.menu")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
contentWindowInsets = WindowInsets(0),
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).navigationBarsPadding(),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(username.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(14.dp))
Column {
Text(username, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
Divider()
MenuRow(Icons.Filled.Person, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer)
MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout, destructive = true)
}
}
}
}
@Composable
private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit, destructive: Boolean = false) {
val tint = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = tint)
Spacer(Modifier.width(18.dp))
Text(label, style = MaterialTheme.typography.bodyLarge,
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}
@@ -0,0 +1,163 @@
package com.scarriffle.calendarr.ui.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(onClose: () -> Unit, vm: ProfileViewModel = hiltViewModel()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("profile.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
if (vm.loading) {
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
) {
val p = vm.profile
SectionTitle(tr("profile.account"))
InfoRow(tr("profile.username"), p?.username ?: "—")
InfoRow(tr("profile.role"), if (p?.isAdmin == true) tr("profile.role.admin") else tr("profile.role.user"))
Spacer(Modifier.size(20.dp))
SectionTitle(tr("profile.email"))
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::saveEmail) { Text(tr("profile.save_email")) }
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
PasswordSection(vm)
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
TwoFactorSection(vm)
vm.message?.let {
Spacer(Modifier.size(16.dp))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun PasswordSection(vm: ProfileViewModel) {
val mismatchMsg = tr("profile.password_mismatch")
var current by remember { mutableStateOf("") }
var new1 by remember { mutableStateOf("") }
var new2 by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
SectionTitle(tr("profile.change_password"))
PasswordField(value = current, onValueChange = { current = it }, label = tr("profile.current_password"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(value = new1, onValueChange = { new1 = it }, label = tr("profile.new_password"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(value = new2, onValueChange = { new2 = it }, label = tr("profile.new_password_repeat"), modifier = Modifier.fillMaxWidth())
localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.size(8.dp))
Button(onClick = {
if (new1 != new2) { localError = mismatchMsg; return@Button }
localError = null
vm.changePassword(current, new1) { ok -> if (ok) { current = ""; new1 = ""; new2 = "" } }
}) { Text(tr("profile.change_password")) }
}
@Composable
private fun TwoFactorSection(vm: ProfileViewModel) {
val enabled = vm.profile?.totpEnabled == true
var disablePw by remember { mutableStateOf("") }
SectionTitle(tr("profile.twofa"))
Text(if (enabled) tr("profile.twofa.active") else tr("profile.twofa.inactive"), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.size(8.dp))
if (enabled) {
PasswordField(value = disablePw, onValueChange = { disablePw = it }, label = tr("twofa.password_placeholder"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) }
} else {
val setup = vm.totpSetup
if (setup == null) {
Button(onClick = vm::begin2fa) { Text(tr("profile.twofa.enable")) }
} else {
Text(tr("twofa.scan_hint"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(setup.secret, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.size(8.dp))
OutlinedTextField(value = vm.totpCode, onValueChange = vm::onTotpChange, label = { Text(tr("twofa.code_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
Button(onClick = vm::confirm2fa) { Text(tr("twofa.activate")) }
OutlinedButton(onClick = vm::cancel2fa) { Text(tr("common.cancel")) }
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,91 @@
package com.scarriffle.calendarr.ui.profile
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.TotpSetup
import com.scarriffle.calendarr.domain.model.UserProfile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var profile by mutableStateOf<UserProfile?>(null)
private set
var email by mutableStateOf("")
private set
var loading by mutableStateOf(true)
private set
var message by mutableStateOf<String?>(null)
private set
// 2FA enrolment
var totpSetup by mutableStateOf<TotpSetup?>(null)
private set
var totpCode by mutableStateOf("")
private set
init { load() }
fun load() {
viewModelScope.launch {
loading = true
runCatching { repository.getProfile() }
.onSuccess { p -> profile = p; email = p.email ?: "" }
.onFailure { message = it.message }
loading = false
}
}
fun onEmailChange(v: String) { email = v }
fun onTotpChange(v: String) { totpCode = v }
fun saveEmail() {
viewModelScope.launch {
runCatching { repository.updateEmail(email.trim()) }
.onSuccess { message = "✓"; load() }
.onFailure { message = it.message }
}
}
fun changePassword(current: String, new: String, onDone: (Boolean) -> Unit) {
viewModelScope.launch {
runCatching { repository.changePassword(current, new) }
.onSuccess { message = "✓"; onDone(true) }
.onFailure { message = it.message; onDone(false) }
}
}
fun begin2fa() {
viewModelScope.launch {
runCatching { repository.setup2fa() }
.onSuccess { totpSetup = it }
.onFailure { message = it.message }
}
}
fun confirm2fa() {
viewModelScope.launch {
runCatching { repository.enable2fa(totpCode.trim()) }
.onSuccess { totpSetup = null; totpCode = ""; load() }
.onFailure { message = it.message }
}
}
fun disable2fa(password: String) {
viewModelScope.launch {
runCatching { repository.disable2fa(password) }
.onSuccess { load() }
.onFailure { message = it.message }
}
}
fun cancel2fa() { totpSetup = null; totpCode = "" }
}
@@ -0,0 +1,432 @@
package com.scarriffle.calendarr.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
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
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex
// Canonical default colours (single source for the reset buttons).
private val DEFAULT_COLORS = mapOf(
"primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4",
"text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52",
"month_divider_color" to "#7090C0", "month_label_color" to "#7090C0",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onClose: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit,
vm: SettingsViewModel = hiltViewModel(),
) {
val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) }
fun update(newSettings: AppSettings) {
settings = newSettings
onSettingsChanged(newSettings)
vm.apply(newSettings, onSettingsSynced)
}
// Per-row sync toggle: enabling adopts this device's current value.
fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) }
fun synced(key: String) = vm.syncFlags[key] == true
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("settings.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
// Global "sync everything" master switch.
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = vm.syncableKeys.all { vm.syncFlags[it] == true },
onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) },
)
}
Divider(Modifier.padding(vertical = 16.dp))
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Termine (synced) ----
Section(tr("settings.calview"))
SyncDropdownRow(
tr("settings.default_duration"),
listOf("15" to "15 min", "30" to "30 min", "45" to "45 min", "60" to "1 h", "90" to "1.5 h", "120" to "2 h"),
settings.defaultEventDurationMinutes.toString(),
{ update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") },
)
SyncDropdownRow(
tr("settings.defaultreminder"),
reminderOptions(),
(settings.defaultReminderMinutes ?: -1).toString(),
{ update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Ansicht (synced) ----
Section(tr("settings.appearance"))
SyncDropdownRow(
tr("settings.defaultview"),
CalViewType.entries.map { it.key to tr("view.${it.key}") },
settings.defaultView,
{ update(settings.copy(defaultView = it)) },
synced("default_view"), { toggle("default_view") },
)
SyncDropdownRow(
tr("settings.firstweekday"),
listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
settings.weekStartDay,
{ update(settings.copy(weekStartDay = it)) },
synced("week_start_day"), { toggle("week_start_day") },
)
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
SyncDropdownRow(
tr("settings.month_mode"),
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
settings.monthViewPaged.toString(),
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
synced("month_view_paged"), { toggle("month_view_paged") },
)
SyncDropdownRow(
tr("settings.hourheight"),
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
settings.hourHeight.toString(),
{ update(settings.copy(hourHeight = it.toInt())) },
synced("hour_height"), { toggle("hour_height") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Farben (synced) ----
Section(tr("settings.colors"))
SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) }
SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) }
SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) }
SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) }
Divider(Modifier.padding(vertical = 16.dp))
// ---- Cache (synced) ----
SyncDropdownRow(
tr("settings.cache.range"),
listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
settings.cacheMonths.toString(),
{ update(settings.copy(cacheMonths = it.toInt())) },
synced("cache_months"), { toggle("cache_months") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Gerät (device-local: language + contrast) ----
Section(tr("settings.device"))
DeviceDropdownRow(
tr("settings.language"),
listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
settings.language,
) { update(settings.copy(language = it)) }
DeviceDropdownRow(
tr("settings.textcontrast"),
listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")),
settings.textContrast.toString(),
) { update(settings.copy(textContrast = it.toInt())) }
DeviceDropdownRow(
tr("settings.linecontrast"),
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
settings.lineContrast.toString(),
) { update(settings.copy(lineContrast = it.toInt())) }
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
}
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
Spacer(Modifier.size(40.dp))
}
}
}
}
@Composable
private fun reminderOptions(): List<Pair<String, String>> = listOf(
"-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min",
"30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"),
)
@Composable
private fun Section(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
/** Leading per-row sync toggle: highlighted = synced across devices. */
@Composable
private fun SyncIcon(on: Boolean, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = tr("settings.sync_this"),
modifier = Modifier.size(18.dp),
tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Compact dropdown trigger (a small outlined chip) — much shorter than a full
// OutlinedTextField, so the settings rows aren't inflated.
@Composable
private fun Dropdown(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected
Box {
Row(
Modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.clickable { expanded = true }
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (key, label) ->
DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false })
}
}
}
}
@Composable
private fun SyncDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
synced: Boolean,
onToggleSync: () -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun DeviceDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun SyncColorRow(
syncKey: String,
label: String,
current: String,
synced: Boolean,
onToggleSync: () -> Unit,
onPick: (String) -> Unit,
) {
var showPicker by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(12.dp))
Box(
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current))
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.clickable { showPicker = true },
)
TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall)
}
}
if (showPicker) {
ColorPickerDialog(
initial = current,
title = label,
onDismiss = { showPicker = false },
onConfirm = { showPicker = false; onPick(it) },
)
}
}
/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */
@OptIn(ExperimentalMaterial3Api::class)
@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))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge)
Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange)
}
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"))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Dropdown(
listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
vm.privateVisibility,
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 })
}
}
}
}
@@ -0,0 +1,124 @@
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
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val settingsStore: SettingsStore,
) : ViewModel() {
/** Persist locally immediately, then push the synced values to the server. */
fun apply(settings: AppSettings, onSynced: () -> Unit) {
settingsStore.saveSettings(settings)
viewModelScope.launch {
runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
onSynced()
}
}
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = settingsStore.hideMenuButton
set(value) { settingsStore.hideMenuButton = value }
// ---- Per-setting sync flags ----
val syncableKeys: List<String> get() = settingsStore.syncableKeys
var syncFlags by mutableStateOf(settingsStore.loadSyncFlags())
private set
/** Toggle one setting's flag. Enabling adopts this device's current value
* (pushes the synced values up); disabling keeps the value local. */
fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false))
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
/** Global "sync everything" switch. */
fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setAllSyncFlags(on)
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
// ---- Profile chapter (server-backed) ----
var displayName by mutableStateOf("")
var loginName by mutableStateOf("")
var email by mutableStateOf("")
private set
var directoryHidden by mutableStateOf(false)
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 }
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
private fun loadProfile() {
viewModelScope.launch {
runCatching { repository.getProfile() }.onSuccess { p ->
displayName = p.displayName ?: p.username
loginName = p.username
email = p.email ?: ""
directoryHidden = p.directoryHidden
}
runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?: 0
}
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
// A birthday calendar may be shared directly, but never stand in
// as the group-visible personal calendar.
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
}
}
}
fun saveProfile(savedLabel: String) {
viewModelScope.launch {
runCatching {
repository.updateProfile(
displayName = displayName.trim().ifEmpty { null },
username = null,
email = email.trim(),
directoryHidden = directoryHidden,
)
}
.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 }) } }
}
}
@@ -0,0 +1,13 @@
package com.scarriffle.calendarr.ui.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFF4285F4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_background = Color(0xFFFFFFFF)
val md_theme_light_onBackground = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF8AB4F8)
val md_theme_dark_onPrimary = Color(0xFF000000)
val md_theme_dark_background = Color(0xFF000000)
val md_theme_dark_onBackground = Color(0xFFFFFFFF)
@@ -0,0 +1,74 @@
package com.scarriffle.calendarr.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
* primary colour is unset. */
val BrandGreen = Color(0xFF20A050)
/**
* Fully dynamic theme: every colour is derived from [AppSettings] so the whole
* app follows the user's palette (matching the web client). primary/accent tint
* the controls; background/text/line drive `background`/`onBackground`/`outline`,
* which the calendar views already read (grid, secondary text). "today", divider
* and label colours are read directly in the views.
*/
@Composable
fun CalendarrTheme(
settings: AppSettings = AppSettings(),
content: @Composable () -> Unit,
) {
val primary = colorFromHex(settings.primaryColor, BrandGreen)
val accent = colorFromHex(settings.accentColor, primary)
val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000))
val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7))
val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52))
val surface = lerp(bg, Color.White, 0.10f)
val surfaceVariant = lerp(bg, Color.White, 0.17f)
val container = lerp(primary, Color.Black, 0.55f)
val onContainer = lerp(primary, Color.White, 0.75f)
val colors = darkColorScheme(
primary = primary,
onPrimary = primary.contrastingTextColor(),
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = accent,
onSecondary = accent.contrastingTextColor(),
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = accent,
onTertiary = accent.contrastingTextColor(),
tertiaryContainer = container,
onTertiaryContainer = onContainer,
surfaceTint = primary,
background = bg,
onBackground = onBg,
surface = surface,
onSurface = onBg,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onBg.copy(alpha = 0.7f),
outline = line,
)
MaterialTheme(
colorScheme = colors,
typography = Typography(),
// extraSmall drives DropdownMenu containers (default 4dp looks boxy);
// rounder popups to match the iOS look.
shapes = Shapes(extraSmall = RoundedCornerShape(14.dp)),
content = content,
)
}
@@ -0,0 +1,51 @@
package com.scarriffle.calendarr.util
import androidx.compose.ui.graphics.Color
/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
if (hex.isNullOrBlank()) return fallback
val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() }
return when (clean.length) {
3 -> runCatching {
// #RGB shorthand -> expand each nibble
val r = clean[0].digitToInt(16) * 17
val g = clean[1].digitToInt(16) * 17
val b = clean[2].digitToInt(16) * 17
Color(r / 255f, g / 255f, b / 255f, 1f)
}.getOrDefault(fallback)
6 -> runCatching {
val v = clean.toLong(16)
Color(
red = ((v shr 16) and 0xFF) / 255f,
green = ((v shr 8) and 0xFF) / 255f,
blue = (v and 0xFF) / 255f,
alpha = 1f,
)
}.getOrDefault(fallback)
8 -> runCatching {
val v = clean.toLong(16)
Color(
alpha = ((v shr 24) and 0xFF) / 255f,
red = ((v shr 16) and 0xFF) / 255f,
green = ((v shr 8) and 0xFF) / 255f,
blue = (v and 0xFF) / 255f,
)
}.getOrDefault(fallback)
else -> fallback
}
}
/** Convert a Compose [Color] back to a "#RRGGBB" string. */
fun Color.toHex(): String {
val r = (red * 255).toInt().coerceIn(0, 255)
val g = (green * 255).toInt().coerceIn(0, 255)
val b = (blue * 255).toInt().coerceIn(0, 255)
return String.format("#%02X%02X%02X", r, g, b)
}
/** Pick black or white text for legibility on a given background color. */
fun Color.contrastingTextColor(): Color {
val luminance = 0.299 * red + 0.587 * green + 0.114 * blue
return if (luminance > 0.6) Color.Black else Color.White
}
@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.util
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Date parsing/formatting that mirrors the iOS client (CalEvent.swift).
*
* The backend may produce any of these forms:
* "2026-05-17" (all-day, date only)
* "2026-05-17T10:00:00Z"
* "2026-05-17T10:00:00+02:00"
* "2026-05-17T10:00:00.000Z"
* "2026-05-17T10:00:00" (no timezone -> treat as UTC)
* "2026-05-17 10:00:00+00:00" (Python isoformat, space separator)
*/
object Dates {
private val zone: ZoneId get() = ZoneId.systemDefault()
private val spaceSep: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX")
private val noTz: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
/** Parse a server date string into an [Instant]. Returns null if unparseable. */
fun parse(raw: String, allDay: Boolean): Instant? {
val s = raw.trim()
if (s.isEmpty()) return null
if (allDay || (s.length == 10 && !s.contains('T'))) {
return runCatching {
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
}.getOrNull()
}
// ISO with offset / Z
runCatching { return OffsetDateTime.parse(s).toInstant() }
runCatching { return Instant.parse(s) }
// Python isoformat with a space
runCatching { return OffsetDateTime.parse(s, spaceSep).toInstant() }
// No timezone -> UTC
runCatching {
return LocalDateTime.parse(s.take(19), noTz).toInstant(ZoneOffset.UTC)
}
// Last resort: just the date part
return runCatching {
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
}.getOrNull()
}
/** Format an [Instant] for sending to the server. */
fun format(instant: Instant, allDay: Boolean): String {
return if (allDay) {
LocalDate.ofInstant(instant, zone).toString() // yyyy-MM-dd
} else {
// ISO8601 with Z, no fractional seconds (matches iOS isoBasic)
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(instant)
}
}
/** ISO8601 UTC string used for the events query range (`?start=&end=`). */
fun isoUtc(instant: Instant): String =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(instant)
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
fun startOfDay(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- High-res logo inset so it sits comfortably inside the round splash mask. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_splash_logo"
android:inset="18%" />
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Invisible system-splash icon: the system splash is just black, so the
branded in-app splash (icon + name + copyright) is the only thing seen. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,8 @@
<resources>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Calendarr</string>
</resources>
@@ -0,0 +1,20 @@
<resources>
<color name="splash_bg">#000000</color>
<color name="ic_launcher_background">#16703D</color>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Black window/system bars (matches the app background). -->
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar">false</item>
</style>
<!-- System splash (core-splashscreen): plain black with an invisible icon,
so the only branded screen the user sees is the in-app splash. -->
<style name="Theme.Calendarr.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_transparent</item>
<item name="postSplashScreenTheme">@style/Theme.Calendarr</item>
</style>
</resources>