From 9a429f11a65a9459593501a7fde6dbfdc6fd0c54 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 22 Apr 2026 05:18:24 +0200 Subject: [PATCH] =?UTF-8?q?Resolves=20#10=20Todos:=20Vollst=C3=A4ndige=20I?= =?UTF-8?q?mplementierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Todo-Modell (V8-Migration): dueDate, reminderDate, isCompleted, completedAt - AddTodoView + EditTodoView: Fälligkeit, Erinnerungspush, intelligenter Reminder-Default - TodayView: Fällige Todos-Sektion mit Fade-out (5 s) nach Abhaken - PersonDetailView: Todo-Sektion, tap-to-edit, Glockensymbol, Fade-out - Momente: tap-to-edit, Favoritenstern inline, Löschen-Button in EditMomentView - DeletableMomentRow entfernt (Swipe durch direkte Interaktion ersetzt) - Geburtstage-Sektion zwischen Unternehmungen und Todos in TodayView - Lokalisierung (de/en) und Tests aktualisiert Co-Authored-By: Claude Sonnet 4.6 --- nahbar/nahbar.xcodeproj/project.pbxproj | 4 + nahbar/nahbar/AddTodoView.swift | 189 +++++++ nahbar/nahbar/Localizable.xcstrings | 182 ++++++- nahbar/nahbar/Models.swift | 38 ++ nahbar/nahbar/NahbarMigration.swift | 263 ++++++++- nahbar/nahbar/PersonDetailView.swift | 614 ++++++++++++++++------ nahbar/nahbar/TodayView.swift | 176 ++++++- nahbar/nahbarTests/AppEventLogTests.swift | 12 +- nahbar/nahbarTests/ModelTests.swift | 51 ++ nahbar/nahbarTests/VisitRatingTests.swift | 12 +- 10 files changed, 1344 insertions(+), 197 deletions(-) create mode 100644 nahbar/nahbar/AddTodoView.swift diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index 8825d25..844161b 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 26BB85D42F926A9700889312 /* nahbarShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 26BB85DE2F926CAB00889312 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; }; 26BB85DF2F926CC500889312 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85C42F926A1C00889312 /* AppGroup.swift */; }; + 26D07C692F9866DE001D3F98 /* AddTodoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D07C682F9866DE001D3F98 /* AddTodoView.swift */; }; 26EF66312F9112E700824F91 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26EF66252F9112E700824F91 /* Assets.xcassets */; }; 26EF66322F9112E700824F91 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; }; 26EF66332F9112E700824F91 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662F2F9112E700824F91 /* TodayView.swift */; }; @@ -127,6 +128,7 @@ 26BB85C42F926A1C00889312 /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = nahbarShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 26BB85E02F926D8E00889312 /* nahbar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nahbar.entitlements; sourceTree = ""; }; + 26D07C682F9866DE001D3F98 /* AddTodoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTodoView.swift; sourceTree = ""; }; 26EF66232F9112E700824F91 /* AddMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMomentView.swift; sourceTree = ""; }; 26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = ""; }; 26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -287,6 +289,7 @@ 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */, 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */, 2670595B2F96640E00956084 /* CalendarManager.swift */, + 26D07C682F9866DE001D3F98 /* AddTodoView.swift */, ); path = nahbar; sourceTree = ""; @@ -457,6 +460,7 @@ 26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */, 26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */, 26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */, + 26D07C692F9866DE001D3F98 /* AddTodoView.swift in Sources */, 26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */, 26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */, 26EF66382F9112E700824F91 /* SettingsView.swift in Sources */, diff --git a/nahbar/nahbar/AddTodoView.swift b/nahbar/nahbar/AddTodoView.swift new file mode 100644 index 0000000..5b215e5 --- /dev/null +++ b/nahbar/nahbar/AddTodoView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import SwiftData +import UserNotifications + +struct AddTodoView: View { + @Environment(\.nahbarTheme) var theme + @Environment(\.modelContext) var modelContext + @Environment(\.dismiss) var dismiss + + let person: Person + + @State private var title = "" + @State private var dueDate: Date = { + Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() + }() + @State private var addReminder = false + @State private var reminderDate: Date = { + let due = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() + return Self.defaultReminderDate(for: due) + }() + @FocusState private var isFocused: Bool + + private var isValid: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty } + + /// Berechnet den Erinnerungs-Standardwert: dueDate − 1 Tag (9:00), + /// falls das mehr als einen Tag in der Zukunft liegt, sonst morgen um 9:00. + static func defaultReminderDate(for dueDate: Date) -> Date { + let cal = Calendar.current + let now = Date() + if let dayBefore = cal.date(byAdding: .day, value: -1, to: dueDate), + dayBefore > now { + return cal.date(bySettingHour: 9, minute: 0, second: 0, of: dayBefore) ?? dayBefore + } + let tomorrow = cal.date(byAdding: .day, value: 1, to: now) ?? now + return cal.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + // Titel-Eingabe + ZStack(alignment: .topLeading) { + if title.isEmpty { + Text("Was möchtest du erledigen?") + .font(.system(size: 16)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .allowsHitTesting(false) + } + TextEditor(text: $title) + .font(.system(size: 16, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .scrollContentBackground(.hidden) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .focused($isFocused) + } + .frame(minHeight: 100) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + + // Fälligkeitsdatum + Erinnerung + VStack(spacing: 0) { + DatePicker( + "Fällig am", + selection: $dueDate, + displayedComponents: .date + ) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .environment(\.locale, Locale.current) + .padding(.horizontal, 16) + .padding(.vertical, 10) + + RowDivider() + + HStack { + Image(systemName: "bell") + .font(.system(size: 14)) + .foregroundStyle(addReminder ? theme.accent : theme.contentTertiary) + Text("Erinnerung setzen") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Toggle("", isOn: $addReminder) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if addReminder { + RowDivider() + DatePicker( + "Wann?", + selection: $reminderDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .environment(\.locale, Locale.current) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + .animation(.easeInOut(duration: 0.2), value: addReminder) + + Spacer(minLength: 40) + } + .padding(.top, 16) + } + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("Todo anlegen") + .navigationBarTitleDisplayMode(.inline) + .themedNavBar() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundStyle(theme.contentSecondary) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Speichern") { save() } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isValid ? theme.accent : theme.contentTertiary) + .disabled(!isValid) + } + } + } + .onAppear { isFocused = true } + .onChange(of: dueDate) { _, newDue in + reminderDate = Self.defaultReminderDate(for: newDue) + } + } + + private func save() { + let trimmed = title.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + let reminder = addReminder ? reminderDate : nil + let todo = Todo(title: trimmed, dueDate: dueDate, reminderDate: reminder, person: person) + modelContext.insert(todo) + person.touch() + + if addReminder { + scheduleReminder(for: todo) + } + + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Speichern des Todos: \(error.localizedDescription)", + level: .error, category: "Todo" + ) + } + dismiss() + } + + private func scheduleReminder(for todo: Todo) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + guard granted else { return } + let content = UNMutableNotificationContent() + content.title = person.firstName + content.body = todo.title + content.sound = .default + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], from: reminderDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: "todo-\(todo.id)", + content: content, + trigger: trigger + ) + center.add(request) + } + } +} diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index eec20e1..06bc181 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -793,7 +793,8 @@ } }, "Anstehende Erinnerungen" : { - "comment" : "TodayView – section title for all plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates", + "comment" : "TodayView – replaced by 'Anstehende Unternehmungen'", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -803,9 +804,19 @@ } } }, + "Anstehende Geburtstage" : { + "comment" : "TodayView – section title for upcoming birthdays", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming Birthdays" + } + } + } + }, "Anstehende Unternehmungen" : { - "comment" : "TodayView – legacy key, replaced by 'Anstehende Erinnerungen'", - "extractionState" : "stale", + "comment" : "TodayView – section title for plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates", "localizations" : { "en" : { "stringUnit" : { @@ -5739,6 +5750,171 @@ } } } + }, + "Todo" : { + "comment" : "PersonDetailView – button label to add a new Todo", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo" + } + } + } + }, + "Todo anlegen" : { + "comment" : "AddTodoView – navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Todo" + } + } + } + }, + "Dieser Moment wird unwiderruflich gelöscht." : { + "comment" : "EditMomentView – delete confirmation message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This moment will be permanently deleted." + } + } + } + }, + "Moment löschen" : { + "comment" : "EditMomentView – delete button label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Moment" + } + } + } + }, + "Moment löschen?" : { + "comment" : "EditMomentView – delete confirmation dialog title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Moment?" + } + } + } + }, + "Moment + Kalendereintrag löschen" : { + "comment" : "EditMomentView – delete moment + calendar event option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Moment + Calendar Event" + } + } + } + }, + "Moment mit Kalendereintrag löschen?" : { + "comment" : "EditMomentView – calendar delete confirmation dialog title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Moment with Calendar Event?" + } + } + } + }, + "Nur Moment löschen" : { + "comment" : "EditMomentView – delete only the moment, keep calendar event", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Moment Only" + } + } + } + }, + "Todo bearbeiten" : { + "comment" : "EditTodoView – navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Todo" + } + } + } + }, + "Todos" : { + "comment" : "PersonDetailView – section header for todos", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos" + } + } + } + }, + "Was möchtest du erledigen?" : { + "comment" : "AddTodoView – placeholder text for todo title input", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What do you want to do?" + } + } + } + }, + "Fällig am" : { + "comment" : "AddTodoView – label for due date picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Due on" + } + } + } + }, + "Fällige Todos" : { + "comment" : "TodayView – section header for due todos", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Due Todos" + } + } + } + }, + "Noch keine Todos." : { + "comment" : "PersonDetailView – empty state message when person has no todos", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No todos yet." + } + } + } + }, + "Speichern" : { + "comment" : "AddTodoView – save button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } } }, "version" : "1.1" diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index bf53934..de1964e 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -136,6 +136,7 @@ class Person { @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] // V4 + @Relationship(deleteRule: .cascade) var todos: [Todo]? = [] // V7 init( name: String, @@ -170,6 +171,7 @@ class Person { self.moments = [] self.logEntries = [] self.visits = [] + self.todos = [] } var tag: PersonTag { @@ -276,6 +278,15 @@ class Person { .sorted { $0.createdAt < $1.createdAt } } + var openTodos: [Todo] { + (todos ?? []).filter { !$0.isCompleted } + .sorted { $0.dueDate < $1.dueDate } + } + + var sortedTodos: [Todo] { + (todos ?? []).sorted { $0.dueDate < $1.dueDate } + } + /// Muss nach jeder inhaltlichen Änderung aufgerufen werden. func touch() { updatedAt = Date() @@ -623,6 +634,33 @@ class Rating { } } +// MARK: - Todo (V7) +// Aufgaben mit Fälligkeitsdatum, die einer Person zugeordnet sind. +// Im Gegensatz zu Momenten sind Todos rein aufgabenorientiert. + +@Model +class Todo { + var id: UUID = UUID() + var title: String = "" + var dueDate: Date = Date() + var isCompleted: Bool = false + var completedAt: Date? = nil + var reminderDate: Date? = nil // V8: optionale Push-Benachrichtigung + var person: Person? + var createdAt: Date = Date() + + init(title: String, dueDate: Date, reminderDate: Date? = nil, person: Person? = nil) { + self.id = UUID() + self.title = title + self.dueDate = dueDate + self.isCompleted = false + self.completedAt = nil + self.reminderDate = reminderDate + self.person = person + self.createdAt = Date() + } +} + // MARK: - HealthSnapshot (Phase-2-Platzhalter) @Model diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift index 5dcce3e..714eced 100644 --- a/nahbar/nahbar/NahbarMigration.swift +++ b/nahbar/nahbar/NahbarMigration.swift @@ -403,18 +403,255 @@ enum NahbarSchemaV5: VersionedSchema { } } -// MARK: - Schema V6 (aktuelles Schema) -// Referenziert die Live-Typen aus Models.swift. -// Beim Hinzufügen von V7 muss V6 als eingefrorener Snapshot gesichert werden. +// MARK: - Schema V6 (eingefrorener Snapshot) +// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten +// Schema-Hash von V6-Datenbanken auf Nutzer-Geräten entsprechen. // -// V6 fügt hinzu: +// V6 fügte hinzu: // • Person: culturalBackground (optionaler Freitext für kulturellen Hintergrund) enum NahbarSchemaV6: VersionedSchema { static var versionIdentifier = Schema.Version(6, 0, 0) + static var models: [any PersistentModel.Type] { + [PersonPhoto.self, Person.self, Moment.self, LogEntry.self, + Visit.self, Rating.self, HealthSnapshot.self] + } + + @Model final class PersonPhoto { + var id: UUID = UUID() + @Attribute(.externalStorage) var imageData: Data = Data() + var createdAt: Date = Date() + init() {} + } + + @Model final class Person { + var id: UUID = UUID() + var name: String = "" + var tagRaw: String = "Andere" + var birthday: Date? = nil + var occupation: String? = nil + var location: String? = nil + var interests: String? = nil + var generalNotes: String? = nil + var culturalBackground: String? = nil // V6 + var nudgeFrequencyRaw: String = "Monatlich" + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isArchived: Bool = false + @Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil + var photoData: Data? = nil + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] + init() {} + } + + @Model final class Moment { + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = "Gespräch" + var sourceRaw: String? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isImportant: Bool = false + var person: Person? = nil + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + var statusRaw: String? = nil + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + var reminderDate: Date? = nil + var isCompleted: Bool = false + init() {} + } + + @Model final class LogEntry { + var id: UUID = UUID() + var typeRaw: String = "Schritt abgeschlossen" + var title: String = "" + var loggedAt: Date = Date() + var updatedAt: Date = Date() + var person: Person? = nil + init() {} + } + + @Model final class Visit { + var id: UUID = UUID() + var visitDate: Date = Date() + var statusRaw: String = "sofort_abgeschlossen" + var note: String? = nil + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + var person: Person? = nil + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + init() {} + } + + @Model final class Rating { + var id: UUID = UUID() + var categoryRaw: String = "Selbst" + var questionIndex: Int = 0 + var value: Int? = nil + var isAftermath: Bool = false + var visit: Visit? = nil + var moment: Moment? = nil + init() {} + } + + @Model final class HealthSnapshot { + var id: UUID = UUID() + var sleepHours: Double? = nil + var hrvMs: Double? = nil + var restingHR: Int? = nil + var steps: Int? = nil + var visit: Visit? = nil + var moment: Moment? = nil + init() {} + } +} + +// MARK: - Schema V7 (eingefrorener Snapshot) +// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten +// Schema-Hash von V7-Datenbanken auf Nutzer-Geräten entsprechen. +// +// V7 fügte hinzu: +// • Todo: neues Modell (ohne reminderDate) +// • Person: todos-Relationship + +enum NahbarSchemaV7: VersionedSchema { + static var versionIdentifier = Schema.Version(7, 0, 0) + static var models: [any PersistentModel.Type] { + [PersonPhoto.self, Person.self, Moment.self, LogEntry.self, + Visit.self, Rating.self, HealthSnapshot.self, Todo.self] + } + + @Model final class PersonPhoto { + var id: UUID = UUID() + @Attribute(.externalStorage) var imageData: Data = Data() + var createdAt: Date = Date() + init() {} + } + + @Model final class Person { + var id: UUID = UUID() + var name: String = "" + var tagRaw: String = "Andere" + var birthday: Date? = nil + var occupation: String? = nil + var location: String? = nil + var interests: String? = nil + var generalNotes: String? = nil + var culturalBackground: String? = nil + var nudgeFrequencyRaw: String = "Monatlich" + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isArchived: Bool = false + @Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil + var photoData: Data? = nil + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] + @Relationship(deleteRule: .cascade) var todos: [Todo]? = [] + init() {} + } + + @Model final class Moment { + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = "Gespräch" + var sourceRaw: String? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isImportant: Bool = false + var person: Person? = nil + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + var statusRaw: String? = nil + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + var reminderDate: Date? = nil + var isCompleted: Bool = false + init() {} + } + + @Model final class LogEntry { + var id: UUID = UUID() + var typeRaw: String = "Schritt abgeschlossen" + var title: String = "" + var loggedAt: Date = Date() + var updatedAt: Date = Date() + var person: Person? = nil + init() {} + } + + @Model final class Visit { + var id: UUID = UUID() + var visitDate: Date = Date() + var statusRaw: String = "sofort_abgeschlossen" + var note: String? = nil + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + var person: Person? = nil + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + init() {} + } + + @Model final class Rating { + var id: UUID = UUID() + var categoryRaw: String = "Selbst" + var questionIndex: Int = 0 + var value: Int? = nil + var isAftermath: Bool = false + var visit: Visit? = nil + var moment: Moment? = nil + init() {} + } + + @Model final class HealthSnapshot { + var id: UUID = UUID() + var sleepHours: Double? = nil + var hrvMs: Double? = nil + var restingHR: Int? = nil + var steps: Int? = nil + var visit: Visit? = nil + var moment: Moment? = nil + init() {} + } + + @Model final class Todo { + var id: UUID = UUID() + var title: String = "" + var dueDate: Date = Date() + var isCompleted: Bool = false + var completedAt: Date? = nil + var person: Person? = nil + var createdAt: Date = Date() + init() {} + } +} + +// MARK: - Schema V8 (aktuelles Schema) +// Referenziert die Live-Typen aus Models.swift. +// Beim Hinzufügen von V9 muss V8 als eingefrorener Snapshot gesichert werden. +// +// V8 fügt hinzu: +// • Todo: reminderDate (optionale Push-Benachrichtigung) + +enum NahbarSchemaV8: VersionedSchema { + static var versionIdentifier = Schema.Version(8, 0, 0) static var models: [any PersistentModel.Type] { [nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, - nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self] + nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self] } } @@ -423,7 +660,8 @@ enum NahbarSchemaV6: VersionedSchema { enum NahbarMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, - NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self] + NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self, + NahbarSchemaV7.self, NahbarSchemaV8.self] } static var stages: [MigrationStage] { @@ -447,7 +685,15 @@ enum NahbarMigrationPlan: SchemaMigrationPlan { // V5 → V6: Person bekommt culturalBackground = nil. // Optionales Feld mit nil-Default → lightweight-Migration reicht aus. - .lightweight(fromVersion: NahbarSchemaV5.self, toVersion: NahbarSchemaV6.self) + .lightweight(fromVersion: NahbarSchemaV5.self, toVersion: NahbarSchemaV6.self), + + // V6 → V7: Todo als neues Modell (ohne reminderDate), Person.todos-Relationship. + // Alle neuen Felder haben Default-Werte → lightweight-Migration reicht aus. + .lightweight(fromVersion: NahbarSchemaV6.self, toVersion: NahbarSchemaV7.self), + + // V7 → V8: Todo bekommt reminderDate = nil. + // Optionales Feld mit nil-Default → lightweight-Migration reicht aus. + .lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self) ] } } @@ -472,7 +718,8 @@ extension AppGroup { nahbar.PersonPhoto.self, nahbar.Visit.self, nahbar.Rating.self, - nahbar.HealthSnapshot.self + nahbar.HealthSnapshot.self, + nahbar.Todo.self ]) let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 30de3b3..bcd4389 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -10,6 +10,7 @@ struct PersonDetailView: View { @Bindable var person: Person @State private var showingAddMoment = false + @State private var showingAddTodo = false @State private var showingEditPerson = false // Meeting-Rating-Flow (V5) @@ -21,6 +22,10 @@ struct PersonDetailView: View { // Moment-Bearbeiten @State private var momentForTextEdit: Moment? = nil + // Todo-Bearbeiten + @State private var todoForEdit: Todo? = nil + @State private var fadingOutTodos: [Todo] = [] + // Kalender-Lösch-Bestätigung @State private var momentPendingDelete: Moment? = nil @State private var showCalendarDeleteDialog = false @@ -33,6 +38,7 @@ struct PersonDetailView: View { VStack(alignment: .leading, spacing: 28) { personHeader momentsSection + todosSection if !person.sortedLogEntries.isEmpty { logbuchSection } if hasInfoContent { infoSection } } @@ -50,6 +56,9 @@ struct PersonDetailView: View { .foregroundStyle(theme.accent) } } + .sheet(isPresented: $showingAddTodo) { + AddTodoView(person: person) + } .sheet(isPresented: $showingAddMoment) { AddMomentView(person: person) { meetingMoment in // Rating-Flow nur für vergangene Treffen – zukünftige Termine überspringen @@ -89,6 +98,9 @@ struct PersonDetailView: View { .sheet(item: $momentForTextEdit) { moment in EditMomentView(moment: moment) } + .sheet(item: $todoForEdit) { todo in + EditTodoView(todo: todo) + } .confirmationDialog( "Moment löschen", isPresented: $showCalendarDeleteDialog, @@ -181,18 +193,19 @@ struct PersonDetailView: View { } else { VStack(spacing: 0) { ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in - DeletableMomentRow( - moment: moment, - isLast: index == person.sortedMoments.count - 1, - onDelete: { deleteMoment(moment) }, - onToggleImportant: { toggleImportant(moment) }, - onEdit: { momentForTextEdit = moment }, - onRateMeeting: { momentForRating = moment }, - onAftermathMeeting: { momentForAftermath = moment }, - onViewSummary: { momentForSummary = moment }, - onEditMeeting: { momentForEdit = moment }, - onToggleIntention: { toggleIntention(moment) } - ) + VStack(spacing: 0) { + MomentRowView( + moment: moment, + onRateMeeting: { momentForRating = moment }, + onAftermathMeeting: { momentForAftermath = moment }, + onViewSummary: { momentForSummary = moment }, + onEditMeeting: { momentForEdit = moment }, + onToggleIntention: { toggleIntention(moment) }, + onEdit: { momentForTextEdit = moment }, + onToggleImportant: { toggleImportant(moment) } + ) + if index < person.sortedMoments.count - 1 { RowDivider() } + } } } .background(theme.surfaceCard) @@ -394,6 +407,101 @@ struct PersonDetailView: View { moment.updatedAt = Date() } + // MARK: - Todos + + /// Offene Todos + gerade abgehakte (noch ausblendend). + private var visibleTodos: [Todo] { + let fadingIDs = Set(fadingOutTodos.map(\.id)) + return person.openTodos.filter { !fadingIDs.contains($0.id) } + fadingOutTodos + } + + private var todosSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + SectionHeader(title: "Todos", icon: "checkmark.circle") + Spacer() + Button { + showingAddTodo = true + } label: { + HStack(spacing: 4) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + Text("Todo") + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(theme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(theme.accent.opacity(0.10)) + .clipShape(Capsule()) + } + } + + if visibleTodos.isEmpty { + Text("Noch keine Todos.") + .font(.system(size: 14)) + .foregroundStyle(theme.contentTertiary) + .padding(.vertical, 4) + } else { + VStack(spacing: 0) { + ForEach(Array(visibleTodos.enumerated()), id: \.element.id) { index, todo in + TodoRowView( + todo: todo, + isLast: index == visibleTodos.count - 1, + onToggle: { toggleTodo(todo) }, + onDelete: { deleteTodo(todo) }, + onEdit: { todoForEdit = todo } + ) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + } + } + + private func toggleTodo(_ todo: Todo) { + if !todo.isCompleted { + // Sofort in Fade-out-Liste aufnehmen, damit der Eintrag sichtbar bleibt + fadingOutTodos.append(todo) + + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + todo.isCompleted = true + todo.completedAt = Date() + } + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"]) + + // Nach 5 Sek. sanft ausblenden + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + withAnimation(.easeOut(duration: 0.35)) { + fadingOutTodos.removeAll { $0.id == todo.id } + } + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + todo.isCompleted = false + todo.completedAt = nil + } + } + person.touch() + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Abhaken des Todos: \(error.localizedDescription)", + level: .error, category: "Todo" + ) + } + } + + private func deleteTodo(_ todo: Todo) { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"]) + modelContext.delete(todo) + person.touch() + } + private func toggleIntention(_ moment: Moment) { guard moment.isIntention else { return } withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { @@ -427,138 +535,79 @@ struct PersonDetailView: View { // MARK: - Deletable Moment Row // Links wischen → Löschen (rot) -// Rechts wischen → Bearbeiten (accent) + Wichtig (orange) -// Vollständig rechts wischen → sofortiger Wichtig-Toggle, Zeile springt zurück +// MARK: - Todo Row -private struct DeletableMomentRow: View { +private struct TodoRowView: View { @Environment(\.nahbarTheme) var theme - let moment: Moment + let todo: Todo let isLast: Bool + let onToggle: () -> Void let onDelete: () -> Void - let onToggleImportant: () -> Void let onEdit: () -> Void - let onRateMeeting: () -> Void - let onAftermathMeeting: () -> Void - let onViewSummary: () -> Void - let onEditMeeting: () -> Void - let onToggleIntention: () -> Void - - @State private var offset: CGFloat = 0 - private let actionWidth: CGFloat = 76 var body: some View { - ZStack { - // Hintergrund-Buttons - HStack(spacing: 0) { - - // Linke Seite (sichtbar bei Rechts-Wischen): Bearbeiten + Wichtig - HStack(spacing: 0) { - Button { - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { onEdit() } - } label: { - VStack(spacing: 4) { - Image(systemName: "pencil") - .font(.system(size: 15, weight: .medium)) - Text("Bearbeiten") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(.white) - .frame(width: actionWidth) - .frame(maxHeight: .infinity) - } - .background(theme.accent) - - Button { - onToggleImportant() - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - } label: { - VStack(spacing: 4) { - Image(systemName: moment.isImportant ? "star.slash.fill" : "star.fill") - .font(.system(size: 15, weight: .medium)) - Text(moment.isImportant ? "Entfernen" : "Wichtig") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(.white) - .frame(width: actionWidth) - .frame(maxHeight: .infinity) - } - .background(Color.orange) + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 12) { + Button { onToggle() } label: { + Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundStyle(todo.isCompleted ? Color.green : theme.contentTertiary) + .padding(.top, 1) } + .buttonStyle(.plain) - Spacer() + Button { onEdit() } label: { + VStack(alignment: .leading, spacing: 4) { + Text(todo.title) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(todo.isCompleted ? theme.contentTertiary : theme.contentPrimary) + .strikethrough(todo.isCompleted, color: theme.contentTertiary) + .fixedSize(horizontal: false, vertical: true) - // Rechte Seite (sichtbar bei Links-Wischen): Löschen - Button { - withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) { - offset = -UIScreen.main.bounds.width + HStack(spacing: 6) { + Label( + todo.dueDate.formatted(.dateTime.day().month(.abbreviated).year().locale(Locale.current)), + systemImage: "calendar" + ) + .font(.system(size: 12)) + .foregroundStyle(isOverdue ? .red : theme.contentTertiary) + + // Erinnerungs-Indikator + if let reminder = todo.reminderDate, !todo.isCompleted { + Label( + reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)), + systemImage: "bell" + ) + .font(.system(size: 12)) + .foregroundStyle(theme.accent.opacity(0.8)) + } + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { onDelete() } - } label: { - VStack(spacing: 4) { - Image(systemName: "trash") - .font(.system(size: 15, weight: .medium)) - Text("Löschen") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(.white) - .frame(width: actionWidth) - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) } - .background(Color.red) + .buttonStyle(.plain) } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .opacity(todo.isCompleted ? 0.55 : 1.0) - // Zeilen-Inhalt schiebt sich über die Buttons - VStack(spacing: 0) { - MomentRowView( - moment: moment, - onRateMeeting: onRateMeeting, - onAftermathMeeting: onAftermathMeeting, - onViewSummary: onViewSummary, - onEditMeeting: onEditMeeting, - onToggleIntention: onToggleIntention - ) - if !isLast { RowDivider() } - } - .background(theme.surfaceCard) - .offset(x: offset) - .simultaneousGesture( - DragGesture(minimumDistance: 50, coordinateSpace: .local) - .onChanged { value in - let x = value.translation.width - let y = value.translation.height - guard abs(x) > abs(y) * 2.5 else { return } - if x > 0 { - // Rechts: bis zu zwei Button-Breiten - offset = min(x, actionWidth * 2 + 16) - } else { - // Links: ein Button - offset = max(x, -(actionWidth + 16)) - } - } - .onEnded { value in - let x = value.translation.width - let y = value.translation.height - guard abs(x) > abs(y) * 2.5 else { - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - return - } - if x > actionWidth * 2 + 20 { - // Vollständiges Rechts-Wischen → Wichtig-Toggle, zurückspringen - onToggleImportant() - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - } else if x > actionWidth * 1.5 { - // Bewusstes Rechts-Wischen → linke Buttons zeigen - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 } - } else if x < -(actionWidth * 1.5) { - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth } - } else { - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - } - } - ) + if !isLast { RowDivider() } } - .clipped() + .swipeActions(edge: .leading) { + Button { onEdit() } label: { + Label("Bearbeiten", systemImage: "pencil") + } + .tint(theme.accent) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { onDelete() } label: { + Label("Löschen", systemImage: "trash") + } + } + } + + private var isOverdue: Bool { + !todo.isCompleted && todo.dueDate < Calendar.current.startOfDay(for: Date()) } } @@ -574,6 +623,8 @@ struct MomentRowView: View { var onViewSummary: (() -> Void)? = nil var onEditMeeting: (() -> Void)? = nil var onToggleIntention: (() -> Void)? = nil + var onEdit: (() -> Void)? = nil + var onToggleImportant: (() -> Void)? = nil /// Wird lokal gecacht, damit die Ansicht auf CalendarEventStore-Änderungen reagieren kann. @State private var hasCalendarEvent = false @@ -600,8 +651,12 @@ struct MomentRowView: View { private var standardRow: some View { HStack(alignment: .top, spacing: 12) { typeIcon - momentContent - Spacer() + Button { onEdit?() } label: { + momentContent + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + starButton } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -613,8 +668,12 @@ struct MomentRowView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 12) { typeIcon - momentContent - Spacer() + Button { onEdit?() } label: { + momentContent + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + starButton // Score-Badge oder Bearbeiten-Button if let avg = moment.immediateAverage { Button { onViewSummary?() } label: { @@ -691,30 +750,34 @@ struct MomentRowView: View { } .buttonStyle(.plain) - VStack(alignment: .leading, spacing: 4) { - Text(moment.text) - .font(.system(size: 15, design: theme.displayDesign)) - .foregroundStyle(moment.isCompleted ? theme.contentTertiary : theme.contentPrimary) - .strikethrough(moment.isCompleted, color: theme.contentTertiary) - .fixedSize(horizontal: false, vertical: true) + Button { onEdit?() } label: { + VStack(alignment: .leading, spacing: 4) { + Text(moment.text) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(moment.isCompleted ? theme.contentTertiary : theme.contentPrimary) + .strikethrough(moment.isCompleted, color: theme.contentTertiary) + .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 6) { - if let reminder = moment.reminderDate, !moment.isCompleted { - Label( - reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)), - systemImage: "bell" - ) - .font(.system(size: 12)) - .foregroundStyle(theme.accent.opacity(0.8)) - } else { - Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) + HStack(spacing: 6) { + if let reminder = moment.reminderDate, !moment.isCompleted { + Label( + reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)), + systemImage: "bell" + ) .font(.system(size: 12)) - .foregroundStyle(theme.contentTertiary) + .foregroundStyle(theme.accent.opacity(0.8)) + } else { + Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } } } + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) - Spacer() + starButton } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -745,6 +808,16 @@ struct MomentRowView: View { .padding(.top, 2) } + private var starButton: some View { + Button { onToggleImportant?() } label: { + Image(systemName: moment.isImportant ? "star.fill" : "star") + .font(.system(size: 15)) + .foregroundStyle(moment.isImportant ? Color.orange : theme.contentTertiary.opacity(0.4)) + } + .buttonStyle(.plain) + .padding(.top, 1) + } + private var momentContent: some View { VStack(alignment: .leading, spacing: 4) { Text(moment.text) @@ -753,11 +826,6 @@ struct MomentRowView: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { - if moment.isImportant { - Image(systemName: "star.fill") - .font(.system(size: 10)) - .foregroundStyle(.orange) - } if hasCalendarEvent { Image(systemName: "calendar") .font(.system(size: 10)) @@ -821,6 +889,8 @@ struct EditMomentView: View { @State private var text: String @State private var createdAt: Date + @State private var showDeleteConfirm = false + @State private var showCalendarDeleteConfirm = false @FocusState private var isFocused: Bool init(moment: Moment) { @@ -877,6 +947,24 @@ struct EditMomentView: View { .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .padding(.horizontal, 20) + // Löschen-Button + Button { + if CalendarEventStore.identifier(for: moment.id) != nil { + showCalendarDeleteConfirm = true + } else { + showDeleteConfirm = true + } + } label: { + Label("Moment löschen", systemImage: "trash") + .font(.system(size: 15)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.red.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + .padding(.horizontal, 20) + Spacer() } .padding(.top, 16) @@ -898,6 +986,38 @@ struct EditMomentView: View { } } .onAppear { isFocused = true } + .confirmationDialog("Moment löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("Löschen", role: .destructive) { delete(deleteCalendarEvent: false) } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Dieser Moment wird unwiderruflich gelöscht.") + } + .confirmationDialog("Moment mit Kalendereintrag löschen?", isPresented: $showCalendarDeleteConfirm, titleVisibility: .visible) { + Button("Moment + Kalendereintrag löschen", role: .destructive) { delete(deleteCalendarEvent: true) } + Button("Nur Moment löschen", role: .destructive) { delete(deleteCalendarEvent: false) } + Button("Abbrechen", role: .cancel) {} + } + } + + private func delete(deleteCalendarEvent: Bool) { + let momentID = moment.id + if deleteCalendarEvent, let eventID = CalendarEventStore.identifier(for: momentID) { + Task { + _ = await CalendarManager.shared.deleteEvent(identifier: eventID) + CalendarEventStore.remove(momentID: momentID) + } + } else { + CalendarEventStore.remove(momentID: momentID) + } + moment.person?.touch() + modelContext.delete(moment) + do { try modelContext.save() } catch { + AppEventLog.shared.record( + "Fehler beim Löschen des Moments: \(error.localizedDescription)", + level: .error, category: "Moment" + ) + } + dismiss() } private func save() { @@ -933,6 +1053,188 @@ struct EditMomentView: View { } } +// MARK: - Edit Todo View + +struct EditTodoView: View { + @Environment(\.nahbarTheme) var theme + @Environment(\.modelContext) var modelContext + @Environment(\.dismiss) var dismiss + + let todo: Todo + + @State private var title: String + @State private var dueDate: Date + @State private var addReminder: Bool + @State private var reminderDate: Date + @FocusState private var isFocused: Bool + + init(todo: Todo) { + self.todo = todo + self._title = State(initialValue: todo.title) + self._dueDate = State(initialValue: todo.dueDate) + self._addReminder = State(initialValue: todo.reminderDate != nil) + self._reminderDate = State(initialValue: todo.reminderDate ?? AddTodoView.defaultReminderDate(for: todo.dueDate)) + } + + private var isValid: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + // Titel-Eingabe + ZStack(alignment: .topLeading) { + if title.isEmpty { + Text("Was möchtest du erledigen?") + .font(.system(size: 16)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .allowsHitTesting(false) + } + TextEditor(text: $title) + .font(.system(size: 16, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .scrollContentBackground(.hidden) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .focused($isFocused) + } + .frame(minHeight: 100) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + + // Fälligkeitsdatum + Erinnerung + VStack(spacing: 0) { + DatePicker( + "Fällig am", + selection: $dueDate, + displayedComponents: .date + ) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .environment(\.locale, Locale.current) + .padding(.horizontal, 16) + .padding(.vertical, 10) + + RowDivider() + + HStack { + Image(systemName: "bell") + .font(.system(size: 14)) + .foregroundStyle(addReminder ? theme.accent : theme.contentTertiary) + Text("Erinnerung setzen") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Toggle("", isOn: $addReminder) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if addReminder { + RowDivider() + DatePicker( + "Wann?", + selection: $reminderDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .environment(\.locale, Locale.current) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + .animation(.easeInOut(duration: 0.2), value: addReminder) + + Spacer(minLength: 40) + } + .padding(.top, 16) + } + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("Todo bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .themedNavBar() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundStyle(theme.contentSecondary) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Fertig") { save() } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isValid ? theme.accent : theme.contentTertiary) + .disabled(!isValid) + } + } + } + .onChange(of: dueDate) { _, newDue in + reminderDate = AddTodoView.defaultReminderDate(for: newDue) + } + .onAppear { isFocused = true } + } + + private func save() { + let trimmed = title.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + // Alte Erinnerung immer zuerst entfernen + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"]) + + todo.title = trimmed + todo.dueDate = dueDate + todo.reminderDate = addReminder ? reminderDate : nil + todo.person?.touch() + + if addReminder { + scheduleReminder() + } + + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Bearbeiten des Todos: \(error.localizedDescription)", + level: .error, category: "Todo" + ) + } + dismiss() + } + + private func scheduleReminder() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + guard granted else { return } + let content = UNMutableNotificationContent() + content.title = todo.person?.firstName ?? "" + content.body = todo.title + content.sound = .default + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], from: reminderDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: "todo-\(todo.id)", + content: content, + trigger: trigger + ) + center.add(request) + } + } +} + // MARK: - Info Row struct InfoRowView: View { diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 9cabcc8..b713457 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -1,8 +1,10 @@ import SwiftUI import SwiftData +import UserNotifications struct TodayView: View { @Environment(\.nahbarTheme) var theme + @Environment(\.modelContext) private var modelContext @Query private var people: [Person] // V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung" @Query(filter: #Predicate { @@ -12,6 +14,8 @@ struct TodayView: View { @State private var selectedMomentForAftermath: Moment? = nil @State private var showPersonPicker = false @State private var personForNewMoment: Person? = nil + @State private var todoForEdit: Todo? = nil + @State private var fadingOutTodos: [Todo] = [] @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 private var needsAttention: [Person] { @@ -59,9 +63,27 @@ struct TodayView: View { }, sort: \Moment.createdAt) private var openIntentions: [Moment] + // V7: Fällige Todos (alle offenen, nach Fälligkeitsdatum sortiert) + @Query(filter: #Predicate { + !$0.isCompleted + }, sort: \Todo.dueDate) + private var allOpenTodos: [Todo] + + private var dueTodos: [Todo] { + let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date.now) ?? Date.now + return allOpenTodos.filter { $0.dueDate <= horizon } + } + private var isEmpty: Bool { needsAttention.isEmpty && birthdayPeople.isEmpty && openIntentions.isEmpty && upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty + && dueTodos.isEmpty && fadingOutTodos.isEmpty + } + + /// Kombiniert offene Todos mit gerade abgehakten (die noch ausblenden). + private var visibleTodos: [Todo] { + let fadingIDs = Set(fadingOutTodos.map(\.id)) + return dueTodos.filter { !fadingIDs.contains($0.id) } + fadingOutTodos } private var activePeople: [Person] { @@ -109,23 +131,6 @@ struct TodayView: View { emptyState } else { VStack(spacing: 24) { - if !birthdayPeople.isEmpty { - TodaySection(title: birthdaySectionTitle, icon: "gift") { - ForEach(birthdayPeople) { person in - VStack(spacing: 0) { - NavigationLink(destination: PersonDetailView(person: person)) { - TodayRow(person: person, hint: birthdayHint(for: person)) - } - .buttonStyle(.plain) - GiftSuggestionRow(person: person) - } - if person.id != birthdayPeople.last?.id { - RowDivider() - } - } - } - } - if !plannedMeetings.isEmpty { TodaySection(title: "Geplante Treffen", icon: "calendar.badge.clock") { ForEach(plannedMeetings) { moment in @@ -143,7 +148,7 @@ struct TodayView: View { } if !upcomingReminders.isEmpty { - TodaySection(title: "Anstehende Erinnerungen", icon: "bell") { + TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") { ForEach(upcomingReminders) { moment in if let person = moment.person { NavigationLink(destination: PersonDetailView(person: person)) { @@ -174,6 +179,38 @@ struct TodayView: View { } } + if !birthdayPeople.isEmpty { + TodaySection(title: "Anstehende Geburtstage", icon: "gift") { + ForEach(birthdayPeople) { person in + VStack(spacing: 0) { + NavigationLink(destination: PersonDetailView(person: person)) { + TodayRow(person: person, hint: birthdayHint(for: person)) + } + .buttonStyle(.plain) + GiftSuggestionRow(person: person) + } + if person.id != birthdayPeople.last?.id { + RowDivider() + } + } + } + } + + if !visibleTodos.isEmpty { + TodaySection(title: "Fällige Todos", icon: "checkmark.circle") { + ForEach(visibleTodos) { todo in + TodoTodayRow( + todo: todo, + onComplete: { completeTodo(todo) }, + onEdit: { todoForEdit = todo } + ) + if todo.id != visibleTodos.last?.id { + RowDivider() + } + } + } + } + if !pendingAftermaths.isEmpty { TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") { ForEach(pendingAftermaths) { moment in @@ -231,6 +268,9 @@ struct TodayView: View { .sheet(item: $selectedMomentForAftermath) { moment in AftermathRatingFlowView(moment: moment) } + .sheet(item: $todoForEdit) { todo in + EditTodoView(todo: todo) + } .sheet(isPresented: $showPersonPicker) { TodayPersonPickerSheet(people: activePeople) { person in personForNewMoment = person @@ -318,6 +358,37 @@ struct TodayView: View { return "\(label) · \(dateStr)" } + private func completeTodo(_ todo: Todo) { + // Sofort in die Fade-out-Liste aufnehmen, damit der Eintrag sichtbar bleibt + fadingOutTodos.append(todo) + + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + todo.isCompleted = true + todo.completedAt = Date() + todo.person?.touch() + } + + // Ausstehende Push-Erinnerung abbrechen + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"]) + + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Abhaken des Todos: \(error.localizedDescription)", + level: .error, category: "Todo" + ) + } + + // Nach 5 Sek. sanft ausblenden + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + withAnimation(.easeOut(duration: 0.35)) { + fadingOutTodos.removeAll { $0.id == todo.id } + } + } + } + private func lastSeenHint(for person: Person) -> String { guard let last = person.lastMomentDate else { return String(localized: "Noch keine Momente festgehalten") } let formatter = RelativeDateTimeFormatter() @@ -663,3 +734,72 @@ struct TodayRow: View { .padding(.vertical, 12) } } + +// MARK: - Todo Today Row + +private struct TodoTodayRow: View { + @Environment(\.nahbarTheme) var theme + let todo: Todo + let onComplete: () -> Void + let onEdit: () -> Void + + var body: some View { + HStack(spacing: 12) { + Button { + guard !todo.isCompleted else { return } + onComplete() + } label: { + Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundStyle(todo.isCompleted ? Color.green : theme.contentTertiary) + } + .buttonStyle(.plain) + + Button { onEdit() } label: { + VStack(alignment: .leading, spacing: 2) { + Text(todo.title) + .font(.system(size: 15, weight: .medium, design: theme.displayDesign)) + .foregroundStyle(todo.isCompleted ? theme.contentTertiary : theme.contentPrimary) + .strikethrough(todo.isCompleted, color: theme.contentTertiary) + HStack(spacing: 4) { + if let person = todo.person { + Text(person.name) + .font(.system(size: 13)) + .foregroundStyle(theme.contentSecondary) + Text("·") + .font(.system(size: 13)) + .foregroundStyle(theme.contentTertiary) + } + Text(todo.dueDate.formatted(.dateTime.day().month(.abbreviated).locale(Locale.current))) + .font(.system(size: 13)) + .foregroundStyle(isOverdue ? .red : theme.contentTertiary) + if todo.reminderDate != nil { + Image(systemName: "bell.fill") + .font(.system(size: 10)) + .foregroundStyle(theme.accent.opacity(0.75)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + if let person = todo.person { + NavigationLink(destination: PersonDetailView(person: person)) { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .opacity(todo.isCompleted ? 0.45 : 1.0) + .animation(.easeOut(duration: 0.2), value: todo.isCompleted) + } + + private var isOverdue: Bool { + todo.dueDate < Calendar.current.startOfDay(for: Date()) + } +} diff --git a/nahbar/nahbarTests/AppEventLogTests.swift b/nahbar/nahbarTests/AppEventLogTests.swift index 8e279c1..cdbdb8b 100644 --- a/nahbar/nahbarTests/AppEventLogTests.swift +++ b/nahbar/nahbarTests/AppEventLogTests.swift @@ -155,14 +155,14 @@ struct SchemaRegressionTests { #expect(NahbarSchemaV3.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 5 Schemas") - func migrationPlanHasFiveSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 5) + @Test("Migrationsplan enthält genau 8 Schemas (V1–V8)") + func migrationPlanHasEightSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 8) } - @Test("Migrationsplan enthält genau 4 Stages") - func migrationPlanHasFourStages() { - #expect(NahbarMigrationPlan.stages.count == 4) + @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") + func migrationPlanHasSevenStages() { + #expect(NahbarMigrationPlan.stages.count == 7) } @Test("ContainerFallback-Gleichheit funktioniert korrekt") diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift index e04676a..3034cad 100644 --- a/nahbar/nahbarTests/ModelTests.swift +++ b/nahbar/nahbarTests/ModelTests.swift @@ -370,6 +370,57 @@ struct MomentComputedPropertyTests { } } +// MARK: - Todo Tests + +@Suite("Todo – Initialisierung und Computed Properties") +struct TodoTests { + + @Test("Todo startet mit isCompleted = false") + func todoDefaultsNotCompleted() { + let todo = Todo(title: "Test", dueDate: Date()) + #expect(!todo.isCompleted) + } + + @Test("Todo startet mit completedAt = nil") + func todoDefaultsCompletedAtNil() { + let todo = Todo(title: "Test", dueDate: Date()) + #expect(todo.completedAt == nil) + } + + @Test("Todo speichert Titel korrekt") + func todoStoresTitleCorrectly() { + let todo = Todo(title: "Anruf machen", dueDate: Date()) + #expect(todo.title == "Anruf machen") + } + + @Test("Todo speichert dueDate korrekt") + func todoStoresDueDateCorrectly() { + let date = Calendar.current.date(byAdding: .day, value: 3, to: Date())! + let todo = Todo(title: "Test", dueDate: date) + #expect(todo.dueDate == date) + } + + @Test("Person-Link ist nil wenn nicht angegeben") + func todoPersonLinkIsNilByDefault() { + let todo = Todo(title: "Test", dueDate: Date()) + #expect(todo.person == nil) + } + + @Test("Person-Link kann beim Initialisieren gesetzt werden") + func todoPersonLinkCanBeSet() { + let person = Person(name: "Max Mustermann") + let todo = Todo(title: "Anrufen", dueDate: Date(), person: person) + #expect(todo.person?.name == "Max Mustermann") + } + + @Test("Todo erhält bei Erstellung eine eindeutige UUID") + func todoGetsUniqueID() { + let todo1 = Todo(title: "Eins", dueDate: Date()) + let todo2 = Todo(title: "Zwei", dueDate: Date()) + #expect(todo1.id != todo2.id) + } +} + // MARK: - LogEntry Tests @Suite("LogEntry – Computed Properties") diff --git a/nahbar/nahbarTests/VisitRatingTests.swift b/nahbar/nahbarTests/VisitRatingTests.swift index 4a8cb3e..68bcbce 100644 --- a/nahbar/nahbarTests/VisitRatingTests.swift +++ b/nahbar/nahbarTests/VisitRatingTests.swift @@ -359,13 +359,13 @@ struct SchemaV5RegressionTests { #expect(NahbarSchemaV5.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 5 Schemas") - func migrationPlanHasFiveSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 5) + @Test("Migrationsplan enthält genau 8 Schemas (V1–V8)") + func migrationPlanHasEightSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 8) } - @Test("Migrationsplan enthält genau 4 Stages") - func migrationPlanHasFourStages() { - #expect(NahbarMigrationPlan.stages.count == 4) + @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") + func migrationPlanHasSevenStages() { + #expect(NahbarMigrationPlan.stages.count == 7) } }