From f054b2a07f36a631b0e5ff65c6b43a08971f4c03 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 30 May 2026 12:22:51 +0200 Subject: [PATCH] =?UTF-8?q?Tagebuch=20+=20Heim-Tab=20mit=20t=C3=A4glichem?= =?UTF-8?q?=20Background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tagebuch (Diary): - DiaryEntry + DiaryMedia + DiaryCreateBody DTOs - TagebuchView: Liste der Einträge für aktiven Hund mit Titel, Text, Ortsname, Meilenstein-Stern, Foto-Strip - AddDiaryEntrySheet: Titel/Text/Datum/Meilenstein/Ort/Tags + PhotosPicker, nach POST /api/dogs/{id}/diary werden Fotos einzeln via POST /api/dogs/{id}/diary/{entry_id}/media hochgeladen (mit ImageResize.resizedJPEG) Heim-Tab als neuer 1. Tab: - DashboardSnapshot DTO für /api/dogs/{id}/welcome-dashboard - ActiveDogStore (@Observable + UserDefaults("activeDogId")): hält den aktiven Hund app-weit - HeimView: tägliches Hintergrundfoto aus random_photo.url (rotiert pro Tag, vom Backend gewählt), Gradient zur Lesbarkeit, Tagezeit- Begrüßung mit User-Namen, Hund-Picker (Menu), Info-Karten für letzten Eintrag/nächsten Termin/Gewicht/Eintragszahl, Quick-Action-Buttons (Tagebuch, Wetter, Erste Hilfe) Reorganisation: - 5 Tabs: Heim, Touren, Aufnehmen, Statistik, Mehr - Hunde-Liste wandert in Mehr → "Hund & Alltag" - Tagebuch in Mehr → "Hund & Alltag" + erreichbar von Heim --- BanYaroGo/API/DTOs.swift | 70 ++++++ BanYaroGo/BanYaroGoApp.swift | 2 + BanYaroGo/Support/ActiveDogStore.swift | 37 +++ BanYaroGo/Views/AddDiaryEntrySheet.swift | 178 +++++++++++++++ BanYaroGo/Views/HeimView.swift | 272 +++++++++++++++++++++++ BanYaroGo/Views/MainTabView.swift | 6 +- BanYaroGo/Views/SettingsView.swift | 10 + BanYaroGo/Views/TagebuchView.swift | 140 ++++++++++++ 8 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 BanYaroGo/Support/ActiveDogStore.swift create mode 100644 BanYaroGo/Views/AddDiaryEntrySheet.swift create mode 100644 BanYaroGo/Views/HeimView.swift create mode 100644 BanYaroGo/Views/TagebuchView.swift diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift index babe51a..5f2a8a4 100644 --- a/BanYaroGo/API/DTOs.swift +++ b/BanYaroGo/API/DTOs.swift @@ -185,6 +185,76 @@ struct LostDogCreateBody: Encodable { let dogId: Int? } +// MARK: - Diary (Tagebuch) + +struct DiaryEntry: Decodable, Identifiable { + let id: Int + let dogId: Int? + let datum: String? + let typ: String? + let titel: String? + let text: String? + let tags: [String]? + let gpsLat: Double? + let gpsLon: Double? + let locationName: String? + let isMilestone: Bool? + let media: [DiaryMedia]? + let createdAt: String? +} + +struct DiaryMedia: Decodable, Identifiable { + let id: Int + let url: String + let mediaType: String? + let imgWidth: Int? + let imgHeight: Int? +} + +struct DiaryCreateBody: Encodable { + let datum: String? + let typ: String + let titel: String? + let text: String? + let tags: [String]? + let gpsLat: Double? + let gpsLon: Double? + let locationName: String? + let isMilestone: Bool +} + +// MARK: - Welcome Dashboard + +struct DashboardSnapshot: Decodable { + let randomPhoto: DashboardPhoto? + let lastDiary: DashboardLastDiary? + let nextAppointment: DashboardNextAppointment? + let lastWeight: DashboardLastWeight? + let diaryCount: Int? +} + +struct DashboardPhoto: Decodable { + let url: String + let previewUrl: String? +} + +struct DashboardLastDiary: Decodable { + let titel: String? + let datum: String? +} + +struct DashboardNextAppointment: Decodable { + let bezeichnung: String? + let naechstes: String? + let typ: String? +} + +struct DashboardLastWeight: Decodable { + let wert: Double? + let einheit: String? + let datum: String? +} + // MARK: - Weather struct WeatherForecast: Decodable { diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift index 7642df6..70070cf 100644 --- a/BanYaroGo/BanYaroGoApp.swift +++ b/BanYaroGo/BanYaroGoApp.swift @@ -4,11 +4,13 @@ import SwiftData @main struct BanYaroGoApp: App { @State private var auth = AuthSession() + @State private var activeDog = ActiveDogStore() var body: some Scene { WindowGroup { RootView() .environment(auth) + .environment(activeDog) } .modelContainer(for: [ActiveWalk.self, PhotoLocation.self]) } diff --git a/BanYaroGo/Support/ActiveDogStore.swift b/BanYaroGo/Support/ActiveDogStore.swift new file mode 100644 index 0000000..9dea00e --- /dev/null +++ b/BanYaroGo/Support/ActiveDogStore.swift @@ -0,0 +1,37 @@ +import Foundation +import Observation + +/// Tracks the user's currently selected dog across the app. Picked once, +/// reused everywhere (Heim, Tagebuch, Statistik, …). Persisted in UserDefaults. +@Observable +@MainActor +final class ActiveDogStore { + var activeDogId: Int + var dogs: [Dog] = [] + + var activeDog: Dog? { + dogs.first(where: { $0.id == activeDogId }) ?? dogs.first + } + + init() { + self.activeDogId = UserDefaults.standard.integer(forKey: "activeDogId") + } + + func loadDogs() async { + do { + let fetched: [Dog] = try await APIClient.shared.get("/api/dogs") + self.dogs = fetched + if !fetched.contains(where: { $0.id == activeDogId }), + let first = fetched.first { + setActive(first.id) + } + } catch { + print("ActiveDogStore loadDogs failed: \(error)") + } + } + + func setActive(_ dogId: Int) { + activeDogId = dogId + UserDefaults.standard.set(dogId, forKey: "activeDogId") + } +} diff --git a/BanYaroGo/Views/AddDiaryEntrySheet.swift b/BanYaroGo/Views/AddDiaryEntrySheet.swift new file mode 100644 index 0000000..af1a47a --- /dev/null +++ b/BanYaroGo/Views/AddDiaryEntrySheet.swift @@ -0,0 +1,178 @@ +import SwiftUI +import PhotosUI + +struct AddDiaryEntrySheet: View { + let dogId: Int + let onSaved: () async -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var titel = "" + @State private var text = "" + @State private var date = Date() + @State private var isMilestone = false + @State private var locationName = "" + @State private var tagsInput = "" + + @State private var photoSelection: [PhotosPickerItem] = [] + @State private var photoData: [Data] = [] + + @State private var saveState: SaveState = .idle + @State private var errorMessage: String? + + private enum SaveState: Equatable { + case idle + case savingEntry + case uploadingMedia(done: Int, total: Int) + } + + var body: some View { + NavigationStack { + Form { + Section("Titel") { + TextField("z. B. Erster Strandbesuch", text: $titel) + } + Section("Text") { + TextField("Was hat dein Hund heute erlebt?", text: $text, axis: .vertical) + .lineLimit(4...10) + } + Section("Datum") { + DatePicker("Datum", selection: $date, displayedComponents: .date) + .environment(\.locale, Locale(identifier: "de_DE")) + } + Section { + Toggle("Meilenstein", isOn: $isMilestone) + TextField("Ort (optional)", text: $locationName) + TextField("Tags (komma-getrennt)", text: $tagsInput) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + PhotosPicker( + selection: $photoSelection, + maxSelectionCount: 6, + matching: .images + ) { + Label(photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern", systemImage: "photo.badge.plus") + } + if !photoData.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(photoData.enumerated()), id: \.offset) { _, d in + if let img = UIImage(data: d) { + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + } header: { + Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))") + } + + if let errorMessage { + Section { Text(errorMessage).font(.footnote).foregroundStyle(.red) } + } + } + .navigationTitle("Neuer Eintrag") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + .disabled(saveState != .idle) + } + ToolbarItem(placement: .confirmationAction) { + saveToolbarItem + } + } + .onChange(of: photoSelection) { _, items in + Task { await loadPhotos(from: items) } + } + .interactiveDismissDisabled(saveState != .idle) + } + } + + @ViewBuilder + private var saveToolbarItem: some View { + switch saveState { + case .idle: + Button("Sichern") { Task { await save() } } + .disabled(titel.trimmingCharacters(in: .whitespaces).isEmpty && + text.trimmingCharacters(in: .whitespaces).isEmpty) + case .savingEntry: + ProgressView() + case .uploadingMedia(let done, let total): + Text("\(done)/\(total)").font(.caption.monospacedDigit()) + } + } + + private func loadPhotos(from items: [PhotosPickerItem]) async { + var loaded: [Data] = [] + for item in items { + if let d = try? await item.loadTransferable(type: Data.self) { + loaded.append(d) + } + } + photoData = loaded + } + + private func save() async { + errorMessage = nil + saveState = .savingEntry + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let tags = tagsInput + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let body = DiaryCreateBody( + datum: formatter.string(from: date), + typ: isMilestone ? "meilenstein" : "eintrag", + titel: titel.trimmingCharacters(in: .whitespaces).isEmpty ? nil : titel.trimmingCharacters(in: .whitespaces), + text: text.trimmingCharacters(in: .whitespaces).isEmpty ? nil : text.trimmingCharacters(in: .whitespacesAndNewlines), + tags: tags.isEmpty ? nil : tags, + gpsLat: nil, + gpsLon: nil, + locationName: locationName.trimmingCharacters(in: .whitespaces).isEmpty ? nil : locationName, + isMilestone: isMilestone + ) + + let entry: DiaryEntry + do { + entry = try await APIClient.shared.post("/api/dogs/\(dogId)/diary", body: body) + } catch { + errorMessage = error.localizedDescription + saveState = .idle + return + } + + if !photoData.isEmpty { + for (i, raw) in photoData.enumerated() { + saveState = .uploadingMedia(done: i, total: photoData.count) + let resized = ImageResize.resizedJPEG(from: raw) + do { + _ = try await APIClient.shared.uploadFile( + "/api/dogs/\(dogId)/diary/\(entry.id)/media", + filename: "media_\(i + 1).jpg", + data: resized + ) + } catch { + errorMessage = "Eintrag gespeichert, Foto \(i + 1) fehlgeschlagen: \(error.localizedDescription)" + await onSaved() + saveState = .idle + return + } + } + } + + await onSaved() + dismiss() + } +} diff --git a/BanYaroGo/Views/HeimView.swift b/BanYaroGo/Views/HeimView.swift new file mode 100644 index 0000000..3e08dcc --- /dev/null +++ b/BanYaroGo/Views/HeimView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +struct HeimView: View { + @Environment(AuthSession.self) private var auth + @Environment(ActiveDogStore.self) private var activeDog + + @State private var dashboard: DashboardSnapshot? + @State private var isLoading = false + + private var greeting: String { + let hour = Calendar.current.component(.hour, from: .now) + let name = auth.profile?.name ?? auth.userName ?? "" + let salute: String = { + switch hour { + case 5..<11: return "Guten Morgen" + case 11..<14: return "Hallo" + case 14..<18: return "Hallo" + case 18..<22: return "Guten Abend" + default: return "Gute Nacht" + } + }() + return name.isEmpty ? salute : "\(salute), \(name)" + } + + var body: some View { + NavigationStack { + ZStack { + background + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + Spacer(minLength: 200) // breathing room for the photo + header + dogPickerCard + if let dashboard { + dashboardCards(dashboard) + } + quickActions + Spacer(minLength: 24) + } + .padding(.horizontal) + } + .refreshable { await load() } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) + .task { + if activeDog.dogs.isEmpty { await activeDog.loadDogs() } + await load() + } + .onChange(of: activeDog.activeDogId) { _, _ in + Task { await load() } + } + } + } + + @ViewBuilder + private var background: some View { + ZStack { + Color.accentColor.opacity(0.08).ignoresSafeArea() + if let path = dashboard?.randomPhoto?.url, + let url = URL(string: "https://banyaro.app\(path)") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + Color.clear + } + } + .frame(maxWidth: .infinity, maxHeight: 320, alignment: .top) + .clipped() + .overlay( + LinearGradient( + colors: [.clear, .clear, Color(.systemBackground).opacity(0.95), Color(.systemBackground)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private var header: some View { + VStack(spacing: 4) { + Text(greeting) + .font(.title2.bold()) + .multilineTextAlignment(.center) + .shadow(color: .black.opacity(0.4), radius: 3, y: 1) + if let dog = activeDog.activeDog { + Text("Was machst du heute mit \(dog.name)?") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(.bottom, 6) + } + + private var dogPickerCard: some View { + Group { + if activeDog.dogs.isEmpty { + EmptyView() + } else { + Menu { + ForEach(activeDog.dogs) { dog in + Button { + activeDog.setActive(dog.id) + } label: { + if dog.id == activeDog.activeDogId { + Label(dog.name, systemImage: "checkmark") + } else { + Text(dog.name) + } + } + } + } label: { + HStack(spacing: 12) { + dogAvatar + .frame(width: 48, height: 48) + .clipShape(Circle()) + VStack(alignment: .leading) { + Text(activeDog.activeDog?.name ?? "Hund wählen") + .font(.headline) + .foregroundStyle(.primary) + if let rasse = activeDog.activeDog?.rasse, !rasse.isEmpty { + Text(rasse) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + } + } + } + + @ViewBuilder + private var dogAvatar: some View { + if let path = activeDog.activeDog?.fotoUrl, + let url = URL(string: "https://banyaro.app\(path)") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().scaledToFill() + default: avatarFallback + } + } + } else { + avatarFallback + } + } + + private var avatarFallback: some View { + ZStack { + Color.accentColor.opacity(0.2) + Image(systemName: "pawprint.fill") + .foregroundStyle(Color.accentColor) + } + } + + private func dashboardCards(_ snap: DashboardSnapshot) -> some View { + VStack(spacing: 10) { + if let last = snap.lastDiary, last.titel != nil { + infoCard( + icon: "book.fill", + title: "Letzter Eintrag", + value: last.titel ?? "—", + detail: last.datum.map(DiaryUtil.format) ?? "" + ) + } + if let appt = snap.nextAppointment, let bez = appt.bezeichnung { + infoCard( + icon: "calendar", + title: "Nächster Termin", + value: bez, + detail: appt.naechstes.map(DiaryUtil.format) ?? "" + ) + } + if let weight = snap.lastWeight, let wert = weight.wert { + infoCard( + icon: "scalemass.fill", + title: "Letztes Gewicht", + value: String(format: "%.1f %@", wert, weight.einheit ?? "kg"), + detail: weight.datum.map(DiaryUtil.format) ?? "" + ) + } + if let count = snap.diaryCount, count > 0 { + infoCard( + icon: "books.vertical.fill", + title: "Tagebucheinträge", + value: "\(count)", + detail: "" + ) + } + } + } + + private func infoCard(icon: String, title: String, value: String, detail: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.caption).foregroundStyle(.secondary) + Text(value).font(.subheadline.bold()) + if !detail.isEmpty { + Text(detail).font(.caption2).foregroundStyle(.tertiary) + } + } + Spacer() + } + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + + private var quickActions: some View { + VStack(spacing: 10) { + NavigationLink { + TagebuchView() + } label: { + actionRow(icon: "book.fill", title: "Tagebuch", subtitle: "Eintrag anlegen oder lesen") + } + NavigationLink { + WetterView() + } label: { + actionRow(icon: "cloud.sun.fill", title: "Wetter", subtitle: "Vorhersage für heute") + } + NavigationLink { + ErsteHilfeView() + } label: { + actionRow(icon: "cross.case.fill", title: "Erste Hilfe", subtitle: "Notfall-Anleitung — offline") + } + } + } + + private func actionRow(icon: String, title: String, subtitle: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.subheadline.bold()).foregroundStyle(.primary) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.tertiary) + } + .padding(14) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + + private func load() async { + guard let dog = activeDog.activeDog else { return } + isLoading = true + defer { isLoading = false } + dashboard = try? await APIClient.shared.get("/api/dogs/\(dog.id)/welcome-dashboard") + } +} diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift index f8c1e7f..93f9512 100644 --- a/BanYaroGo/Views/MainTabView.swift +++ b/BanYaroGo/Views/MainTabView.swift @@ -5,15 +5,15 @@ struct MainTabView: View { var body: some View { TabView { + HeimView() + .tabItem { Label("Heim", systemImage: "house.fill") } + RoutesListView() .tabItem { Label("Touren", systemImage: "map.fill") } TrackingView() .tabItem { Label("Aufnehmen", systemImage: "figure.walk") } - DogsListView() - .tabItem { Label("Hunde", systemImage: "pawprint.fill") } - StatisticsView() .tabItem { Label("Statistik", systemImage: "chart.bar.fill") } diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift index 039dbf6..91d0e51 100644 --- a/BanYaroGo/Views/SettingsView.swift +++ b/BanYaroGo/Views/SettingsView.swift @@ -28,6 +28,16 @@ struct SettingsView: View { } Section("Hund & Alltag") { + NavigationLink { + DogsListView() + } label: { + Label("Meine Hunde", systemImage: "pawprint.fill") + } + NavigationLink { + TagebuchView() + } label: { + Label("Tagebuch", systemImage: "book.fill") + } NavigationLink { ErsteHilfeView() } label: { diff --git a/BanYaroGo/Views/TagebuchView.swift b/BanYaroGo/Views/TagebuchView.swift new file mode 100644 index 0000000..e935c95 --- /dev/null +++ b/BanYaroGo/Views/TagebuchView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct TagebuchView: View { + @Environment(ActiveDogStore.self) private var activeDog + + @State private var entries: [DiaryEntry] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showAdd = false + + var body: some View { + content + .navigationTitle("Tagebuch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showAdd = true } label: { + Image(systemName: "plus") + } + .disabled(activeDog.activeDog == nil) + } + } + .sheet(isPresented: $showAdd) { + if let dog = activeDog.activeDog { + AddDiaryEntrySheet(dogId: dog.id) { await load() } + } + } + .task { + if activeDog.dogs.isEmpty { await activeDog.loadDogs() } + await load() + } + .refreshable { await load() } + } + + @ViewBuilder + private var content: some View { + if activeDog.activeDog == nil { + ContentUnavailableView( + "Noch kein Hund", + systemImage: "pawprint", + description: Text("Lege deinen ersten Hund in der PWA an, dann kannst du Tagebucheinträge anlegen.") + ) + } else if isLoading && entries.isEmpty { + ProgressView() + } else if let errorMessage, entries.isEmpty { + ContentUnavailableView("Konnte nicht laden", systemImage: "wifi.slash", description: Text(errorMessage)) + } else if entries.isEmpty { + ContentUnavailableView( + "Noch keine Einträge", + systemImage: "book", + description: Text("Tippe oben rechts auf +, um deinen ersten Eintrag anzulegen.") + ) + } else { + List(entries) { entry in + DiaryRow(entry: entry) + } + .listStyle(.plain) + } + } + + private func load() async { + guard let dog = activeDog.activeDog else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + entries = try await APIClient.shared.get("/api/dogs/\(dog.id)/diary?limit=50") + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct DiaryRow: View { + let entry: DiaryEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + if entry.isMilestone == true || entry.typ == "meilenstein" { + Image(systemName: "star.fill").foregroundStyle(.orange) + } + if let titel = entry.titel, !titel.isEmpty { + Text(titel).font(.headline) + } else { + Text("Eintrag").font(.headline).foregroundStyle(.secondary) + } + Spacer() + if let datum = entry.datum { + Text(DiaryUtil.format(datum)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + if let text = entry.text, !text.isEmpty { + Text(text) + .font(.subheadline) + .lineLimit(3) + .foregroundStyle(.secondary) + } + if let loc = entry.locationName, !loc.isEmpty { + Label(loc, systemImage: "mappin.and.ellipse") + .font(.caption) + .foregroundStyle(.tertiary) + } + if let media = entry.media, !media.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(media) { m in + AsyncImage(url: URL(string: "https://banyaro.app\(m.url)")) { phase in + switch phase { + case .success(let img): img.resizable().scaledToFill() + default: Rectangle().fill(.gray.opacity(0.15)) + } + } + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + .padding(.vertical, 6) + } +} + +enum DiaryUtil { + static func format(_ str: String) -> String { + let parser = DateFormatter() + parser.locale = Locale(identifier: "en_US_POSIX") + parser.dateFormat = "yyyy-MM-dd" + if let d = parser.date(from: String(str.prefix(10))) { + let out = DateFormatter() + out.locale = Locale(identifier: "de_DE") + out.dateStyle = .medium + return out.string(from: d) + } + return str + } +}