feat: data foundation (models, secure storage, Retrofit API, repository, L10n)
- java.time via core library desugaring (minSdk 24) - Domain models: CalEvent, AppSettings, account/calendar models - CredentialStore (EncryptedSharedPreferences) + SettingsStore - Retrofit CalendarrApi covering all server endpoints - CalendarRepository: auth, settings, accounts, events (org.json parsing for mixed-type payloads), writable calendars, visibility toggles - Hilt DI (Moshi), dynamic-baseUrl ApiProvider, AuthInterceptor - L10n (de/en) ported from iOS Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
@@ -51,6 +52,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.scarriffle.calendarr.data
|
||||
|
||||
import com.scarriffle.calendarr.data.remote.ApiException
|
||||
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.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 com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||
import com.scarriffle.calendarr.util.Dates
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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()
|
||||
}
|
||||
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
|
||||
|
||||
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
|
||||
val token = json.optString("access_token").takeIf { it.isNotBlank() }
|
||||
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
|
||||
val user = json.optJSONObject("user")
|
||||
val uname = user?.optString("username") ?: username
|
||||
val isAdmin = user?.optBoolean("is_admin", false) ?: false
|
||||
|
||||
credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
|
||||
credentialStore.saveLogin(token, uname, isAdmin)
|
||||
apiProvider.invalidate()
|
||||
LoginResult(token, uname, isAdmin)
|
||||
}
|
||||
|
||||
// ---- Settings / profile ----
|
||||
|
||||
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
|
||||
|
||||
suspend fun updateSettings(s: AppSettings) = guarded {
|
||||
api.updateSettings(
|
||||
jsonBody(
|
||||
"default_view" to s.defaultView,
|
||||
"week_start_day" to s.weekStartDay,
|
||||
"primary_color" to s.primaryColor,
|
||||
"accent_color" to s.accentColor,
|
||||
"today_color" to s.todayColor,
|
||||
"dim_past_events" to s.dimPastEvents,
|
||||
"text_contrast" to s.textContrast,
|
||||
"line_contrast" to s.lineContrast,
|
||||
"hour_height" to s.hourHeight,
|
||||
"language" to s.language,
|
||||
"month_divider_color" to s.monthDividerColor,
|
||||
"month_label_color" to s.monthLabelColor,
|
||||
)
|
||||
).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) =
|
||||
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) }
|
||||
|
||||
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() }
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()).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): List<CalEvent> = withContext(Dispatchers.IO) {
|
||||
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
|
||||
resp.ensureSuccess()
|
||||
val raw = resp.body()?.string() ?: return@withContext emptyList()
|
||||
val root = JSONObject(raw)
|
||||
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 createLocalEvent(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
) = guarded {
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun updateLocalEvent(
|
||||
uid: String, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
) = guarded {
|
||||
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
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(
|
||||
jsonBody(
|
||||
"calendar_db_id" to calendarDbId, "title" to title,
|
||||
"start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay),
|
||||
"allDay" to isAllDay, "location" to location, "description" to description,
|
||||
)
|
||||
).ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun createHAEvent(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String,
|
||||
) = guarded {
|
||||
api.createHAEvent(
|
||||
jsonBody(
|
||||
"calendar_id" to calendarId, "title" to title,
|
||||
"start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay),
|
||||
"allDay" to isAllDay, "location" to location, "description" to description,
|
||||
)
|
||||
).ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun deleteHAEvent(calendarId: Int, uid: String) =
|
||||
guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() }
|
||||
|
||||
private fun eventBody(
|
||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
) = 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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()
|
||||
|
||||
/** 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) {
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putBoolean(KEY_IS_ADMIN, isAdmin)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Clear the token (logout) but keep the server URL. */
|
||||
fun clearToken() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_IS_ADMIN)
|
||||
.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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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",
|
||||
)
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
// --- 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()
|
||||
|
||||
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_CACHE_MONTHS = "cache_months"
|
||||
const val K_HIDDEN = "hidden_calendar_keys"
|
||||
const val K_BANISHED = "banished_calendar_keys"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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). */
|
||||
class UnauthorizedException : ApiException("Benutzername oder Passwort falsch")
|
||||
|
||||
/** 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()))
|
||||
}
|
||||
}
|
||||
@@ -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,181 @@
|
||||
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.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.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
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>
|
||||
|
||||
@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>
|
||||
|
||||
// ---- 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
|
||||
|
||||
@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
|
||||
|
||||
@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>
|
||||
|
||||
@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>
|
||||
|
||||
@POST("api/homeassistant/events")
|
||||
suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
@DELETE("api/homeassistant/events/{calendarId}/{uid}")
|
||||
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)
|
||||
}
|
||||
21
app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt
Normal file
21
app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt
Normal file
@@ -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,97 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class LocalCalendar(
|
||||
val id: Int,
|
||||
val name: String = "",
|
||||
val color: String = "#34a853",
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class UserProfile(
|
||||
val id: Int,
|
||||
val username: String = "",
|
||||
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,
|
||||
)
|
||||
|
||||
/** 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,27 @@
|
||||
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",
|
||||
) {
|
||||
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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`.
|
||||
*/
|
||||
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,
|
||||
) {
|
||||
/** Per-event override colour, falling back to the calendar's colour. */
|
||||
val effectiveColor: String get() = color?.takeIf { it.isNotBlank() } ?: calendarColor
|
||||
|
||||
companion object {
|
||||
/** Parse one event object from the `/api/caldav/events` aggregate response. */
|
||||
fun fromJson(json: JSONObject): CalEvent? {
|
||||
val title = json.optString("title").takeIf { json.has("title") } ?: return null
|
||||
val startStr = json.optString("start").takeIf { json.has("start") } ?: return null
|
||||
val endStr = json.optString("end").takeIf { json.has("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()
|
||||
}
|
||||
|
||||
val colorRaw = json.optString("color", "")
|
||||
return CalEvent(
|
||||
id = id,
|
||||
url = json.optString("url", ""),
|
||||
title = title,
|
||||
startDate = start,
|
||||
endDate = end,
|
||||
isAllDay = isAllDay,
|
||||
location = json.optString("location", ""),
|
||||
notes = json.optString("description", ""),
|
||||
color = colorRaw.takeIf { it.isNotBlank() },
|
||||
calendarId = calendarId,
|
||||
calendarName = json.optString("calendar_name", ""),
|
||||
calendarColor = json.optString("calendarColor", "#4285f4"),
|
||||
source = json.optString("source", "local"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt
Normal file
220
app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
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",
|
||||
"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.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
|
||||
"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.hourheight" to "Stundenhöhe",
|
||||
"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.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.save" to "Sichern", "event.add" to "Hinzufügen",
|
||||
"event.delete_confirm" to "Diesen Termin löschen?",
|
||||
"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.button" to "Kalender ein-/ausblenden",
|
||||
"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",
|
||||
"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",
|
||||
// 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",
|
||||
),
|
||||
"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",
|
||||
"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.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
|
||||
"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.hourheight" to "Hour height",
|
||||
"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.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.save" to "Save", "event.add" to "Add",
|
||||
"event.delete_confirm" to "Delete this event?",
|
||||
"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.button" to "Show/hide calendars",
|
||||
"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",
|
||||
"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",
|
||||
// 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
44
app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt
Normal file
44
app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
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("#")
|
||||
return when (clean.length) {
|
||||
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
|
||||
}
|
||||
77
app/src/main/java/com/scarriffle/calendarr/util/Dates.kt
Normal file
77
app/src/main/java/com/scarriffle/calendarr/util/Dates.kt
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user