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:
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