stremio/apps/ios/VMC/UI/EventsScreen.swift
Jos Vooges | STH 3e2549c33a Fix iOS Live TV streamId String decode and add Events + ClearKey path.
Restore channel list decoding for string/custom IDs, URL-encode play paths, parse DRM/heartbeat, and ship Events tab (0.5.0/12) with best-effort VLC ClearKey.
2026-09-19 03:59:51 +02:00

803 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
// MARK: - List
struct EventsScreen: View {
@EnvironmentObject private var app: AppModel
@State private var events: [ScheduleEvent] = []
@State private var loading = true
@State private var refreshing = false
@State private var error: String?
@State private var providerFilter: String?
@State private var now = Date()
@State private var path = NavigationPath()
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
private let autoRefresh = Timer.publish(every: 75, on: .main, in: .common).autoconnect()
private var filtered: [ScheduleEvent] {
guard let providerFilter, !providerFilter.isEmpty else { return events }
return events.filter { $0.provider.caseInsensitiveCompare(providerFilter) == .orderedSame }
}
private var liveEvents: [ScheduleEvent] {
filtered.filter { EventHelpers.isLive($0, now: now) }
.sorted { ($0.isPlayable ? 1 : 0) > ($1.isPlayable ? 1 : 0) }
}
private var upcomingSections: [(String, [ScheduleEvent])] {
EventHelpers.groupByDay(filtered).compactMap { label, dayEvents in
let upcoming = dayEvents.filter { !EventHelpers.isLive($0, now: now) }
return upcoming.isEmpty ? nil : (label, upcoming)
}
}
private var providers: [String] {
Array(Set(events.map(\.provider))).sorted()
}
var body: some View {
NavigationStack(path: $path) {
content
.background(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
BrandHeader(subtitle: "EVENTS")
}
}
.toolbarBackground(McColors.bg, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.navigationDestination(for: String.self) { eventId in
EventDetailScreen(eventId: eventId)
.environmentObject(app)
}
}
.task { await refresh() }
.onReceive(clock) { now = $0 }
.onReceive(autoRefresh) { _ in Task { await refresh(silent: true) } }
}
@ViewBuilder
private var content: some View {
VStack(spacing: 0) {
if providers.count > 1 {
providerChips
}
if loading && events.isEmpty {
ProgressView("Events laden…")
.tint(.white)
.foregroundStyle(McColors.textDim)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error, events.isEmpty {
VStack(spacing: 12) {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
Button("Opnieuw") { Task { await refresh() } }
.buttonStyle(McPrimaryButtonStyle())
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filtered.isEmpty {
ContentUnavailableView(
providerFilter != nil
? "Geen events voor \(EventHelpers.prettyProvider(providerFilter!))"
: "Geen events in de komende dagen",
systemImage: "calendar"
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
if !liveEvents.isEmpty {
Section {
ForEach(liveEvents) { ev in
Button { path.append(ev.id) } label: {
EventRowView(event: ev, now: now)
}
.listRowBackground(McColors.bgElevated)
.listRowSeparatorTint(Color.white.opacity(0.06))
}
} header: {
Text("Nu live")
.font(McFonts.headline)
.foregroundStyle(McColors.text)
.textCase(nil)
}
}
ForEach(upcomingSections, id: \.0) { label, dayEvents in
Section {
ForEach(dayEvents) { ev in
Button { path.append(ev.id) } label: {
EventRowView(event: ev, now: now)
}
.listRowBackground(McColors.bgElevated)
.listRowSeparatorTint(Color.white.opacity(0.06))
}
} header: {
Text(label)
.font(McFonts.headline)
.foregroundStyle(McColors.text)
.textCase(nil)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable { await refresh(isPull: true) }
}
}
}
private var providerChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: "Alles", active: providerFilter == nil) {
providerFilter = nil
}
ForEach(providers, id: \.self) { p in
chip(label: EventHelpers.prettyProvider(p), active: providerFilter?.caseInsensitiveCompare(p) == .orderedSame) {
providerFilter = p
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
private func chip(label: String, active: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(McFonts.caption)
.foregroundStyle(active ? .white : McColors.textDim)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(active ? McColors.accent : McColors.surface, in: Capsule())
}
.buttonStyle(.plain)
}
private func refresh(isPull: Bool = false, silent: Bool = false) async {
if isPull { refreshing = true }
if !silent && events.isEmpty { loading = true }
do {
let payload = try await app.api.listScheduleEvents()
events = EventHelpers.enrichImages(payload.events)
error = nil
} catch {
if events.isEmpty {
self.error = error.localizedDescription
}
}
loading = false
refreshing = false
}
}
// MARK: - Detail
struct EventDetailScreen: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
let eventId: String
@State private var event: ScheduleEvent?
@State private var siblings: [ScheduleEvent] = []
@State private var error: String?
@State private var now = Date()
@State private var playInfo: IptvPlayInfo?
@State private var playError: String?
private let clock = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
var body: some View {
Group {
if let event {
detailBody(event)
} else if error != nil {
VStack(spacing: 12) {
Text(error ?? "Fout").foregroundStyle(McColors.danger)
Button("Terug") { dismiss() }.buttonStyle(McPrimaryButtonStyle())
}
} else {
ProgressView("Laden…").tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(McColors.bg.ignoresSafeArea())
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.task { await load() }
.onReceive(clock) { date in
now = date
Task { await refreshEvent() }
}
.fullScreenCover(item: Binding(
get: { playInfo.map { EventPlayItem(info: $0) } },
set: { playInfo = $0?.info }
)) { item in
if let url = URL(string: item.info.streamUrl) {
let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:))
PlayerScreen(request: .live(
url: url,
title: item.info.name,
fallback: fallback,
format: item.info.format,
drm: item.info.drm,
watchSessionId: item.info.watchSessionId,
watchKind: "event",
watchId: eventId
))
.environmentObject(app)
}
}
.alert("Afspelen mislukt", isPresented: Binding(
get: { playError != nil },
set: { if !$0 { playError = nil } }
)) {
Button("OK", role: .cancel) { playError = nil }
} message: {
Text(playError ?? "")
}
}
private func detailBody(_ ev: ScheduleEvent) -> some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
hero(ev)
VStack(alignment: .leading, spacing: 10) {
Text(ev.name)
.font(McFonts.title)
.foregroundStyle(.white)
.lineLimit(3)
let meta = EventHelpers.detailMeta(ev)
if !meta.isEmpty {
Text(meta)
.font(McFonts.body)
.foregroundStyle(Color(white: 0.8))
.lineLimit(2)
}
let window = EventHelpers.detailTimeWindow(ev, now: now)
if !window.isEmpty {
Text(window)
.font(McFonts.headline)
.foregroundStyle(McColors.text)
}
if let subtitle = ev.subtitle, !subtitle.isEmpty,
subtitle.caseInsensitiveCompare(ev.name) != .orderedSame {
Text(subtitle)
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.lineLimit(3)
}
if let description = ev.description, !description.isEmpty {
Text(description)
.font(McFonts.body)
.foregroundStyle(Color(white: 0.91))
.lineSpacing(4)
}
if let langs = ev.audioLanguages, !langs.isEmpty {
Text("Audio")
.font(McFonts.caption)
.foregroundStyle(Color(white: 0.56))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(langs.prefix(8), id: \.self) { lang in
Text(lang.uppercased())
.font(McFonts.caption)
.foregroundStyle(Color(white: 0.86))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.white.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
stickyBar(ev)
}
}
private func hero(_ ev: ScheduleEvent) -> some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(url: EventHelpers.backdropUrl(ev), contentMode: .fill)
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipped()
.overlay {
LinearGradient(
stops: [
.init(color: .black.opacity(0.2), location: 0),
.init(color: .clear, location: 0.45),
.init(color: McColors.bg.opacity(0.95), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
HStack {
Button { dismiss() } label: {
Text("Terug")
.font(McFonts.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 4))
}
Spacer()
if let logo = ev.logoUrl, !logo.isEmpty {
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
.frame(width: 96, height: 36)
}
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
EventStatusBadge(event: ev, now: now)
.padding(16)
}
.frame(height: 220)
}
private func stickyBar(_ ev: ScheduleEvent) -> some View {
HStack(spacing: 10) {
if ev.isPlayable {
Button {
Task { await play(ev) }
} label: {
Text("Kijk live")
.frame(maxWidth: .infinity)
}
.buttonStyle(McPrimaryButtonStyle())
Button("Terug") { dismiss() }
.buttonStyle(McSecondaryButtonStyle())
} else {
Button {} label: {
Text(EventHelpers.ctaLabel(ev, now: now))
.frame(maxWidth: .infinity)
}
.buttonStyle(McSecondaryButtonStyle())
.disabled(true)
.opacity(0.55)
Button("Terug") { dismiss() }
.buttonStyle(McPrimaryButtonStyle())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(McColors.bgElevated.ignoresSafeArea(edges: .bottom))
}
private func load() async {
do {
let list = try await app.api.listScheduleEvents()
siblings = EventHelpers.enrichImages(list.events)
let raw = try await app.api.getScheduleEvent(eventId: eventId)
event = EventHelpers.enrichSingle(raw, siblings: siblings)
error = nil
} catch {
self.error = error.localizedDescription
}
}
private func refreshEvent() async {
guard let raw = try? await app.api.getScheduleEvent(eventId: eventId) else { return }
event = EventHelpers.enrichSingle(raw, siblings: siblings)
}
private func play(_ ev: ScheduleEvent) async {
do {
playInfo = try await app.api.scheduleEventPlay(eventId: ev.id)
} catch {
playError = error.localizedDescription
}
}
}
private struct EventPlayItem: Identifiable {
var id: String { info.streamId.isEmpty ? info.name : info.streamId }
let info: IptvPlayInfo
}
// MARK: - Row
private struct EventRowView: View {
let event: ScheduleEvent
let now: Date
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(white: 0.1))
.frame(width: 112, height: 63)
if let art = EventHelpers.artUrl(event) {
RemoteImage(url: art, contentMode: .fill)
.frame(width: 112, height: 63)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
} else {
Text(EventHelpers.prettyProvider(event.provider))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(McColors.textDim)
}
}
.frame(width: 112, height: 63)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
EventStatusBadge(event: event, now: now, compact: true)
Text(EventHelpers.shortTime(event, now: now))
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
.lineLimit(1)
}
Text(event.name)
.font(McFonts.headline)
.foregroundStyle(McColors.text)
.lineLimit(2)
Text(
[EventHelpers.prettyProvider(event.provider), EventHelpers.displayChannel(event)]
.compactMap { $0 }
.filter { !$0.isEmpty }
.uniqued()
.joined(separator: " · ")
)
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
}
private struct EventStatusBadge: View {
let event: ScheduleEvent
let now: Date
var compact: Bool = false
var body: some View {
let (label, color) = EventHelpers.statusVisual(event, now: now)
Text(label)
.font(.system(size: compact ? 11 : 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, compact ? 8 : 10)
.padding(.vertical, compact ? 3 : 5)
.background(color, in: RoundedRectangle(cornerRadius: 4))
}
}
// MARK: - Helpers
enum EventHelpers {
static let zone = TimeZone(identifier: "Europe/Amsterdam") ?? .current
private static let streamLabel = try! NSRegularExpression(pattern: #"^stream\s*\d+"#, options: .caseInsensitive)
static func isLive(_ event: ScheduleEvent, now: Date) -> Bool {
if event.phaseValue == "live" || event.isLive == true { return true }
guard let start = parse(event.startLocal, event.start),
let stop = parse(event.stopLocal, event.stop) else { return false }
return now >= start && now < stop
}
static func minutesUntilStart(_ event: ScheduleEvent, now: Date) -> Int? {
guard let start = parse(event.startLocal, event.start) else { return nil }
let m = Int(start.timeIntervalSince(now) / 60)
return m
}
static func minutesUntilEnd(_ event: ScheduleEvent, now: Date) -> Int? {
guard let stop = parse(event.stopLocal, event.stop) else { return nil }
let m = Int(stop.timeIntervalSince(now) / 60)
return m
}
static func statusVisual(_ event: ScheduleEvent, now: Date) -> (String, Color) {
if isLive(event, now: now) { return ("LIVE", McColors.accent) }
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
return ("Straks · over \(m) min", Color(red: 0.9, green: 0.49, blue: 0.13))
}
return ("Gepland", Color(white: 0.23))
}
static func ctaLabel(_ event: ScheduleEvent, now: Date) -> String {
if isLive(event, now: now) { return "Stream start binnenkort" }
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
return "Start over \(m) min"
}
guard let start = parse(event.startLocal, event.start) else { return "Meer info" }
return "Start om \(timeFmt.string(from: start))"
}
static func shortTime(_ event: ScheduleEvent, now: Date) -> String {
if isLive(event, now: now) {
if let left = minutesUntilEnd(event, now: now), left > 0 { return "Nog \(left) min" }
return "Nu"
}
if let m = minutesUntilStart(event, now: now), m >= 0, m < 60 {
return "Over \(m) min"
}
guard let start = parse(event.startLocal, event.start) else { return "" }
let cal = Calendar.current
var c = cal
c.timeZone = zone
let today = c.startOfDay(for: now)
let day = c.startOfDay(for: start)
if day == today { return "Vandaag \(timeFmt.string(from: start))" }
if day == c.date(byAdding: .day, value: 1, to: today) {
return "Morgen \(timeFmt.string(from: start))"
}
return dayTimeFmt.string(from: start)
}
static func detailTimeWindow(_ event: ScheduleEvent, now: Date) -> String {
guard let start = parse(event.startLocal, event.start) else { return "" }
let stop = parse(event.stopLocal, event.stop)
let range: String = {
if let stop {
return "\(timeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
}
return timeFmt.string(from: start)
}()
if isLive(event, now: now) { return range }
var c = Calendar.current
c.timeZone = zone
let today = c.startOfDay(for: now)
let day = c.startOfDay(for: start)
if day == today { return "Vandaag · \(range)" }
if day == c.date(byAdding: .day, value: 1, to: today) {
return "Morgen · \(range)"
}
if let stop {
return "\(dayTimeFmt.string(from: start)) – \(timeFmt.string(from: stop))"
}
return dayTimeFmt.string(from: start)
}
static func displayChannel(_ event: ScheduleEvent) -> String? {
let raw = event.channel?.nilIfBlank ?? event.network?.nilIfBlank
guard let raw, !isStreamLabel(raw) else { return nil }
return raw
}
static func detailMeta(_ event: ScheduleEvent) -> String {
[
prettyProvider(event.provider),
event.category?.nilIfBlank,
event.eventType?.nilIfBlank,
displayChannel(event),
]
.compactMap { $0 }
.uniqued()
.joined(separator: " · ")
}
static func artUrl(_ event: ScheduleEvent) -> String? {
if let u = event.imageLandscape?.nilIfBlank { return u }
let imgs = event.images ?? []
if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) {
return u
}
if let u = imgs.first(where: { looksLikeArtwork($0) }) { return u }
if let u = event.imagePortrait?.nilIfBlank { return u }
return imgs.first { !$0.isEmpty && !$0.localizedCaseInsensitiveContains("assets/channels") }
}
static func backdropUrl(_ event: ScheduleEvent) -> String? {
if let u = event.imageBackdrop?.nilIfBlank { return u }
if let u = (event.images ?? []).first(where: { $0.localizedCaseInsensitiveContains("hero-16x9") }) {
return u
}
return artUrl(event)
}
static func prettyProvider(_ raw: String) -> String {
switch raw.lowercased() {
case "viaplay": return "Viaplay"
case "ziggosport": return "Ziggo Sport"
case "espn": return "ESPN"
case "appletv": return "Apple TV"
case "prime_hbo": return "Prime / HBO"
case "prime": return "Prime Video"
case "disneyplus": return "Disney+"
case "eyecons": return "Eyecons"
case "odido": return "Odido"
default:
return raw.replacingOccurrences(of: "_", with: " ").capitalized
}
}
static func groupByDay(_ events: [ScheduleEvent]) -> [(String, [ScheduleEvent])] {
var c = Calendar.current
c.timeZone = zone
let today = c.startOfDay(for: Date())
var groups: [(String, [ScheduleEvent])] = []
var index: [String: Int] = [:]
for e in events {
guard let start = parse(e.startLocal, e.start) else { continue }
let day = c.startOfDay(for: start)
let label: String
if day == today {
label = "Vandaag"
} else if day == c.date(byAdding: .day, value: 1, to: today) {
label = "Morgen"
} else if day == c.date(byAdding: .day, value: 2, to: today) {
label = "Overmorgen"
} else {
label = fullDayFmt.string(from: start)
}
if let i = index[label] {
groups[i].1.append(e)
} else {
index[label] = groups.count
groups.append((label, [e]))
}
}
return groups
}
static func enrichImages(_ events: [ScheduleEvent]) -> [ScheduleEvent] {
var donors: [String: ScheduleEvent] = [:]
for e in events {
if e.imageLandscape == nil && (e.images ?? []).isEmpty && e.logoUrl == nil { continue }
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
if let existing = donors[key], donorScore(existing) >= donorScore(e) { continue }
donors[key] = e
}
}
return events.map { enrichSingle($0, siblings: events, donors: donors) }
}
static func enrichSingle(
_ e: ScheduleEvent,
siblings: [ScheduleEvent],
donors: [String: ScheduleEvent]? = nil
) -> ScheduleEvent {
let cleanedChannel = e.channel.flatMap { isStreamLabel($0) ? nil : $0 }
let cleanedNetwork = e.network.flatMap { isStreamLabel($0) ? nil : $0 }
if e.imageLandscape != nil || !(e.images ?? []).isEmpty {
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
}
let map: [String: ScheduleEvent] = donors ?? {
var built: [String: ScheduleEvent] = [:]
for sib in siblings {
if sib.imageLandscape == nil && (sib.images ?? []).isEmpty { continue }
for key in [sib.channel, sib.network].compactMap({ normalizeChannel($0) }) {
if let existing = built[key], donorScore(existing) >= donorScore(sib) { continue }
built[key] = sib
}
}
return built
}()
var donor: ScheduleEvent?
for key in [e.channel, e.network].compactMap({ normalizeChannel($0) }) {
if let hit = map[key], hit.id != e.id {
donor = hit
break
}
}
guard let donor else {
return e.withImages(channel: cleanedChannel, network: cleanedNetwork)
}
return e.withImages(
landscape: e.imageLandscape ?? donor.imageLandscape,
backdrop: e.imageBackdrop ?? donor.imageBackdrop,
portrait: e.imagePortrait ?? donor.imagePortrait,
logo: e.logoUrl ?? donor.logoUrl,
imgs: (e.images?.isEmpty == false ? e.images : donor.images),
channel: cleanedChannel,
network: cleanedNetwork
)
}
private static func donorScore(_ e: ScheduleEvent) -> Int {
var s = 0
if e.imageLandscape != nil { s += 20 }
if e.imageBackdrop != nil { s += 10 }
if (e.images ?? []).contains(where: {
$0.localizedCaseInsensitiveContains("thumb") || $0.localizedCaseInsensitiveContains("akamaized.net/live")
}) { s += 30 }
if e.logoUrl != nil { s += 5 }
s += min((e.images ?? []).count, 5)
if e.provider.caseInsensitiveCompare("ziggosport") == .orderedSame { s += 8 }
if e.isPlayable { s += 3 }
return s
}
private static func normalizeChannel(_ value: String?) -> String? {
let s = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) ?? ""
return s.isEmpty ? nil : s
}
private static func isStreamLabel(_ value: String) -> Bool {
let s = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !s.isEmpty else { return false }
let range = NSRange(s.startIndex..<s.endIndex, in: s)
return streamLabel.firstMatch(in: s, options: [], range: range) != nil
}
private static func looksLikeArtwork(_ url: String) -> Bool {
let u = url.trimmingCharacters(in: .whitespacesAndNewlines)
if u.isEmpty { return false }
let lower = u.lowercased()
if lower.contains("assets/channels") { return false }
if lower.contains("channels-logo") { return false }
if lower.contains("/logos/") && lower.hasSuffix(".png") { return false }
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")),
!lower.contains("boxart"), !lower.contains("hero-") {
return false
}
if lower.hasSuffix(".svg") { return false }
return true
}
static func parse(_ local: String?, _ utc: String) -> Date? {
let raw = local?.nilIfBlank ?? utc.nilIfBlank
guard let raw else { return nil }
let isoFrac = ISO8601DateFormatter()
isoFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = isoFrac.date(from: raw) { return d }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
if let d = iso.date(from: raw) { return d }
let withZ = raw.hasSuffix("Z") || raw.contains("+") ? raw : "\(raw)Z"
return iso.date(from: withZ) ?? isoFrac.date(from: withZ)
}
private static let timeFmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "nl_NL")
f.timeZone = zone
f.dateFormat = "HH:mm"
return f
}()
private static let dayTimeFmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "nl_NL")
f.timeZone = zone
f.dateFormat = "EEE d MMM · HH:mm"
return f
}()
private static let fullDayFmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "nl_NL")
f.timeZone = zone
f.dateFormat = "EEEE d MMMM"
return f
}()
}
private extension String {
var nilIfBlank: String? {
let t = trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? nil : t
}
}
private extension Array where Element: Hashable {
func uniqued() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}