Resolves #10 Todos: Vollständige Implementierung

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 05:18:24 +02:00
parent 3611c849c5
commit 9a429f11a6
10 changed files with 1344 additions and 197 deletions
+4
View File
@@ -35,6 +35,7 @@
26BB85D42F926A9700889312 /* nahbarShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 */; }; 26BB85DE2F926CAB00889312 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; };
26BB85DF2F926CC500889312 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85C42F926A1C00889312 /* AppGroup.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 */; }; 26EF66312F9112E700824F91 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26EF66252F9112E700824F91 /* Assets.xcassets */; };
26EF66322F9112E700824F91 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; }; 26EF66322F9112E700824F91 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66282F9112E700824F91 /* Models.swift */; };
26EF66332F9112E700824F91 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF662F2F9112E700824F91 /* TodayView.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 = "<group>"; }; 26BB85C42F926A1C00889312 /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = nahbarShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 26BB85E02F926D8E00889312 /* nahbar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nahbar.entitlements; sourceTree = "<group>"; };
26D07C682F9866DE001D3F98 /* AddTodoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTodoView.swift; sourceTree = "<group>"; };
26EF66232F9112E700824F91 /* AddMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMomentView.swift; sourceTree = "<group>"; }; 26EF66232F9112E700824F91 /* AddMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMomentView.swift; sourceTree = "<group>"; };
26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = "<group>"; }; 26EF66242F9112E700824F91 /* AddPersonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPersonView.swift; sourceTree = "<group>"; };
26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 26EF66252F9112E700824F91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -287,6 +289,7 @@
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */, 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */, 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
2670595B2F96640E00956084 /* CalendarManager.swift */, 2670595B2F96640E00956084 /* CalendarManager.swift */,
26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
); );
path = nahbar; path = nahbar;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -457,6 +460,7 @@
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */, 26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */, 26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */, 26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
26D07C692F9866DE001D3F98 /* AddTodoView.swift in Sources */,
26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */, 26EF66372F9112E700824F91 /* AddPersonView.swift in Sources */,
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */, 26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */,
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */, 26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
+189
View File
@@ -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)
}
}
}
+179 -3
View File
@@ -793,7 +793,8 @@
} }
}, },
"Anstehende Erinnerungen" : { "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" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -803,9 +804,19 @@
} }
} }
}, },
"Anstehende Geburtstage" : {
"comment" : "TodayView section title for upcoming birthdays",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upcoming Birthdays"
}
}
}
},
"Anstehende Unternehmungen" : { "Anstehende Unternehmungen" : {
"comment" : "TodayView legacy key, replaced by 'Anstehende Erinnerungen'", "comment" : "TodayView section title for plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" "version" : "1.1"
+38
View File
@@ -136,6 +136,7 @@ class Person {
@Relationship(deleteRule: .cascade) var moments: [Moment]? = [] @Relationship(deleteRule: .cascade) var moments: [Moment]? = []
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
@Relationship(deleteRule: .cascade) var visits: [Visit]? = [] // V4 @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] // V4
@Relationship(deleteRule: .cascade) var todos: [Todo]? = [] // V7
init( init(
name: String, name: String,
@@ -170,6 +171,7 @@ class Person {
self.moments = [] self.moments = []
self.logEntries = [] self.logEntries = []
self.visits = [] self.visits = []
self.todos = []
} }
var tag: PersonTag { var tag: PersonTag {
@@ -276,6 +278,15 @@ class Person {
.sorted { $0.createdAt < $1.createdAt } .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. /// Muss nach jeder inhaltlichen Änderung aufgerufen werden.
func touch() { func touch() {
updatedAt = Date() 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) // MARK: - HealthSnapshot (Phase-2-Platzhalter)
@Model @Model
+255 -8
View File
@@ -403,18 +403,255 @@ enum NahbarSchemaV5: VersionedSchema {
} }
} }
// MARK: - Schema V6 (aktuelles Schema) // MARK: - Schema V6 (eingefrorener Snapshot)
// Referenziert die Live-Typen aus Models.swift. // WICHTIG: Niemals nachträglich ändern dieser Snapshot muss dem gespeicherten
// Beim Hinzufügen von V7 muss V6 als eingefrorener Snapshot gesichert werden. // 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) // Person: culturalBackground (optionaler Freitext für kulturellen Hintergrund)
enum NahbarSchemaV6: VersionedSchema { enum NahbarSchemaV6: VersionedSchema {
static var versionIdentifier = Schema.Version(6, 0, 0) 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] { static var models: [any PersistentModel.Type] {
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, [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 { enum NahbarMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { static var schemas: [any VersionedSchema.Type] {
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, [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] { static var stages: [MigrationStage] {
@@ -447,7 +685,15 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
// V5 V6: Person bekommt culturalBackground = nil. // V5 V6: Person bekommt culturalBackground = nil.
// Optionales Feld mit nil-Default lightweight-Migration reicht aus. // 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.PersonPhoto.self,
nahbar.Visit.self, nahbar.Visit.self,
nahbar.Rating.self, nahbar.Rating.self,
nahbar.HealthSnapshot.self nahbar.HealthSnapshot.self,
nahbar.Todo.self
]) ])
let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey)
+434 -132
View File
@@ -10,6 +10,7 @@ struct PersonDetailView: View {
@Bindable var person: Person @Bindable var person: Person
@State private var showingAddMoment = false @State private var showingAddMoment = false
@State private var showingAddTodo = false
@State private var showingEditPerson = false @State private var showingEditPerson = false
// Meeting-Rating-Flow (V5) // Meeting-Rating-Flow (V5)
@@ -21,6 +22,10 @@ struct PersonDetailView: View {
// Moment-Bearbeiten // Moment-Bearbeiten
@State private var momentForTextEdit: Moment? = nil @State private var momentForTextEdit: Moment? = nil
// Todo-Bearbeiten
@State private var todoForEdit: Todo? = nil
@State private var fadingOutTodos: [Todo] = []
// Kalender-Lösch-Bestätigung // Kalender-Lösch-Bestätigung
@State private var momentPendingDelete: Moment? = nil @State private var momentPendingDelete: Moment? = nil
@State private var showCalendarDeleteDialog = false @State private var showCalendarDeleteDialog = false
@@ -33,6 +38,7 @@ struct PersonDetailView: View {
VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 28) {
personHeader personHeader
momentsSection momentsSection
todosSection
if !person.sortedLogEntries.isEmpty { logbuchSection } if !person.sortedLogEntries.isEmpty { logbuchSection }
if hasInfoContent { infoSection } if hasInfoContent { infoSection }
} }
@@ -50,6 +56,9 @@ struct PersonDetailView: View {
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
} }
} }
.sheet(isPresented: $showingAddTodo) {
AddTodoView(person: person)
}
.sheet(isPresented: $showingAddMoment) { .sheet(isPresented: $showingAddMoment) {
AddMomentView(person: person) { meetingMoment in AddMomentView(person: person) { meetingMoment in
// Rating-Flow nur für vergangene Treffen zukünftige Termine überspringen // Rating-Flow nur für vergangene Treffen zukünftige Termine überspringen
@@ -89,6 +98,9 @@ struct PersonDetailView: View {
.sheet(item: $momentForTextEdit) { moment in .sheet(item: $momentForTextEdit) { moment in
EditMomentView(moment: moment) EditMomentView(moment: moment)
} }
.sheet(item: $todoForEdit) { todo in
EditTodoView(todo: todo)
}
.confirmationDialog( .confirmationDialog(
"Moment löschen", "Moment löschen",
isPresented: $showCalendarDeleteDialog, isPresented: $showCalendarDeleteDialog,
@@ -181,18 +193,19 @@ struct PersonDetailView: View {
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in
DeletableMomentRow( VStack(spacing: 0) {
MomentRowView(
moment: moment, moment: moment,
isLast: index == person.sortedMoments.count - 1,
onDelete: { deleteMoment(moment) },
onToggleImportant: { toggleImportant(moment) },
onEdit: { momentForTextEdit = moment },
onRateMeeting: { momentForRating = moment }, onRateMeeting: { momentForRating = moment },
onAftermathMeeting: { momentForAftermath = moment }, onAftermathMeeting: { momentForAftermath = moment },
onViewSummary: { momentForSummary = moment }, onViewSummary: { momentForSummary = moment },
onEditMeeting: { momentForEdit = moment }, onEditMeeting: { momentForEdit = moment },
onToggleIntention: { toggleIntention(moment) } onToggleIntention: { toggleIntention(moment) },
onEdit: { momentForTextEdit = moment },
onToggleImportant: { toggleImportant(moment) }
) )
if index < person.sortedMoments.count - 1 { RowDivider() }
}
} }
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
@@ -394,6 +407,101 @@ struct PersonDetailView: View {
moment.updatedAt = Date() 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) { private func toggleIntention(_ moment: Moment) {
guard moment.isIntention else { return } guard moment.isIntention else { return }
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
@@ -427,138 +535,79 @@ struct PersonDetailView: View {
// MARK: - Deletable Moment Row // MARK: - Deletable Moment Row
// Links wischen Löschen (rot) // Links wischen Löschen (rot)
// Rechts wischen Bearbeiten (accent) + Wichtig (orange) // MARK: - Todo Row
// Vollständig rechts wischen sofortiger Wichtig-Toggle, Zeile springt zurück
private struct DeletableMomentRow: View { private struct TodoRowView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
let moment: Moment let todo: Todo
let isLast: Bool let isLast: Bool
let onToggle: () -> Void
let onDelete: () -> Void let onDelete: () -> Void
let onToggleImportant: () -> Void
let onEdit: () -> 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 { 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)
}
Spacer()
// Rechte Seite (sichtbar bei Links-Wischen): Löschen
Button {
withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) {
offset = -UIScreen.main.bounds.width
}
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)
}
.background(Color.red)
}
// Zeilen-Inhalt schiebt sich über die Buttons
VStack(spacing: 0) { VStack(spacing: 0) {
MomentRowView( HStack(alignment: .top, spacing: 12) {
moment: moment, Button { onToggle() } label: {
onRateMeeting: onRateMeeting, Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
onAftermathMeeting: onAftermathMeeting, .font(.system(size: 20))
onViewSummary: onViewSummary, .foregroundStyle(todo.isCompleted ? Color.green : theme.contentTertiary)
onEditMeeting: onEditMeeting, .padding(.top, 1)
onToggleIntention: onToggleIntention }
.buttonStyle(.plain)
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)
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))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(todo.isCompleted ? 0.55 : 1.0)
if !isLast { RowDivider() } if !isLast { RowDivider() }
} }
.background(theme.surfaceCard) .swipeActions(edge: .leading) {
.offset(x: offset) Button { onEdit() } label: {
.simultaneousGesture( Label("Bearbeiten", systemImage: "pencil")
DragGesture(minimumDistance: 50, coordinateSpace: .local) }
.onChanged { value in .tint(theme.accent)
let x = value.translation.width }
let y = value.translation.height .swipeActions(edge: .trailing, allowsFullSwipe: true) {
guard abs(x) > abs(y) * 2.5 else { return } Button(role: .destructive) { onDelete() } label: {
if x > 0 { Label("Löschen", systemImage: "trash")
// 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 private var isOverdue: Bool {
onToggleImportant() !todo.isCompleted && todo.dueDate < Calendar.current.startOfDay(for: Date())
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 }
}
}
)
}
.clipped()
} }
} }
@@ -574,6 +623,8 @@ struct MomentRowView: View {
var onViewSummary: (() -> Void)? = nil var onViewSummary: (() -> Void)? = nil
var onEditMeeting: (() -> Void)? = nil var onEditMeeting: (() -> Void)? = nil
var onToggleIntention: (() -> 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. /// Wird lokal gecacht, damit die Ansicht auf CalendarEventStore-Änderungen reagieren kann.
@State private var hasCalendarEvent = false @State private var hasCalendarEvent = false
@@ -600,8 +651,12 @@ struct MomentRowView: View {
private var standardRow: some View { private var standardRow: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
typeIcon typeIcon
Button { onEdit?() } label: {
momentContent momentContent
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
starButton
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -613,8 +668,12 @@ struct MomentRowView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
typeIcon typeIcon
Button { onEdit?() } label: {
momentContent momentContent
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
starButton
// Score-Badge oder Bearbeiten-Button // Score-Badge oder Bearbeiten-Button
if let avg = moment.immediateAverage { if let avg = moment.immediateAverage {
Button { onViewSummary?() } label: { Button { onViewSummary?() } label: {
@@ -691,6 +750,7 @@ struct MomentRowView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button { onEdit?() } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(moment.text) Text(moment.text)
.font(.system(size: 15, design: theme.displayDesign)) .font(.system(size: 15, design: theme.displayDesign))
@@ -713,8 +773,11 @@ struct MomentRowView: View {
} }
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
Spacer() starButton
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -745,6 +808,16 @@ struct MomentRowView: View {
.padding(.top, 2) .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 { private var momentContent: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(moment.text) Text(moment.text)
@@ -753,11 +826,6 @@ struct MomentRowView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) { HStack(spacing: 6) {
if moment.isImportant {
Image(systemName: "star.fill")
.font(.system(size: 10))
.foregroundStyle(.orange)
}
if hasCalendarEvent { if hasCalendarEvent {
Image(systemName: "calendar") Image(systemName: "calendar")
.font(.system(size: 10)) .font(.system(size: 10))
@@ -821,6 +889,8 @@ struct EditMomentView: View {
@State private var text: String @State private var text: String
@State private var createdAt: Date @State private var createdAt: Date
@State private var showDeleteConfirm = false
@State private var showCalendarDeleteConfirm = false
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
init(moment: Moment) { init(moment: Moment) {
@@ -877,6 +947,24 @@ struct EditMomentView: View {
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20) .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() Spacer()
} }
.padding(.top, 16) .padding(.top, 16)
@@ -898,6 +986,38 @@ struct EditMomentView: View {
} }
} }
.onAppear { isFocused = true } .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() { 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 // MARK: - Info Row
struct InfoRowView: View { struct InfoRowView: View {
+158 -18
View File
@@ -1,8 +1,10 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import UserNotifications
struct TodayView: View { struct TodayView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext
@Query private var people: [Person] @Query private var people: [Person]
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung" // V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
@Query(filter: #Predicate<Moment> { @Query(filter: #Predicate<Moment> {
@@ -12,6 +14,8 @@ struct TodayView: View {
@State private var selectedMomentForAftermath: Moment? = nil @State private var selectedMomentForAftermath: Moment? = nil
@State private var showPersonPicker = false @State private var showPersonPicker = false
@State private var personForNewMoment: Person? = nil @State private var personForNewMoment: Person? = nil
@State private var todoForEdit: Todo? = nil
@State private var fadingOutTodos: [Todo] = []
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
private var needsAttention: [Person] { private var needsAttention: [Person] {
@@ -59,9 +63,27 @@ struct TodayView: View {
}, sort: \Moment.createdAt) }, sort: \Moment.createdAt)
private var openIntentions: [Moment] private var openIntentions: [Moment]
// V7: Fällige Todos (alle offenen, nach Fälligkeitsdatum sortiert)
@Query(filter: #Predicate<Todo> {
!$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 { private var isEmpty: Bool {
needsAttention.isEmpty && birthdayPeople.isEmpty && openIntentions.isEmpty needsAttention.isEmpty && birthdayPeople.isEmpty && openIntentions.isEmpty
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.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] { private var activePeople: [Person] {
@@ -109,23 +131,6 @@ struct TodayView: View {
emptyState emptyState
} else { } else {
VStack(spacing: 24) { 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 { if !plannedMeetings.isEmpty {
TodaySection(title: "Geplante Treffen", icon: "calendar.badge.clock") { TodaySection(title: "Geplante Treffen", icon: "calendar.badge.clock") {
ForEach(plannedMeetings) { moment in ForEach(plannedMeetings) { moment in
@@ -143,7 +148,7 @@ struct TodayView: View {
} }
if !upcomingReminders.isEmpty { if !upcomingReminders.isEmpty {
TodaySection(title: "Anstehende Erinnerungen", icon: "bell") { TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") {
ForEach(upcomingReminders) { moment in ForEach(upcomingReminders) { moment in
if let person = moment.person { if let person = moment.person {
NavigationLink(destination: PersonDetailView(person: 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 { if !pendingAftermaths.isEmpty {
TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") { TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") {
ForEach(pendingAftermaths) { moment in ForEach(pendingAftermaths) { moment in
@@ -231,6 +268,9 @@ struct TodayView: View {
.sheet(item: $selectedMomentForAftermath) { moment in .sheet(item: $selectedMomentForAftermath) { moment in
AftermathRatingFlowView(moment: moment) AftermathRatingFlowView(moment: moment)
} }
.sheet(item: $todoForEdit) { todo in
EditTodoView(todo: todo)
}
.sheet(isPresented: $showPersonPicker) { .sheet(isPresented: $showPersonPicker) {
TodayPersonPickerSheet(people: activePeople) { person in TodayPersonPickerSheet(people: activePeople) { person in
personForNewMoment = person personForNewMoment = person
@@ -318,6 +358,37 @@ struct TodayView: View {
return "\(label) · \(dateStr)" 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 { private func lastSeenHint(for person: Person) -> String {
guard let last = person.lastMomentDate else { return String(localized: "Noch keine Momente festgehalten") } guard let last = person.lastMomentDate else { return String(localized: "Noch keine Momente festgehalten") }
let formatter = RelativeDateTimeFormatter() let formatter = RelativeDateTimeFormatter()
@@ -663,3 +734,72 @@ struct TodayRow: View {
.padding(.vertical, 12) .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())
}
}
+6 -6
View File
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
#expect(NahbarSchemaV3.versionIdentifier.patch == 0) #expect(NahbarSchemaV3.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 5 Schemas") @Test("Migrationsplan enthält genau 8 Schemas (V1V8)")
func migrationPlanHasFiveSchemas() { func migrationPlanHasEightSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 5) #expect(NahbarMigrationPlan.schemas.count == 8)
} }
@Test("Migrationsplan enthält genau 4 Stages") @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
func migrationPlanHasFourStages() { func migrationPlanHasSevenStages() {
#expect(NahbarMigrationPlan.stages.count == 4) #expect(NahbarMigrationPlan.stages.count == 7)
} }
@Test("ContainerFallback-Gleichheit funktioniert korrekt") @Test("ContainerFallback-Gleichheit funktioniert korrekt")
+51
View File
@@ -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 // MARK: - LogEntry Tests
@Suite("LogEntry Computed Properties") @Suite("LogEntry Computed Properties")
+6 -6
View File
@@ -359,13 +359,13 @@ struct SchemaV5RegressionTests {
#expect(NahbarSchemaV5.versionIdentifier.patch == 0) #expect(NahbarSchemaV5.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 5 Schemas") @Test("Migrationsplan enthält genau 8 Schemas (V1V8)")
func migrationPlanHasFiveSchemas() { func migrationPlanHasEightSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 5) #expect(NahbarMigrationPlan.schemas.count == 8)
} }
@Test("Migrationsplan enthält genau 4 Stages") @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
func migrationPlanHasFourStages() { func migrationPlanHasSevenStages() {
#expect(NahbarMigrationPlan.stages.count == 4) #expect(NahbarMigrationPlan.stages.count == 7)
} }
} }