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:
Guido Schmit
2026-05-31 11:53:34 +02:00
parent a015a45265
commit 676b7ee2c6
17 changed files with 1450 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
@@ -51,6 +52,7 @@ android {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

View File

@@ -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)
}
)
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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()))
}
}

View File

@@ -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/"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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>
}

View File

@@ -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)
}

View 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()
}

View File

@@ -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,
)

View File

@@ -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"
}

View File

@@ -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"),
)
}
}
}

View File

@@ -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
}
}

View 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",
),
)
}

View 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
}

View 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()
}