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:
@@ -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 = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -287,6 +289,7 @@
|
||||
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
|
||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
||||
2670595B2F96640E00956084 /* CalendarManager.swift */,
|
||||
26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
|
||||
);
|
||||
path = nahbar;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
VStack(spacing: 0) {
|
||||
MomentRowView(
|
||||
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) }
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
MomentRowView(
|
||||
moment: moment,
|
||||
onRateMeeting: onRateMeeting,
|
||||
onAftermathMeeting: onAftermathMeeting,
|
||||
onViewSummary: onViewSummary,
|
||||
onEditMeeting: onEditMeeting,
|
||||
onToggleIntention: onToggleIntention
|
||||
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)
|
||||
|
||||
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() }
|
||||
}
|
||||
.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))
|
||||
.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")
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.clipped()
|
||||
|
||||
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
|
||||
Button { onEdit?() } label: {
|
||||
momentContent
|
||||
Spacer()
|
||||
.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
|
||||
Button { onEdit?() } label: {
|
||||
momentContent
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
starButton
|
||||
// Score-Badge oder Bearbeiten-Button
|
||||
if let avg = moment.immediateAverage {
|
||||
Button { onViewSummary?() } label: {
|
||||
@@ -691,6 +750,7 @@ struct MomentRowView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button { onEdit?() } label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(moment.text)
|
||||
.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(.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 {
|
||||
|
||||
+158
-18
@@ -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<Moment> {
|
||||
@@ -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<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 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user