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>
@@ -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()
|
||||
}
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
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>
|
||||