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:
44
app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt
Normal file
44
app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.scarriffle.calendarr.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
|
||||
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
|
||||
if (hex.isNullOrBlank()) return fallback
|
||||
val clean = hex.trim().removePrefix("#")
|
||||
return when (clean.length) {
|
||||
6 -> runCatching {
|
||||
val v = clean.toLong(16)
|
||||
Color(
|
||||
red = ((v shr 16) and 0xFF) / 255f,
|
||||
green = ((v shr 8) and 0xFF) / 255f,
|
||||
blue = (v and 0xFF) / 255f,
|
||||
alpha = 1f,
|
||||
)
|
||||
}.getOrDefault(fallback)
|
||||
8 -> runCatching {
|
||||
val v = clean.toLong(16)
|
||||
Color(
|
||||
alpha = ((v shr 24) and 0xFF) / 255f,
|
||||
red = ((v shr 16) and 0xFF) / 255f,
|
||||
green = ((v shr 8) and 0xFF) / 255f,
|
||||
blue = (v and 0xFF) / 255f,
|
||||
)
|
||||
}.getOrDefault(fallback)
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a Compose [Color] back to a "#RRGGBB" string. */
|
||||
fun Color.toHex(): String {
|
||||
val r = (red * 255).toInt().coerceIn(0, 255)
|
||||
val g = (green * 255).toInt().coerceIn(0, 255)
|
||||
val b = (blue * 255).toInt().coerceIn(0, 255)
|
||||
return String.format("#%02X%02X%02X", r, g, b)
|
||||
}
|
||||
|
||||
/** Pick black or white text for legibility on a given background color. */
|
||||
fun Color.contrastingTextColor(): Color {
|
||||
val luminance = 0.299 * red + 0.587 * green + 0.114 * blue
|
||||
return if (luminance > 0.6) Color.Black else Color.White
|
||||
}
|
||||
77
app/src/main/java/com/scarriffle/calendarr/util/Dates.kt
Normal file
77
app/src/main/java/com/scarriffle/calendarr/util/Dates.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.scarriffle.calendarr.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Date parsing/formatting that mirrors the iOS client (CalEvent.swift).
|
||||
*
|
||||
* The backend may produce any of these forms:
|
||||
* "2026-05-17" (all-day, date only)
|
||||
* "2026-05-17T10:00:00Z"
|
||||
* "2026-05-17T10:00:00+02:00"
|
||||
* "2026-05-17T10:00:00.000Z"
|
||||
* "2026-05-17T10:00:00" (no timezone -> treat as UTC)
|
||||
* "2026-05-17 10:00:00+00:00" (Python isoformat, space separator)
|
||||
*/
|
||||
object Dates {
|
||||
|
||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||
|
||||
private val spaceSep: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX")
|
||||
private val noTz: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
|
||||
|
||||
/** Parse a server date string into an [Instant]. Returns null if unparseable. */
|
||||
fun parse(raw: String, allDay: Boolean): Instant? {
|
||||
val s = raw.trim()
|
||||
if (s.isEmpty()) return null
|
||||
|
||||
if (allDay || (s.length == 10 && !s.contains('T'))) {
|
||||
return runCatching {
|
||||
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
|
||||
}.getOrNull()
|
||||
}
|
||||
// ISO with offset / Z
|
||||
runCatching { return OffsetDateTime.parse(s).toInstant() }
|
||||
runCatching { return Instant.parse(s) }
|
||||
// Python isoformat with a space
|
||||
runCatching { return OffsetDateTime.parse(s, spaceSep).toInstant() }
|
||||
// No timezone -> UTC
|
||||
runCatching {
|
||||
return LocalDateTime.parse(s.take(19), noTz).toInstant(ZoneOffset.UTC)
|
||||
}
|
||||
// Last resort: just the date part
|
||||
return runCatching {
|
||||
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Format an [Instant] for sending to the server. */
|
||||
fun format(instant: Instant, allDay: Boolean): String {
|
||||
return if (allDay) {
|
||||
LocalDate.ofInstant(instant, zone).toString() // yyyy-MM-dd
|
||||
} else {
|
||||
// ISO8601 with Z, no fractional seconds (matches iOS isoBasic)
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||
.withZone(ZoneOffset.UTC)
|
||||
.format(instant)
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO8601 UTC string used for the events query range (`?start=&end=`). */
|
||||
fun isoUtc(instant: Instant): String =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||
.withZone(ZoneOffset.UTC)
|
||||
.format(instant)
|
||||
|
||||
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
|
||||
|
||||
fun startOfDay(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
|
||||
}
|
||||
Reference in New Issue
Block a user