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

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