diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55f5bf2..d5956c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt new file mode 100644 index 0000000..2b3b18c --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -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 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 = 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 = 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 = 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 = guarded { api.getGoogleAccounts() } + + suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() } + + suspend fun getHomeAssistantAccounts(): List = + 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 = withContext(Dispatchers.IO) { + val result = mutableListOf() + 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 = 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) + } + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt new file mode 100644 index 0000000..d9c5ab6 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt new file mode 100644 index 0000000..cf7ab91 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -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 + get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply() + + // --- Banished calendars ("source:id") --- + + var banishedCalendarKeys: Set + 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" + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt new file mode 100644 index 0000000..526e52b --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt @@ -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())) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt new file mode 100644 index 0000000..f7c7979 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt @@ -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? = 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/" + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..ab34832 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt new file mode 100644 index 0000000..65cb42a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -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 + + @POST("api/auth/login") + suspend fun login(@Body body: RequestBody): Response + + @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 + + // ---- Profile ---- + + @PATCH("api/profile/") + suspend fun updateEmail(@Body body: RequestBody): Response + + @POST("api/profile/password") + suspend fun changePassword(@Body body: RequestBody): Response + + @POST("api/profile/2fa/setup") + suspend fun setup2fa(): Response + + @POST("api/profile/2fa/enable") + suspend fun enable2fa(@Body body: RequestBody): Response + + @POST("api/profile/2fa/disable") + suspend fun disable2fa(@Body body: RequestBody): Response + + // ---- CalDAV ---- + + @GET("api/caldav/accounts") + suspend fun getCalDAVAccounts(): List + + @POST("api/caldav/accounts") + suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount + + @DELETE("api/caldav/accounts/{id}") + suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response + + @POST("api/caldav/accounts/{id}/sync") + suspend fun syncCalDAVAccount(@Path("id") id: Int): Response + + @PUT("api/caldav/calendars/{id}") + suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Local ---- + + @GET("api/local/calendars") + suspend fun getLocalCalendars(): List + + @POST("api/local/calendars") + suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar + + @DELETE("api/local/calendars/{id}") + suspend fun deleteLocalCalendar(@Path("id") id: Int): Response + + // ---- iCal subscriptions ---- + + @GET("api/ical/subscriptions") + suspend fun getICalSubscriptions(): List + + @POST("api/ical/subscriptions") + suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription + + @DELETE("api/ical/subscriptions/{id}") + suspend fun deleteICalSubscription(@Path("id") id: Int): Response + + @POST("api/ical/subscriptions/{id}/refresh") + suspend fun refreshICalSubscription(@Path("id") id: Int): Response + + // ---- Google ---- + + @GET("api/google/accounts") + suspend fun getGoogleAccounts(): List + + @DELETE("api/google/accounts/{id}") + suspend fun deleteGoogleAccount(@Path("id") id: Int): Response + + @PUT("api/google/calendars/{id}") + suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Home Assistant ---- + + @GET("api/homeassistant/accounts") + suspend fun getHomeAssistantAccounts(): List + + @POST("api/homeassistant/accounts") + suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount + + @DELETE("api/homeassistant/accounts/{id}") + suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response + + @PUT("api/homeassistant/calendars/{id}") + suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Events ---- + + @GET("api/caldav/events") + suspend fun fetchEvents( + @Query("start") start: String, + @Query("end") end: String, + ): Response + + @POST("api/local/events") + suspend fun createLocalEvent(@Body body: RequestBody): Response + + @PUT("api/local/events/{uid}") + suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response + + @DELETE("api/local/events/{uid}") + suspend fun deleteLocalEvent(@Path("uid") uid: String): Response + + @POST("api/caldav/events") + suspend fun createCalDAVEvent(@Body body: RequestBody): Response + + @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 + + @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 + + @POST("api/google/events") + suspend fun createGoogleEvent(@Body body: RequestBody): Response + + @POST("api/homeassistant/events") + suspend fun createHAEvent(@Body body: RequestBody): Response + + @DELETE("api/homeassistant/events/{calendarId}/{uid}") + suspend fun deleteHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + ): Response +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt new file mode 100644 index 0000000..b6d08d5 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt @@ -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): 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): RequestBody { + val obj = JSONObject() + for ((k, v) in map) { + if (v != null) obj.put(k, v) + } + return obj.toString().toRequestBody(JSON) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt b/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt new file mode 100644 index 0000000..95ba7a7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt @@ -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() +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt new file mode 100644 index 0000000..aa41878 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -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? = 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? = 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? = 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, +) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt new file mode 100644 index 0000000..2e5f5d0 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -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" +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt new file mode 100644 index 0000000..a67ff6a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -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 "-" 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"), + ) + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt new file mode 100644 index 0000000..cb67305 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt @@ -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 + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt new file mode 100644 index 0000000..5170248 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -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> = 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", + ), + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt new file mode 100644 index 0000000..3220474 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt @@ -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 +} diff --git a/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt b/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt new file mode 100644 index 0000000..1f42f10 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt @@ -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() +}