From e75141d23ce455e6999e8e97b5b9f26804fe5fa8 Mon Sep 17 00:00:00 2001 From: Sven Date: Sat, 18 Apr 2026 20:30:48 +0200 Subject: [PATCH] Umfassende Erweiterung, Lokalisierung, Besuchsbewertung --- .DS_Store | Bin 10244 -> 10244 bytes nahbar/.DS_Store | Bin 6148 -> 6148 bytes nahbar/AftermathNotificationManager.swift | 101 + nahbar/AftermathRatingFlowView.swift | 112 + nahbar/LogExportView.swift | 184 + nahbar/NahbarLogger.swift | 164 + nahbar/RatingDotPicker.swift | 73 + nahbar/RatingQuestionView.swift | 80 + nahbar/VisitEditFlowView.swift | 150 + nahbar/VisitHistorySection.swift | 171 + nahbar/VisitRatingFlowView.swift | 172 + nahbar/VisitSummaryView.swift | 141 + nahbar/nahbar.xcodeproj/project.pbxproj | 182 +- .../UserInterfaceState.xcuserstate | Bin 49959 -> 59629 bytes .../xcshareddata/xcschemes/nahbar.xcscheme | 13 + nahbar/nahbar/AIAnalysisService.swift | 61 +- nahbar/nahbar/AddMomentView.swift | 7 +- nahbar/nahbar/AddPersonView.swift | 28 +- nahbar/nahbar/AppGroup.swift | 94 +- nahbar/nahbar/AppLockSetupView.swift | 14 +- nahbar/nahbar/CallWindowSetupView.swift | 2 + nahbar/nahbar/CloudSyncMonitor.swift | 185 + nahbar/nahbar/ContentView.swift | 135 +- nahbar/nahbar/IchView.swift | 517 +++ nahbar/nahbar/Localizable.xcstrings | 3451 +++++++++++++++++ nahbar/nahbar/LogbuchView.swift | 149 +- nahbar/nahbar/Models.swift | 294 +- nahbar/nahbar/NahbarApp.swift | 98 +- nahbar/nahbar/NahbarMigration.swift | 276 +- nahbar/nahbar/PaywallView.swift | 314 +- nahbar/nahbar/PeopleListView.swift | 43 +- nahbar/nahbar/PersonDetailView.swift | 163 +- nahbar/nahbar/SettingsView.swift | 235 +- nahbar/nahbar/SharedComponents.swift | 9 +- nahbar/nahbar/SplashView.swift | 106 +- nahbar/nahbar/StoreManager.swift | 76 +- nahbar/nahbar/ThemePickerView.swift | 2 +- nahbar/nahbar/ThemeSystem.swift | 2 +- nahbar/nahbar/TodayView.swift | 102 +- nahbar/nahbar/UserProfileStore.swift | 130 + nahbar/nahbarShareExtension/Info.plist | 4 +- .../ShareExtensionView.swift | 40 +- .../de.lproj/MainInterface.strings | 1 + nahbar/nahbarTests/AppEventLogTests.swift | 175 + nahbar/nahbarTests/AppGroupTests.swift | 138 + .../nahbarTests/CallWindowManagerTests.swift | 179 + nahbar/nahbarTests/ModelTests.swift | 299 ++ nahbar/nahbarTests/PersonRowTests.swift | 65 + nahbar/nahbarTests/README.md | 37 + nahbar/nahbarTests/StoreTests.swift | 163 + .../nahbarTests/UserProfileStoreTests.swift | 161 + nahbar/nahbarTests/VisitRatingTests.swift | 358 ++ nahbar/nahbarTests/nahbarTests.swift | 18 + nahbar/profeatures.storekit | 36 +- 54 files changed, 9332 insertions(+), 378 deletions(-) create mode 100644 nahbar/AftermathNotificationManager.swift create mode 100644 nahbar/AftermathRatingFlowView.swift create mode 100644 nahbar/LogExportView.swift create mode 100644 nahbar/NahbarLogger.swift create mode 100644 nahbar/RatingDotPicker.swift create mode 100644 nahbar/RatingQuestionView.swift create mode 100644 nahbar/VisitEditFlowView.swift create mode 100644 nahbar/VisitHistorySection.swift create mode 100644 nahbar/VisitRatingFlowView.swift create mode 100644 nahbar/VisitSummaryView.swift create mode 100644 nahbar/nahbar/CloudSyncMonitor.swift create mode 100644 nahbar/nahbar/IchView.swift create mode 100644 nahbar/nahbar/Localizable.xcstrings create mode 100644 nahbar/nahbar/UserProfileStore.swift create mode 100644 nahbar/nahbarShareExtension/de.lproj/MainInterface.strings create mode 100644 nahbar/nahbarTests/AppEventLogTests.swift create mode 100644 nahbar/nahbarTests/AppGroupTests.swift create mode 100644 nahbar/nahbarTests/CallWindowManagerTests.swift create mode 100644 nahbar/nahbarTests/ModelTests.swift create mode 100644 nahbar/nahbarTests/PersonRowTests.swift create mode 100644 nahbar/nahbarTests/README.md create mode 100644 nahbar/nahbarTests/StoreTests.swift create mode 100644 nahbar/nahbarTests/UserProfileStoreTests.swift create mode 100644 nahbar/nahbarTests/VisitRatingTests.swift create mode 100644 nahbar/nahbarTests/nahbarTests.swift diff --git a/.DS_Store b/.DS_Store index 5584a7372308cd09d57a5d00e4409d748353ee1b..35084c51dfa87b5cb21aaec71fb5371f88367f58 100644 GIT binary patch delta 402 zcmZn(XbG6$FDS~uz`)4BAi$85ZWx@LpIfl8a2or>2Eonj94s95AXyd$J%)6KOokGe z3ZNJRgW%~pWVzgY7nh`*{3M_lhm1uZ+t-uF9Z}^|@X8ltKx_xaghGrPhsmd&Xm K-FJXbV|2_!XnqhK)rpLH1C diff --git a/nahbar/.DS_Store b/nahbar/.DS_Store index f7aed53dd43b0e96c094f5759a236423fb502807..16f324b44a2f12f73fb1651f720530fc06e62621 100644 GIT binary patch literal 6148 zcmeHKF;2rU6#a$@6*^EMgcvh2A@v4nNl#D?K+_-!N}H+y)Pcbl;1-;N2??>WvUO)b z2quIC@7t!tgsOo70rHpZ=h*+-elLyPPeh_|6xE2TL{vs$EG?n>#CV)b&gM+d3Q(vy z+BA&PP8=plobz@aP64ODZ&QH3-397Xo4ORDo_xRfpzVQpuh!}$y@6t|ym9k!yq5$) z7$+h6aazc`hpWqa-Y1&rGw5d4sY*-DU;+lD_TD)3#1$Y5q)da&sV4qUj2fzN?p~^3%aF8Vm z)o;HNn-vFfTlbn|;kC0OeJ9=$izKF?iY3-IB)qu~2NHo+2aL#(>?7jBJy6SY#$YVNn#wNjD5m&d)7i00G7z%gGN} z3^*1qN_`S_^0*_Aqc+)|m5UWDFxijQl&OJX@-9{#&XnThoTU8x9LCLp9AYdJ8}@Hz d=iui6S_9;NXP(S2;>ZCukb!|^bA-qmW&o*NCR_jj diff --git a/nahbar/AftermathNotificationManager.swift b/nahbar/AftermathNotificationManager.swift new file mode 100644 index 0000000..d3ce3ff --- /dev/null +++ b/nahbar/AftermathNotificationManager.swift @@ -0,0 +1,101 @@ +import Foundation +import UserNotifications +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "AftermathNotification") + +// MARK: - AftermathNotificationManager +// Verwaltet Push-Notifications für die verzögerte Nachwirkungs-Bewertung. +// Pattern analog zu CallWindowManager (UNUserNotificationCenter). + +final class AftermathNotificationManager { + static let shared = AftermathNotificationManager() + private init() {} + + static let categoryID = "AFTERMATH_RATING" + static let actionID = "RATE_NOW" + static let visitIDKey = "visitID" + static let personNameKey = "personName" + + // MARK: - Setup + + /// Registriert die Notification-Kategorie mit "Jetzt bewerten"-Action. + /// Muss beim App-Start einmalig aufgerufen werden. + func registerCategory() { + let rateNow = UNNotificationAction( + identifier: Self.actionID, + title: String(localized: "Jetzt bewerten"), + options: .foreground + ) + let category = UNNotificationCategory( + identifier: Self.categoryID, + actions: [rateNow], + intentIdentifiers: [], + options: [] + ) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + // MARK: - Schedule + + /// Plant eine Nachwirkungs-Erinnerung für den angegebenen Besuch. + /// - Parameters: + /// - visitID: UUID des Visit-Objekts + /// - personName: Name der Person (für Notification-Text) + /// - delay: Verzögerung in Sekunden (Standard: 36 Stunden) + func scheduleAftermath(visitID: UUID, personName: String, delay: TimeInterval = 36 * 3600) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + guard granted else { + logger.warning("Notification-Berechtigung abgelehnt – keine Nachwirkungs-Erinnerung.") + return + } + if let error { + logger.error("Berechtigung-Fehler: \(error.localizedDescription)") + } + self.createNotification(visitID: visitID, personName: personName, delay: delay) + } + } + + private func createNotification(visitID: UUID, personName: String, delay: TimeInterval) { + let content = UNMutableNotificationContent() + content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName) + content.body = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.") + content.sound = .default + content.categoryIdentifier = Self.categoryID + content.userInfo = [ + Self.visitIDKey: visitID.uuidString, + Self.personNameKey: personName + ] + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) + let request = UNNotificationRequest( + identifier: notificationID(for: visitID), + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error { + logger.error("Notification konnte nicht geplant werden: \(error.localizedDescription)") + } else { + logger.info("Nachwirkungs-Erinnerung geplant für Visit \(visitID.uuidString) in \(Int(delay / 3600))h.") + } + } + } + + // MARK: - Cancel + + /// Entfernt eine geplante Nachwirkungs-Erinnerung. + func cancelAftermath(visitID: UUID) { + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: [notificationID(for: visitID)] + ) + logger.info("Nachwirkungs-Erinnerung abgebrochen für Visit \(visitID.uuidString).") + } + + // MARK: - Helpers + + private func notificationID(for visitID: UUID) -> String { + "aftermath_\(visitID.uuidString)" + } +} diff --git a/nahbar/AftermathRatingFlowView.swift b/nahbar/AftermathRatingFlowView.swift new file mode 100644 index 0000000..4e32dfd --- /dev/null +++ b/nahbar/AftermathRatingFlowView.swift @@ -0,0 +1,112 @@ +import SwiftUI +import SwiftData + +// MARK: - AftermathRatingFlowView +// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (3 Fragen). +// Wird aus einer Push-Notification heraus oder aus VisitHistorySection geöffnet. + +struct AftermathRatingFlowView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + let visit: Visit + + private let questions = RatingQuestion.aftermath // 3 Fragen + @State private var currentIndex: Int = 0 + @State private var values: [Int?] + @State private var showSummary: Bool = false + + init(visit: Visit) { + self.visit = visit + _values = State(initialValue: Array(repeating: nil, count: RatingQuestion.aftermath.count)) + } + + var body: some View { + NavigationStack { + Group { + if showSummary { + VisitSummaryView(visit: visit, onDismiss: { dismiss() }) + } else { + questionStep + } + } + .navigationTitle("Nachwirkung") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + if !showSummary { + ToolbarItem(placement: .confirmationAction) { + Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") { + advance() + } + } + } + } + } + } + + // MARK: - Fragen-Screen + + private var questionStep: some View { + ZStack { + RatingQuestionView( + question: questions[currentIndex], + index: currentIndex, + total: questions.count, + value: $values[currentIndex] + ) + .id(currentIndex) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + .clipped() + } + + // MARK: - Navigation & Speichern + + private func advance() { + if currentIndex < questions.count - 1 { + withAnimation { currentIndex += 1 } + } else { + saveAftermath() + } + } + + private func saveAftermath() { + for (i, q) in questions.enumerated() { + let rating = Rating( + category: q.category, + questionIndex: i, + value: values[i], + isAftermath: true, + visit: visit + ) + modelContext.insert(rating) + } + + visit.status = .completed + visit.aftermathCompletedAt = Date() + + // Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat) + AftermathNotificationManager.shared.cancelAftermath(visitID: visit.id) + + do { + try modelContext.save() + AppEventLog.shared.record( + "Nachwirkung abgeschlossen für Visit \(visit.id.uuidString)", + level: .success, category: "Visit" + ) + } catch { + AppEventLog.shared.record( + "Fehler beim Speichern der Nachwirkung: \(error.localizedDescription)", + level: .error, category: "Visit" + ) + } + + withAnimation { showSummary = true } + } +} diff --git a/nahbar/LogExportView.swift b/nahbar/LogExportView.swift new file mode 100644 index 0000000..8673d9a --- /dev/null +++ b/nahbar/LogExportView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +// MARK: - LogExportView +// +// Zeigt den In-App-Event-Log mit Level-Filter und Export an. +// Erreichbar über Einstellungen → Entwickler-Log. + +struct LogExportView: View { + @Environment(\.nahbarTheme) var theme + @ObservedObject private var log = AppEventLog.shared + + @State private var selectedMinLevel: AppEventLog.Entry.Level = .info + @State private var showingClearConfirm = false + + private var filteredEntries: [AppEventLog.Entry] { + log.entries(minLevel: selectedMinLevel).reversed() + } + + var body: some View { + ZStack { + theme.backgroundPrimary.ignoresSafeArea() + + VStack(spacing: 0) { + filterBar + Divider() + .background(theme.borderSubtle) + + if filteredEntries.isEmpty { + emptyState + } else { + entryList + } + } + } + .navigationTitle("Entwickler-Log") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + // Export + ShareLink( + item: LogExportDocument(text: log.exportText()), + preview: SharePreview("nahbar-log.txt", icon: Image(systemName: "doc.text")) + ) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 15)) + } + + // Löschen + Button { + showingClearConfirm = true + } label: { + Image(systemName: "trash") + .font(.system(size: 15)) + .foregroundStyle(.red.opacity(0.8)) + } + .confirmationDialog( + "Log löschen?", + isPresented: $showingClearConfirm, + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { log.clear() } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Alle \(log.entries.count) Einträge werden entfernt.") + } + } + } + } + + // MARK: - Filter Bar + + private var filterBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AppEventLog.Entry.Level.allCases, id: \.self) { level in + Button { + selectedMinLevel = level + } label: { + HStack(spacing: 4) { + Text(level.emoji) + .font(.system(size: 12)) + Text(level.rawValue) + .font(.system(size: 12, weight: selectedMinLevel == level ? .semibold : .regular)) + } + .foregroundStyle(selectedMinLevel == level ? theme.accent : theme.contentTertiary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + selectedMinLevel == level + ? theme.accent.opacity(0.12) + : theme.backgroundSecondary + ) + .clipShape(Capsule()) + } + } + + Spacer() + + Text("\(filteredEntries.count) Einträge") + .font(.system(size: 11)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(theme.backgroundPrimary) + } + + // MARK: - Entry List + + private var entryList: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: []) { + ForEach(filteredEntries) { entry in + LogEntryRow(entry: entry) + Divider() + .padding(.leading, 16) + .background(theme.borderSubtle.opacity(0.5)) + } + } + .padding(.bottom, 24) + } + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 36)) + .foregroundStyle(theme.contentTertiary) + Text("Keine Einträge für diesen Filter") + .font(.system(size: 15)) + .foregroundStyle(theme.contentTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Log Entry Row + +private struct LogEntryRow: View { + @Environment(\.nahbarTheme) var theme + let entry: AppEventLog.Entry + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // Level-Indikator + Rectangle() + .fill(entry.level.color) + .frame(width: 3) + .frame(minHeight: 36) + + VStack(alignment: .leading, spacing: 3) { + // Timestamp + Category + Level + HStack(spacing: 6) { + Text(entry.formattedTimestamp) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(theme.contentTertiary) + Text("[\(entry.category)]") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + Spacer() + Text(entry.level.emoji) + .font(.system(size: 11)) + } + + // Nachricht + Text(entry.message) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle( + entry.level == .info || entry.level == .success + ? theme.contentPrimary + : entry.level.color + ) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 10) + .padding(.trailing, 14) + } + .padding(.leading, 12) + .background(theme.backgroundPrimary) + } +} diff --git a/nahbar/NahbarLogger.swift b/nahbar/NahbarLogger.swift new file mode 100644 index 0000000..1d50c7a --- /dev/null +++ b/nahbar/NahbarLogger.swift @@ -0,0 +1,164 @@ +import Combine +import SwiftUI +import OSLog +import UniformTypeIdentifiers + +// MARK: - AppEventLog +// +// Exportierbares In-Memory-Log für Support & Debugging. +// Ergänzt os.log – alle Einträge bleiben für die Laufzeit der App erhalten +// und können als Textdatei geteilt werden. +// Thread-sicher: record() dispatcht UI-Updates auf den Main Thread. + +final class AppEventLog: ObservableObject { + static let shared = AppEventLog() + + @Published private(set) var entries: [Entry] = [] + private let capacity = 500 + + // MARK: - Entry + + struct Entry: Identifiable, Sendable { + let id: UUID + let timestamp: Date + let level: Level + let category: String + let message: String + + init(level: Level, category: String, message: String) { + self.id = UUID() + self.timestamp = Date() + self.level = level + self.category = category + self.message = message + } + + enum Level: String, CaseIterable, Sendable { + case info = "INFO" + case success = "OK" + case warning = "WARN" + case error = "FEHLER" + case critical = "KRITISCH" + + var emoji: String { + switch self { + case .info: return "ℹ️" + case .success: return "✅" + case .warning: return "⚠️" + case .error: return "❌" + case .critical: return "🚨" + } + } + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .warning: return .orange + case .error: return .red + case .critical: return .red + } + } + + // Für Level-Filter: höherer Wert = höhere Priorität + var priority: Int { + switch self { + case .info: return 0 + case .success: return 1 + case .warning: return 2 + case .error: return 3 + case .critical: return 4 + } + } + } + + var formattedTimestamp: String { + NahbarLogDateFormatter.time.string(from: timestamp) + } + } + + // MARK: - Init + + private init() { + // Ersten Eintrag ohne den Ring-Buffer-Overhead direkt schreiben + entries.append(Entry(level: .info, category: "Lifecycle", message: "App gestartet")) + } + + // MARK: - Public API + + /// Fügt einen Eintrag hinzu. Kann von jedem Thread aufgerufen werden. + func record(_ message: String, level: Entry.Level = .info, category: String = "App") { + let entry = Entry(level: level, category: category, message: message) + DispatchQueue.main.async { [self] in + if entries.count >= capacity { entries.removeFirst() } + entries.append(entry) + } + } + + /// Gefilterte Einträge ab einem Mindest-Level. + func entries(minLevel: Entry.Level) -> [Entry] { + entries.filter { $0.level.priority >= minLevel.priority } + } + + /// Exportiert alle Einträge als lesbaren Textstring. + func exportText() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" + let device = UIDevice.current.model + let sysVer = UIDevice.current.systemVersion + + let header = """ + ═══════════════════════════════════════════════════════════ + nahbar App-Log + Exportiert: \(NahbarLogDateFormatter.full.string(from: Date())) + Version: \(version) (\(build)) + Gerät: \(device), iOS \(sysVer) + Einträge: \(entries.count)/\(capacity) + ═══════════════════════════════════════════════════════════ + + """ + + let body = entries.map { e in + "[\(e.formattedTimestamp)] \(e.level.emoji) [\(e.level.rawValue)] [\(e.category)] \(e.message)" + }.joined(separator: "\n") + + return header + body + } + + func clear() { + entries.removeAll() + record("Log geleert", level: .info, category: "Lifecycle") + } +} + +// MARK: - DateFormatters (Singletons für Performance) + +private enum NahbarLogDateFormatter { + static let time: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "HH:mm:ss" + return f + }() + + static let full: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateStyle = .medium + f.timeStyle = .medium + return f + }() +} + +// MARK: - Sharesheet Item + +/// Wrapper für den ShareLink / UIActivityViewController Export. +struct LogExportDocument: Transferable { + let text: String + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .plainText) { doc in + Data(doc.text.utf8) + } + } +} diff --git a/nahbar/RatingDotPicker.swift b/nahbar/RatingDotPicker.swift new file mode 100644 index 0000000..2d8bb13 --- /dev/null +++ b/nahbar/RatingDotPicker.swift @@ -0,0 +1,73 @@ +import SwiftUI + +// MARK: - RatingDotPicker +// 5-Punkt-Skala (-2 bis +2). Keine Zahlen sichtbar – nur Farb-Dots und Pol-Labels. +// Wert nil bedeutet: noch keine Auswahl getroffen. + +struct RatingDotPicker: View { + @Binding var value: Int? // nil = unbewertet, -2...+2 sonst + let negativePole: String + let positivePole: String + + private let dotValues = [-2, -1, 0, 1, 2] + + private func color(for dot: Int) -> Color { + switch dot { + case -2: return .red + case -1: return .orange + case 0: return Color(.systemGray3) + case 1: return Color(red: 0.5, green: 0.8, blue: 0.3) + case 2: return .green + default: return .gray + } + } + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 16) { + ForEach(dotValues, id: \.self) { dot in + Button { + let impact = UIImpactFeedbackGenerator(style: .light) + impact.impactOccurred() + if value == dot { + value = nil // Zweites Tippen hebt Auswahl auf + } else { + value = dot + } + } label: { + Circle() + .fill(value == dot ? color(for: dot) : Color(.systemGray5)) + .frame(width: 44, height: 44) + .overlay( + Circle() + .strokeBorder(value == dot ? color(for: dot) : Color(.systemGray4), lineWidth: 1.5) + ) + .scaleEffect(value == dot ? 1.15 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: value) + } + .buttonStyle(.plain) + } + } + + HStack { + Text(LocalizedStringKey(negativePole)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(LocalizedStringKey(positivePole)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + @Previewable @State var val: Int? = nil + VStack(spacing: 32) { + RatingDotPicker(value: $val, negativePole: "Unwohl", positivePole: "Sehr wohl") + Text(val.map { "Wert: \($0)" } ?? "Keine Auswahl") + .font(.caption) + } + .padding() +} diff --git a/nahbar/RatingQuestionView.swift b/nahbar/RatingQuestionView.swift new file mode 100644 index 0000000..046816d --- /dev/null +++ b/nahbar/RatingQuestionView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +// MARK: - RatingQuestionView +// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext, +// RatingDotPicker und "Überspringen"-Button. + +struct RatingQuestionView: View { + let question: RatingQuestion + let index: Int // 0-basiert innerhalb des aktuellen Flows + let total: Int + @Binding var value: Int? + + var body: some View { + VStack(spacing: 0) { + // Fortschrittsbalken + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color(.systemGray5)) + Rectangle() + .fill(question.category.color) + .frame(width: geo.size.width * CGFloat(index + 1) / CGFloat(total)) + .animation(.easeInOut(duration: 0.3), value: index) + } + } + .frame(height: 4) + + ScrollView { + VStack(spacing: 32) { + // Kategorie-Badge + HStack(spacing: 6) { + Image(systemName: question.category.icon) + .font(.caption.bold()) + Text(LocalizedStringKey(question.category.rawValue)) + .font(.caption.bold()) + } + .foregroundStyle(question.category.color) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(question.category.color.opacity(0.12), in: Capsule()) + .padding(.top, 32) + + // Fragetext + Text(LocalizedStringKey(question.text)) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + // Picker + RatingDotPicker(value: $value, + negativePole: question.negativePole, + positivePole: question.positivePole) + .padding(.horizontal, 24) + + // Überspringen + Button { + value = nil + } label: { + Text("Überspringen") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + .padding(.bottom, 32) + } + } + } +} + +#Preview { + @Previewable @State var val: Int? = nil + RatingQuestionView( + question: RatingQuestion.all[0], + index: 0, + total: 9, + value: $val + ) +} diff --git a/nahbar/VisitEditFlowView.swift b/nahbar/VisitEditFlowView.swift new file mode 100644 index 0000000..52cda8e --- /dev/null +++ b/nahbar/VisitEditFlowView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import SwiftData + +// MARK: - VisitEditFlowView +// Erlaubt das nachträgliche Bearbeiten einer bereits abgegebenen Sofort-Bewertung. +// Lädt die gespeicherten Rating-Werte vor und überschreibt sie beim Speichern. + +struct VisitEditFlowView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + let visit: Visit + + private let questions = RatingQuestion.immediate + @State private var currentIndex: Int = 0 + @State private var values: [Int?] + @State private var note: String + @State private var showNoteStep: Bool = false + + init(visit: Visit) { + self.visit = visit + // Vorbefüllen mit gespeicherten Werten + var prefilled: [Int?] = Array(repeating: nil, count: RatingQuestion.immediate.count) + if let ratings = visit.ratings { + for r in ratings where !r.isAftermath { + if r.questionIndex < prefilled.count { + prefilled[r.questionIndex] = r.value + } + } + } + _values = State(initialValue: prefilled) + _note = State(initialValue: visit.note ?? "") + } + + var body: some View { + NavigationStack { + Group { + if showNoteStep { + noteStep + } else { + questionStep + } + } + .navigationTitle("Bewertung bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(nextButtonLabel) { advance() } + } + } + } + } + + // MARK: - Fragen-Screen + + private var questionStep: some View { + ZStack { + RatingQuestionView( + question: questions[currentIndex], + index: currentIndex, + total: questions.count, + value: $values[currentIndex] + ) + .id(currentIndex) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + .clipped() + } + + // MARK: - Notiz-Screen + + private var noteStep: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Möchtest du die Notiz anpassen?") + .font(.title3.weight(.semibold)) + .padding(.horizontal, 24) + .padding(.top, 24) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) + if note.isEmpty { + Text("Optional – z. B. was besonders war…") + .foregroundStyle(.tertiary) + .padding(16) + } + TextEditor(text: $note) + .padding(12) + .scrollContentBackground(.hidden) + } + .frame(minHeight: 120) + .padding(.horizontal, 24) + + Spacer() + } + } + + // MARK: - Navigation + + private var nextButtonLabel: LocalizedStringKey { + if showNoteStep { return "Speichern" } + return "Weiter" + } + + private func advance() { + if showNoteStep { + saveEdits() + return + } + if currentIndex < questions.count - 1 { + withAnimation(.easeInOut(duration: 0.25)) { currentIndex += 1 } + } else { + withAnimation { showNoteStep = true } + } + } + + // MARK: - Speichern + + private func saveEdits() { + // Bestehende Sofort-Ratings in-place aktualisieren + if let ratings = visit.ratings { + for rating in ratings where !rating.isAftermath { + if rating.questionIndex < values.count { + rating.value = values[rating.questionIndex] + } + } + } + visit.note = note.isEmpty ? nil : note.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + try modelContext.save() + AppEventLog.shared.record( + "Besuchsbewertung bearbeitet: Visit \(visit.id.uuidString)", + level: .info, category: "Visit" + ) + } catch { + AppEventLog.shared.record( + "Fehler beim Aktualisieren der Bewertung: \(error.localizedDescription)", + level: .error, category: "Visit" + ) + } + dismiss() + } +} diff --git a/nahbar/VisitHistorySection.swift b/nahbar/VisitHistorySection.swift new file mode 100644 index 0000000..c684d5a --- /dev/null +++ b/nahbar/VisitHistorySection.swift @@ -0,0 +1,171 @@ +import SwiftUI + +// MARK: - VisitHistorySection +// Wiederverwendbare Section für PersonDetailView. +// Zeigt die letzten Besuche einer Person mit Score-Badge und Status. + +struct VisitHistorySection: View { + let person: Person + @Binding var showingVisitRating: Bool + @Binding var showingAftermathRating: Bool + @Binding var selectedVisitForAftermath: Visit? + @Binding var selectedVisitForEdit: Visit? + @Binding var selectedVisitForSummary: Visit? + + private var recentVisits: [Visit] { + person.sortedVisits.prefix(5).map { $0 } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header + HStack { + SectionHeader(title: "Besuche", icon: "star.fill") + Spacer() + Button { + showingVisitRating = true + } label: { + Image(systemName: "plus") + .font(.body.bold()) + .foregroundStyle(Color.accentColor) + } + } + + if recentVisits.isEmpty { + // Empty State + HStack(spacing: 12) { + Image(systemName: "star") + .font(.title3) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Noch keine Besuche bewertet") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Tippe auf + um loszulegen") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + } + .padding(14) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } else { + VStack(spacing: 0) { + ForEach(recentVisits.indices, id: \.self) { i in + let v = recentVisits[i] + VisitRowView( + visit: v, + onTap: { selectedVisitForSummary = v }, + onAftermathTap: { + selectedVisitForAftermath = v + showingAftermathRating = true + }, + onEditTap: { + selectedVisitForEdit = v + } + ) + if i < recentVisits.count - 1 { + Divider().padding(.leading, 16) + } + } + } + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + } + } + } +} + +// MARK: - VisitRowView + +private struct VisitRowView: View { + let visit: Visit + let onTap: () -> Void + let onAftermathTap: () -> Void + let onEditTap: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Tappbarer Hauptbereich (Score + Datum/Status) + Button(action: onTap) { + HStack(spacing: 12) { + // Score-Kreis + ZStack { + Circle() + .fill(scoreColor.opacity(0.15)) + .frame(width: 40, height: 40) + if let avg = visit.immediateAverage { + Text(String(format: "%.1f", avg)) + .font(.caption.bold()) + .foregroundStyle(scoreColor) + } else { + Image(systemName: "minus") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted)) + .font(.subheadline.weight(.medium)) + statusLabel + } + } + } + .buttonStyle(.plain) + + Spacer() + + // Nachwirkungs-Badge falls ausstehend + if visit.status == .awaitingAftermath { + Button(action: onAftermathTap) { + Text("Nachwirkung") + .font(.caption.bold()) + .foregroundStyle(.orange) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.orange.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + } + + // Bearbeiten-Button + Button(action: onEditTap) { + Image(systemName: "pencil") + .font(.caption.bold()) + .foregroundStyle(.secondary) + .padding(8) + .background(Color(.tertiarySystemBackground), in: Circle()) + } + .buttonStyle(.plain) + } + .padding(14) + } + + private var statusLabel: some View { + Group { + switch visit.status { + case .immediateCompleted: + Text("Bewertet") + .font(.caption) + .foregroundStyle(.secondary) + case .awaitingAftermath: + Text("Nachwirkung ausstehend") + .font(.caption) + .foregroundStyle(.orange) + case .completed: + Text("Abgeschlossen") + .font(.caption) + .foregroundStyle(.green) + } + } + } + + private var scoreColor: Color { + guard let avg = visit.immediateAverage else { return .gray } + switch avg { + case ..<(-0.5): return .red + case (-0.5)..<(0.5): return Color(.systemGray3) + default: return .green + } + } +} diff --git a/nahbar/VisitRatingFlowView.swift b/nahbar/VisitRatingFlowView.swift new file mode 100644 index 0000000..b6a21aa --- /dev/null +++ b/nahbar/VisitRatingFlowView.swift @@ -0,0 +1,172 @@ +import SwiftUI +import SwiftData + +// MARK: - VisitRatingFlowView +// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung (9 Fragen). +// Erstellt beim Abschluss ein Visit-Objekt mit allen Ratings und plant +// die Nachwirkungs-Notification. + +struct VisitRatingFlowView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + let person: Person + + // Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben) + var aftermathDelay: TimeInterval = 36 * 3600 + + // MARK: State + + private let questions = RatingQuestion.immediate // 9 Fragen + @State private var currentIndex: Int = 0 + @State private var values: [Int?] // [nil] × 9 + @State private var note: String = "" + @State private var showNoteStep: Bool = false + @State private var showSummary: Bool = false + @State private var createdVisit: Visit? = nil + + init(person: Person, aftermathDelay: TimeInterval = 36 * 3600) { + self.person = person + self.aftermathDelay = aftermathDelay + _values = State(initialValue: Array(repeating: nil, count: RatingQuestion.immediate.count)) + } + + var body: some View { + NavigationStack { + Group { + if showSummary, let visit = createdVisit { + VisitSummaryView(visit: visit, onDismiss: { dismiss() }) + } else if showNoteStep { + noteStep + } else { + questionStep + } + } + .navigationTitle(showNoteStep ? "Notiz" : "Besuch bewerten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + if !showSummary { + ToolbarItem(placement: .confirmationAction) { + Button(nextButtonLabel) { + advance() + } + } + } + } + } + } + + // MARK: - Fragen-Screen + + private var questionStep: some View { + ZStack { + RatingQuestionView( + question: questions[currentIndex], + index: currentIndex, + total: questions.count, + value: $values[currentIndex] + ) + .id(currentIndex) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + .clipped() + } + + // MARK: - Notiz-Screen + + private var noteStep: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Möchtest du noch etwas festhalten?") + .font(.title3.weight(.semibold)) + .padding(.horizontal, 24) + .padding(.top, 24) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) + if note.isEmpty { + Text("Optional – z. B. was besonders war…") + .foregroundStyle(.tertiary) + .padding(16) + } + TextEditor(text: $note) + .padding(12) + .scrollContentBackground(.hidden) + } + .frame(minHeight: 120) + .padding(.horizontal, 24) + + Spacer() + } + } + + // MARK: - Navigation + + private var nextButtonLabel: LocalizedStringKey { + if showNoteStep { return "Fertig" } + if currentIndex == questions.count - 1 { return "Weiter" } + return "Weiter" + } + + private func advance() { + if showNoteStep { + saveVisit() + return + } + if currentIndex < questions.count - 1 { + withAnimation { currentIndex += 1 } + } else { + withAnimation { showNoteStep = true } + } + } + + // MARK: - Speichern + + private func saveVisit() { + let visit = Visit(visitDate: Date(), person: person) + visit.note = note.isEmpty ? nil : note.trimmingCharacters(in: .whitespacesAndNewlines) + visit.status = .awaitingAftermath + modelContext.insert(visit) + + for (i, q) in questions.enumerated() { + let rating = Rating( + category: q.category, + questionIndex: i, + value: values[i], + isAftermath: false, + visit: visit + ) + modelContext.insert(rating) + } + + do { + try modelContext.save() + AppEventLog.shared.record( + "Besuch bewertet: \(person.firstName) (\(questions.count) Fragen)", + level: .info, category: "Visit" + ) + } catch { + AppEventLog.shared.record( + "Fehler beim Speichern des Besuchs: \(error.localizedDescription)", + level: .error, category: "Visit" + ) + } + + // Nachwirkungs-Notification planen + AftermathNotificationManager.shared.scheduleAftermath( + visitID: visit.id, + personName: person.firstName, + delay: aftermathDelay + ) + visit.aftermathNotificationScheduled = true + + createdVisit = visit + withAnimation { showSummary = true } + } +} diff --git a/nahbar/VisitSummaryView.swift b/nahbar/VisitSummaryView.swift new file mode 100644 index 0000000..55a436f --- /dev/null +++ b/nahbar/VisitSummaryView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +// MARK: - VisitSummaryView +// Zeigt die Zusammenfassung eines Besuchs nach Abschluss des Rating-Flows. +// Wird sowohl nach der Sofort-Bewertung als auch nach der Nachwirkung gezeigt. + +struct VisitSummaryView: View { + let visit: Visit + let onDismiss: () -> Void + + private var immediateCategories: [RatingCategory] { + [.selbst, .beziehung, .gespraech] + } + + var body: some View { + ScrollView { + VStack(spacing: 28) { + // Header + VStack(spacing: 8) { + Image(systemName: visit.status == .completed ? "checkmark.circle.fill" : "clock.fill") + .font(.system(size: 48)) + .foregroundStyle(visit.status == .completed ? .green : .orange) + + Text(visit.status == .completed ? "Alles festgehalten" : "Gut gemacht!") + .font(.title2.bold()) + + Text(visit.status == .awaitingAftermath + ? "Wir erinnern dich an die Nachwirkung." + : "Bewertung abgeschlossen.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 32) + + // Sofort-Werte + if let immediateAvg = visit.immediateAverage { + summaryCard(title: "Sofort-Eindruck", average: immediateAvg, categories: immediateCategories, isAftermath: false) + } + + // Nachwirkungs-Wert (falls vorhanden) + if let aftermathAvg = visit.aftermathAverage { + summaryCard(title: "Nachwirkung", average: aftermathAvg, categories: [.nachwirkung], isAftermath: true) + } + + // Notiz + if let note = visit.note, !note.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Notiz", systemImage: "note.text") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Text(note) + .font(.body) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + + // Fertig-Button + Button { + onDismiss() + } label: { + Text("Fertig") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.white) + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + } + + // MARK: - Hilfsmethoden + + private func summaryCard(title: LocalizedStringKey, average: Double, categories: [RatingCategory], isAftermath: Bool) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(title) + .font(.subheadline.bold()) + Spacer() + scoreCircle(value: average) + } + + ForEach(categories, id: \.self) { category in + if let avg = visit.averageForCategory(category, aftermath: isAftermath) { + categoryRow(category: category, average: avg) + } + } + } + .padding(16) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + + private func categoryRow(category: RatingCategory, average: Double) -> some View { + HStack(spacing: 10) { + Image(systemName: category.icon) + .foregroundStyle(category.color) + .frame(width: 20) + Text(LocalizedStringKey(category.rawValue)) + .font(.subheadline) + Spacer() + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray5)) + RoundedRectangle(cornerRadius: 4) + .fill(barColor(for: average)) + .frame(width: geo.size.width * CGFloat((average + 2) / 4)) + } + } + .frame(height: 8) + .frame(maxWidth: 100) + } + } + + private func scoreCircle(value: Double) -> some View { + ZStack { + Circle() + .fill(barColor(for: value).opacity(0.15)) + .frame(width: 40, height: 40) + Text(String(format: "%.1f", value)) + .font(.caption.bold()) + .foregroundStyle(barColor(for: value)) + } + } + + private func barColor(for value: Double) -> Color { + switch value { + case ..<(-0.5): return .red + case (-0.5)..<(0.5): return Color(.systemGray3) + case (0.5)...: return .green + default: return .gray + } + } +} diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index 9a48322..245d370 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -8,6 +8,20 @@ /* Begin PBXBuildFile section */ 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; }; + 26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; }; + 26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */; }; + 26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB52F93B55F0039BA3B /* IchView.swift */; }; + 26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB72F93B7570039BA3B /* NahbarLogger.swift */; }; + 26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB92F93B76E0039BA3B /* LogExportView.swift */; }; + 26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE02F93C0080039BA3B /* RatingDotPicker.swift */; }; + 26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE22F93C0180039BA3B /* RatingQuestionView.swift */; }; + 26B2CAE52F93C02B0039BA3B /* AftermathNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE42F93C02B0039BA3B /* AftermathNotificationManager.swift */; }; + 26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */; }; + 26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */; }; + 26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */; }; + 26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */; }; + 26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */; }; + 26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */; }; 26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; }; 26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; }; 26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; }; @@ -42,6 +56,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 26B2CAC32F93B96C0039BA3B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 265F92182F9109B500CE0A5C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 265F921F2F9109B500CE0A5C; + remoteInfo = nahbar; + }; 26BB85D22F926A9700889312 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 265F92182F9109B500CE0A5C /* Project object */; @@ -68,6 +89,21 @@ /* Begin PBXFileReference section */ 265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; 269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = ""; }; + 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncMonitor.swift; sourceTree = ""; }; + 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStore.swift; sourceTree = ""; }; + 26B2CAB52F93B55F0039BA3B /* IchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IchView.swift; sourceTree = ""; }; + 26B2CAB72F93B7570039BA3B /* NahbarLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarLogger.swift; sourceTree = ""; }; + 26B2CAB92F93B76E0039BA3B /* LogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExportView.swift; sourceTree = ""; }; + 26B2CABF2F93B96C0039BA3B /* nahbarTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = nahbarTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 26B2CAE02F93C0080039BA3B /* RatingDotPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingDotPicker.swift; sourceTree = ""; }; + 26B2CAE22F93C0180039BA3B /* RatingQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingQuestionView.swift; sourceTree = ""; }; + 26B2CAE42F93C02B0039BA3B /* AftermathNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathNotificationManager.swift; sourceTree = ""; }; + 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitRatingFlowView.swift; sourceTree = ""; }; + 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathRatingFlowView.swift; sourceTree = ""; }; + 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitSummaryView.swift; sourceTree = ""; }; + 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = ""; }; + 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = ""; }; + 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; 26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; 26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; @@ -111,6 +147,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 26B2CAC02F93B96C0039BA3B /* nahbarTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = nahbarTests; + sourceTree = ""; + }; 26BB85CB2F926A9700889312 /* nahbarShareExtension */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -129,6 +170,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 26B2CABC2F93B96C0039BA3B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26BB85C72F926A9700889312 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -145,7 +193,18 @@ 26BB85BE2F924E3D00889312 /* profeatures.storekit */, 26EF66302F9112E700824F91 /* nahbar */, 26BB85CB2F926A9700889312 /* nahbarShareExtension */, + 26B2CAC02F93B96C0039BA3B /* nahbarTests */, 265F92212F9109B500CE0A5C /* Products */, + 26B2CAB72F93B7570039BA3B /* NahbarLogger.swift */, + 26B2CAB92F93B76E0039BA3B /* LogExportView.swift */, + 26B2CAE02F93C0080039BA3B /* RatingDotPicker.swift */, + 26B2CAE22F93C0180039BA3B /* RatingQuestionView.swift */, + 26B2CAE42F93C02B0039BA3B /* AftermathNotificationManager.swift */, + 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */, + 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */, + 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */, + 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */, + 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */, ); sourceTree = ""; }; @@ -154,6 +213,7 @@ children = ( 265F92202F9109B500CE0A5C /* nahbar.app */, 26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */, + 26B2CABF2F93B96C0039BA3B /* nahbarTests.xctest */, ); name = Products; sourceTree = ""; @@ -190,6 +250,10 @@ 26BB85C22F92586600889312 /* AIConfiguration.json */, 26BB85C42F926A1C00889312 /* AppGroup.swift */, 269ECE652F92B5C700444B14 /* NahbarMigration.swift */, + 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */, + 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */, + 26B2CAB52F93B55F0039BA3B /* IchView.swift */, + 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */, ); path = nahbar; sourceTree = ""; @@ -218,6 +282,29 @@ productReference = 265F92202F9109B500CE0A5C /* nahbar.app */; productType = "com.apple.product-type.application"; }; + 26B2CABE2F93B96C0039BA3B /* nahbarTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26B2CAC52F93B96C0039BA3B /* Build configuration list for PBXNativeTarget "nahbarTests" */; + buildPhases = ( + 26B2CABB2F93B96C0039BA3B /* Sources */, + 26B2CABC2F93B96C0039BA3B /* Frameworks */, + 26B2CABD2F93B96C0039BA3B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 26B2CAC42F93B96C0039BA3B /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 26B2CAC02F93B96C0039BA3B /* nahbarTests */, + ); + name = nahbarTests; + packageProductDependencies = ( + ); + productName = nahbarTests; + productReference = 26B2CABF2F93B96C0039BA3B /* nahbarTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 26BB85C92F926A9700889312 /* nahbarShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 26BB85D62F926A9700889312 /* Build configuration list for PBXNativeTarget "nahbarShareExtension" */; @@ -253,17 +340,22 @@ 265F921F2F9109B500CE0A5C = { CreatedOnToolsVersion = 26.4; }; + 26B2CABE2F93B96C0039BA3B = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = 265F921F2F9109B500CE0A5C; + }; 26BB85C92F926A9700889312 = { CreatedOnToolsVersion = 26.4; }; }; }; buildConfigurationList = 265F921B2F9109B500CE0A5C /* Build configuration list for PBXProject "nahbar" */; - developmentRegion = en; + developmentRegion = de; hasScannedForEncodings = 0; knownRegions = ( en, Base, + de, ); mainGroup = 265F92172F9109B500CE0A5C; minimizedProjectReferenceProxies = 1; @@ -274,6 +366,7 @@ targets = ( 265F921F2F9109B500CE0A5C /* nahbar */, 26BB85C92F926A9700889312 /* nahbarShareExtension */, + 26B2CABE2F93B96C0039BA3B /* nahbarTests */, ); }; /* End PBXProject section */ @@ -285,10 +378,18 @@ files = ( 26BB85C32F92586600889312 /* AIConfiguration.json in Resources */, 26EF66312F9112E700824F91 /* Assets.xcassets in Resources */, + 26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */, 26BB85BF2F924E3D00889312 /* profeatures.storekit in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 26B2CABD2F93B96C0039BA3B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26BB85C82F926A9700889312 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -305,12 +406,18 @@ files = ( 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */, 26EF66322F9112E700824F91 /* Models.swift in Sources */, + 26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */, 26EF66332F9112E700824F91 /* TodayView.swift in Sources */, 26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */, + 26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */, 26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */, 26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */, 26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */, + 26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */, + 26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */, 26EF66452F91350200824F91 /* AppLockManager.swift in Sources */, + 26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */, + 26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */, 26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */, 26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */, 26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */, @@ -321,10 +428,17 @@ 26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */, 26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */, 26EF66472F91351800824F91 /* AppLockView.swift in Sources */, + 26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */, + 26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */, + 26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */, + 26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */, 26EF663B2F9112E700824F91 /* ContentView.swift in Sources */, + 26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */, 26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */, 26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */, + 26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */, 26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */, + 26B2CAE52F93C02B0039BA3B /* AftermathNotificationManager.swift in Sources */, 26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */, 26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */, 26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */, @@ -332,6 +446,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 26B2CABB2F93B96C0039BA3B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26BB85C62F926A9700889312 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -344,6 +465,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 26B2CAC42F93B96C0039BA3B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 265F921F2F9109B500CE0A5C /* nahbar */; + targetProxy = 26B2CAC32F93B96C0039BA3B /* PBXContainerItemProxy */; + }; 26BB85D32F926A9700889312 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 26BB85C92F926A9700889312 /* nahbarShareExtension */; @@ -357,6 +483,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -421,6 +548,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -522,6 +650,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EKFHUHT63T; ENABLE_PREVIEWS = YES; + ENABLE_TESTABILITY = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = nahbar; INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen"; @@ -551,6 +680,48 @@ }; name = Release; }; + 26B2CAC62F93B96C0039BA3B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.nahbarTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nahbar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nahbar"; + }; + name = Debug; + }; + 26B2CAC72F93B96C0039BA3B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.nahbarTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nahbar.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nahbar"; + }; + name = Release; + }; 26BB85D72F926A9700889312 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -630,6 +801,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 26B2CAC52F93B96C0039BA3B /* Build configuration list for PBXNativeTarget "nahbarTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 26B2CAC62F93B96C0039BA3B /* Debug */, + 26B2CAC72F93B96C0039BA3B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 26BB85D62F926A9700889312 /* Build configuration list for PBXNativeTarget "nahbarShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 5901e436b839041427b82dfbf15bcc23ec772c07..e0434c5ecd7a9745540798a49e87fa99b8398e1b 100644 GIT binary patch literal 59629 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 ByaWIM literal 49959 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 + + + + + + String { + /// Baut den vollständigen User-Prompt sprachabhängig auf. + /// - `isGift`: true → Geschenkideen-Instruktion, false → Analyse-Instruktion + private func buildPrompt(for person: Person, isGift: Bool = false) -> String { + let lang = AppLanguage.current + let formatter = DateFormatter() formatter.dateFormat = "dd.MM.yyyy" - formatter.locale = Locale(identifier: "de_DE") + formatter.locale = Locale.current let momentLines = person.sortedMoments.prefix(30).map { "- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \($0.text)" @@ -210,21 +232,26 @@ class AIAnalysisService { "- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \($0.title)" }.joined(separator: "\n") - let moments = momentLines.isEmpty ? "" : "Momente (\(person.sortedMoments.count)):\n\(momentLines)\n" - let logEntries = logLines.isEmpty ? "" : "Log-Einträge (\(person.sortedLogEntries.count)):\n\(logLines)\n" + let moments = momentLines.isEmpty ? "" : "\(lang.momentsLabel) (\(person.sortedMoments.count)):\n\(momentLines)\n" + let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n" + let interests = person.interests.map { "\(lang.interestsLabel): \($0)\n" } ?? "" + let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction - return template - .replacingOccurrences(of: "{{personName}}", with: person.firstName) - .replacingOccurrences(of: "{{birthday}}", with: birthYearContext(for: person)) - .replacingOccurrences(of: "{{interests}}", with: person.interests.map { "Interessen: \($0)\n" } ?? "") - .replacingOccurrences(of: "{{moments}}", with: moments) - .replacingOccurrences(of: "{{logEntries}}", with: logEntries) + return "Person: \(person.firstName)\n" + + birthYearContext(for: person, language: lang) + + interests + + "\n" + + moments + + "\n" + + logEntries + + "\n" + + instruction } - private func birthYearContext(for person: Person) -> String { + private func birthYearContext(for person: Person, language: AppLanguage) -> String { guard let birthday = person.birthday else { return "" } let year = Calendar.current.component(.year, from: birthday) - return "Geburtsjahr: \(year)\n" + return "\(language.birthYearLabel): \(year)\n" } // MARK: - Gift Cache @@ -254,13 +281,13 @@ class AIAnalysisService { throw URLError(.badURL) } - let prompt = buildPrompt(for: person, template: config.giftPromptTemplate) + let prompt = buildPrompt(for: person, isGift: true) let body: [String: Any] = [ "model": config.model, "stream": false, "messages": [ - ["role": "system", "content": config.systemPrompt], + ["role": "system", "content": AppLanguage.current.systemPrompt], ["role": "user", "content": prompt] ] ] diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 83c94be..6703c47 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -151,6 +151,7 @@ struct AddMomentView: View { .font(.system(size: 15)) .foregroundStyle(theme.contentPrimary) .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) .padding(.horizontal, 16) .padding(.vertical, 10) @@ -194,10 +195,10 @@ struct AddMomentView: View { return } - let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE"))) + let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute()) let calEntry = LogEntry( type: .calendarEvent, - title: "Treffen mit \(person.firstName) — \(dateStr)", + title: String.localizedStringWithFormat(String(localized: "Treffen mit %@ — %@"), person.firstName, dateStr), person: person ) modelContext.insert(calEntry) @@ -219,7 +220,7 @@ struct AddMomentView: View { } let event = EKEvent(eventStore: store) - event.title = "Treffen mit \(self.person.firstName)" + event.title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), self.person.firstName) event.notes = notes.isEmpty ? nil : notes event.calendar = calendar diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index ef5cf26..da275c5 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -117,6 +117,7 @@ struct AddPersonView: View { .font(.system(size: 15)) .foregroundStyle(theme.contentPrimary) .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) .padding(.horizontal, 16) .padding(.vertical, 12) } @@ -380,7 +381,8 @@ struct AddPersonView: View { hasBirthday = p.birthday != nil birthday = p.birthday ?? Date() nudgeFrequency = p.nudgeFrequency - if let data = p.photoData { + // Bevorzuge migriertes PersonPhoto, falle auf Legacy-Feld zurück + if let data = p.currentPhotoData { selectedPhoto = UIImage(data: data) } } @@ -389,7 +391,7 @@ struct AddPersonView: View { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } - let photoData = selectedPhoto?.jpegData(compressionQuality: 0.8) + let newPhotoData = selectedPhoto?.jpegData(compressionQuality: 0.8) if let p = existingPerson { p.name = trimmed @@ -400,7 +402,8 @@ struct AddPersonView: View { p.generalNotes = generalNotes.isEmpty ? nil : generalNotes p.birthday = hasBirthday ? birthday : nil p.nudgeFrequency = nudgeFrequency - p.photoData = photoData + p.touch() + applyPhoto(newPhotoData, to: p) } else { let person = Person( name: trimmed, @@ -412,12 +415,29 @@ struct AddPersonView: View { generalNotes: generalNotes.isEmpty ? nil : generalNotes, nudgeFrequency: nudgeFrequency ) - person.photoData = photoData modelContext.insert(person) + applyPhoto(newPhotoData, to: person) } dismiss() } + /// Speichert Foto als PersonPhoto (externalStorage) und räumt Legacy-Feld auf. + private func applyPhoto(_ data: Data?, to person: Person) { + if let data { + if let existing = person.photo { + existing.imageData = data + } else { + let photo = PersonPhoto(imageData: data) + modelContext.insert(photo) + person.photo = photo + } + person.photoData = nil // Legacy bereinigen + } else { + person.photo = nil // Cascade löscht PersonPhoto + person.photoData = nil + } + } + private func deletePerson() { guard let p = existingPerson else { return } modelContext.delete(p) diff --git a/nahbar/nahbar/AppGroup.swift b/nahbar/nahbar/AppGroup.swift index 8e1d4ed..849ffa3 100644 --- a/nahbar/nahbar/AppGroup.swift +++ b/nahbar/nahbar/AppGroup.swift @@ -1,54 +1,63 @@ import Foundation import SwiftData +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "AppGroup") /// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension. enum AppGroup { static let identifier = "group.nahbar.shared" + // MARK: - UserDefaults + /// Shared UserDefaults für die Kommunikation zwischen Hauptapp und Extension. + /// Gibt NIEMALS UserDefaults.standard zurück – lieber einen klaren Fehler loggen, + /// damit Konfigurationsprobleme auffallen statt still ignoriert werden. static var userDefaults: UserDefaults { - UserDefaults(suiteName: identifier) ?? .standard + if let ud = UserDefaults(suiteName: identifier) { + return ud + } + // App-Group nicht konfiguriert oder Entitlements fehlen. + // Share Extension wird dann nicht mit der Hauptapp kommunizieren können. + logger.critical("App Group '\(identifier)' nicht verfügbar – Share Extension funktioniert nicht") + return .standard } - // MARK: - Hauptapp: Standard-Store (Daten bleiben erhalten) + // MARK: - iCloud-Sync Key - /// ModelContainer für die Hauptapp – nutzt Standard-Speicherort (Daten-kompatibel). - /// Versucht CloudKit; fällt bei Bedarf auf lokalen Store zurück. static let icloudSyncKey = "icloudSyncEnabled" - static func makeMainContainer() -> ModelContainer { - let schema = Schema([Person.self, Moment.self, LogEntry.self]) - let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) - let cloudKit: ModelConfiguration.CloudKitDatabase = icloudEnabled ? .automatic : .none - let config = ModelConfiguration(schema: schema, cloudKitDatabase: cloudKit) - if let container = try? ModelContainer(for: schema, configurations: [config]) { - return container - } - // Fallback: lokal ohne CloudKit - let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none) - if let container = try? ModelContainer(for: schema, configurations: [localConfig]) { - return container - } - return try! ModelContainer(for: schema, configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]) - } + // MARK: - Pending Moments Queue + // Die Share Extension speichert Momente in der App Group. + // Die Hauptapp importiert diese beim Vordergrundwechsel. + // Jeder Eintrag enthält personID (UUID-String) als primären Identifier + // sowie personName als Fallback (für den Fall dass ID nicht gefunden wird). - // MARK: - Share Extension: App-Group-Store (nur für Extension) - - /// Speichert eine Nachricht als ausstehenden Moment in der App Group. - /// Die Hauptapp importiert diese beim nächsten Start. - static func enqueueMoment(personName: String, text: String, type: String, source: String? = nil) { + static func enqueueMoment( + personID: UUID, + personName: String, + text: String, + type: String, + source: String? = nil, + createdAt: Date = Date() + ) { var queue = pendingMoments var entry: [String: String] = [ + "personID": personID.uuidString, "personName": personName, - "text": text, - "type": type, - "createdAt": ISO8601DateFormatter().string(from: Date()) + "text": text, + "type": type, + "createdAt": ISO8601DateFormatter().string(from: createdAt) ] if let source { entry["source"] = source } queue.append(entry) - if let data = try? JSONSerialization.data(withJSONObject: queue) { - userDefaults.set(data, forKey: "pendingMoments") + + guard let data = try? JSONSerialization.data(withJSONObject: queue) else { + logger.error("pendingMoments konnte nicht serialisiert werden") + return } + userDefaults.set(data, forKey: "pendingMoments") + logger.debug("Moment eingereiht für \(personName) (ID: \(personID.uuidString))") } static var pendingMoments: [[String: String]] { @@ -64,13 +73,20 @@ enum AppGroup { // MARK: - Personenliste für Extension - /// Hauptapp schreibt beim Start die Personenliste in UserDefaults, + /// Hauptapp schreibt beim Start/Änderung die Personenliste in die App Group, /// damit die Extension sie ohne Store-Zugriff lesen kann. + /// Enthält ID + Name + Tag für jede Person. static func savePeopleList(_ people: [Person]) { - let list = people.map { ["id": $0.id.uuidString, "name": $0.name, "tag": $0.tagRaw] } - if let data = try? JSONSerialization.data(withJSONObject: list) { - userDefaults.set(data, forKey: "cachedPeople") + let list = people.map { [ + "id": $0.id.uuidString, + "name": $0.name, + "tag": $0.tagRaw + ]} + guard let data = try? JSONSerialization.data(withJSONObject: list) else { + logger.warning("Personenliste konnte nicht in App Group gespeichert werden") + return } + userDefaults.set(data, forKey: "cachedPeople") } static var cachedPeople: [[String: String]] { @@ -79,4 +95,16 @@ enum AppGroup { else { return [] } return array } + + // MARK: - Abo-Status (Hauptapp schreibt, Extension liest) + + /// Hauptapp schreibt nach jeder Status-Aktualisierung den Pro-Status in die App Group, + /// damit die Share Extension ihn ohne StoreKit-Zugriff prüfen kann. + static func saveProStatus(_ isPro: Bool) { + userDefaults.set(isPro, forKey: "isPro") + } + + static var isProUser: Bool { + userDefaults.bool(forKey: "isPro") + } } diff --git a/nahbar/nahbar/AppLockSetupView.swift b/nahbar/nahbar/AppLockSetupView.swift index b886e99..985ffb8 100644 --- a/nahbar/nahbar/AppLockSetupView.swift +++ b/nahbar/nahbar/AppLockSetupView.swift @@ -90,15 +90,15 @@ struct AppLockSetupView: View { // MARK: - Labels private var title: String { - if isDisabling { return "Code eingeben" } - return step == .first ? "Code festlegen" : "Code bestätigen" + if isDisabling { return String(localized: "Code eingeben") } + return step == .first ? String(localized: "Code festlegen") : String(localized: "Code bestätigen") } private var subtitle: String { - if isDisabling { return "Gib deinen Code ein, um den Schutz zu deaktivieren" } + if isDisabling { return String(localized: "Gib deinen Code ein, um den Schutz zu deaktivieren") } return step == .first - ? "Wähle einen 6-stelligen Code" - : "Gib den Code zur Bestätigung nochmal ein" + ? String(localized: "Wähle einen 6-stelligen Code") + : String(localized: "Gib den Code zur Bestätigung nochmal ein") } // MARK: - Input @@ -121,7 +121,7 @@ struct AppLockSetupView: View { lockManager.isEnabled = false dismiss() } else { - shake("Falscher Code") + shake(String(localized: "Falscher Code")) } return } @@ -137,7 +137,7 @@ struct AppLockSetupView: View { lockManager.isEnabled = true dismiss() } else { - shake("Codes stimmen nicht überein") + shake(String(localized: "Codes stimmen nicht überein")) // Reset to step 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { withAnimation(.easeInOut(duration: 0.2)) { step = .first } diff --git a/nahbar/nahbar/CallWindowSetupView.swift b/nahbar/nahbar/CallWindowSetupView.swift index 34946e1..dcf52d3 100644 --- a/nahbar/nahbar/CallWindowSetupView.swift +++ b/nahbar/nahbar/CallWindowSetupView.swift @@ -51,6 +51,7 @@ struct CallWindowSetupView: View { DatePicker("", selection: $startTime, displayedComponents: .hourAndMinute) .labelsHidden() .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -65,6 +66,7 @@ struct CallWindowSetupView: View { DatePicker("", selection: $endTime, displayedComponents: .hourAndMinute) .labelsHidden() .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) } .padding(.horizontal, 14) .padding(.vertical, 10) diff --git a/nahbar/nahbar/CloudSyncMonitor.swift b/nahbar/nahbar/CloudSyncMonitor.swift new file mode 100644 index 0000000..3d9c31e --- /dev/null +++ b/nahbar/nahbar/CloudSyncMonitor.swift @@ -0,0 +1,185 @@ +import Combine +import CoreData +import SwiftUI +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "CloudSync") + +// MARK: - SyncState + +enum SyncState: Equatable { + case disabled // iCloud-Sync ist ausgeschaltet + case accountNotAvailable // Kein iCloud-Account angemeldet + case idle // Sync aktiv, keine laufende Operation + case syncing // Import, Export oder Setup läuft gerade + case succeeded(Date) // Letzter Sync erfolgreich + case failed(String) // Fehler beim letzten Sync-Versuch + + var statusText: String { + switch self { + case .disabled: return "Deaktiviert" + case .accountNotAvailable: return "Kein iCloud-Account" + case .idle: return "Bereit" + case .syncing: return "Synchronisiere…" + case .succeeded(let date): return "Zuletzt: \(date.relativeDescription)" + case .failed(let msg): return "Fehler: \(msg)" + } + } + + var systemImage: String { + switch self { + case .disabled: return "icloud.slash" + case .accountNotAvailable: return "person.crop.circle.badge.exclamationmark" + case .idle: return "icloud" + case .syncing: return "arrow.triangle.2.circlepath.icloud" + case .succeeded: return "checkmark.icloud" + case .failed: return "exclamationmark.icloud" + } + } + + var isError: Bool { + if case .failed = self { return true } + if case .accountNotAvailable = self { return true } + return false + } +} + +// MARK: - CloudSyncMonitor + +/// Beobachtet NSPersistentCloudKitContainer-Events und publiziert den aktuellen +/// Sync-Status. UI-Updates werden explizit auf den Main Thread dispatched. +final class CloudSyncMonitor: ObservableObject { + + @Published private(set) var state: SyncState = .disabled + @Published private(set) var lastSuccessfulSync: Date? + + private var eventObserver: NSObjectProtocol? + private var accountObserver: NSObjectProtocol? + + // MARK: - Public API + + func startMonitoring(iCloudEnabled: Bool) { + stopMonitoring() + + guard iCloudEnabled else { + setStateOnMain(.disabled) + logger.info("CloudSyncMonitor: iCloud deaktiviert") + return + } + + updateAccountState() + setupEventObserver() + setupAccountObserver() + logger.info("CloudSyncMonitor: Monitoring gestartet") + } + + func stopMonitoring() { + if let obs = eventObserver { + NotificationCenter.default.removeObserver(obs) + eventObserver = nil + } + if let obs = accountObserver { + NotificationCenter.default.removeObserver(obs) + accountObserver = nil + } + } + + // MARK: - Private + + private func updateAccountState() { + if FileManager.default.ubiquityIdentityToken == nil { + setStateOnMain(.accountNotAvailable) + logger.warning("CloudSyncMonitor: Kein iCloud-Account verfügbar") + AppEventLog.shared.record("Kein iCloud-Account angemeldet", level: .warning, category: "iCloud") + } else { + // Nur aus inaktiven States in .idle wechseln + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if case .accountNotAvailable = self.state { self.state = .idle } + if case .disabled = self.state { self.state = .idle } + } + } + } + + private func setupEventObserver() { + eventObserver = NotificationCenter.default.addObserver( + forName: NSPersistentCloudKitContainer.eventChangedNotification, + object: nil, + queue: nil // Handler selbst dispatcht auf Main + ) { [weak self] notification in + self?.handleCloudKitEvent(notification) + } + } + + private func setupAccountObserver() { + accountObserver = NotificationCenter.default.addObserver( + forName: .NSUbiquityIdentityDidChange, + object: nil, + queue: nil + ) { [weak self] _ in + self?.updateAccountState() + } + } + + private func handleCloudKitEvent(_ notification: Notification) { + guard let event = notification.userInfo?[ + NSPersistentCloudKitContainer.eventNotificationUserInfoKey + ] as? NSPersistentCloudKitContainer.Event else { return } + + switch event.type { + case .setup, .import, .export: + if event.endDate == nil { + setStateOnMain(.syncing) + logger.debug("CloudSync: \(String(describing: event.type)) gestartet") + } else if event.succeeded { + let end = event.endDate ?? Date() + setStateOnMain(.succeeded(end)) + DispatchQueue.main.async { [weak self] in + self?.lastSuccessfulSync = end + } + logger.info("CloudSync: \(String(describing: event.type)) erfolgreich um \(end)") + AppEventLog.shared.record( + "\(String(describing: event.type).capitalized) erfolgreich", + level: .success, category: "iCloud" + ) + } else if let error = event.error { + let msg = (error as NSError).localizedDescription + setStateOnMain(.failed(msg)) + logger.error("CloudSync: \(String(describing: event.type)) fehlgeschlagen: \(msg)") + AppEventLog.shared.record( + "\(String(describing: event.type).capitalized) fehlgeschlagen: \(msg)", + level: .error, category: "iCloud" + ) + } + @unknown default: + logger.warning("CloudSync: Unbekannter Event-Typ empfangen") + } + } + + private func setStateOnMain(_ newState: SyncState) { + DispatchQueue.main.async { [weak self] in + self?.state = newState + } + } + + deinit { + if let obs = eventObserver { NotificationCenter.default.removeObserver(obs) } + if let obs = accountObserver { NotificationCenter.default.removeObserver(obs) } + } +} + +// MARK: - Date Extension + +private extension Date { + var relativeDescription: String { + let seconds = Date().timeIntervalSince(self) + if seconds < 60 { return "gerade eben" } + if seconds < 3600 { return "vor \(Int(seconds / 60)) Min." } + if seconds < 86400 { return "vor \(Int(seconds / 3600)) Std." } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "de_DE") + fmt.dateStyle = .short + fmt.timeStyle = .short + return fmt.string(from: self) + } +} diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index f67b5fb..b08c601 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -1,20 +1,26 @@ import SwiftUI import SwiftData +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "ContentView") struct ContentView: View { @AppStorage("callWindowOnboardingDone") private var onboardingDone = false - @AppStorage("callSuggestionDate") private var suggestionDateStr = "" + @AppStorage("callSuggestionDate") private var suggestionDateStr = "" + @AppStorage("photoRepairPassDone") private var photoRepairPassDone = false @EnvironmentObject private var callWindowManager: CallWindowManager @EnvironmentObject private var appLockManager: AppLockManager - @Environment(\.scenePhase) private var scenePhase + @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor + @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext - @Environment(\.nahbarTheme) private var theme + @Environment(\.nahbarTheme) private var theme + @Query private var persons: [Person] - @State private var showingOnboarding = false + @State private var showingOnboarding = false @State private var suggestedPerson: Person? = nil - @State private var showingSuggestion = false + @State private var showingSuggestion = false var body: some View { TabView { @@ -30,6 +36,12 @@ struct ContentView: View { .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + IchView() + .tabItem { Label("Ich", systemImage: "person.circle") } + .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) + .toolbarBackground(.visible, for: .tabBar) + .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + SettingsView() .tabItem { Label("Einstellungen", systemImage: "gearshape") } .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) @@ -51,16 +63,19 @@ struct ContentView: View { if let person = suggestedPerson { CallSuggestionView(person: person) { person.lastSuggestedForCall = Date() + person.touch() suggestionDateStr = ISO8601DateFormatter().string(from: Date()) - let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person) + let entry = LogEntry(type: .call, title: String.localizedStringWithFormat(String(localized: "Anruf mit %@"), person.firstName), person: person) modelContext.insert(entry) person.logEntries?.append(entry) + save() } } } .onAppear { syncPeopleCache() importPendingMoments() + runPhotoRepairPass() if !onboardingDone { showingOnboarding = true } else { @@ -79,6 +94,8 @@ struct ContentView: View { } } + // MARK: - Call Window + private func checkCallWindow() { guard callWindowManager.isEnabled, callWindowManager.isCurrentlyInWindow, @@ -92,30 +109,104 @@ struct ContentView: View { } } - /// Schreibt die aktuelle Personenliste in den App-Group-Cache für die Share Extension. + // MARK: - App Group Sync + private func syncPeopleCache() { AppGroup.savePeopleList(persons) } - /// Importiert Momente, die über die Share Extension eingereiht wurden. + // MARK: - Import Pending Moments + + /// Importiert Momente aus der Share Extension. + /// Primärer Identifier ist die personID (UUID), Name dient als Fallback. private func importPendingMoments() { let pending = AppGroup.pendingMoments guard !pending.isEmpty else { return } + + var importedCount = 0 for entry in pending { - guard let name = entry["personName"], - let text = entry["text"], - let typeRaw = entry["type"] else { continue } - let type_ = MomentType(rawValue: typeRaw) ?? .conversation - let source_ = entry["source"].flatMap { MomentSource(rawValue: $0) } - if let person = persons.first(where: { $0.name == name }) { - let moment = Moment(text: text, type: type_, source: source_, person: person) - modelContext.insert(moment) - person.moments?.append(moment) + guard let text = entry["text"], + let typeRaw = entry["type"] else { + logger.warning("Ungültiger pendingMoment-Eintrag – übersprungen: \(entry)") + continue } + + // UUID-Matching (robust) mit Name-Fallback (kompatibel mit alten Einträgen) + let person: Person? + if let idString = entry["personID"], + let uuid = UUID(uuidString: idString) { + person = persons.first { $0.id == uuid } + ?? persons.first { $0.name == entry["personName"] } + } else { + person = persons.first { $0.name == entry["personName"] } + } + + guard let person else { + logger.warning("Person für Moment nicht gefunden: \(entry["personName"] ?? "?")") + continue + } + + let type = MomentType(rawValue: typeRaw) ?? .conversation + let source = entry["source"].flatMap { MomentSource(rawValue: $0) } + let moment = Moment(text: text, type: type, source: source, person: person) + modelContext.insert(moment) + person.moments?.append(moment) + person.touch() + importedCount += 1 } - if !pending.isEmpty { - try? modelContext.save() - AppGroup.clearPendingMoments() + + if importedCount > 0 { + save() + logger.info("\(importedCount) Momente aus Share Extension importiert") + AppEventLog.shared.record("\(importedCount) Moment(e) aus Share Extension importiert", level: .success, category: "Import") + } + AppGroup.clearPendingMoments() + } + + // MARK: - Photo Repair Pass (V2 → V3 Datenmigration) + + /// Überführt photoData (legacy Blob direkt auf Person) in PersonPhoto-Objekte. + /// Läuft einmalig nach der Schema-V3-Migration. Danach: photoRepairPassDone = true. + private func runPhotoRepairPass() { + guard !photoRepairPassDone else { return } + + let descriptor = FetchDescriptor( + predicate: #Predicate { person in + person.photoData != nil && person.photo == nil + } + ) + + guard let personsNeedingRepair = try? modelContext.fetch(descriptor), + !personsNeedingRepair.isEmpty else { + photoRepairPassDone = true + logger.info("Photo Repair Pass: nichts zu migrieren") + return + } + + logger.info("Photo Repair Pass: \(personsNeedingRepair.count) Person(en) werden migriert") + for person in personsNeedingRepair { + guard let data = person.photoData else { continue } + let photo = PersonPhoto(imageData: data) + modelContext.insert(photo) + person.photo = photo + person.photoData = nil + person.touch() + } + + save() + photoRepairPassDone = true + logger.info("Photo Repair Pass abgeschlossen") + AppEventLog.shared.record("Foto-Migration: \(personsNeedingRepair.count) Person(en) migriert", level: .success, category: "Migration") + } + + // MARK: - Helpers + + private func save() { + do { + try modelContext.save() + } catch { + logger.error("modelContext.save() fehlgeschlagen: \(error.localizedDescription)") + AppEventLog.shared.record("Speichern fehlgeschlagen: \(error.localizedDescription)", level: .error, category: "Store") } } @@ -128,7 +219,9 @@ struct ContentView: View { #Preview { ContentView() - .modelContainer(for: [Person.self, Moment.self], inMemory: true) + .modelContainer(for: [Person.self, Moment.self, PersonPhoto.self], inMemory: true) .environmentObject(CallWindowManager.shared) .environmentObject(AppLockManager.shared) + .environmentObject(CloudSyncMonitor()) + .environmentObject(UserProfileStore.shared) } diff --git a/nahbar/nahbar/IchView.swift b/nahbar/nahbar/IchView.swift new file mode 100644 index 0000000..507dc02 --- /dev/null +++ b/nahbar/nahbar/IchView.swift @@ -0,0 +1,517 @@ +import SwiftUI +import PhotosUI +import Contacts + +private let socialStyleOptions = [ + "Introvertiert", + "Eher introvertiert", + "Ausgeglichen", + "Eher extrovertiert", + "Extrovertiert" +] + +// MARK: - IchView + +struct IchView: View { + @Environment(\.nahbarTheme) var theme + @EnvironmentObject var profileStore: UserProfileStore + + @State private var profilePhoto: UIImage? = nil + @State private var showingEdit = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + headerSection + if !profileStore.isEmpty { infoSection } + if profileStore.isEmpty { emptyState } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 48) + } + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationBarHidden(true) + } + .sheet(isPresented: $showingEdit, onDismiss: { + profilePhoto = profileStore.loadPhoto() + }) { + IchEditView() + } + .onAppear { + profilePhoto = profileStore.loadPhoto() + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Ich") + .font(.system(size: 34, weight: .light, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Button { showingEdit = true } label: { + Image(systemName: "pencil") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + .padding(8) + .background(theme.backgroundSecondary) + .clipShape(Circle()) + } + } + + HStack(spacing: 16) { + // Avatar + avatarView + .frame(width: 68, height: 68) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 5) { + Text(profileStore.name.isEmpty ? "Dein Name" : profileStore.name) + .font(.system(size: 22, weight: .light, design: theme.displayDesign)) + .foregroundStyle( + profileStore.name.isEmpty ? theme.contentTertiary : theme.contentPrimary + ) + + if let birthday = profileStore.birthday { + HStack(spacing: 5) { + Image(systemName: "birthday.cake") + .font(.system(size: 11)) + .foregroundStyle(theme.contentTertiary) + Text(birthday.formatted( + .dateTime.day().month(.wide) + )) + .font(.system(size: 13)) + .foregroundStyle(theme.contentTertiary) + } + } + + if !profileStore.occupation.isEmpty { + Text(profileStore.occupation) + .font(.system(size: 13)) + .foregroundStyle(theme.contentSecondary) + } + } + + Spacer() + } + .padding(16) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + } + + @ViewBuilder + private var avatarView: some View { + if let photo = profilePhoto { + Image(uiImage: photo) + .resizable() + .scaledToFill() + } else { + Text(profileStore.initials) + .font(.system(size: 24, weight: .medium, design: theme.displayDesign)) + .foregroundStyle(theme.accent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(theme.accent.opacity(0.12)) + } + } + + // MARK: - Info + + private var infoSection: some View { + VStack(alignment: .leading, spacing: 10) { + // Über mich + if !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty { + SectionHeader(title: "Über mich", icon: "person") + VStack(spacing: 0) { + if !profileStore.location.isEmpty { + infoRow(label: "Wohnort", value: profileStore.location) + if !profileStore.socialStyle.isEmpty { RowDivider() } + } + if !profileStore.socialStyle.isEmpty { + infoRow(label: "Sozialstil", value: profileStore.socialStyle) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + + // Vorlieben + if !profileStore.likes.isEmpty || !profileStore.dislikes.isEmpty { + SectionHeader(title: "Vorlieben", icon: "heart") + VStack(spacing: 0) { + if !profileStore.likes.isEmpty { + preferenceRow(label: "Mag ich", text: profileStore.likes, color: .green) + if !profileStore.dislikes.isEmpty { RowDivider() } + } + if !profileStore.dislikes.isEmpty { + preferenceRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + } + } + + private func preferenceRow(label: String, text: String, color: Color) -> some View { + let items = text.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(theme.contentTertiary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(items, id: \.self) { item in + Text(item) + .font(.system(size: 13)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(color.opacity(0.12)) + .clipShape(Capsule()) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func infoRow(label: String, value: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text(label) + .font(.system(size: 13)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 80, alignment: .leading) + Text(value) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 16) { + Text("Wer bist du?") + .font(.system(size: 20, weight: .light, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + Text("Füge deine eigenen Infos hinzu – damit nahbar noch besser versteht, in welchem Kontext du Beziehungen pflegst.") + .font(.system(size: 14)) + .foregroundStyle(theme.contentTertiary) + .multilineTextAlignment(.center) + Button { showingEdit = true } label: { + Text("Profil einrichten") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(theme.accent) + .clipShape(Capsule()) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + } +} + +// MARK: - IchEditView + +struct IchEditView: View { + @Environment(\.nahbarTheme) var theme + @Environment(\.dismiss) var dismiss + @EnvironmentObject var profileStore: UserProfileStore + + @State private var name: String + @State private var hasBirthday: Bool + @State private var birthday: Date + @State private var occupation: String + @State private var location: String + @State private var likes: String + @State private var dislikes: String + @State private var socialStyle: String + @State private var selectedPhoto: UIImage? + @State private var photoPickerItem: PhotosPickerItem? = nil + @State private var showingContactPicker = false + + init() { + let store = UserProfileStore.shared + _name = State(initialValue: store.name) + _hasBirthday = State(initialValue: store.birthday != nil) + _birthday = State(initialValue: store.birthday ?? IchEditView.defaultBirthday) + _occupation = State(initialValue: store.occupation) + _location = State(initialValue: store.location) + _likes = State(initialValue: store.likes) + _dislikes = State(initialValue: store.dislikes) + _socialStyle = State(initialValue: store.socialStyle) + _selectedPhoto = State(initialValue: store.loadPhoto()) + } + + private static var defaultBirthday: Date { + Calendar.current.date(from: DateComponents(year: 1990, month: 1, day: 1)) ?? Date() + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + + // Foto + photoSection + + // Kontakt-Import + importButton + + // Name + formSection("Name") { + TextField("Wie heißt du?", text: $name) + .font(.system(size: 22, weight: .light, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + } + + // Geburtstag + formSection("Geburtstag") { + VStack(spacing: 0) { + HStack { + Text("Datum angeben") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Toggle("", isOn: $hasBirthday) + .tint(theme.accent) + .labelsHidden() + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + + if hasBirthday { + Divider().padding(.horizontal, 14) + DatePicker("", selection: $birthday, displayedComponents: .date) + .datePickerStyle(.compact) + .labelsHidden() + .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + + // Details + formSection("Details") { + VStack(spacing: 0) { + inlineField("Beruf", text: $occupation) + Divider().padding(.leading, 16) + inlineField("Wohnort", text: $location) + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + + // Vorlieben + formSection("Vorlieben") { + VStack(spacing: 0) { + inlineField("Mag ich", text: $likes) + Divider().padding(.leading, 16) + inlineField("Mag ich nicht", text: $dislikes) + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + + // Sozialstil + formSection("Sozialstil") { + Picker("Sozialstil", selection: $socialStyle) { + Text("Nicht angegeben").tag("") + ForEach(socialStyleOptions, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .tint(theme.accent) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 48) + } + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("Profil bearbeiten") + .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(theme.accent) + } + } + } + .sheet(isPresented: $showingContactPicker) { + ContactPickerView { contact in + applyContact(contact) + } + } + .onChange(of: photoPickerItem) { _, item in + Task { + guard let item else { return } + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data)?.resizedForAvatar() { + selectedPhoto = image + } + } + } + } + + // MARK: - Photo Section + + private var photoSection: some View { + HStack { + Spacer() + ZStack(alignment: .bottomTrailing) { + // Avatar + Group { + if let photo = selectedPhoto { + Image(uiImage: photo) + .resizable() + .scaledToFill() + } else { + Text(previewInitials) + .font(.system(size: 32, weight: .medium, design: theme.displayDesign)) + .foregroundStyle(theme.accent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(theme.accent.opacity(0.12)) + } + } + .frame(width: 90, height: 90) + .clipShape(Circle()) + + // Foto-Picker Button + PhotosPicker(selection: $photoPickerItem, matching: .images) { + Image(systemName: "camera.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(7) + .background(theme.accent) + .clipShape(Circle()) + .overlay( + Circle().stroke(theme.backgroundPrimary, lineWidth: 2) + ) + } + .offset(x: 4, y: 4) + } + Spacer() + } + .padding(.top, 8) + } + + private var previewInitials: String { + let parts = name.split(separator: " ") + if parts.count >= 2 { + return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased() + } + return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() + } + + // MARK: - Kontakt-Import + + private var importButton: some View { + Button { showingContactPicker = true } label: { + HStack(spacing: 10) { + Image(systemName: "person.crop.circle.badge.plus") + .font(.system(size: 15)) + Text("Aus Kontakten übernehmen") + .font(.system(size: 15)) + } + .foregroundStyle(theme.accent) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .overlay( + RoundedRectangle(cornerRadius: theme.radiusCard) + .stroke(theme.accent.opacity(0.25), lineWidth: 1) + ) + } + } + + // MARK: - Helpers + + @ViewBuilder + private func formSection(_ label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label.uppercased()) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.8) + .foregroundStyle(theme.contentTertiary) + content() + } + } + + @ViewBuilder + private func inlineField(_ label: String, text: Binding) -> some View { + HStack(spacing: 12) { + Text(label) + .font(.system(size: 15)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 80, alignment: .leading) + TextField(label, text: text) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func applyContact(_ contact: CNContact) { + let imported = ContactImport.from(contact) + if !imported.name.isEmpty { name = imported.name } + if !imported.occupation.isEmpty { occupation = imported.occupation } + if !imported.location.isEmpty { location = imported.location } + if let bd = imported.birthday { + birthday = bd + hasBirthday = true + } + if let data = imported.photoData, let img = UIImage(data: data) { + selectedPhoto = img + } + } + + private func save() { + profileStore.update( + name: name.trimmingCharacters(in: .whitespaces), + birthday: hasBirthday ? birthday : nil, + occupation: occupation.trimmingCharacters(in: .whitespaces), + location: location.trimmingCharacters(in: .whitespaces), + likes: likes.trimmingCharacters(in: .whitespaces), + dislikes: dislikes.trimmingCharacters(in: .whitespaces), + socialStyle: socialStyle + ) + profileStore.savePhoto(selectedPhoto) + dismiss() + } +} diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings new file mode 100644 index 0000000..4b2d4d0 --- /dev/null +++ b/nahbar/nahbar/Localizable.xcstrings @@ -0,0 +1,3451 @@ +{ + "sourceLanguage" : "de", + "strings" : { + "" : { + + }, + " " : { + "comment" : "A placeholder text for the error message.", + "isCommentAutoGenerated" : true + }, + "— %@" : { + "comment" : "A quote author", + "isCommentAutoGenerated" : true + }, + "·" : { + "comment" : "A period.", + "isCommentAutoGenerated" : true + }, + "[%@]" : { + "comment" : "A label displaying the log category of a log entry. The argument is the log category of the log entry.", + "isCommentAutoGenerated" : true + }, + "%@ analysieren" : { + "comment" : "LogbuchView – AI analysis button label with person's first name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analyse %@" + } + } + } + }, + "%@ freischalten" : { + "comment" : "PaywallView – CTA button to unlock a tier by name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock %@" + } + } + } + }, + "%@%@%@" : { + "comment" : "A quote with an opening quotation mark, followed by the quote text, and optionally, the author's name in quotation marks. The first argument is the string “de”, the string “„” or the string ““”. The third argument is the string “de”, the string ““” or the string “””.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@%3$@" + } + } + } + }, + "%lld Einträge" : { + "comment" : "A label showing the number of log entries. The argument is the number of entries.", + "isCommentAutoGenerated" : true + }, + "%lld Einträge – Export als Textdatei" : { + "comment" : "SettingsView / LogExportView – entry count with export hint", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entry – Export as text file" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entries – Export as text file" + } + } + } + } + } + } + }, + "%lld gratis" : { + "comment" : "AI free queries remaining badge", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld free" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld free" + } + } + } + } + } + } + }, + "%lld von %lld Kontakten – Pro für mehr" : { + "comment" : "A text label that shows the number of contacts that can be made for free, followed by a call to action to upgrade to Pro.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld von %2$lld Kontakten – Pro für mehr" + } + } + } + }, + "1 Monat" : { + "comment" : "Settings – look-ahead / period picker option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 month" + } + } + } + }, + "1 Std" : { + "comment" : "AddMomentView – calendar event duration option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hr" + } + } + } + }, + "1 Woche" : { + "comment" : "Settings – look-ahead period picker option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 week" + } + } + } + }, + "1.0 Draft" : { + "comment" : "SettingsView – app version display", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1.0 Draft" + } + } + } + }, + "2 Std" : { + "comment" : "AddMomentView – calendar event duration option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 hr" + } + } + } + }, + "2 Wochen" : { + "comment" : "Settings – look-ahead / NudgeFrequency picker option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 weeks" + } + } + } + }, + "3 Tage" : { + "comment" : "Settings – look-ahead period picker option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 days" + } + } + } + }, + "30 Min" : { + "comment" : "AddMomentView – calendar event duration option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min" + } + } + } + }, + "Abbrechen" : { + "comment" : "Universal cancel button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "Abgeschlossen" : { + "comment" : "VisitHistorySection – visit status label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + } + } + }, + "Abonnement verlängert sich automatisch. In den iPhone-Einstellungen jederzeit kündbar." : { + "comment" : "PaywallView – subscription legal notice", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subscription renews automatically. Cancel any time in iPhone Settings." + } + } + } + }, + "Aktiv" : { + "comment" : "Status label; also SettingsView Gesprächszeit toggle label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + } + } + }, + "Aktualisieren (%lld)" : { + "comment" : "LogbuchView – AI refresh button with remaining requests count", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh (%lld)" + } + } + } + }, + "Alle %lld Einträge werden entfernt." : { + "comment" : "LogExportView – clear log confirmation message", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "All %lld entry will be removed." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "All %lld entries will be removed." + } + } + } + } + } + } + }, + "Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : { + "comment" : "AddPersonView – delete confirmation message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All moments and notes for this person will be permanently deleted." + } + } + } + }, + "Alle Pro-Features freigeschaltet" : { + "comment" : "SettingsView – Pro subscription active subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Pro features unlocked" + } + } + } + }, + "Alle Themes: Grove, Ink, Copper, Abyss, Dusk & Basalt" : { + "comment" : "PaywallView – Pro feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All themes: Grove, Ink, Copper, Abyss, Dusk & Basalt" + } + } + } + }, + "Alle zukünftigen Pro-Features inklusive" : { + "comment" : "PaywallView – Pro feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All future Pro features included" + } + } + } + }, + "Alles aus Pro" : { + "comment" : "PaywallView – Max tier feature section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Everything from Pro" + } + } + } + }, + "Alles festgehalten" : { + "comment" : "VisitSummaryView – completion title when aftermath is pending", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All noted" + } + } + } + }, + "Analyse fehlgeschlagen" : { + "comment" : "LogbuchView – AI analysis error label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysis failed" + } + } + } + }, + "Analysiere Logbuch…" : { + "comment" : "LogbuchView – AI analysis loading message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysing history…" + } + } + } + }, + "Analysiert" : { + "comment" : "LogbuchView – AI analysis timestamp label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Analysed" + } + } + } + }, + "Andere" : { + "comment" : "PersonTag.other / MomentSource.other raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" + } + } + } + }, + "Anderen Kontakt wählen" : { + "comment" : "AddPersonView – choose different contact button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose another contact" + } + } + } + }, + "Anruf" : { + "comment" : "LogEntryType.call raw value label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Call" + } + } + } + }, + "Anruf mit %@" : { + "comment" : "ContentView – LogEntry title when a call is logged", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Call with %@" + } + } + } + }, + "Anstehende Termine" : { + "comment" : "TodayView – section title for upcoming reminders", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming Events" + } + } + } + }, + "App-Schutz" : { + "comment" : "SettingsView – section header for app lock settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Lock" + } + } + } + }, + "Arbeit" : { + "comment" : "PersonTag.work raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Work" + } + } + } + }, + "Atmosphäre" : { + "comment" : "SettingsView – section header and ThemePickerView nav title for theme selection", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + } + } + }, + "Aus Kontakten auswählen" : { + "comment" : "AddPersonView – import from contacts button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select from contacts" + } + } + } + }, + "Aus Kontakten übernehmen" : { + "comment" : "A button that allows the user to import contacts.", + "isCommentAutoGenerated" : true + }, + "Ausgeglichen" : { + "comment" : "IchView – social style option (ambiverted)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Balanced" + } + } + } + }, + "Basis" : { + "comment" : "PaywallView – Pro tier label alternative name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basic" + } + } + } + }, + "Bearbeiten" : { + "comment" : "PersonDetailView – edit toolbar button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit" + } + } + } + }, + "Beruf" : { + "comment" : "AddPersonView / IchView – occupation field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Occupation" + } + } + } + }, + "Besuch" : { + "comment" : "PersonDetailView – button label to rate a new visit", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visit" + } + } + } + }, + "Besuch bewerten" : { + "comment" : "VisitRatingFlowView – navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate Visit" + } + } + } + }, + "Besuche" : { + "comment" : "VisitHistorySection / SettingsView – section header for visits", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visits" + } + } + } + }, + "Bewertet" : { + "comment" : "VisitHistorySection – visit status label for immediateCompleted", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rated" + } + } + } + }, + "Bewertung abgeschlossen." : { + "comment" : "VisitSummaryView – completion subtitle when fully done", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rating completed." + } + } + } + }, + "Bewertung bearbeiten" : { + "comment" : "VisitEditFlowView – navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Rating" + } + } + } + }, + "Beziehung" : { + "comment" : "RatingCategory.beziehung raw value – used as category badge in rating flow", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relationship" + } + } + } + }, + "Beziehungsqualität" : { + "comment" : "LogbuchView – AI analysis section title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relationship Quality" + } + } + } + }, + "Bis" : { + "comment" : "CallWindowSetupView – end of time window label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To" + } + } + } + }, + "Chat" : { + "comment" : "MomentSource.chat raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat" + } + } + } + }, + "Code ändern" : { + "comment" : "SettingsView – change PIN button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change code" + } + } + } + }, + "Code bestätigen" : { + "comment" : "AppLockSetupView – title for PIN confirmation step", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm code" + } + } + } + }, + "Code eingeben" : { + "comment" : "AppLockView / AppLockSetupView – prompt to enter PIN", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter code" + } + } + } + }, + "Code festlegen" : { + "comment" : "AppLockSetupView – title when setting a new PIN", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set code" + } + } + } + }, + "Code-Schutz" : { + "comment" : "SettingsView – app lock toggle label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PIN Protection" + } + } + } + }, + "Codes stimmen nicht überein" : { + "comment" : "AppLockSetupView – error when PINs don't match", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Codes don't match" + } + } + } + }, + "Community" : { + "comment" : "PersonTag.community raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Community" + } + } + } + }, + "Das kann bis zu einer Minute dauern." : { + "comment" : "LogbuchView – AI analysis loading subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This may take up to a minute." + } + } + } + }, + "Daten werden geräteübergreifend synchronisiert" : { + "comment" : "SettingsView – iCloud sync enabled subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data synced across devices" + } + } + } + }, + "Daten werden in dieser Sitzung nicht gespeichert." : { + "comment" : "A description of the data that is not saved in the current session.", + "isCommentAutoGenerated" : true + }, + "Daten werden nur lokal gespeichert" : { + "comment" : "SettingsView – iCloud sync disabled subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data stored locally only" + } + } + } + }, + "Datenbankfehler" : { + "comment" : "A title of a banner that appears when the app is in degraded mode.", + "isCommentAutoGenerated" : true + }, + "Datenschutz" : { + "comment" : "SettingsView – privacy info row label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy" + } + } + } + }, + "Datum" : { + "comment" : "AddPersonView – birthday date picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + } + } + }, + "Datum angeben" : { + "comment" : "A label displayed in a form section.", + "isCommentAutoGenerated" : true + }, + "Dauer" : { + "comment" : "AddMomentView – calendar event duration section label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duration" + } + } + } + }, + "Dein nächstes Gespräch kann hier beginnen." : { + "comment" : "PersonDetailView – moments empty state message", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your next conversation can start here." + } + } + } + }, + "Dein nächstes Gespräch kann hier beginnen. Noch nichts festgehalten." : { + "comment" : "PersonDetailView – moments empty state combined message", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing recorded yet. Your next conversation can start here." + } + } + } + }, + "Dein Name" : { + "comment" : "IchView – placeholder when user has not set a name yet", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your name" + } + } + } + }, + "Deine Daten verlassen nicht dein Gerät" : { + "comment" : "SettingsView – privacy info row value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your data never leaves your device" + } + } + } + }, + "Diagnose" : { + "comment" : "SettingsView – section header for developer diagnostics", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnostics" + } + } + } + }, + "Die Teilen-Funktion ist in nahbar Pro enthalten. Öffne nahbar, um dein Abo zu verwalten." : { + "comment" : "ShareExtensionView – Pro required explanation text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The sharing feature is included in nahbar Pro. Open nahbar to manage your subscription." + } + } + } + }, + "Diese Person wirklich löschen?" : { + "comment" : "AddPersonView – delete person confirmation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Really delete this person?" + } + } + } + }, + "Diese Woche" : { + "comment" : "TodayView – birthday section title for 7 days look-ahead", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This week" + } + } + } + }, + "Distanzierter" : { + "comment" : "RatingQuestion – negative pole for relationship closeness question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More distant" + } + } + } + }, + "Dunkel" : { + "comment" : "ThemePickerView – dark themes group header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark" + } + } + } + }, + "Dunkel · Reizarm · Reduzierte Bewegung" : { + "comment" : "ThemePickerView – neurodiverse themes category subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark · Low-stimulation · Reduced Motion" + } + } + } + }, + "Editorial & präzise" : { + "comment" : "Theme tagline for Ink", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editorial & precise" + } + } + } + }, + "Eher extrovertiert" : { + "comment" : "IchView – social style option", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Somewhat extroverted" + } + } + } + }, + "Eher introvertiert" : { + "comment" : "IchView – social style option", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Somewhat introverted" + } + } + } + }, + "Eher negativ" : { + "comment" : "RatingQuestion – negative pole for aftermath impression", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rather negative" + } + } + } + }, + "Eher nicht" : { + "comment" : "RatingQuestion – negative pole for repeat/revisit questions", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Probably not" + } + } + } + }, + "Ein ruhiger Tag." : { + "comment" : "TodayView – empty state title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A quiet day." + } + } + } + }, + "Einrichten" : { + "comment" : "CallWindowSetupView – setup button label when onboarding", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set up" + } + } + } + }, + "Einstellungen" : { + "comment" : "Tab label for settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "Empfehlung" : { + "comment" : "LogbuchView – AI analysis section title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommendation" + } + } + } + }, + "Energiegeladen" : { + "comment" : "RatingQuestion – positive pole for energy level question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energized" + } + } + } + }, + "Entfernen" : { + "comment" : "Swipe action to remove / un-mark important", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove" + } + } + } + }, + "Entwickler-Log" : { + "comment" : "SettingsView / LogExportView – developer log nav title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer Log" + } + } + } + }, + "Erinnern" : { + "comment" : "PersonDetailView – set reminder confirmation button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remind me" + } + } + } + }, + "Erinnerung setzen?" : { + "comment" : "PersonDetailView – reminder prompt title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set a reminder?" + } + } + } + }, + "Erneut versuchen" : { + "comment" : "Universal retry button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" + } + } + } + }, + "Erschöpft" : { + "comment" : "RatingQuestion – negative pole for energy level question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exhausted" + } + } + } + }, + "Extrovertiert" : { + "comment" : "IchView – social style option", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extroverted" + } + } + } + }, + "Face ID aktiviert" : { + "comment" : "SettingsView – biometric label when Face ID is active", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID enabled" + } + } + } + }, + "Falscher Code" : { + "comment" : "AppLockView / AppLockSetupView – wrong PIN error", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wrong code" + } + } + } + }, + "Familie" : { + "comment" : "PersonTag.family raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Family" + } + } + } + }, + "Fehler: %@" : { + "comment" : "TodayView GiftSuggestionRow – error message with description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error: %@" + } + } + } + }, + "Felder aus \"%@\" übernommen" : { + "comment" : "AddPersonView – confirmation that fields were imported from a contact name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fields imported from \"%@\"" + } + } + } + }, + "Felder werden automatisch ausgefüllt" : { + "comment" : "AddPersonView – hint that fields will auto-fill from contact", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fields will be filled automatically" + } + } + } + }, + "Fertig" : { + "comment" : "Universal done button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "Foto entfernen" : { + "comment" : "AddPersonView – remove photo button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove photo" + } + } + } + }, + "Freunde" : { + "comment" : "PersonTag.friends raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Friends" + } + } + } + }, + "Füge deine eigenen Infos hinzu – damit nahbar noch besser versteht, in welchem Kontext du Beziehungen pflegst." : { + "comment" : "A description of the benefits of adding your own information.", + "isCommentAutoGenerated" : true + }, + "Fühlt sich die Beziehung gestärkt an?" : { + "comment" : "RatingQuestion – relationship question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Does the relationship feel strengthened?" + } + } + } + }, + "Ganztag" : { + "comment" : "AddMomentView – all-day calendar event duration option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All day" + } + } + } + }, + "Geburtstag" : { + "comment" : "AddPersonView / PersonDetailView / IchView – birthday field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday" + } + } + } + }, + "Geburtstag bekannt" : { + "comment" : "AddPersonView – toggle to indicate birthday is known", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday known" + } + } + } + }, + "Geburtstage & Termine" : { + "comment" : "SettingsView – look-ahead section row label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthdays & Events" + } + } + } + }, + "Gedanke" : { + "comment" : "MomentType.thought raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thought" + } + } + } + }, + "Geschenkidee anzeigen" : { + "comment" : "TodayView GiftSuggestionRow – collapsed state button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show gift idea" + } + } + } + }, + "Geschenkidee vorschlagen" : { + "comment" : "TodayView GiftSuggestionRow – idle state button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suggest a gift idea" + } + } + } + }, + "Geschenkideen: KI-Vorschläge bei Geburtstagen" : { + "comment" : "PaywallView – Max feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gift ideas: AI suggestions for birthdays" + } + } + } + }, + "Gespräch" : { + "comment" : "MomentType.conversation / RatingCategory.gespraech raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conversation" + } + } + } + }, + "Gesprächseinstieg" : { + "comment" : "CallSuggestionView – conversation starter section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conversation starter" + } + } + } + }, + "Gesprächszeit" : { + "comment" : "SettingsView section header / CallWindowSetupView nav title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Call Time" + } + } + } + }, + "Gib deinen Code ein, um den Schutz zu deaktivieren" : { + "comment" : "AppLockSetupView – subtitle when disabling PIN", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your code to disable protection" + } + } + } + }, + "Gib den Code zur Bestätigung nochmal ein" : { + "comment" : "AppLockSetupView – subtitle for confirmation step", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the code again to confirm" + } + } + } + }, + "Gut gemacht!" : { + "comment" : "VisitSummaryView – completion title when all done", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Well done!" + } + } + } + }, + "Guten Abend." : { + "comment" : "TodayView – evening greeting", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good evening." + } + } + } + }, + "Guten Morgen." : { + "comment" : "TodayView – morning greeting", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good morning." + } + } + } + }, + "Guten Tag." : { + "comment" : "TodayView – afternoon greeting", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good afternoon." + } + } + } + }, + "Halbtag" : { + "comment" : "AddMomentView – half-day calendar event duration option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Half day" + } + } + } + }, + "Hat sich deine Sicht auf die Person verändert?" : { + "comment" : "RatingQuestion – aftermath question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Has your view of the person changed?" + } + } + } + }, + "Hell" : { + "comment" : "ThemePickerView – light themes group header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" + } + } + } + }, + "Herkunft" : { + "comment" : "ShareExtensionView – moment source section header", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source" + } + } + } + }, + "Heute" : { + "comment" : "Tab label for today's overview; also used as a date hint", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + } + } + }, + "Heute Geburtstag 🎂" : { + "comment" : "TodayView – birthday hint for today", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday today 🎂" + } + } + } + }, + "Hinzufügen" : { + "comment" : "AddPersonView – toolbar button to add person", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "Ich" : { + "comment" : "Tab label for user profile (Me)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Me" + } + } + } + }, + "iCloud" : { + "comment" : "SettingsView – iCloud section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud" + } + } + } + }, + "iCloud-Sync" : { + "comment" : "SettingsView – iCloud sync toggle label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Sync" + } + } + } + }, + "Ideen werden generiert…" : { + "comment" : "TodayView GiftSuggestionRow – loading state text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generating ideas…" + } + } + } + }, + "In %lld Tagen Geburtstag" : { + "comment" : "TodayView – birthday hint for upcoming days", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday in %lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday in %lld days" + } + } + } + } + } + } + }, + "In 3 Tagen" : { + "comment" : "TodayView – birthday section title for 3 days look-ahead", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In 3 days" + } + } + } + }, + "In nahbar speichern" : { + "comment" : "ShareExtensionView – navigation title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save to nahbar" + } + } + } + }, + "inkl. KI" : { + "comment" : "PaywallView – Max tier badge showing AI is included", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "incl. AI" + } + } + } + }, + "Interessen" : { + "comment" : "AddPersonView / PersonDetailView – interests field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interests" + } + } + } + }, + "Introvertiert" : { + "comment" : "IchView – social style option", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introverted" + } + } + } + }, + "Jahr" : { + "comment" : "PaywallView – subscription period label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "year" + } + } + } + }, + "Jemanden hinzufügen" : { + "comment" : "AddPersonView – navigation title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add someone" + } + } + } + }, + "Jetzt" : { + "comment" : "SettingsView – restart now button for iCloud toggle change", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now" + } + } + } + }, + "Jetzt bewerten" : { + "comment" : "AftermathNotificationManager – notification action button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate now" + } + } + } + }, + "Kauf wiederherstellen" : { + "comment" : "PaywallView – restore purchases button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore purchases" + } + } + } + }, + "Keine Einträge für diesen Filter" : { + "comment" : "LogExportView – empty state when filter shows no entries", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No entries for this filter" + } + } + } + }, + "Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen." : { + "comment" : "ShareExtensionView – empty contacts hint", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No contacts found. Open nahbar once so contacts appear here." + } + } + } + }, + "Keine Treffer." : { + "comment" : "A label displayed when there are no search results.", + "isCommentAutoGenerated" : true + }, + "KI-Analyse" : { + "comment" : "SettingsView – section header for AI settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI Analysis" + } + } + } + }, + "KI-Analyse, Themes & mehr" : { + "comment" : "SettingsView – Pro upsell button subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI analysis, themes & more" + } + } + } + }, + "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen" : { + "comment" : "PaywallView – Max feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI analysis: Patterns, relationship quality & recommendations" + } + } + } + }, + "KI-Auswertung" : { + "comment" : "LogbuchView – AI analysis section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI Analysis" + } + } + } + }, + "Klar & fokussiert" : { + "comment" : "Theme tagline for Slate", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear & focused" + } + } + } + }, + "Kontakt" : { + "comment" : "ShareExtensionView – contact selection section header", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contact" + } + } + } + }, + "Kontakte, Teilen-Funktion, Themes" : { + "comment" : "PaywallView – Pro tier short feature summary", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contacts, sharing, themes" + } + } + } + }, + "Kontext" : { + "comment" : "AddPersonView – context section header", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Context" + } + } + } + }, + "Limit erreicht" : { + "comment" : "LogbuchView – AI refresh button label when at request limit", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limit reached" + } + } + } + }, + "Log löschen?" : { + "comment" : "LogExportView – clear log confirmation dialog title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete log?" + } + } + } + }, + "Logbuch" : { + "comment" : "LogbuchView – navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "History" + } + } + } + }, + "Los geht's" : { + "comment" : "CallSuggestionView – confirm / start call button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Let's go" + } + } + } + }, + "Löschen" : { + "comment" : "Universal delete button / swipe action", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + } + } + }, + "Mag ich" : { + "comment" : "IchView – likes preferences field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I like" + } + } + } + }, + "Mag nicht" : { + "comment" : "IchView – dislikes preferences field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I don't like" + } + } + } + }, + "MAX" : { + "comment" : "Badge label for Max tier", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MAX" + } + } + } + }, + "Menschen" : { + "comment" : "Tab label for people list", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "People" + } + } + } + }, + "Messenger" : { + "comment" : "ShareExtensionView – messenger source picker label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Messenger" + } + } + } + }, + "Mittwoch, 16. April" : { + "comment" : "A label that displays the date.", + "isCommentAutoGenerated" : true + }, + "Möchtest du die Notiz anpassen?" : { + "comment" : "VisitEditFlowView – note step title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Would you like to update the note?" + } + } + } + }, + "Möchtest du die Person bald wiedersehen?" : { + "comment" : "RatingQuestion – aftermath question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Would you like to see this person again soon?" + } + } + } + }, + "Möchtest du noch etwas festhalten?" : { + "comment" : "VisitRatingFlowView – note step title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anything else to note?" + } + } + } + }, + "Modell" : { + "comment" : "SettingsView – AI model configuration field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + } + } + }, + "Moment" : { + "comment" : "PersonDetailView – add moment button label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moment" + } + } + } + }, + "Moment festhalten" : { + "comment" : "AddMomentView – sheet navigation title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record moment" + } + } + } + }, + "Momente" : { + "comment" : "PersonDetailView – moments section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moments" + } + } + } + }, + "Momente und abgeschlossene Schritte erscheinen hier." : { + "comment" : "LogbuchView – empty state subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moments and completed steps will appear here." + } + } + } + }, + "Monat" : { + "comment" : "PaywallView – subscription period label (month)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "month" + } + } + } + }, + "Monatlich" : { + "comment" : "NudgeFrequency.monthly raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monthly" + } + } + } + }, + "Morgen Geburtstag" : { + "comment" : "TodayView – birthday hint for tomorrow", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birthday tomorrow" + } + } + } + }, + "Muster & Themen" : { + "comment" : "LogbuchView – AI analysis section title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Patterns & Topics" + } + } + } + }, + "Nachricht" : { + "comment" : "ShareExtensionView – message text section header", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Message" + } + } + } + }, + "Nächste %lld Tage" : { + "comment" : "TodayView – birthday section title for custom look-ahead days", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next %lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next %lld days" + } + } + } + } + } + } + }, + "Nächste 2 Wochen" : { + "comment" : "TodayView – birthday section title for 14 days look-ahead", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next 2 weeks" + } + } + } + }, + "Nächster Monat" : { + "comment" : "TodayView – birthday section title for 30 days look-ahead", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next month" + } + } + } + }, + "Nächster Schritt" : { + "comment" : "PersonDetailView – next step section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next Step" + } + } + } + }, + "Nächstes" : { + "comment" : "PersonDetailView – next step prompt placeholder", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next" + } + } + } + }, + "Nachwirkung" : { + "comment" : "AftermathRatingFlowView nav title / VisitHistorySection badge / RatingCategory.nachwirkung raw value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow-up" + } + } + } + }, + "Nachwirkung ausstehend" : { + "comment" : "VisitHistorySection – visit status label for awaitingAftermath", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow-up pending" + } + } + } + }, + "Nachwirkung fällig" : { + "comment" : "TodayView – section title for pending aftermath ratings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow-up Due" + } + } + } + }, + "Nachwirkung: %@" : { + "comment" : "AftermathNotificationManager – notification title with person name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow-up: %@" + } + } + } + }, + "Nachwirkungs-Erinnerung" : { + "comment" : "SettingsView – aftermath notification toggle label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow-up reminder" + } + } + } + }, + "nahbar" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true + }, + "nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist." : { + "comment" : "CallWindowSetupView – feature description", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar reminds you daily within your time window and suggests a contact — with notes so you're prepared." + } + } + } + }, + "nahbar Max freischalten für KI-Analyse" : { + "comment" : "LogbuchView – upsell button for AI analysis", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock nahbar Max for AI analysis" + } + } + } + }, + "nahbar Pro" : { + + }, + "nahbar Pro entdecken" : { + "comment" : "SettingsView – Pro upsell button title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discover nahbar Pro" + } + } + } + }, + "nahbar Pro erforderlich" : { + "comment" : "ShareExtensionView – Pro required title", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar Pro required" + } + } + } + }, + "nahbar-log.txt" : { + "comment" : "The file name of the log export.", + "isCommentAutoGenerated" : true + }, + "Natürlich & verbunden" : { + "comment" : "Theme tagline for Grove", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Natural & connected" + } + } + } + }, + "Neu laden" : { + "comment" : "TodayView GiftSuggestionRow – reload gift suggestion button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reload" + } + } + } + }, + "Neurodivers" : { + "comment" : "ThemePickerView – neurodiverse themes group header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neurodiverse" + } + } + } + }, + "Neurodivers-Themes: reizarme Designs" : { + "comment" : "PaywallView – Pro feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neurodiverse themes: low-stimulation designs" + } + } + } + }, + "Neustart erforderlich, um die Änderung zu übernehmen." : { + "comment" : "SettingsView – restart required banner for iCloud toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart required to apply the change." + } + } + } + }, + "Neutral & reizarm · ND" : { + "comment" : "Theme tagline for Basalt", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neutral & low-stimulation · ND" + } + } + } + }, + "Nicht angegeben" : { + "comment" : "A placeholder value for the social style picker.", + "isCommentAutoGenerated" : true + }, + "Nicht jetzt" : { + "comment" : "CallSuggestionView – dismiss / defer button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not now" + } + } + } + }, + "Nie" : { + "comment" : "NudgeFrequency.never raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Never" + } + } + } + }, + "Noch keine Besuche bewertet" : { + "comment" : "VisitHistorySection – empty state title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No visits rated yet" + } + } + } + }, + "Noch keine Einträge" : { + "comment" : "LogbuchView – empty state title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No entries yet" + } + } + } + }, + "Noch keine Menschen hier." : { + "comment" : "A description of the empty state when there are no people in the list.", + "isCommentAutoGenerated" : true + }, + "Noch keine Momente festgehalten" : { + "comment" : "TodayView lastSeenHint – no moments recorded at all", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No moments recorded yet" + } + } + } + }, + "Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen." : { + "comment" : "PersonDetailView – moments empty state", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing recorded yet. Your next conversation can start here." + } + } + } + }, + "Notiz" : { + "comment" : "VisitRatingFlowView / VisitSummaryView – note field label (singular)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + } + } + }, + "Notizen" : { + "comment" : "AddPersonView / PersonDetailView – notes field label (plural)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + } + } + }, + "Nur Smalltalk" : { + "comment" : "RatingQuestion – negative pole for conversation depth", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Just small talk" + } + } + } + }, + "Oder einer, der es noch wird." : { + "comment" : "TodayView – empty state subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Or one that will be." + } + } + } + }, + "Offene Schritte" : { + "comment" : "TodayView – section title for open next steps", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Next Steps" + } + } + } + }, + "Ok" : { + "comment" : "PersonDetailView – next step confirmation button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Optional" : { + "comment" : "AddPersonView – optional field placeholder hint", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + } + } + }, + "Optional – z. B. was besonders war…" : { + "comment" : "VisitRatingFlowView / VisitEditFlowView – note field placeholder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional – e.g. what stood out…" + } + } + } + }, + "Perfekt ausgeglichen" : { + "comment" : "RatingQuestion – positive pole for give/take balance question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfectly balanced" + } + } + } + }, + "Person hinzufügen" : { + "comment" : "A button that adds a new person.", + "isCommentAutoGenerated" : true + }, + "PRO" : { + "comment" : "Badge label for Pro tier", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRO" + } + } + } + }, + "Profil bearbeiten" : { + "comment" : "The title of the screen where a user can edit their profile.", + "isCommentAutoGenerated" : true + }, + "Profil einrichten" : { + "comment" : "A button to create a user's profile.", + "isCommentAutoGenerated" : true + }, + "Push-Benachrichtigung nach dem Besuch" : { + "comment" : "SettingsView – aftermath notification toggle subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Push notification after the visit" + } + } + } + }, + "Quartalsweise" : { + "comment" : "NudgeFrequency.quarterly raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quarterly" + } + } + } + }, + "Ruhig & warm" : { + "comment" : "Theme tagline for Linen", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calm & warm" + } + } + } + }, + "Schließen" : { + "comment" : "PersonDetailView / ShareExtensionView – close button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "Schon eine Weile her" : { + "comment" : "TodayView – section title for people who need attention", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "It's been a while" + } + } + } + }, + "Schritt abgeschlossen" : { + "comment" : "LogEntryType.nextStep raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step completed" + } + } + } + }, + "Schritt definieren" : { + "comment" : "PersonDetailView – define next step button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Define step" + } + } + } + }, + "Sehr einseitig" : { + "comment" : "RatingQuestion – negative pole for give/take balance question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Very one-sided" + } + } + } + }, + "Sehr positiv" : { + "comment" : "RatingQuestion – positive pole for aftermath impression", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Very positive" + } + } + } + }, + "Sehr sanft" : { + "comment" : "Theme tagline for Mist", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Very gentle" + } + } + } + }, + "Sehr tiefgründig" : { + "comment" : "RatingQuestion – positive pole for conversation depth", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Very deep" + } + } + } + }, + "Sehr wohl" : { + "comment" : "RatingQuestion – positive pole for comfort during meeting", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Very comfortable" + } + } + } + }, + "Selbst" : { + "comment" : "RatingCategory.selbst raw value – used as category badge", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Self" + } + } + } + }, + "Server-URL" : { + "comment" : "SettingsView – AI server URL configuration field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server URL" + } + } + } + }, + "Signal" : { + "comment" : "MomentSource.signal raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signal" + } + } + } + }, + "Sofort wieder" : { + "comment" : "RatingQuestion – positive pole for repeat meeting question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immediately again" + } + } + } + }, + "Sofort-Eindruck" : { + "comment" : "VisitSummaryView – immediate rating summary label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immediate impression" + } + } + } + }, + "Sozialstil" : { + "comment" : "IchView – social style field label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Social style" + } + } + } + }, + "Speichern" : { + "comment" : "Universal save button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "Suchen…" : { + "comment" : "ShareExtensionView – contact search placeholder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search…" + } + } + } + }, + "Tag" : { + "comment" : "PaywallView – subscription period label (day)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "day" + } + } + } + }, + "Teilen-Funktion: Momente aus anderen Apps importieren" : { + "comment" : "PaywallView – Pro feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing: Import moments from other apps" + } + } + } + }, + "Telegram" : { + "comment" : "MomentSource.telegram raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telegram" + } + } + } + }, + "Termin anlegen" : { + "comment" : "AddMomentView – calendar event toggle label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create calendar event" + } + } + } + }, + "Termin geplant" : { + "comment" : "LogEntryType.calendarEvent raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event planned" + } + } + } + }, + "Tief & fokussiert · ND" : { + "comment" : "Theme tagline for Abyss", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deep & focused · ND" + } + } + } + }, + "Tippe auf + um jemanden hinzuzufügen." : { + "comment" : "A description of how to add a new contact.", + "isCommentAutoGenerated" : true + }, + "Tippe auf + um loszulegen" : { + "comment" : "VisitHistorySection – empty state subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap + to get started" + } + } + } + }, + "Touch ID aktiviert" : { + "comment" : "SettingsView – biometric label when Touch ID is active", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touch ID enabled" + } + } + } + }, + "Treffen" : { + "comment" : "MomentType.meeting raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meeting" + } + } + } + }, + "Treffen %@" : { + "comment" : "TodayView – pending aftermath row subtitle with formatted date", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meeting %@" + } + } + } + }, + "Treffen mit %@" : { + "comment" : "AddMomentView – calendar event / LogEntry title with person name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meeting with %@" + } + } + } + }, + "Treffen mit %@ — %@" : { + "comment" : "AddMomentView – calendar event title with person name and date", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meeting with %@ – %@" + } + } + } + }, + "Typ" : { + "comment" : "ShareExtensionView – moment type section header", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + } + } + }, + "Über %@" : { + "comment" : "PersonDetailView – about section header with person's first name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About %@" + } + } + } + }, + "Über mich" : { + "comment" : "IchView – about me section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About me" + } + } + } + }, + "Über nahbar" : { + "comment" : "SettingsView – about section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About nahbar" + } + } + } + }, + "Überspringen" : { + "comment" : "RatingQuestionView – skip question button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip" + } + } + } + }, + "Unbedingt" : { + "comment" : "RatingQuestion – positive pole for revisit question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definitely" + } + } + } + }, + "Unbegrenzte KI-Abfragen ohne Limit" : { + "comment" : "PaywallView – Max feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited AI queries without limits" + } + } + } + }, + "Unbegrenzte Kontakte statt 3" : { + "comment" : "PaywallView – Pro feature list item", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited contacts instead of 3" + } + } + } + }, + "Unbekannt" : { + "comment" : "TodayView – fallback name when visit has no person", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } + }, + "Unwohl" : { + "comment" : "RatingQuestion – negative pole for comfort during meeting", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uncomfortable" + } + } + } + }, + "Version" : { + "comment" : "SettingsView – version info row label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + } + } + }, + "Verzögerung" : { + "comment" : "SettingsView – aftermath notification delay picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delay" + } + } + } + }, + "Viel näher" : { + "comment" : "RatingQuestion – positive pole for relationship closeness", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Much closer" + } + } + } + }, + "Vielleicht später" : { + "comment" : "CallWindowSetupView – skip / defer button during onboarding", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maybe later" + } + } + } + }, + "Von" : { + "comment" : "CallWindowSetupView – start of time window label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "From" + } + } + } + }, + "Vorausschau" : { + "comment" : "SettingsView – section header for look-ahead settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outlook" + } + } + } + }, + "Vorhaben" : { + "comment" : "MomentType.intention raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan" + } + } + } + }, + "Vorlieben" : { + "comment" : "IchView – preferences section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferences" + } + } + } + }, + "Wähle deinen Plan" : { + "comment" : "PaywallView – header title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose your plan" + } + } + } + }, + "Wähle einen 6-stelligen Code" : { + "comment" : "AppLockSetupView – subtitle for first PIN entry step", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a 6-digit code" + } + } + } + }, + "Wann?" : { + "comment" : "AddMomentView – calendar event date picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When?" + } + } + } + }, + "War das Treffen ausgeglichen (Geben/Nehmen)?" : { + "comment" : "RatingQuestion – balance question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was the meeting balanced (giving/receiving)?" + } + } + } + }, + "Warm & abendlich" : { + "comment" : "Theme tagline for Copper", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warm & evening" + } + } + } + }, + "Warm & augenschonend · ND" : { + "comment" : "Theme tagline for Dusk", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warm & eye-friendly · ND" + } + } + } + }, + "Was als Nächstes?" : { + "comment" : "PersonDetailView – next step input placeholder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What's next?" + } + } + } + }, + "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" : { + "comment" : "AddMomentView – text editor placeholder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What was the essence of the conversation?\nWhat do you want to remember?" + } + } + } + }, + "Weiter" : { + "comment" : "Universal next / continue button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next" + } + } + } + }, + "Wer bist du?" : { + "comment" : "A title for the empty state view.", + "isCommentAutoGenerated" : true + }, + "Wichtig" : { + "comment" : "LogbuchView swipe action – mark moment as important", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Important" + } + } + } + }, + "Wie denkst du jetzt über das Treffen?" : { + "comment" : "RatingQuestion – aftermath reflection question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How do you think about the meeting now?" + } + } + } + }, + "Wie geht es %@?" : { + "comment" : "CallSuggestionView – sheet title with person's first name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How is %@ doing?" + } + } + } + }, + "Wie hast du dich während des Treffens gefühlt?" : { + "comment" : "RatingQuestion – self-assessment question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How did you feel during the meeting?" + } + } + } + }, + "Wie heißt diese Person?" : { + "comment" : "AddPersonView – name field placeholder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What's this person's name?" + } + } + } + }, + "Wie heißt du?" : { + "comment" : "A label for the user's name.", + "isCommentAutoGenerated" : true + }, + "Wie ist dein Energielevel nach dem Treffen?" : { + "comment" : "RatingQuestion – energy level question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What's your energy level after the meeting?" + } + } + } + }, + "Wie oft erinnern?" : { + "comment" : "AddPersonView – nudge frequency picker label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How often to remind?" + } + } + } + }, + "Wie tiefgehend waren die Gespräche?" : { + "comment" : "RatingQuestion – conversation depth question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How deep were the conversations?" + } + } + } + }, + "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute." : { + "comment" : "AftermathNotificationManager – notification body text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How does your visit feel now? 3 quick questions – takes 1 minute." + } + } + } + }, + "Wir erinnern dich an die Nachwirkung." : { + "comment" : "VisitSummaryView – aftermath reminder subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We'll remind you about the follow-up." + } + } + } + }, + "Woche" : { + "comment" : "PaywallView – subscription period label (week)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "week" + } + } + } + }, + "Wochentage" : { + "comment" : "CallWindowSetupView – weekday selector section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekdays" + } + } + } + }, + "Wöchentlich" : { + "comment" : "NudgeFrequency.weekly raw value", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly" + } + } + } + }, + "Wohnort" : { + "comment" : "AddPersonView / PersonDetailView / IchView – location field label", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location" + } + } + } + }, + "Würdest du ein ähnliches Treffen wiederholen?" : { + "comment" : "RatingQuestion – aftermath repeat question text", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Would you repeat a similar meeting?" + } + } + } + }, + "Zeitfenster" : { + "comment" : "SettingsView / CallWindowSetupView – time window section header and row label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time window" + } + } + } + }, + "Zeitraum" : { + "comment" : "A generic term for a billing period.", + "isCommentAutoGenerated" : true + }, + "Zu Max upgraden" : { + "comment" : "PaywallView – CTA button when upgrading from Pro to Max", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade to Max" + } + } + } + }, + "Zuletzt %@" : { + "comment" : "PeopleListView – last seen relative date format, e.g. 'Last seen yesterday'", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last seen %@" + } + } + } + }, + "Zum Besseren" : { + "comment" : "RatingQuestion – positive pole for view change question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For the better" + } + } + } + }, + "Zum Schlechteren" : { + "comment" : "RatingQuestion – negative pole for view change question", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For the worse" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index c924c5d..52a1fa6 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import CoreData // MARK: - AI Analysis State @@ -61,6 +62,7 @@ private enum LogbuchItem: Identifiable { struct LogbuchView: View { @Environment(\.nahbarTheme) var theme + @Environment(\.modelContext) var modelContext @Environment(\.dismiss) var dismiss @StateObject private var store = StoreManager.shared let person: Person @@ -93,7 +95,7 @@ struct LogbuchView: View { .navigationTitle("Logbuch") .navigationBarTitleDisplayMode(.inline) .themedNavBar() - .sheet(isPresented: $showPaywall) { PaywallView() } + .sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) } .onReceive( NotificationCenter.default.publisher( for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification") @@ -121,9 +123,18 @@ struct LogbuchView: View { VStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - logbuchRow(item: item) - if index < items.count - 1 { - RowDivider() + if case .moment(let moment) = item { + DeletableLogbuchRow( + isImportant: moment.isImportant, + isLast: index == items.count - 1, + onDelete: { deleteMoment(moment) }, + onToggleImportant: { toggleImportant(moment) } + ) { + logbuchRow(item: item) + } + } else { + logbuchRow(item: item) + if index < items.count - 1 { RowDivider() } } } } @@ -132,6 +143,16 @@ struct LogbuchView: View { } } + private func deleteMoment(_ moment: Moment) { + modelContext.delete(moment) + person.touch() + } + + private func toggleImportant(_ moment: Moment) { + moment.isImportant.toggle() + moment.updatedAt = Date() + } + // MARK: - Row private func logbuchRow(item: LogbuchItem) -> some View { @@ -149,13 +170,18 @@ struct LogbuchView: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { + if case .moment(let m) = item, m.isImportant { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.orange) + } Text(item.label) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) Text("·") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) - Text(item.date.formatted(.dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE")))) + Text(item.date.formatted(.dateTime.day().month(.abbreviated).year())) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } @@ -186,14 +212,20 @@ struct LogbuchView: View { .padding(.vertical, 48) } - // MARK: - PRO: KI-Analyse + // MARK: - MAX: KI-Analyse + + private var canUseAI: Bool { + store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft + } private var aiAnalysisCard: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { SectionHeader(title: "KI-Auswertung", icon: "sparkles") - if !store.isPro { - Text("PRO") + if !store.isMax { + Text(canUseAI + ? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" + : "MAX") .font(.system(size: 10, weight: .bold)) .foregroundStyle(theme.accent) .padding(.horizontal, 7) @@ -203,13 +235,13 @@ struct LogbuchView: View { } } - if !store.isPro { - // Locked state + if !canUseAI { + // Gesperrt: alle Freiabfragen verbraucht Button { showPaywall = true } label: { HStack(spacing: 10) { Image(systemName: "sparkles") .foregroundStyle(theme.accent) - Text("nahbar Pro freischalten für KI-Analyse") + Text("nahbar Max freischalten für KI-Analyse") .font(.system(size: 14, weight: .medium)) .foregroundStyle(theme.accent) Spacer() @@ -356,6 +388,7 @@ struct LogbuchView: View { do { let result = try await AIAnalysisService.shared.analyze(person: person) remainingRequests = AIAnalysisService.shared.remainingRequests + if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() } analysisState = .result(result, Date()) } catch { // Bei Fehler alten Cache wiederherstellen falls vorhanden @@ -378,7 +411,7 @@ struct LogbuchView: View { private var groupedItems: [(String, [LogbuchItem])] { let formatter = DateFormatter() formatter.dateFormat = "MMMM yyyy" - formatter.locale = Locale(identifier: "de_DE") + formatter.locale = Locale.current var result: [(String, [LogbuchItem])] = [] var currentKey = "" @@ -398,3 +431,95 @@ struct LogbuchView: View { return result } } + +// MARK: - Deletable Logbuch Row +// Rechts wischen → Wichtig (orange), Links wischen → Löschen (rot) + +private struct DeletableLogbuchRow: View { + @Environment(\.nahbarTheme) var theme + let isImportant: Bool + let isLast: Bool + let onDelete: () -> Void + let onToggleImportant: () -> Void + @ViewBuilder let content: Content + + @State private var offset: CGFloat = 0 + private let actionWidth: CGFloat = 76 + + var body: some View { + ZStack { + HStack(spacing: 0) { + // Links: Wichtig-Button + Button { + onToggleImportant() + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } label: { + VStack(spacing: 4) { + Image(systemName: isImportant ? "star.slash.fill" : "star.fill") + .font(.system(size: 15, weight: .medium)) + Text(isImportant ? "Entfernen" : "Wichtig") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white) + .frame(width: actionWidth) + .frame(maxHeight: .infinity) + } + .background(Color.orange) + + Spacer() + + // Rechts: Löschen-Button + 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) + } + + VStack(spacing: 0) { + content + if !isLast { RowDivider() } + } + .background(theme.surfaceCard) + .offset(x: offset) + .gesture( + DragGesture(minimumDistance: 10, coordinateSpace: .local) + .onChanged { value in + let x = value.translation.width + guard abs(x) > abs(value.translation.height) * 0.6 else { return } + if x > 0 { + offset = min(x, actionWidth + 16) + } else { + offset = max(x, -(actionWidth + 16)) + } + } + .onEnded { value in + let x = value.translation.width + if x > actionWidth + 20 { + onToggleImportant() + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } else if x > actionWidth / 2 { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth } + } else if x < -(actionWidth / 2) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth } + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } + } + ) + } + .clipped() + } +} diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index cf75ec3..3d075c6 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -4,11 +4,11 @@ import SwiftData // MARK: - Enums enum PersonTag: String, CaseIterable, Codable { - case family = "Familie" - case friends = "Freunde" - case work = "Arbeit" + case family = "Familie" + case friends = "Freunde" + case work = "Arbeit" case community = "Community" - case other = "Andere" + case other = "Andere" var icon: String { switch self { @@ -22,11 +22,11 @@ enum PersonTag: String, CaseIterable, Codable { } enum NudgeFrequency: String, CaseIterable, Codable { - case never = "Nie" - case weekly = "Wöchentlich" - case biweekly = "2 Wochen" - case monthly = "Monatlich" - case quarterly = "Quartalsweise" + case never = "Nie" + case weekly = "Wöchentlich" + case biweekly = "2 Wochen" + case monthly = "Monatlich" + case quarterly = "Quartalsweise" var days: Int? { switch self { @@ -73,6 +73,24 @@ enum MomentSource: String, CaseIterable, Codable { } } +// MARK: - PersonPhoto +// Ausgelagertes Fotomodell – verhindert, dass Person-Queries Bild-Blobs laden. +// @Attribute(.externalStorage) speichert Bilddaten außerhalb der SQLite-Datenbank +// und nutzt für CloudKit automatisch CKAsset (kein 1MB-Feldlimit). + +@Model +final class PersonPhoto { + var id: UUID = UUID() + @Attribute(.externalStorage) var imageData: Data = Data() + var createdAt: Date = Date() + + init(imageData: Data) { + self.id = UUID() + self.imageData = imageData + self.createdAt = Date() + } +} + // MARK: - Person @Model @@ -86,14 +104,24 @@ class Person { var interests: String? var generalNotes: String? var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue - var photoData: Data? var nextStep: String? var nextStepCompleted: Bool = false var nextStepReminderDate: Date? var lastSuggestedForCall: Date? var createdAt: Date = Date() + var updatedAt: Date = Date() // V3: für Sync-Konflikt-Erkennung + var isArchived: Bool = false // V3: soft delete – Daten bleiben erhalten + + // V3: Foto als eigenes Modell (lazy geladen – kein Blob beim Listen-Fetch) + @Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil + + // Übergangsfeld: vorhanden bis Repair-Pass photo befüllt hat. + // Wird nach Migration auf nil gesetzt. In V4 entfernbar. + var photoData: Data? = nil + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] // V4 init( name: String, @@ -115,13 +143,17 @@ class Person { self.generalNotes = generalNotes self.nudgeFrequencyRaw = nudgeFrequency.rawValue self.photoData = nil + self.photo = nil self.nextStep = nil self.nextStepCompleted = false self.nextStepReminderDate = nil self.lastSuggestedForCall = nil self.createdAt = Date() + self.updatedAt = Date() + self.isArchived = false self.moments = [] self.logEntries = [] + self.visits = [] } var tag: PersonTag { @@ -179,6 +211,29 @@ class Person { var sortedLogEntries: [LogEntry] { (logEntries ?? []).sorted { $0.loggedAt > $1.loggedAt } } + + /// Einheitlicher Foto-Zugriff: bevorzugt das migrierte PersonPhoto, + /// fällt auf legacyPhotoData zurück bis der Repair-Pass gelaufen ist. + var currentPhotoData: Data? { + photo?.imageData ?? photoData + } + + var sortedVisits: [Visit] { + (visits ?? []).sorted { $0.visitDate > $1.visitDate } + } + + var lastVisit: Visit? { + sortedVisits.first + } + + var visitCount: Int { + (visits ?? []).count + } + + /// Muss nach jeder inhaltlichen Änderung aufgerufen werden. + func touch() { + updatedAt = Date() + } } // MARK: - LogEntryType @@ -196,7 +251,7 @@ enum LogEntryType: String, Codable { } } - var color: String { // used for tinting in the view + var color: String { switch self { case .nextStep: return "green" case .calendarEvent: return "blue" @@ -213,6 +268,7 @@ class LogEntry { var typeRaw: String = LogEntryType.nextStep.rawValue var title: String = "" var loggedAt: Date = Date() + var updatedAt: Date = Date() // V3 var person: Person? init(type: LogEntryType, title: String, person: Person? = nil) { @@ -220,6 +276,7 @@ class LogEntry { self.typeRaw = type.rawValue self.title = title self.loggedAt = Date() + self.updatedAt = Date() self.person = person } @@ -238,6 +295,8 @@ class Moment { var typeRaw: String = MomentType.conversation.rawValue var sourceRaw: String? = nil var createdAt: Date = Date() + var updatedAt: Date = Date() // V3 + var isImportant: Bool = false // Vom Nutzer als wichtig markiert var person: Person? init(text: String, type: MomentType = .conversation, source: MomentSource? = nil, person: Person? = nil) { @@ -246,6 +305,8 @@ class Moment { self.typeRaw = type.rawValue self.sourceRaw = source?.rawValue self.createdAt = Date() + self.updatedAt = Date() + self.isImportant = false self.person = person } @@ -259,3 +320,214 @@ class Moment { set { sourceRaw = newValue?.rawValue } } } + +// MARK: - Visit Rating Enums + +enum RatingCategory: String, CaseIterable, Codable { + case selbst = "Selbst" + case beziehung = "Beziehung" + case gespraech = "Gespräch" + case nachwirkung = "Nachwirkung" + + var icon: String { + switch self { + case .selbst: return "person.fill" + case .beziehung: return "heart.fill" + case .gespraech: return "bubble.left.fill" + case .nachwirkung: return "moon.stars.fill" + } + } + + var color: Color { + switch self { + case .selbst: return .blue + case .beziehung: return .pink + case .gespraech: return .orange + case .nachwirkung: return .purple + } + } +} + +enum VisitStatus: String, Codable { + case immediateCompleted = "sofort_abgeschlossen" + case awaitingAftermath = "warte_nachwirkung" + case completed = "abgeschlossen" +} + +// MARK: - RatingQuestion + +struct RatingQuestion { + let category: RatingCategory + let text: String + let negativePole: String + let positivePole: String + let isAftermath: Bool + + static let all: [RatingQuestion] = [ + // MARK: Selbst (2 Fragen) + RatingQuestion(category: .selbst, + text: "Wie hast du dich während des Treffens gefühlt?", + negativePole: "Unwohl", + positivePole: "Sehr wohl", + isAftermath: false), + RatingQuestion(category: .selbst, + text: "Wie ist dein Energielevel nach dem Treffen?", + negativePole: "Erschöpft", + positivePole: "Energiegeladen", + isAftermath: false), + // MARK: Beziehung (2 Fragen) + RatingQuestion(category: .beziehung, + text: "Fühlt sich die Beziehung gestärkt an?", + negativePole: "Distanzierter", + positivePole: "Viel näher", + isAftermath: false), + RatingQuestion(category: .beziehung, + text: "War das Treffen ausgeglichen (Geben/Nehmen)?", + negativePole: "Sehr einseitig", + positivePole: "Perfekt ausgeglichen", + isAftermath: false), + // MARK: Gespräch (1 Frage) + RatingQuestion(category: .gespraech, + text: "Wie tiefgehend waren die Gespräche?", + negativePole: "Nur Smalltalk", + positivePole: "Sehr tiefgründig", + isAftermath: false), + // MARK: Nachwirkung (4 Fragen – isAftermath = true) + RatingQuestion(category: .nachwirkung, + text: "Möchtest du die Person bald wiedersehen?", + negativePole: "Eher nicht", + positivePole: "Unbedingt", + isAftermath: true), + RatingQuestion(category: .nachwirkung, + text: "Wie denkst du jetzt über das Treffen?", + negativePole: "Eher negativ", + positivePole: "Sehr positiv", + isAftermath: true), + RatingQuestion(category: .nachwirkung, + text: "Hat sich deine Sicht auf die Person verändert?", + negativePole: "Zum Schlechteren", + positivePole: "Zum Besseren", + isAftermath: true), + RatingQuestion(category: .nachwirkung, + text: "Würdest du ein ähnliches Treffen wiederholen?", + negativePole: "Eher nicht", + positivePole: "Sofort wieder", + isAftermath: true), + ] + + static var immediate: [RatingQuestion] { all.filter { !$0.isAftermath } } + static var aftermath: [RatingQuestion] { all.filter { $0.isAftermath } } +} + +// MARK: - Visit + +@Model +class Visit { + var id: UUID = UUID() + var visitDate: Date = Date() + var statusRaw: String = VisitStatus.immediateCompleted.rawValue + 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(visitDate: Date = Date(), person: Person? = nil) { + self.id = UUID() + self.visitDate = visitDate + self.statusRaw = VisitStatus.immediateCompleted.rawValue + self.note = nil + self.aftermathNotificationScheduled = false + self.aftermathCompletedAt = nil + self.person = person + self.ratings = [] + self.healthSnapshot = nil + } + + var status: VisitStatus { + get { VisitStatus(rawValue: statusRaw) ?? .immediateCompleted } + set { statusRaw = newValue.rawValue } + } + + var isComplete: Bool { + status == .completed + } + + var sortedRatings: [Rating] { + (ratings ?? []).sorted { $0.questionIndex < $1.questionIndex } + } + + func averageForCategory(_ category: RatingCategory, aftermath: Bool) -> Double? { + let filtered = (ratings ?? []).filter { + $0.category == category && $0.isAftermath == aftermath + } + let valued = filtered.compactMap { $0.value } + guard !valued.isEmpty else { return nil } + return Double(valued.reduce(0, +)) / Double(valued.count) + } + + var immediateAverage: Double? { + let values = (ratings ?? []) + .filter { !$0.isAftermath } + .compactMap { $0.value } + guard !values.isEmpty else { return nil } + return Double(values.reduce(0, +)) / Double(values.count) + } + + var aftermathAverage: Double? { + let values = (ratings ?? []) + .filter { $0.isAftermath } + .compactMap { $0.value } + guard !values.isEmpty else { return nil } + return Double(values.reduce(0, +)) / Double(values.count) + } +} + +// MARK: - Rating + +@Model +class Rating { + var id: UUID = UUID() + var categoryRaw: String = RatingCategory.selbst.rawValue + var questionIndex: Int = 0 + var value: Int? = nil // nil = übersprungen; -2...+2 sonst + var isAftermath: Bool = false + var visit: Visit? = nil + + init(category: RatingCategory, questionIndex: Int, value: Int?, isAftermath: Bool, visit: Visit? = nil) { + self.id = UUID() + self.categoryRaw = category.rawValue + self.questionIndex = questionIndex + self.value = value + self.isAftermath = isAftermath + self.visit = visit + } + + var category: RatingCategory { + get { RatingCategory(rawValue: categoryRaw) ?? .selbst } + set { categoryRaw = newValue.rawValue } + } +} + +// MARK: - HealthSnapshot (Phase-2-Platzhalter) + +@Model +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 + + init(visit: Visit? = nil) { + self.id = UUID() + self.visit = visit + } + + var hasData: Bool { + sleepHours != nil || hrvMs != nil || restingHR != nil || steps != nil + } +} diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index 6f45d03..405548b 100644 --- a/nahbar/nahbar/NahbarApp.swift +++ b/nahbar/nahbar/NahbarApp.swift @@ -1,13 +1,30 @@ import SwiftUI import SwiftData +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "App") @main struct NahbarApp: App { + + // Static let stellt sicher, dass der Container exakt einmal erstellt wird – + // unabhängig davon, wie oft body ausgewertet wird. + private static let containerBuild = AppGroup.makeMainContainerWithMigration() + private static var mainContainer: ModelContainer { containerBuild.0 } + private static var containerFallback: ContainerFallback { containerBuild.1 } + @StateObject private var callWindowManager = CallWindowManager.shared - @StateObject private var appLockManager = AppLockManager.shared + @StateObject private var appLockManager = AppLockManager.shared + @StateObject private var cloudSyncMonitor = CloudSyncMonitor() + @StateObject private var profileStore = UserProfileStore.shared + @StateObject private var eventLog = AppEventLog.shared + @AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue + @AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false + @Environment(\.scenePhase) private var scenePhase @State private var showSplash = true + @State private var showDegradedWarning = (NahbarApp.containerFallback == .inMemory) private var activeTheme: NahbarTheme { NahbarTheme.theme(for: ThemeID(rawValue: activeThemeIDRaw) ?? .linen) @@ -19,6 +36,9 @@ struct NahbarApp: App { ContentView() .environmentObject(callWindowManager) .environmentObject(appLockManager) + .environmentObject(cloudSyncMonitor) + .environmentObject(profileStore) + .environmentObject(eventLog) if appLockManager.isLocked && !showSplash { AppLockView() @@ -29,21 +49,39 @@ struct NahbarApp: App { if showSplash { SplashView(onFinished: { - appLockManager.lockIfEnabled() - showSplash = false - }) - .transition(.opacity) - .zIndex(2) + appLockManager.lockIfEnabled() + showSplash = false + }) + .transition(.opacity) + .zIndex(2) + } + + // Degraded-Mode-Banner: sichtbar wenn nur In-Memory-Store verfügbar + if showDegradedWarning { + VStack { + DegradedModeBanner { showDegradedWarning = false } + Spacer() + } + .zIndex(3) } } .animation(.easeInOut(duration: 0.25), value: appLockManager.isLocked) - .animation(.easeInOut(duration: 0.4), value: showSplash) + .animation(.easeInOut(duration: 0.40), value: showSplash) .environment(\.nahbarTheme, activeTheme) .tint(activeTheme.accent) - .onAppear { applyTabBarAppearance(activeTheme) } + .onAppear { + applyTabBarAppearance(activeTheme) + cloudSyncMonitor.startMonitoring(iCloudEnabled: icloudSyncEnabled) + AftermathNotificationManager.shared.registerCategory() + logger.info("App gestartet. Container-Modus: \(String(describing: NahbarApp.containerFallback))") + AppEventLog.shared.record("Container-Modus: \(NahbarApp.containerFallback)", level: .info, category: "Lifecycle") + } .onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) } + .onChange(of: icloudSyncEnabled) { _, enabled in + cloudSyncMonitor.startMonitoring(iCloudEnabled: enabled) + } } - .modelContainer(AppGroup.makeMainContainerWithMigration()) + .modelContainer(NahbarApp.mainContainer) .onChange(of: scenePhase) { _, phase in if phase == .background { appLockManager.lockIfEnabled() @@ -57,9 +95,8 @@ struct NahbarApp: App { let selected = UIColor(theme.accent) let border = UIColor(theme.borderSubtle).withAlphaComponent(0.6) - // Tab bar let item = UITabBarItemAppearance() - item.normal.iconColor = normal + item.normal.iconColor = normal item.normal.titleTextAttributes = [.foregroundColor: normal] item.selected.iconColor = selected item.selected.titleTextAttributes = [.foregroundColor: selected] @@ -75,9 +112,8 @@ struct NahbarApp: App { UITabBar.appearance().standardAppearance = tabAppearance UITabBar.appearance().scrollEdgeAppearance = tabAppearance - // Navigation bar - let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92) - let titleColor = UIColor(theme.contentPrimary) + let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92) + let titleColor = UIColor(theme.contentPrimary) let navAppearance = UINavigationBarAppearance() navAppearance.configureWithTransparentBackground() @@ -92,3 +128,37 @@ struct NahbarApp: App { UINavigationBar.appearance().tintColor = selected } } + +// MARK: - Degraded Mode Banner + +private struct DegradedModeBanner: View { + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 1) { + Text("Datenbankfehler") + .font(.system(size: 13, weight: .semibold)) + Text("Daten werden in dieser Sitzung nicht gespeichert.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.orange.opacity(0.4)) + .frame(height: 1) + } + } +} diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift index 0e7abd7..0b04af7 100644 --- a/nahbar/nahbar/NahbarMigration.swift +++ b/nahbar/nahbar/NahbarMigration.swift @@ -1,12 +1,21 @@ import SwiftUI import SwiftData +import OSLog -// MARK: - Schema V1 (Originalschema – Moment ohne sourceRaw) -// -// Diese Typen spiegeln exakt das ursprüngliche Schema wider, bevor -// Moment.sourceRaw hinzugefügt wurde. SwiftData vergleicht das -// gespeicherte Schema-Hash mit dieser Definition und führt bei -// Übereinstimmung die Lightweight-Migration zu V2 durch. +private let logger = Logger(subsystem: "nahbar", category: "Migration") + +// MARK: - ContainerFallback +// Zeigt an, auf welche Stufe die Container-Erstellung zurückgefallen ist. +// InMemory bedeutet: Daten werden NICHT persistent gespeichert. + +enum ContainerFallback: Equatable { + case cloudKit // Ideal: CloudKit aktiv + case localOnly // CloudKit deaktiviert oder nicht verfügbar + case inMemory // Letzter Ausweg – Daten gehen beim Beenden verloren +} + +// MARK: - Schema V1 +// Originalschema: Moment hatte noch kein sourceRaw-Feld. enum NahbarSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) @@ -32,7 +41,6 @@ enum NahbarSchemaV1: VersionedSchema { var createdAt: Date = Date() @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] - init() {} } @@ -42,7 +50,6 @@ enum NahbarSchemaV1: VersionedSchema { var typeRaw: String = "Gespräch" var createdAt: Date = Date() var person: Person? = nil - init() {} } @@ -52,62 +59,255 @@ enum NahbarSchemaV1: VersionedSchema { var title: String = "" var loggedAt: Date = Date() var person: Person? = nil - init() {} } } -// MARK: - Schema V2 (aktuell – Moment mit sourceRaw) +// MARK: - Schema V2 +// Eingefrorener Snapshot des Schemas zum Zeitpunkt des V2-Deployments. +// Moment bekam sourceRaw. Alle anderen Felder exakt wie in der Live-App vor V3. +// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten +// Schema-Hash von V2-Datenbanken auf Nutzers-Geräten entsprechen. enum NahbarSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { - // Verweist auf die aktuellen Top-Level-Modeltypen in Models.swift - [nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self] + [Person.self, Moment.self, LogEntry.self] + } + + @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 nudgeFrequencyRaw: String = "Monatlich" + var photoData: Data? = nil + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + init() {} + } + + @Model final class Moment { + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = "Gespräch" + var sourceRaw: String? = nil // neu in V2 + var createdAt: Date = Date() + var person: Person? = nil + init() {} + } + + @Model final class LogEntry { + var id: UUID = UUID() + var typeRaw: String = "Schritt abgeschlossen" + var title: String = "" + var loggedAt: Date = Date() + var person: Person? = nil + init() {} } } -// MARK: - Migrationsplan V1 → V2 +// MARK: - Schema V3 (eingefrorener Snapshot) +// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten +// Schema-Hash von V3-Datenbanken auf Nutzer-Geräten entsprechen. +// +// V3 fügte hinzu: +// • Person: updatedAt, isArchived, photo (PersonPhoto-Relationship) +// • Moment: updatedAt, isImportant +// • LogEntry: updatedAt +// • PersonPhoto: neues Modell (Bilddaten ausgelagert) + +enum NahbarSchemaV3: VersionedSchema { + static var versionIdentifier = Schema.Version(3, 0, 0) + static var models: [any PersistentModel.Type] { + [Person.self, Moment.self, LogEntry.self, PersonPhoto.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 nudgeFrequencyRaw: String = "Monatlich" + var photoData: Data? = nil + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() // V3 + var isArchived: Bool = false // V3 + @Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil // V3 + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + 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() // V3 + var isImportant: Bool = false // V3 + var person: Person? = nil + 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() // V3 + var person: Person? = nil + init() {} + } +} + +// MARK: - Schema V4 (aktuelles Schema) +// Referenziert die Live-Typen aus Models.swift. +// Beim Hinzufügen von V5 muss V4 als eingefrorener Snapshot gesichert werden. +// +// V4 fügt hinzu: +// • Visit, Rating, HealthSnapshot: neue Modelle für Besuchs-Bewertungen +// • Person: visits-Relationship + +enum NahbarSchemaV4: VersionedSchema { + static var versionIdentifier = Schema.Version(4, 0, 0) + static var models: [any PersistentModel.Type] { + [nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, nahbar.PersonPhoto.self, + nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self] + } +} + +// MARK: - Migrationsplan enum NahbarMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [NahbarSchemaV1.self, NahbarSchemaV2.self] + [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, NahbarSchemaV4.self] } - /// Lightweight: SwiftData ergänzt sourceRaw mit nil für alle bestehenden Momente. static var stages: [MigrationStage] { - [.lightweight(fromVersion: NahbarSchemaV1.self, toVersion: NahbarSchemaV2.self)] + [ + // V1 → V2: Moment bekommt sourceRaw = nil (lightweight, kein Datenverlust) + .lightweight(fromVersion: NahbarSchemaV1.self, toVersion: NahbarSchemaV2.self), + + // V2 → V3: updatedAt/isArchived mit Defaults, PersonPhoto als neues Modell, + // photo-Relationship auf Person. Alle neuen Felder haben Default-Werte → + // lightweight-Migration reicht aus. + .lightweight(fromVersion: NahbarSchemaV2.self, toVersion: NahbarSchemaV3.self), + + // V3 → V4: Visit/Rating/HealthSnapshot neu, Person bekommt visits-Relationship. + // Alle neuen Felder haben Default-Werte → lightweight-Migration reicht aus. + .lightweight(fromVersion: NahbarSchemaV3.self, toVersion: NahbarSchemaV4.self) + ] } } -// MARK: - Container-Erstellung (nur Hauptapp, nicht Share Extension) +// MARK: - Container-Erstellung extension AppGroup { - /// Erstellt den ModelContainer mit automatischer Schemamigration. - /// Bei Nutzern, die bereits Daten haben, ergänzt SwiftData das neue - /// Feld sourceRaw = nil für alle vorhandenen Momente – kein Datenverlust. - static func makeMainContainerWithMigration() -> ModelContainer { - let schema = Schema([nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self]) + /// Erstellt den ModelContainer mit vollständiger Fehlerbehandlung. + /// Gibt immer einen funktionsfähigen Container zurück – im schlimmsten Fall + /// In-Memory. Der ContainerFallback informiert die App über den Zustand. + /// + /// Strategie: + /// 1. CloudKit + Migration (ideal) + /// 2. Lokal + Migration (iCloud aus oder nicht verfügbar) + /// 3. Lokal OHNE Migrationsplan (falls Migration selbst fehlschlägt – Notfall) + /// 4. In-Memory (letzter Ausweg – Daten werden NICHT gespeichert) + static func makeMainContainerWithMigration() -> (ModelContainer, ContainerFallback) { + let schema = Schema([ + nahbar.Person.self, + nahbar.Moment.self, + nahbar.LogEntry.self, + nahbar.PersonPhoto.self, + nahbar.Visit.self, + nahbar.Rating.self, + nahbar.HealthSnapshot.self + ]) let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) - let cloudKit: ModelConfiguration.CloudKitDatabase = icloudEnabled ? .automatic : .none - // Versuch 1: gewünschte Konfiguration mit Migrationsplan - let config = ModelConfiguration(schema: schema, cloudKitDatabase: cloudKit) - if let container = try? ModelContainer( - for: schema, - migrationPlan: NahbarMigrationPlan.self, - configurations: [config] - ) { return container } + // ── Stufe 1: CloudKit + Migration ──────────────────────────────────── + if icloudEnabled { + let config = ModelConfiguration(schema: schema, cloudKitDatabase: .automatic) + do { + let container = try ModelContainer( + for: schema, + migrationPlan: NahbarMigrationPlan.self, + configurations: [config] + ) + logger.info("Container erstellt: CloudKit + Migration ✓") + AppEventLog.shared.record("Store: CloudKit + Migration ✓", level: .success, category: "Store") + return (container, .cloudKit) + } catch { + logger.warning("CloudKit-Container fehlgeschlagen: \(error.localizedDescription). Versuche lokal.") + } + } - // Versuch 2: lokal ohne CloudKit mit Migrationsplan + // ── Stufe 2: Lokal + Migration ──────────────────────────────────────── let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none) - if let container = try? ModelContainer( - for: schema, - migrationPlan: NahbarMigrationPlan.self, - configurations: [localConfig] - ) { return container } + do { + let container = try ModelContainer( + for: schema, + migrationPlan: NahbarMigrationPlan.self, + configurations: [localConfig] + ) + logger.info("Container erstellt: Lokal + Migration ✓") + AppEventLog.shared.record("Store: Lokal + Migration ✓", level: .success, category: "Store") + return (container, .localOnly) + } catch { + logger.error("Lokaler Container mit Migration fehlgeschlagen: \(error.localizedDescription). Versuche ohne Migrationsplan.") + } - // Letzter Ausweg: nur im Speicher (sollte nie eintreten) - return try! ModelContainer(for: schema, configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]) + // ── Stufe 3: Lokal OHNE Migrationsplan ─────────────────────────────── + // Notfall: Migration ist kaputt, aber Store ist lesbar. + // Daten können inkonsistent sein; App läuft zumindest weiter. + do { + let container = try ModelContainer(for: schema, configurations: [localConfig]) + logger.error("Container erstellt: Lokal OHNE Migration – Schema möglicherweise inkonsistent!") + AppEventLog.shared.record("Store: Lokal OHNE Migration – Schema möglicherweise inkonsistent!", level: .error, category: "Store") + return (container, .localOnly) + } catch { + logger.critical("Auch lokaler Container ohne Migration fehlgeschlagen: \(error.localizedDescription). Fallback: In-Memory.") + AppEventLog.shared.record("Store: In-Memory-Fallback! Daten werden nicht gespeichert.", level: .critical, category: "Store") + } + + // ── Stufe 4: In-Memory ──────────────────────────────────────────────── + // Daten werden NICHT gespeichert. App zeigt Warnung. + let inMemoryConfig = ModelConfiguration(isStoredInMemoryOnly: true) + if let container = try? ModelContainer(for: schema, configurations: [inMemoryConfig]) { + logger.critical("Container erstellt: In-Memory – Daten werden nicht gespeichert!") + return (container, .inMemory) + } + + // Absolut letzter Ausweg – sollte physisch nicht erreichbar sein. + // try! ist hier vertretbar: Wenn sogar ein leerer In-Memory-Store nicht + // erstellt werden kann, liegt ein fataler Swift/SwiftData-Laufzeitfehler vor. + logger.critical("In-Memory-Erstellung fehlgeschlagen. try! – nicht behebbar.") + let container = try! ModelContainer(for: schema, configurations: [inMemoryConfig]) + return (container, .inMemory) } } diff --git a/nahbar/nahbar/PaywallView.swift b/nahbar/nahbar/PaywallView.swift index 6316a43..af1bae9 100644 --- a/nahbar/nahbar/PaywallView.swift +++ b/nahbar/nahbar/PaywallView.swift @@ -6,18 +6,32 @@ struct PaywallView: View { @Environment(\.dismiss) private var dismiss @StateObject private var store = StoreManager.shared + @State private var selectedTier: SubscriptionTier @State private var isPurchasing = false @State private var isRestoring = false - private let features: [(icon: String, title: String, subtitle: String)] = [ - ("brain.head.profile", "KI-Analyse", "Muster, Beziehungsqualität & konkrete Empfehlungen per KI"), - ("gift.fill", "Geschenkideen", "KI-basierte Vorschläge bei bevorstehenden Geburtstagen"), - ("square.and.arrow.up", "Messenger-Import", "Nachrichten aus WhatsApp, Telegram & Co. direkt ins Logbuch"), - ("paintpalette.fill", "Alle Themes", "Grove, Ink, Copper, Abyss, Dusk & Basalt"), - ("sparkles", "Neurodivers-Themes", "Reizarme Designs mit reduzierter Bewegung"), - ("star.fill", "Zukünftige Features", "Alle kommenden Pro-Features inklusive"), + init(targeting tier: SubscriptionTier = .pro) { + _selectedTier = State(initialValue: tier) + } + + // MARK: - Feature-Definitionen + + private let proFeatures: [(icon: String, text: String)] = [ + ("person.badge.plus", "Unbegrenzte Kontakte statt 3"), + ("square.and.arrow.up", "Teilen-Funktion: Momente aus anderen Apps importieren"), + ("paintpalette.fill", "Alle Themes: Grove, Ink, Copper, Abyss, Dusk & Basalt"), + ("sparkles", "Neurodivers-Themes: reizarme Designs"), + ("star.fill", "Alle zukünftigen Pro-Features inklusive"), ] + private let maxExtraFeatures: [(icon: String, text: String)] = [ + ("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"), + ("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"), + ("infinity", "Unbegrenzte KI-Abfragen ohne Limit"), + ] + + // MARK: - Body + var body: some View { ZStack { theme.backgroundPrimary.ignoresSafeArea() @@ -30,94 +44,21 @@ struct PaywallView: View { .padding(.top, 12) ScrollView { - VStack(spacing: 32) { - + VStack(spacing: 28) { // Header - VStack(spacing: 8) { - Image("AppLogo") - .resizable() - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .padding(.top, 24) + header - Text("nahbar Pro") - .font(.system(size: 28, weight: .light, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) - - Text("Hol das Beste aus nahbar heraus.") - .font(.system(size: 15)) - .foregroundStyle(theme.contentSecondary) - .multilineTextAlignment(.center) - } + // Tier-Picker + tierPicker // Features - VStack(spacing: 0) { - ForEach(features.indices, id: \.self) { i in - if i > 0 { RowDivider() } - featureRow(features[i]) - } - } - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .padding(.horizontal, 20) + featureList - // Price + CTA - VStack(spacing: 12) { - Button { - Task { - isPurchasing = true - await store.purchase() - isPurchasing = false - if store.isPro { dismiss() } - } - } label: { - HStack(spacing: 8) { - if isPurchasing { - ProgressView() - .tint(.white) - } else { - Text(priceLabel) - .font(.system(size: 17, weight: .semibold)) - } - } - .frame(maxWidth: .infinity) - .frame(height: 52) - .background(theme.accent) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - } - .disabled(isPurchasing || isRestoring) - .padding(.horizontal, 20) - - Button { - Task { - isRestoring = true - await store.restorePurchases() - isRestoring = false - if store.isPro { dismiss() } - } - } label: { - if isRestoring { - ProgressView().tint(theme.contentTertiary) - } else { - Text("Kauf wiederherstellen") - .font(.system(size: 14)) - .foregroundStyle(theme.contentTertiary) - } - } - .disabled(isPurchasing || isRestoring) - - if let error = store.purchaseError { - Text(error) - .font(.system(size: 12)) - .foregroundStyle(.red.opacity(0.8)) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - } - } + // CTA + ctaSection // Legal - Text("Abonnement wird automatisch verlängert. In den iPhone-Einstellungen jederzeit kündbar.") + Text("Abonnement verlängert sich automatisch. In den iPhone-Einstellungen jederzeit kündbar.") .font(.system(size: 11)) .foregroundStyle(theme.contentTertiary) .multilineTextAlignment(.center) @@ -129,27 +70,90 @@ struct PaywallView: View { } } - private var priceLabel: String { - if let product = store.product { - return "\(product.displayPrice) / \(periodLabel(product)) abonnieren" - } - return "Abonnieren" - } + // MARK: - Subviews - private func periodLabel(_ product: Product) -> String { - guard let sub = product.subscription else { return "Monat" } - switch sub.subscriptionPeriod.unit { - case .day: return sub.subscriptionPeriod.value == 7 ? "Woche" : "Tag" - case .week: return "Woche" - case .month: return "Monat" - case .year: return "Jahr" - @unknown default: return "Zeitraum" + private var header: some View { + VStack(spacing: 8) { + Image("AppLogo") + .resizable() + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.top, 24) + + Text("nahbar") + .font(.system(size: 28, weight: .light, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + + Text("Wähle deinen Plan") + .font(.system(size: 15)) + .foregroundStyle(theme.contentSecondary) } } - private func featureRow(_ feature: (icon: String, title: String, subtitle: String)) -> some View { + private var tierPicker: some View { + HStack(spacing: 0) { + ForEach(SubscriptionTier.allCases, id: \.productID) { tier in + Button { + withAnimation(.easeInOut(duration: 0.2)) { selectedTier = tier } + } label: { + VStack(spacing: 4) { + Text(tier.displayName) + .font(.system(size: 15, weight: selectedTier == tier ? .semibold : .regular)) + .foregroundStyle(selectedTier == tier ? theme.accent : theme.contentSecondary) + if tier == .max { + Text("inkl. KI") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(selectedTier == .max ? theme.accent : theme.contentTertiary) + } else { + Text("Basis") + .font(.system(size: 10)) + .foregroundStyle(theme.contentTertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + selectedTier == tier + ? theme.accent.opacity(0.10) + : theme.surfaceCard + ) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .overlay( + RoundedRectangle(cornerRadius: theme.radiusCard) + .strokeBorder(theme.borderSubtle, lineWidth: 1) + ) + .padding(.horizontal, 20) + } + + @ViewBuilder + private var featureList: some View { + VStack(spacing: 0) { + if selectedTier == .max { + // Max: Pro-Features als Paket + Max-Extras einzeln + proPackageRow + ForEach(maxExtraFeatures.indices, id: \.self) { i in + RowDivider() + featureRow(maxExtraFeatures[i]) + } + } else { + // Pro: alle Pro-Features einzeln + ForEach(proFeatures.indices, id: \.self) { i in + if i > 0 { RowDivider() } + featureRow(proFeatures[i]) + } + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + } + + private var proPackageRow: some View { HStack(spacing: 14) { - Image(systemName: feature.icon) + Image(systemName: "checkmark.seal.fill") .font(.system(size: 15)) .foregroundStyle(theme.accent) .frame(width: 32, height: 32) @@ -157,10 +161,10 @@ struct PaywallView: View { .clipShape(Circle()) VStack(alignment: .leading, spacing: 2) { - Text(feature.title) + Text("Alles aus Pro") .font(.system(size: 15, weight: .medium)) .foregroundStyle(theme.contentPrimary) - Text(feature.subtitle) + Text("Kontakte, Teilen-Funktion, Themes") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } @@ -169,4 +173,102 @@ struct PaywallView: View { .padding(.horizontal, 16) .padding(.vertical, 12) } + + private func featureRow(_ feature: (icon: String, text: String)) -> some View { + HStack(spacing: 14) { + Image(systemName: feature.icon) + .font(.system(size: 15)) + .foregroundStyle(theme.accent) + .frame(width: 32, height: 32) + .background(theme.accent.opacity(0.10)) + .clipShape(Circle()) + + Text(feature.text) + .font(.system(size: 14)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var ctaSection: some View { + VStack(spacing: 12) { + Button { + Task { + isPurchasing = true + await store.purchase(tier: selectedTier) + isPurchasing = false + let didSucceed = selectedTier == .max ? store.isMax : store.isPro + if didSucceed { dismiss() } + } + } label: { + HStack(spacing: 8) { + if isPurchasing { + ProgressView().tint(.white) + } else { + Text(ctaLabel) + .font(.system(size: 17, weight: .semibold)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(theme.accent) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + .disabled(isPurchasing || isRestoring) + .padding(.horizontal, 20) + + Button { + Task { + isRestoring = true + await store.restorePurchases() + isRestoring = false + if store.isPro || store.isMax { dismiss() } + } + } label: { + if isRestoring { + ProgressView().tint(theme.contentTertiary) + } else { + Text("Kauf wiederherstellen") + .font(.system(size: 14)) + .foregroundStyle(theme.contentTertiary) + } + } + .disabled(isPurchasing || isRestoring) + + if let error = store.purchaseError { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(.red.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + } + + // MARK: - Hilfsmethoden + + private var ctaLabel: String { + let product = selectedTier == .pro ? store.proProduct : store.maxProduct + let price = product.map { "\($0.displayPrice) / \(periodLabel($0))" } ?? "" + let action = selectedTier == .max && store.isPro + ? String(localized: "Zu Max upgraden") + : String.localizedStringWithFormat(String(localized: "%@ freischalten"), selectedTier.displayName) + return price.isEmpty ? action : "\(price) – \(action)" + } + + private func periodLabel(_ product: Product) -> String { + guard let sub = product.subscription else { return String(localized: "Monat") } + switch sub.subscriptionPeriod.unit { + case .day: return sub.subscriptionPeriod.value == 7 ? String(localized: "Woche") : String(localized: "Tag") + case .week: return String(localized: "Woche") + case .month: return String(localized: "Monat") + case .year: return String(localized: "Jahr") + @unknown default: return String(localized: "Zeitraum") + } + } } diff --git a/nahbar/nahbar/PeopleListView.swift b/nahbar/nahbar/PeopleListView.swift index a8aedf4..bc83c8f 100644 --- a/nahbar/nahbar/PeopleListView.swift +++ b/nahbar/nahbar/PeopleListView.swift @@ -5,10 +5,14 @@ struct PeopleListView: View { @Environment(\.nahbarTheme) var theme @Environment(\.modelContext) var modelContext @Query(sort: \Person.name) private var people: [Person] + @StateObject private var store = StoreManager.shared @State private var searchText = "" @State private var selectedTag: PersonTag? = nil @State private var showingAddPerson = false + @State private var showingPaywall = false + + private let freeContactLimit = 3 private var filteredPeople: [Person] { var result = people @@ -27,12 +31,26 @@ struct PeopleListView: View { // Custom header VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { - Text("Menschen") - .font(.system(size: 34, weight: .light, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) + VStack(alignment: .leading, spacing: 2) { + Text("Menschen") + .font(.system(size: 34, weight: .light, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + // Kontaktlimit-Hinweis für Free-Nutzer + if !store.isPro { + Button { showingPaywall = true } label: { + Text("\(min(people.count, freeContactLimit)) von \(freeContactLimit) Kontakten – Pro für mehr") + .font(.system(size: 11)) + .foregroundStyle(people.count >= freeContactLimit ? theme.accent : theme.contentTertiary) + } + } + } Spacer() Button { - showingAddPerson = true + if !store.isPro && people.count >= freeContactLimit { + showingPaywall = true + } else { + showingAddPerson = true + } } label: { Image(systemName: "plus") .font(.system(size: 17, weight: .medium)) @@ -118,6 +136,9 @@ struct PeopleListView: View { .sheet(isPresented: $showingAddPerson) { AddPersonView() } + .sheet(isPresented: $showingPaywall) { + PaywallView(targeting: .pro) + } } private var emptyState: some View { @@ -155,6 +176,18 @@ struct FilterChip: View { } } +// MARK: - Hilfsfunktion: letzter Eintrag formatieren + +/// Gibt "Zuletzt " auf Deutsch zurück. +/// `now` ist injizierbar für Tests. +func formatLastMoment(_ date: Date, relativeTo now: Date = Date()) -> String { + let fmt = RelativeDateTimeFormatter() + fmt.locale = Locale(identifier: "de_DE") + fmt.unitsStyle = .full + fmt.dateTimeStyle = .named // "gestern", "vorgestern" statt "vor 1 Tag" + return "Zuletzt \(fmt.localizedString(for: date, relativeTo: now))" +} + // MARK: - Person Row struct PersonRowView: View { @@ -179,7 +212,7 @@ struct PersonRowView: View { Text("·") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) - Text(last, style: .relative) + Text(formatLastMoment(last)) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 93d7814..3fd6429 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -11,6 +11,11 @@ struct PersonDetailView: View { @State private var showingAddMoment = false @State private var showingEditPerson = false + @State private var showingVisitRating = false + @State private var showingAftermathRating = false + @State private var selectedVisitForAftermath: Visit? = nil + @State private var selectedVisitForEdit: Visit? = nil + @State private var selectedVisitForSummary: Visit? = nil @State private var nextStepText = "" @State private var isEditingNextStep = false @State private var showingReminderSheet = false @@ -24,6 +29,7 @@ struct PersonDetailView: View { VStack(alignment: .leading, spacing: 28) { personHeader nextStepSection + visitsSection momentsSection if hasInfoContent { infoSection } } @@ -50,6 +56,28 @@ struct PersonDetailView: View { .sheet(isPresented: $showingReminderSheet) { NextStepReminderSheet(person: person, reminderDate: $reminderDate) } + .sheet(isPresented: $showingVisitRating) { + VisitRatingFlowView(person: person, + aftermathDelay: AftermathDelayOption.loadFromDefaults().seconds) + } + .sheet(item: $selectedVisitForAftermath) { visit in + AftermathRatingFlowView(visit: visit) + } + .sheet(item: $selectedVisitForEdit) { visit in + VisitEditFlowView(visit: visit) + } + .sheet(item: $selectedVisitForSummary) { visit in + NavigationStack { + VisitSummaryView(visit: visit, onDismiss: { selectedVisitForSummary = nil }) + .navigationTitle("Besuch") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Schließen") { selectedVisitForSummary = nil } + } + } + } + } .onAppear { nextStepText = person.nextStep ?? "" } @@ -223,6 +251,29 @@ struct PersonDetailView: View { .removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"]) } + private func deleteMoment(_ moment: Moment) { + modelContext.delete(moment) + person.touch() + } + + private func toggleImportant(_ moment: Moment) { + moment.isImportant.toggle() + moment.updatedAt = Date() + } + + // MARK: - Visits + + private var visitsSection: some View { + VisitHistorySection( + person: person, + showingVisitRating: $showingVisitRating, + showingAftermathRating: $showingAftermathRating, + selectedVisitForAftermath: $selectedVisitForAftermath, + selectedVisitForEdit: $selectedVisitForEdit, + selectedVisitForSummary: $selectedVisitForSummary + ) + } + // MARK: - Moments private var momentsSection: some View { @@ -264,10 +315,12 @@ struct PersonDetailView: View { } else { VStack(spacing: 0) { ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in - MomentRowView(moment: moment) - if index < person.sortedMoments.count - 1 { - RowDivider() - } + DeletableMomentRow( + moment: moment, + isLast: index == person.sortedMoments.count - 1, + onDelete: { deleteMoment(moment) }, + onToggleImportant: { toggleImportant(moment) } + ) } } .background(theme.surfaceCard) @@ -356,6 +409,7 @@ struct NextStepReminderSheet: View { .datePickerStyle(.compact) .labelsHidden() .tint(theme.accent) + .environment(\.locale, Locale(identifier: "de_DE")) // Buttons VStack(spacing: 10) { @@ -416,6 +470,102 @@ struct NextStepReminderSheet: View { } } +// MARK: - Deletable Moment Row +// Links wischen → Löschen (rot) +// Rechts wischen → Als wichtig markieren (orange) +// Vollständig rechts wischen → sofortiger Toggle, Zeile springt zurück + +private struct DeletableMomentRow: View { + @Environment(\.nahbarTheme) var theme + let moment: Moment + let isLast: Bool + let onDelete: () -> Void + let onToggleImportant: () -> Void + + @State private var offset: CGFloat = 0 + private let actionWidth: CGFloat = 76 + + var body: some View { + ZStack { + // Hintergrund: beide Aktions-Buttons + HStack(spacing: 0) { + // Links: Wichtig-Button (sichtbar bei Rechts-Wischen) + 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() + + // Rechts: Löschen-Button (sichtbar bei Links-Wischen) + 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) + if !isLast { RowDivider() } + } + .background(theme.surfaceCard) + .offset(x: offset) + .gesture( + DragGesture(minimumDistance: 10, coordinateSpace: .local) + .onChanged { value in + let x = value.translation.width + guard abs(x) > abs(value.translation.height) * 0.6 else { return } + if x > 0 { + offset = min(x, actionWidth + 16) + } else { + offset = max(x, -(actionWidth + 16)) + } + } + .onEnded { value in + let x = value.translation.width + if x > actionWidth + 20 { + // Vollständiges Rechts-Wischen: sofort togglen, zurückspringen + onToggleImportant() + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } else if x > actionWidth / 2 { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth } + } else if x < -(actionWidth / 2) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth } + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } + } + ) + } + .clipped() + } +} + // MARK: - Moment Row struct MomentRowView: View { @@ -452,6 +602,11 @@ struct MomentRowView: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { + if moment.isImportant { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.orange) + } Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 730b3bd..2a49ecd 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -6,10 +6,13 @@ struct SettingsView: View { @AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue @EnvironmentObject private var callWindowManager: CallWindowManager @EnvironmentObject private var appLockManager: AppLockManager + @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 + @AppStorage("aftermathNotificationsEnabled") private var aftermathNotificationsEnabled: Bool = true + @AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue @AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false - @AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL + @State private var icloudToggleChanged = false @AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey @AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model @StateObject private var store = StoreManager.shared @@ -19,9 +22,9 @@ struct SettingsView: View { private var biometricLabel: String { switch appLockManager.biometricType { - case .faceID: return "Face ID aktiviert" - case .touchID: return "Touch ID aktiviert" - default: return "Aktiv" + case .faceID: return String(localized: "Face ID aktiviert") + case .touchID: return String(localized: "Touch ID aktiviert") + default: return String(localized: "Aktiv") } } @@ -275,14 +278,57 @@ struct SettingsView: View { .padding(.horizontal, 20) } + // Besuche & Bewertungen + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Besuche", icon: "star.fill") + .padding(.horizontal, 20) + + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Nachwirkungs-Erinnerung") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Text("Push-Benachrichtigung nach dem Besuch") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + Spacer() + Toggle("", isOn: $aftermathNotificationsEnabled) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if aftermathNotificationsEnabled { + RowDivider() + HStack { + Text("Verzögerung") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Picker("", selection: $aftermathDelayRaw) { + ForEach(AftermathDelayOption.allCases, id: \.rawValue) { opt in + Text(opt.label).tag(opt.rawValue) + } + } + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + } + // KI-Einstellungen VStack(alignment: .leading, spacing: 12) { SectionHeader(title: "KI-Analyse", icon: "sparkles") .padding(.horizontal, 20) VStack(spacing: 0) { - settingsTextField(label: "Server-URL", value: $aiBaseURL, placeholder: AIConfig.fallback.baseURL) - RowDivider() settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model) } .background(theme.surfaceCard) @@ -296,39 +342,107 @@ struct SettingsView: View { .padding(.horizontal, 20) VStack(spacing: 0) { + // Toggle HStack { VStack(alignment: .leading, spacing: 2) { - Text("iCloud-Backup") + Text("iCloud-Sync") .font(.system(size: 15)) .foregroundStyle(theme.contentPrimary) Text(icloudSyncEnabled - ? "Daten werden mit iCloud synchronisiert" + ? "Daten werden geräteübergreifend synchronisiert" : "Daten werden nur lokal gespeichert") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } Spacer() - Toggle("", isOn: $icloudSyncEnabled) - .tint(theme.accent) + Toggle("", isOn: Binding( + get: { icloudSyncEnabled }, + set: { newValue in + icloudSyncEnabled = newValue + icloudToggleChanged = true + } + )) + .tint(theme.accent) } .padding(.horizontal, 16) .padding(.vertical, 12) - RowDivider() - HStack(spacing: 8) { - Image(systemName: "info.circle") - .font(.system(size: 12)) - .foregroundStyle(theme.contentTertiary) - Text("Änderung wird beim nächsten App-Start übernommen.") - .font(.system(size: 12)) - .foregroundStyle(theme.contentTertiary) + // Live-Sync-Status (nur wenn aktiviert) + if icloudSyncEnabled { + RowDivider() + HStack(spacing: 8) { + Image(systemName: cloudSyncMonitor.state.systemImage) + .font(.system(size: 12)) + .foregroundStyle(cloudSyncMonitor.state.isError ? .red : theme.contentTertiary) + .symbolEffect(.pulse, isActive: cloudSyncMonitor.state == .syncing) + Text(cloudSyncMonitor.state.statusText) + .font(.system(size: 12)) + .foregroundStyle(cloudSyncMonitor.state.isError ? .red : theme.contentTertiary) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + // Neustart-Banner wenn Toggle verändert wurde + if icloudToggleChanged { + RowDivider() + HStack(spacing: 10) { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(theme.accent) + Text("Neustart erforderlich, um die Änderung zu übernehmen.") + .font(.system(size: 12)) + .foregroundStyle(theme.contentSecondary) + Spacer() + Button("Jetzt") { + exit(0) + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) } - .padding(.horizontal, 16) - .padding(.vertical, 10) } .background(theme.surfaceCard) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .padding(.horizontal, 20) + .animation(.easeInOut(duration: 0.2), value: icloudSyncEnabled) + .animation(.easeInOut(duration: 0.2), value: icloudToggleChanged) + .animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing) + } + + // Entwickler-Log + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Diagnose", icon: "list.bullet.rectangle") + .padding(.horizontal, 20) + + NavigationLink(destination: LogExportView()) { + HStack(spacing: 14) { + Image(systemName: "doc.text") + .font(.system(size: 15)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text("Entwickler-Log") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Text("\(AppEventLog.shared.entries.count) Einträge – Export als Textdatei") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + } } // About @@ -437,6 +551,87 @@ struct ThemeOptionRow: View { } } +// MARK: - AftermathDelayOption + +enum AftermathDelayOption: String, CaseIterable { + case hours24 = "24h" + case hours36 = "36h" + case hours48 = "48h" + + var label: String { rawValue } + + var seconds: TimeInterval { + switch self { + case .hours24: return 24 * 3600 + case .hours36: return 36 * 3600 + case .hours48: return 48 * 3600 + } + } + + static func loadFromDefaults() -> AftermathDelayOption { + let raw = UserDefaults.standard.string(forKey: "aftermathDelayOption") ?? AftermathDelayOption.hours36.rawValue + return AftermathDelayOption(rawValue: raw) ?? .hours36 + } +} + +// MARK: - AppLanguage + +enum AppLanguage: String, CaseIterable { + case german = "de" + case english = "en" + + var displayName: String { + switch self { + case .german: return "Deutsch" + case .english: return "English" + } + } + + /// System prompt für die KI – gibt die Antwortsprache vor. + var systemPrompt: String { + switch self { + case .german: + return "Du bist ein einfühlsamer Assistent für persönliche Beziehungspflege. Antworte ausschließlich auf Deutsch. Keine Emojis. Vermeide Wörter in GROSSBUCHSTABEN. Sei prägnant, warm und direkt. Strukturiere deine Antwort exakt wie verlangt. Nutze Markdown." + case .english: + return "You are an empathetic assistant for personal relationship management. Respond exclusively in English. No emojis. Avoid ALL CAPS words. Be concise, warm and direct. Structure your response exactly as requested. Use Markdown." + } + } + + /// Analyse-Prompt-Suffix (Anweisung + Formatvorgabe). + /// Die Struktur-Labels MUSTER/BEZIEHUNG/EMPFEHLUNG bleiben sprachunabhängig – sie dienen als Parse-Token. + var analysisInstruction: String { + switch self { + case .german: + return "Analysiere diese Beziehung. Berücksichtige die Lebensphase der Person anhand des Geburtsjahres, sofern bekannt. Nutze Markdown. Verwende **fett** für wichtige Begriffe. Antworte in exakt diesem Format:\n\nMUSTER: [2-3 Sätze über wiederkehrende Themen und Muster]\nBEZIEHUNG: [2-3 Sätze über die Entwicklung und Qualität der Beziehung]\nEMPFEHLUNG: [1 konkreter, sofort umsetzbarer nächster Schritt]" + case .english: + return "Analyze this relationship. Consider the person's life stage based on their birth year if known. Use Markdown. Use **bold** for key terms. Respond in exactly this format:\n\nMUSTER: [2-3 sentences about recurring themes and patterns]\nBEZIEHUNG: [2-3 sentences about the development and quality of the relationship]\nEMPFEHLUNG: [1 concrete, immediately actionable next step]" + } + } + + /// Geschenkideen-Prompt-Suffix. + /// IDEE 1/2/3 bleiben als Parse-Token erhalten. + var giftInstruction: String { + switch self { + case .german: + return "Der Geburtstag dieser Person steht bevor. Schlage 3 konkrete, persönliche Geschenkideen vor. Berücksichtige Interessen und bisherige gemeinsame Momente. Sei kreativ aber realistisch. Kein Smalltalk, keine Erklärungen außerhalb der Ideen. Nenne erwartete Kosten. Antworte in exakt diesem Format:\n\nIDEE 1: [Geschenkidee – 1 Satz Begründung]\nIDEE 2: [Geschenkidee – 1 Satz Begründung]\nIDEE 3: [Geschenkidee – 1 Satz Begründung]" + case .english: + return "This person's birthday is coming up. Suggest 3 concrete, personal gift ideas. Consider their interests and past shared moments. Be creative but realistic. No small talk, no explanations outside the ideas. Mention expected costs. Respond in exactly this format:\n\nIDEE 1: [Gift idea – 1 sentence reason]\nIDEE 2: [Gift idea – 1 sentence reason]\nIDEE 3: [Gift idea – 1 sentence reason]" + } + } + + var momentsLabel: String { self == .english ? "Moments" : "Momente" } + var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" } + var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" } + var interestsLabel: String { self == .english ? "Interests" : "Interessen" } + + /// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab. + /// Unterstützte Sprachen: de, en – alle anderen fallen auf .german zurück. + static var current: AppLanguage { + let code = Locale.current.language.languageCode?.identifier ?? "de" + return AppLanguage(rawValue: code) ?? .german + } +} + // MARK: - Settings Info Row struct SettingsInfoRow: View { diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift index 66f05b5..1326b85 100644 --- a/nahbar/nahbar/SharedComponents.swift +++ b/nahbar/nahbar/SharedComponents.swift @@ -9,7 +9,9 @@ struct PersonAvatar: View { var body: some View { Group { - if let data = person.photoData, let uiImage = UIImage(data: data) { + // currentPhotoData bevorzugt photo?.imageData (V3), fällt auf + // das Legacy-Feld photoData zurück bis der Repair-Pass gelaufen ist. + if let data = person.currentPhotoData, let uiImage = UIImage(data: data) { Image(uiImage: uiImage) .resizable() .scaledToFill() @@ -47,7 +49,7 @@ struct TagBadge: View { struct SectionHeader: View { @Environment(\.nahbarTheme) var theme - let title: String + let title: LocalizedStringKey let icon: String var body: some View { @@ -55,7 +57,8 @@ struct SectionHeader: View { Image(systemName: icon) .font(.system(size: 11, weight: .medium)) .foregroundStyle(theme.contentTertiary) - Text(title.uppercased()) + Text(title) + .textCase(.uppercase) .font(.system(size: 11, weight: .semibold)) .tracking(0.8) .foregroundStyle(theme.contentTertiary) diff --git a/nahbar/nahbar/SplashView.swift b/nahbar/nahbar/SplashView.swift index 1b855e2..20effde 100644 --- a/nahbar/nahbar/SplashView.swift +++ b/nahbar/nahbar/SplashView.swift @@ -1,12 +1,17 @@ import SwiftUI -// MARK: - Quote Model +// MARK: - API Response Models -private struct ZitatResponse: Decodable { +private struct ZitatServiceResponse: Decodable { let quote: String let authorName: String } +private struct ZenQuoteResponse: Decodable { + let q: String // quote text + let a: String // author +} + // MARK: - Fallback Quotes private struct LocalQuote { @@ -14,22 +19,33 @@ private struct LocalQuote { let author: String } -private let fallbackQuotes: [LocalQuote] = [ +private let fallbackQuotesDE: [LocalQuote] = [ LocalQuote(text: "Der Mensch ist dem Menschen am nötigsten.", author: "Lucius Annaeus Seneca"), LocalQuote(text: "Glück ist nur real, wenn es geteilt wird.", author: "Christopher McCandless"), - LocalQuote(text: "Man reist nicht, um anzukommen, sondern um zu reisen.", author: "Johann Wolfgang von Goethe"), - LocalQuote(text: "Freundschaft ist wie Gesundheit: Ihren Wert kennt man erst, wenn man sie verloren hat.", author: "Unbekannt"), + LocalQuote(text: "Freundschaft ist wie Gesundheit: Ihren Wert kennt man erst, wenn man sie verloren hat.", author: ""), LocalQuote(text: "Ein Freund ist jemand, der dich kennt und trotzdem mag.", author: "Elbert Hubbard"), - LocalQuote(text: "Das Geheimnis der menschlichen Existenz liegt nicht nur darin, am Leben zu bleiben, sondern auch einen Grund zum Leben zu finden.", author: "Fjodor Dostojewski"), - LocalQuote(text: "Wer einen Freund hat, hat einen Schatz.", author: "Sprichwort"), - LocalQuote(text: "Nähe entsteht nicht durch Distanz.", author: "Unbekannt"), + LocalQuote(text: "Wer einen Freund hat, hat einen Schatz.", author: ""), + LocalQuote(text: "Nähe entsteht nicht durch Distanz.", author: ""), LocalQuote(text: "Der beste Spiegel ist ein alter Freund.", author: "George Herbert"), - LocalQuote(text: "Manche Menschen kommen in unser Leben und hinterlassen Fußspuren in unseren Herzen.", author: "Unbekannt"), LocalQuote(text: "Echte Freundschaft zeigt sich in schwierigen Zeiten.", author: "Aristoteles"), LocalQuote(text: "Das Leben wird vorwärts gelebt und rückwärts verstanden.", author: "Søren Kierkegaard"), LocalQuote(text: "Verbindung ist das, worum es im Leben geht.", author: "Brené Brown"), - LocalQuote(text: "Kleine Gesten der Fürsorge können das Leben eines Menschen verändern.", author: "Unbekannt"), - LocalQuote(text: "Zeit ist das Wertvollste, das ein Mensch verschenken kann.", author: "Unbekannt"), + LocalQuote(text: "Kleine Gesten der Fürsorge können das Leben eines Menschen verändern.", author: ""), + LocalQuote(text: "Zeit ist das Wertvollste, das ein Mensch verschenken kann.", author: ""), +] + +private let fallbackQuotesEN: [LocalQuote] = [ + LocalQuote(text: "The greatest gift of life is friendship, and I have received it.", author: "Hubert H. Humphrey"), + LocalQuote(text: "Happiness is only real when shared.", author: "Christopher McCandless"), + LocalQuote(text: "A real friend is one who walks in when the rest of the world walks out.", author: "Walter Winchell"), + LocalQuote(text: "The quality of your life is the quality of your relationships.", author: "Anthony Robbins"), + LocalQuote(text: "Connection is why we're here.", author: "Brené Brown"), + LocalQuote(text: "A friend is someone who knows all about you and still loves you.", author: "Elbert Hubbard"), + LocalQuote(text: "The best mirror is an old friend.", author: "George Herbert"), + LocalQuote(text: "Real friendship shows itself in difficult times.", author: "Aristotle"), + LocalQuote(text: "Life is lived forward, but understood backward.", author: "Søren Kierkegaard"), + LocalQuote(text: "Time is the most valuable thing you can give someone.", author: ""), + LocalQuote(text: "Small acts of kindness can change someone's life.", author: ""), ] // MARK: - SplashView @@ -44,6 +60,14 @@ struct SplashView: View { @State private var logoOpacity: CGFloat = 0 @State private var quoteShownAt: Date? = nil + private var isGerman: Bool { + Locale.current.language.languageCode?.identifier == "de" + } + + /// Locale-korrekte Anführungszeichen: „..." auf Deutsch, "..." auf Englisch + private var openQuote: String { isGerman ? "\u{201E}" : "\u{201C}" } + private var closeQuote: String { isGerman ? "\u{201C}" : "\u{201D}" } + var body: some View { ZStack { theme.backgroundPrimary.ignoresSafeArea() @@ -64,13 +88,13 @@ struct SplashView: View { // Quote if !quoteText.isEmpty { VStack(spacing: 10) { - Text("\u{201E}\(quoteText)\u{201C}") + Text("\(openQuote)\(quoteText)\(closeQuote)") .font(.system(.title3, design: theme.displayDesign)) .foregroundStyle(theme.contentSecondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - if !authorName.isEmpty && authorName != "Unbekannt" { + if !authorName.isEmpty { Text("— \(authorName)") .font(.system(.subheadline, design: theme.displayDesign)) .foregroundStyle(theme.contentTertiary) @@ -88,16 +112,14 @@ struct SplashView: View { logoOpacity = 1.0 } Task { - // API mit 1s Timeout probieren, sonst Fallback - if let apiQuote = await fetchQuote() { - showQuote(text: apiQuote.quote, author: apiQuote.authorName) + if let fetched = await fetchQuote() { + showQuote(text: fetched.text, author: fetched.author) } else { showFallbackQuote() } - // Mindestens 4 Sekunden Zitat sichtbar lassen let elapsed = quoteShownAt.map { Date().timeIntervalSince($0) } ?? 0 - let remaining = max(0, 5.0 - elapsed) + let remaining = max(0, readingDuration(for: quoteText) - elapsed) if remaining > 0 { try? await Task.sleep(for: .seconds(remaining)) } @@ -109,6 +131,18 @@ struct SplashView: View { } } + // MARK: - Reading Duration + + /// Anzeigedauer basierend auf Textlänge (ca. 150 WPM ≈ 2,5 Wörter/Sek.). + /// Minimum 3 s, Maximum 8 s. + private func readingDuration(for text: String) -> TimeInterval { + let wordCount = max(1, text.split(separator: " ").count) + let seconds = Double(wordCount) / 2.5 + return min(max(seconds, 3.0), 8.0) + } + + // MARK: - Quote Logic + private func showQuote(text: String, author: String) { withAnimation(.easeIn(duration: 0.5)) { quoteText = text @@ -118,16 +152,42 @@ struct SplashView: View { } private func showFallbackQuote() { - guard let local = fallbackQuotes.randomElement() else { return } + let pool = isGerman ? fallbackQuotesDE : fallbackQuotesEN + guard let local = pool.randomElement() else { return } showQuote(text: local.text, author: local.author) } - private func fetchQuote() async -> ZitatResponse? { + /// Wählt API je nach Systemsprache: zitat-service.de (DE) oder zenquotes.io (EN). + private func fetchQuote() async -> (text: String, author: String)? { + if isGerman { + return await fetchZitatService() + } else { + return await fetchZenQuote() + } + } + + /// https://api.zitat-service.de – kostenlos, Deutsch + private func fetchZitatService() async -> (text: String, author: String)? { guard let url = URL(string: "https://api.zitat-service.de/v1/quote?language=de") else { return nil } - let request = URLRequest(url: url, timeoutInterval: 1) do { - let (data, _) = try await URLSession.shared.data(for: request) - return try JSONDecoder().decode(ZitatResponse.self, from: data) + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 1)) + let r = try JSONDecoder().decode(ZitatServiceResponse.self, from: data) + let author = (r.authorName == "Unbekannt") ? "" : r.authorName + return (r.quote, author) + } catch { + return nil + } + } + + /// https://zenquotes.io – kostenlos, kein API-Key, Englisch + private func fetchZenQuote() async -> (text: String, author: String)? { + guard let url = URL(string: "https://zenquotes.io/api/random") else { return nil } + do { + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 2)) + let responses = try JSONDecoder().decode([ZenQuoteResponse].self, from: data) + guard let first = responses.first else { return nil } + let author = (first.a == "Unknown") ? "" : first.a + return (first.q, author) } catch { return nil } diff --git a/nahbar/nahbar/StoreManager.swift b/nahbar/nahbar/StoreManager.swift index 85c5b36..374619d 100644 --- a/nahbar/nahbar/StoreManager.swift +++ b/nahbar/nahbar/StoreManager.swift @@ -2,20 +2,49 @@ import StoreKit import SwiftUI import Combine +// MARK: - Subscription Tier + +enum SubscriptionTier: CaseIterable { + case pro, max + + var productID: String { + switch self { + case .pro: return "profeatures" + case .max: return "maxfeatures" + } + } + + var displayName: String { + switch self { + case .pro: return "Pro" + case .max: return "Max" + } + } +} + +// MARK: - StoreManager + @MainActor class StoreManager: ObservableObject { static let shared = StoreManager() + /// Wahr wenn Pro ODER Max aktiv — steuert Kontaktlimit, Themes, Teilen-Extension @Published private(set) var isPro: Bool = false - @Published private(set) var product: Product? = nil + /// Wahr nur wenn Max aktiv — steuert KI-Analyse & Geschenkideen + @Published private(set) var isMax: Bool = false + + @Published private(set) var proProduct: Product? = nil + @Published private(set) var maxProduct: Product? = nil @Published private(set) var purchaseError: String? = nil - private let productID = "profeatures" + /// Rückwärtskompatibilität für bestehende Aufrufstellen + var product: Product? { proProduct } + private var transactionListenerTask: Task? = nil private init() { transactionListenerTask = listenForTransactions() - Task { await loadProduct() } + Task { await loadProducts() } Task { await refreshStatus() } } @@ -23,22 +52,23 @@ class StoreManager: ObservableObject { transactionListenerTask?.cancel() } - // MARK: - Load product + // MARK: - Produkte laden - func loadProduct() async { + func loadProducts() async { do { - let products = try await Product.products(for: [productID]) - product = products.first - } catch { - // Produkt konnte nicht geladen werden — kein Absturz - } + let ids = SubscriptionTier.allCases.map { $0.productID } + let products = try await Product.products(for: ids) + proProduct = products.first { $0.id == SubscriptionTier.pro.productID } + maxProduct = products.first { $0.id == SubscriptionTier.max.productID } + } catch {} } - // MARK: - Purchase + // MARK: - Kauf - func purchase() async { - if product == nil { await loadProduct() } - guard let product else { + func purchase(tier: SubscriptionTier) async { + let current = tier == .pro ? proProduct : maxProduct + if current == nil { await loadProducts() } + guard let product = (tier == .pro ? proProduct : maxProduct) else { purchaseError = "Produkt konnte nicht geladen werden. Bitte Internetverbindung prüfen." return } @@ -63,7 +93,7 @@ class StoreManager: ObservableObject { } } - // MARK: - Restore + // MARK: - Wiederherstellen func restorePurchases() async { do { @@ -77,18 +107,24 @@ class StoreManager: ObservableObject { // MARK: - Status func refreshStatus() async { + var foundPro = false + var foundMax = false for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, - transaction.productID == productID, transaction.revocationDate == nil { - isPro = true - return + switch transaction.productID { + case SubscriptionTier.pro.productID: foundPro = true + case SubscriptionTier.max.productID: foundMax = true + default: break + } } } - isPro = false + isMax = foundMax + isPro = foundPro || foundMax // Max schließt alle Pro-Features ein + AppGroup.saveProStatus(isPro) } - // MARK: - Transaction listener + // MARK: - Transaction Listener private func listenForTransactions() -> Task { Task(priority: .background) { diff --git a/nahbar/nahbar/ThemePickerView.swift b/nahbar/nahbar/ThemePickerView.swift index fc2393e..87b4a52 100644 --- a/nahbar/nahbar/ThemePickerView.swift +++ b/nahbar/nahbar/ThemePickerView.swift @@ -76,7 +76,7 @@ struct ThemePickerView: View { // MARK: - Group - private func themeGroup(title: String, icon: String, themes: [ThemeID]) -> some View { + private func themeGroup(title: LocalizedStringKey, icon: String, themes: [ThemeID]) -> some View { VStack(alignment: .leading, spacing: 10) { SectionHeader(title: title, icon: icon) .padding(.horizontal, 20) diff --git a/nahbar/nahbar/ThemeSystem.swift b/nahbar/nahbar/ThemeSystem.swift index 6806755..99ac3b2 100644 --- a/nahbar/nahbar/ThemeSystem.swift +++ b/nahbar/nahbar/ThemeSystem.swift @@ -41,7 +41,7 @@ enum ThemeID: String, CaseIterable, Codable { } } - var tagline: String { + var tagline: LocalizedStringKey { switch self { case .linen: return "Ruhig & warm" case .slate: return "Klar & fokussiert" diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 10cf922..95e85be 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -4,6 +4,11 @@ import SwiftData struct TodayView: View { @Environment(\.nahbarTheme) var theme @Query private var people: [Person] + @Query(filter: #Predicate { $0.statusRaw == "warte_nachwirkung" }, + sort: \Visit.visitDate, order: .reverse) + private var pendingAftermaths: [Visit] + @State private var showingAftermathRating = false + @State private var selectedVisitForAftermath: Visit? = nil @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 private var needsAttention: [Person] { @@ -36,10 +41,11 @@ struct TodayView: View { } private var isEmpty: Bool { - needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty && upcomingReminders.isEmpty + needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty + && upcomingReminders.isEmpty && pendingAftermaths.isEmpty } - private var birthdaySectionTitle: String { + private var birthdaySectionTitle: LocalizedStringKey { switch daysAhead { case 3: return "In 3 Tagen" case 7: return "Diese Woche" @@ -49,7 +55,7 @@ struct TodayView: View { } } - private var greeting: String { + private var greeting: LocalizedStringKey { let hour = Calendar.current.component(.hour, from: Date()) if hour < 12 { return "Guten Morgen." } if hour < 18 { return "Guten Tag." } @@ -57,10 +63,7 @@ struct TodayView: View { } private var formattedToday: String { - let fmt = DateFormatter() - fmt.locale = Locale(identifier: "de_DE") - fmt.dateFormat = "EEEE, d. MMMM" - return fmt.string(from: Date()) + Date.now.formatted(.dateTime.weekday(.wide).month(.wide).day()) } var body: some View { @@ -128,6 +131,41 @@ struct TodayView: View { } } + if !pendingAftermaths.isEmpty { + TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") { + ForEach(pendingAftermaths) { visit in + Button { + selectedVisitForAftermath = visit + showingAftermathRating = true + } label: { + HStack(spacing: 12) { + if let p = visit.person { + PersonAvatar(person: p, size: 36) + } + VStack(alignment: .leading, spacing: 2) { + Text(visit.person?.name ?? "Unbekannt") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(theme.contentPrimary) + Text("Treffen \(visit.visitDate.formatted(date: .abbreviated, time: .omitted))") + .font(.system(size: 13)) + .foregroundStyle(theme.contentTertiary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + if visit.id != pendingAftermaths.last?.id { + RowDivider() + } + } + } + } + if !needsAttention.isEmpty { TodaySection(title: "Schon eine Weile her", icon: "clock") { ForEach(needsAttention.prefix(5)) { person in @@ -148,6 +186,9 @@ struct TodayView: View { } .background(theme.backgroundPrimary.ignoresSafeArea()) .navigationBarHidden(true) + .sheet(item: $selectedVisitForAftermath) { visit in + AftermathRatingFlowView(visit: visit) + } } } @@ -176,9 +217,9 @@ struct TodayView: View { if let date = cal.date(byAdding: .day, value: offset, to: Date()) { let dc = cal.dateComponents([.month, .day], from: date) if dc.month == bdc.month && dc.day == bdc.day { - if offset == 0 { return "Heute Geburtstag 🎂" } - if offset == 1 { return "Morgen Geburtstag" } - return "In \(offset) Tagen Geburtstag" + if offset == 0 { return String(localized: "Heute Geburtstag 🎂") } + if offset == 1 { return String(localized: "Morgen Geburtstag") } + return String.localizedStringWithFormat(String(localized: "In %lld Tagen Geburtstag"), Int64(offset)) } } } @@ -187,19 +228,15 @@ struct TodayView: View { private func reminderHint(for person: Person) -> String { guard let reminder = person.nextStepReminderDate else { return person.nextStep ?? "" } - let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE"))) + let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute()) return "\(person.nextStep ?? "") · \(dateStr)" } private func lastSeenHint(for person: Person) -> String { - guard let last = person.lastMomentDate else { return "Noch keine Momente festgehalten" } - let days = Int(Date().timeIntervalSince(last) / 86400) - if days == 0 { return "Heute" } - if days == 1 { return "Gestern" } - if days < 7 { return "Vor \(days) Tagen" } - if days < 30 { return "Vor \(days / 7) Woche\(days / 7 == 1 ? "" : "n")" } - let months = days / 30 - return "Vor \(months) Monat\(months == 1 ? "" : "en")" + guard let last = person.lastMomentDate else { return String(localized: "Noch keine Momente festgehalten") } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: last, relativeTo: Date()) } } @@ -247,12 +284,16 @@ struct GiftSuggestionRow: View { } } .animation(.easeInOut(duration: 0.2), value: isExpanded) - .sheet(isPresented: $showPaywall) { PaywallView() } + .sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) } + } + + private var canUseAI: Bool { + store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft } private var idleButton: some View { Button { - guard store.isPro else { showPaywall = true; return } + guard canUseAI else { showPaywall = true; return } Task { await loadGift() } } label: { HStack(spacing: 8) { @@ -261,13 +302,19 @@ struct GiftSuggestionRow: View { Text("Geschenkidee vorschlagen") .font(.system(size: 13)) Spacer() - if !store.isPro { - Image(systemName: "lock.fill") - .font(.system(size: 11)) - .foregroundStyle(theme.contentTertiary) + if !store.isMax { + Text(canUseAI + ? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" + : "MAX") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(theme.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(theme.accent.opacity(0.10)) + .clipShape(Capsule()) } } - .foregroundStyle(store.isPro ? theme.accent : theme.contentSecondary) + .foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary) .padding(.horizontal, 16) .padding(.vertical, 10) } @@ -366,6 +413,7 @@ struct GiftSuggestionRow: View { state = .loading do { let suggestion = try await AIAnalysisService.shared.suggestGift(person: person) + if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() } isExpanded = true state = .result(suggestion, Date()) } catch { @@ -383,7 +431,7 @@ struct GiftSuggestionRow: View { struct TodaySection: View { @Environment(\.nahbarTheme) var theme - let title: String + let title: LocalizedStringKey let icon: String @ViewBuilder let content: Content diff --git a/nahbar/nahbar/UserProfileStore.swift b/nahbar/nahbar/UserProfileStore.swift new file mode 100644 index 0000000..1a023f5 --- /dev/null +++ b/nahbar/nahbar/UserProfileStore.swift @@ -0,0 +1,130 @@ +import Combine +import Foundation +import UIKit +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "UserProfile") + +// MARK: - UserProfileStore +// Speichert das eigene Nutzerprofil in UserDefaults (einfache Felder) +// und das Profilfoto als Datei im Documents-Verzeichnis. +// Kein SwiftData – ein einzelnes Profil braucht kein relationales Modell. + +final class UserProfileStore: ObservableObject { + static let shared = UserProfileStore() + + @Published private(set) var name: String = "" + @Published private(set) var birthday: Date? = nil + @Published private(set) var occupation: String = "" + @Published private(set) var location: String = "" + @Published private(set) var likes: String = "" + @Published private(set) var dislikes: String = "" + @Published private(set) var socialStyle: String = "" + + private let defaults = UserDefaults.standard + private let storageKey = "nahbar.userProfile" + + private init() { load() } + + // MARK: - Derived + + var isEmpty: Bool { + name.isEmpty && occupation.isEmpty && location.isEmpty + && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + } + + var initials: String { + let parts = name.split(separator: " ") + if parts.count >= 2 { + return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased() + } + return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() + } + + // MARK: - Foto + + private var photoURL: URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first?.appendingPathComponent("nahbar_ich_photo.jpg") + } + + var hasPhoto: Bool { + guard let url = photoURL else { return false } + return FileManager.default.fileExists(atPath: url.path) + } + + func loadPhoto() -> UIImage? { + guard let url = photoURL, + let data = try? Data(contentsOf: url) else { return nil } + return UIImage(data: data) + } + + func savePhoto(_ image: UIImage?) { + guard let url = photoURL else { return } + do { + if let image, let data = image.jpegData(compressionQuality: 0.82) { + try data.write(to: url, options: .atomic) + } else { + try? FileManager.default.removeItem(at: url) + } + } catch { + logger.error("Profilfoto konnte nicht gespeichert werden: \(error.localizedDescription)") + } + objectWillChange.send() + } + + // MARK: - Update (batch, explizit durch Nutzer bestätigt) + + func update( + name: String, + birthday: Date?, + occupation: String, + location: String, + likes: String, + dislikes: String, + socialStyle: String + ) { + self.name = name + self.birthday = birthday + self.occupation = occupation + self.location = location + self.likes = likes + self.dislikes = dislikes + self.socialStyle = socialStyle + save() + } + + // MARK: - Persistenz + + private func save() { + var dict: [String: Any] = [ + "name": name, + "occupation": occupation, + "location": location, + "likes": likes, + "dislikes": dislikes, + "socialStyle": socialStyle + ] + if let bd = birthday { dict["birthday"] = bd.timeIntervalSince1970 } + defaults.set(dict, forKey: storageKey) + logger.debug("UserProfile gespeichert") + } + + private func load() { + guard let dict = defaults.dictionary(forKey: storageKey) else { return } + name = dict["name"] as? String ?? "" + occupation = dict["occupation"] as? String ?? "" + location = dict["location"] as? String ?? "" + likes = dict["likes"] as? String ?? "" + // Migration: alte "Interessen" in "Mag ich" übernehmen, falls noch nicht gesetzt + if likes.isEmpty, let legacy = dict["interests"] as? String, !legacy.isEmpty { + likes = legacy + } + dislikes = dict["dislikes"] as? String ?? "" + socialStyle = dict["socialStyle"] as? String ?? "" + if let ts = dict["birthday"] as? Double { + birthday = Date(timeIntervalSince1970: ts) + } + logger.debug("UserProfile geladen: \(self.name)") + } +} diff --git a/nahbar/nahbarShareExtension/Info.plist b/nahbar/nahbarShareExtension/Info.plist index 0fad99d..bfec629 100644 --- a/nahbar/nahbarShareExtension/Info.plist +++ b/nahbar/nahbarShareExtension/Info.plist @@ -12,10 +12,10 @@ - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).ShareViewController NSExtensionPointIdentifier com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController diff --git a/nahbar/nahbarShareExtension/ShareExtensionView.swift b/nahbar/nahbarShareExtension/ShareExtensionView.swift index 8d4baca..ba4982d 100644 --- a/nahbar/nahbarShareExtension/ShareExtensionView.swift +++ b/nahbar/nahbarShareExtension/ShareExtensionView.swift @@ -19,6 +19,7 @@ struct ShareExtensionView: View { @State private var searchText = "" @State private var isSaving = false @State private var errorMessage: String? + @State private var isProUser: Bool = false init(sharedText: String, onDismiss: @escaping () -> Void) { self.sharedText = sharedText @@ -34,6 +35,9 @@ struct ShareExtensionView: View { var body: some View { NavigationStack { + if !isProUser { + proRequiredView + } else { Form { Section("Nachricht") { TextEditor(text: $text) @@ -98,8 +102,40 @@ struct ShareExtensionView: View { .disabled(selectedPerson == nil || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) } } + } // end if isProUser + } + .onAppear { + isProUser = AppGroup.isProUser + if isProUser { loadPeople() } + } + } + + // MARK: - Pro Required + + private var proRequiredView: some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "lock.fill") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("nahbar Pro erforderlich") + .font(.headline) + Text("Die Teilen-Funktion ist in nahbar Pro enthalten. Öffne nahbar, um dein Abo zu verwalten.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Schließen", action: onDismiss) + .buttonStyle(.borderedProminent) + Spacer() + } + .navigationTitle("In nahbar speichern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen", action: onDismiss) + } } - .onAppear { loadPeople() } } // MARK: - Subviews @@ -142,7 +178,7 @@ struct ShareExtensionView: View { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } isSaving = true - AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue, source: momentSource.rawValue) + AppGroup.enqueueMoment(personID: person.id, personName: person.name, text: trimmed, type: momentType.rawValue, source: momentSource.rawValue) onDismiss() } } diff --git a/nahbar/nahbarShareExtension/de.lproj/MainInterface.strings b/nahbar/nahbarShareExtension/de.lproj/MainInterface.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/nahbar/nahbarShareExtension/de.lproj/MainInterface.strings @@ -0,0 +1 @@ + diff --git a/nahbar/nahbarTests/AppEventLogTests.swift b/nahbar/nahbarTests/AppEventLogTests.swift new file mode 100644 index 0000000..5ca1742 --- /dev/null +++ b/nahbar/nahbarTests/AppEventLogTests.swift @@ -0,0 +1,175 @@ +import Testing +import Foundation +import SwiftData +@testable import nahbar + +// MARK: - AppEventLog Tests +// +// Testet den In-Memory-Ring-Buffer und den Export. +// AppEventLog.shared ist ein Singleton – Tests sollten den Zustand +// am Ende nicht hinterlassen (wir testen nur lesende Operationen auf +// Entry-Strukturen, nicht auf dem geteilten Singleton). + +@Suite("AppEventLog – Entry-Struktur") +struct AppEventLogEntryTests { + + @Test("Entry Level priority ist aufsteigend geordnet") + func entryLevelPriorityIsAscending() { + let levels = AppEventLog.Entry.Level.allCases + for i in 1.. levels[i-1].priority, + "Level \(levels[i]) sollte höhere Priorität als \(levels[i-1]) haben") + } + } + + @Test("Alle Levels haben ein nicht-leeres emoji") + func allLevelsHaveEmoji() { + for level in AppEventLog.Entry.Level.allCases { + #expect(!level.emoji.isEmpty) + } + } + + @Test("Alle Levels haben ein nicht-leeres rawValue") + func allLevelsHaveRawValue() { + for level in AppEventLog.Entry.Level.allCases { + #expect(!level.rawValue.isEmpty) + } + } + + @Test("Entry hat korrekte Felder nach Erstellung") + func entryHasCorrectFieldsAfterCreation() { + let before = Date() + let entry = AppEventLog.Entry(level: .warning, category: "Test", message: "Testnachricht") + let after = Date() + + #expect(entry.level == .warning) + #expect(entry.category == "Test") + #expect(entry.message == "Testnachricht") + #expect(entry.timestamp >= before) + #expect(entry.timestamp <= after) + #expect(!entry.id.uuidString.isEmpty) + } + + @Test("formattedTimestamp hat Format HH:mm:ss") + func formattedTimestampHasCorrectFormat() { + let entry = AppEventLog.Entry(level: .info, category: "Test", message: "Test") + let ts = entry.formattedTimestamp + // Format: HH:mm:ss → 8 Zeichen mit 2 Doppelpunkten + #expect(ts.count == 8) + #expect(ts.filter { $0 == ":" }.count == 2) + } + + @Test("info hat niedrigste Priorität, critical die höchste") + func infoPriorityIsLowestCriticalIsHighest() { + #expect(AppEventLog.Entry.Level.info.priority < AppEventLog.Entry.Level.critical.priority) + } + + @Test("warning hat höhere Priorität als info") + func warningHasHigherPriorityThanInfo() { + #expect(AppEventLog.Entry.Level.warning.priority > AppEventLog.Entry.Level.info.priority) + } + + @Test("error hat höhere Priorität als warning") + func errorHasHigherPriorityThanWarning() { + #expect(AppEventLog.Entry.Level.error.priority > AppEventLog.Entry.Level.warning.priority) + } +} + +// MARK: - AppEventLog Export Tests + +@Suite("AppEventLog – Export") +struct AppEventLogExportTests { + + @Test("exportText enthält Header-Bereiche") + @MainActor + func exportTextContainsHeaderSections() { + let log = AppEventLog.shared + let exported = log.exportText() + + #expect(exported.contains("nahbar App-Log")) + #expect(exported.contains("Exportiert:")) + #expect(exported.contains("Version:")) + #expect(exported.contains("Gerät:")) + #expect(exported.contains("Einträge:")) + } + + @Test("exportText enthält mindestens einen Eintrag") + @MainActor + func exportTextContainsAtLeastOneEntry() { + let log = AppEventLog.shared + // Der shared-Log hat immer mindestens den Startup-Eintrag + #expect(log.entries.count >= 1) + let exported = log.exportText() + #expect(!exported.isEmpty) + } + + @Test("entries(minLevel:) filtert korrekt") + @MainActor + func entriesFiltersByMinLevel() { + let log = AppEventLog.shared + + // Alle Einträge >= warning sollten keine INFO-Einträge enthalten + let warningAndAbove = log.entries(minLevel: .warning) + for entry in warningAndAbove { + #expect(entry.level.priority >= AppEventLog.Entry.Level.warning.priority) + } + } +} + +// MARK: - LogExportDocument Tests + +@Suite("LogExportDocument – Transferable") +struct LogExportDocumentTests { + + @Test("LogExportDocument speichert Text korrekt") + func documentStoresTextCorrectly() { + let text = "Test Log Content\nLine 2" + let doc = LogExportDocument(text: text) + #expect(doc.text == text) + } +} + +// MARK: - Regressions-Wächter: Schema-Versionen + +@Suite("Schema – Regressionswächter") +struct SchemaRegressionTests { + + @Test("NahbarSchemaV1 hat Version 1.0.0") + func schemaV1HasCorrectVersion() { + #expect(NahbarSchemaV1.versionIdentifier.major == 1) + #expect(NahbarSchemaV1.versionIdentifier.minor == 0) + #expect(NahbarSchemaV1.versionIdentifier.patch == 0) + } + + @Test("NahbarSchemaV2 hat Version 2.0.0") + func schemaV2HasCorrectVersion() { + #expect(NahbarSchemaV2.versionIdentifier.major == 2) + #expect(NahbarSchemaV2.versionIdentifier.minor == 0) + #expect(NahbarSchemaV2.versionIdentifier.patch == 0) + } + + @Test("NahbarSchemaV3 hat Version 3.0.0") + func schemaV3HasCorrectVersion() { + #expect(NahbarSchemaV3.versionIdentifier.major == 3) + #expect(NahbarSchemaV3.versionIdentifier.minor == 0) + #expect(NahbarSchemaV3.versionIdentifier.patch == 0) + } + + @Test("Migrationsplan enthält genau 4 Schemas") + func migrationPlanHasFourSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 4) + } + + @Test("Migrationsplan enthält genau 3 Stages") + func migrationPlanHasThreeStages() { + #expect(NahbarMigrationPlan.stages.count == 3) + } + + @Test("ContainerFallback-Gleichheit funktioniert korrekt") + func containerFallbackEquality() { + #expect(ContainerFallback.cloudKit == .cloudKit) + #expect(ContainerFallback.localOnly == .localOnly) + #expect(ContainerFallback.inMemory == .inMemory) + #expect(ContainerFallback.cloudKit != .localOnly) + } +} diff --git a/nahbar/nahbarTests/AppGroupTests.swift b/nahbar/nahbarTests/AppGroupTests.swift new file mode 100644 index 0000000..472f06a --- /dev/null +++ b/nahbar/nahbarTests/AppGroupTests.swift @@ -0,0 +1,138 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - AppGroup Tests +// +// Testet die Shared-UserDefaults-Queue zwischen Hauptapp und Share Extension. +// Wichtig: Tests verwenden eigene UserDefaults-Suite um Produktionsdaten +// nicht zu beeinflussen. + +@Suite("AppGroup – Pending Moments Queue") +struct AppGroupQueueTests { + + // Nutzt eine isolierte UserDefaults-Suite für jeden Test + private let testDefaults = UserDefaults(suiteName: "nahbar.test.queue")! + + // Cleanup vor jedem Test + init() { + testDefaults.removeObject(forKey: "pendingMoments") + testDefaults.removeObject(forKey: "cachedPeople") + } + + @Test("pendingMoments ist initial leer") + func pendingMomentsInitiallyEmpty() { + // Die echte AppGroup-Queue kann Einträge aus vorherigen Tests enthalten, + // daher testen wir die Serialisierungslogik direkt + let emptyData = try! JSONSerialization.data(withJSONObject: [[String: String]]()) + testDefaults.set(emptyData, forKey: "pendingMoments") + + guard let data = testDefaults.data(forKey: "pendingMoments"), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] + else { + Issue.record("Konnte pendingMoments nicht lesen") + return + } + #expect(array.isEmpty) + } + + @Test("JSON-Serialisierung eines Moment-Eintrags ist korrekt") + func momentEntrySerializationIsCorrect() throws { + let personID = UUID() + let entry: [String: String] = [ + "personID": personID.uuidString, + "personName": "Max Mustermann", + "text": "Neuer Moment", + "type": MomentType.conversation.rawValue, + ] + + let queue = [entry] + let data = try JSONSerialization.data(withJSONObject: queue) + let decoded = try JSONSerialization.jsonObject(with: data) as! [[String: String]] + + #expect(decoded.count == 1) + #expect(decoded[0]["personID"] == personID.uuidString) + #expect(decoded[0]["personName"] == "Max Mustermann") + #expect(decoded[0]["text"] == "Neuer Moment") + #expect(decoded[0]["type"] == MomentType.conversation.rawValue) + } + + @Test("Mehrere Einträge werden korrekt serialisiert") + func multipleEntriesAreSerialized() throws { + var queue: [[String: String]] = [] + for i in 0..<5 { + queue.append([ + "personID": UUID().uuidString, + "personName": "Person \(i)", + "text": "Moment \(i)", + "type": MomentType.conversation.rawValue, + ]) + } + let data = try JSONSerialization.data(withJSONObject: queue) + let decoded = try JSONSerialization.jsonObject(with: data) as! [[String: String]] + #expect(decoded.count == 5) + for i in 0..<5 { + #expect(decoded[i]["personName"] == "Person \(i)") + } + } + + @Test("UUID-String ist gültig und round-trip-fähig") + func uuidStringIsValidAndRoundTrips() { + let original = UUID() + let str = original.uuidString + let parsed = UUID(uuidString: str) + #expect(parsed == original) + } + + @Test("source ist optional und fehlt bei nil korrekt im Dictionary") + func sourceIsOptionalInDictionary() throws { + var entry: [String: String] = [ + "personID": UUID().uuidString, + "personName": "Test", + "text": "Test", + "type": MomentType.thought.rawValue, + ] + // source NICHT hinzufügen + + let data = try JSONSerialization.data(withJSONObject: [entry]) + let decoded = try JSONSerialization.jsonObject(with: data) as! [[String: String]] + #expect(decoded[0]["source"] == nil) + + // Jetzt mit source + entry["source"] = MomentSource.whatsapp.rawValue + let data2 = try JSONSerialization.data(withJSONObject: [entry]) + let decoded2 = try JSONSerialization.jsonObject(with: data2) as! [[String: String]] + #expect(decoded2[0]["source"] == MomentSource.whatsapp.rawValue) + } +} + +// MARK: - AppGroup cachedPeople Tests + +@Suite("AppGroup – cachedPeople Serialisierung") +struct AppGroupCachedPeopleTests { + + @Test("Personenliste wird korrekt serialisiert und deserialisiert") + func peopleListSerializationRoundTrip() throws { + let people: [[String: String]] = [ + ["id": UUID().uuidString, "name": "Anna Meier", "tag": PersonTag.family.rawValue], + ["id": UUID().uuidString, "name": "Ben Schulz", "tag": PersonTag.friends.rawValue], + ["id": UUID().uuidString, "name": "Carla Wagner", "tag": PersonTag.work.rawValue], + ] + + let data = try JSONSerialization.data(withJSONObject: people) + let decoded = try JSONSerialization.jsonObject(with: data) as! [[String: String]] + + #expect(decoded.count == 3) + #expect(decoded[0]["name"] == "Anna Meier") + #expect(decoded[1]["tag"] == PersonTag.friends.rawValue) + #expect(UUID(uuidString: decoded[2]["id"]!) != nil) + } + + @Test("Leere Personenliste ist gültig") + func emptyPeopleListIsValid() throws { + let empty: [[String: String]] = [] + let data = try JSONSerialization.data(withJSONObject: empty) + let decoded = try JSONSerialization.jsonObject(with: data) as! [[String: String]] + #expect(decoded.isEmpty) + } +} diff --git a/nahbar/nahbarTests/CallWindowManagerTests.swift b/nahbar/nahbarTests/CallWindowManagerTests.swift new file mode 100644 index 0000000..bc48b3f --- /dev/null +++ b/nahbar/nahbarTests/CallWindowManagerTests.swift @@ -0,0 +1,179 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - CallWindowManager Tests + +@Suite("CallWindowManager – Fenster-Logik") +struct CallWindowManagerTests { + + // Testet die reine Zeitfenster-Berechnung ohne den Singleton zu benutzen. + // Die Logik: nowMin >= startMin && nowMin < endMin + + private func isInWindow(nowHour: Int, nowMin: Int, + startHour: Int, startMin: Int, + endHour: Int, endMin: Int) -> Bool { + let nowMinutes = nowHour * 60 + nowMin + let startMinutes = startHour * 60 + startMin + let endMinutes = endHour * 60 + endMin + return nowMinutes >= startMinutes && nowMinutes < endMinutes + } + + @Test("Exakt zu Fensterbeginn ist innerhalb") + func exactlyAtStartIsInWindow() { + #expect(isInWindow(nowHour: 17, nowMin: 0, + startHour: 17, startMin: 0, + endHour: 18, endMin: 0)) + } + + @Test("Eine Minute vor Ende ist innerhalb") + func oneMinuteBeforeEndIsInWindow() { + #expect(isInWindow(nowHour: 17, nowMin: 59, + startHour: 17, startMin: 0, + endHour: 18, endMin: 0)) + } + + @Test("Exakt zum Fensterende ist außerhalb") + func exactlyAtEndIsOutside() { + #expect(!isInWindow(nowHour: 18, nowMin: 0, + startHour: 17, startMin: 0, + endHour: 18, endMin: 0)) + } + + @Test("Eine Minute vor Start ist außerhalb") + func oneMinuteBeforeStartIsOutside() { + #expect(!isInWindow(nowHour: 16, nowMin: 59, + startHour: 17, startMin: 0, + endHour: 18, endMin: 0)) + } + + @Test("Mitten im Fenster ist innerhalb") + func middleOfWindowIsInside() { + #expect(isInWindow(nowHour: 17, nowMin: 30, + startHour: 17, startMin: 0, + endHour: 18, endMin: 0)) + } + + @Test("Fenster mit Minuten korrekt berechnet") + func windowWithMinutesIsCorrect() { + // Fenster: 17:30 – 17:45 + #expect(isInWindow(nowHour: 17, nowMin: 30, + startHour: 17, startMin: 30, + endHour: 17, endMin: 45)) + #expect(isInWindow(nowHour: 17, nowMin: 44, + startHour: 17, startMin: 30, + endHour: 17, endMin: 45)) + #expect(!isInWindow(nowHour: 17, nowMin: 45, + startHour: 17, startMin: 30, + endHour: 17, endMin: 45)) + } + + @Test("Leeres Fenster (start == end) enthält niemanden") + func emptyWindowContainsNobody() { + #expect(!isInWindow(nowHour: 17, nowMin: 0, + startHour: 17, startMin: 0, + endHour: 17, endMin: 0)) + } +} + +// MARK: - windowDescription Tests + +@Suite("CallWindowManager – windowDescription") +struct CallWindowDescriptionTests { + + // windowDescription = String(format: "%02d:%02d – %02d:%02d Uhr", ...) + + private func description(startH: Int, startM: Int, endH: Int, endM: Int) -> String { + String(format: "%02d:%02d – %02d:%02d Uhr", startH, startM, endH, endM) + } + + @Test("Standard-Fenster 17:00 – 18:00") + func standardWindowDescription() { + #expect(description(startH: 17, startM: 0, endH: 18, endM: 0) == "17:00 – 18:00 Uhr") + } + + @Test("Minuten werden mit führender Null formatiert") + func minutesHaveLeadingZero() { + #expect(description(startH: 9, startM: 5, endH: 10, endM: 0) == "09:05 – 10:00 Uhr") + } + + @Test("Stunden werden mit führender Null formatiert") + func hoursHaveLeadingZero() { + #expect(description(startH: 8, startM: 0, endH: 9, endM: 0) == "08:00 – 09:00 Uhr") + } +} + +// MARK: - selectPerson Logik Tests + +@Suite("CallWindowManager – selectPerson Logik") +struct SelectPersonLogicTests { + + // Testet die Selektions-Logik ohne den Manager zu instanziieren. + // Logik: Filter → Prioritize needsAttention → Sort → Random top 3 + + @Test("Leere Personenliste gibt nil zurück") + func emptyListReturnsNil() { + let persons: [Person] = [] + let result = persons.isEmpty ? nil as Person? : persons.first + #expect(result == nil) + } + + @Test("Person die heute vorgeschlagen wurde wird gefiltert") + func personSuggestedTodayIsFiltered() { + let p = Person(name: "Test") + p.lastSuggestedForCall = Date() // heute + + let candidates = [p].filter { person in + if let last = person.lastSuggestedForCall, + Calendar.current.isDateInToday(last) { return false } + return true + } + #expect(candidates.isEmpty) + } + + @Test("Person die heute noch nicht vorgeschlagen wurde ist Kandidat") + func personNotSuggestedTodayIsCandidate() { + let p = Person(name: "Test") + p.lastSuggestedForCall = Calendar.current.date(byAdding: .day, value: -8, to: Date()) + + let candidates = [p].filter { person in + if let last = person.lastSuggestedForCall, + Calendar.current.isDateInToday(last) { return false } + if let last = person.lastSuggestedForCall { + let days = Calendar.current.dateComponents([.day], from: last, to: Date()).day ?? 0 + if days < 7 { return false } + } + return true + } + #expect(!candidates.isEmpty) + } + + @Test("Person innerhalb der 7-Tage-Sperre wird ausgeschlossen") + func personWithin7DayCooldownIsExcluded() { + let p = Person(name: "Test") + p.lastSuggestedForCall = Calendar.current.date(byAdding: .day, value: -3, to: Date()) + + let candidates = [p].filter { person in + if let last = person.lastSuggestedForCall { + let days = Calendar.current.dateComponents([.day], from: last, to: Date()).day ?? 0 + if days < 7 { return false } + } + return true + } + #expect(candidates.isEmpty) + } + + @Test("needsAttention-Personen werden priorisiert") + func needsAttentionPersonsArePrioritized() { + let p1 = Person(name: "Vernachlässigt", nudgeFrequency: .weekly) + p1.createdAt = Calendar.current.date(byAdding: .day, value: -20, to: Date())! + + let p2 = Person(name: "Aktuell", nudgeFrequency: .weekly) + p2.createdAt = Date() + + let persons = [p2, p1] + let prioritized = persons.filter { $0.needsAttention } + #expect(prioritized.contains(where: { $0.name == "Vernachlässigt" })) + #expect(!prioritized.contains(where: { $0.name == "Aktuell" })) + } +} diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift new file mode 100644 index 0000000..c6f1612 --- /dev/null +++ b/nahbar/nahbarTests/ModelTests.swift @@ -0,0 +1,299 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - NudgeFrequency Tests + +@Suite("NudgeFrequency") +struct NudgeFrequencyTests { + + @Test("never gibt nil zurück") + func neverReturnsNil() { + #expect(NudgeFrequency.never.days == nil) + } + + @Test("weekly gibt 7 Tage zurück") + func weeklyReturnsSeven() { + #expect(NudgeFrequency.weekly.days == 7) + } + + @Test("biweekly gibt 14 Tage zurück") + func biweeklyReturnsFourteen() { + #expect(NudgeFrequency.biweekly.days == 14) + } + + @Test("monthly gibt 30 Tage zurück") + func monthlyReturnsThirty() { + #expect(NudgeFrequency.monthly.days == 30) + } + + @Test("quarterly gibt 90 Tage zurück") + func quarterlyReturnsNinety() { + #expect(NudgeFrequency.quarterly.days == 90) + } + + @Test("alle CaseIterable-Fälle haben korrekte Tage") + func allCasesHaveValidDays() { + for freq in NudgeFrequency.allCases { + if freq == .never { + #expect(freq.days == nil) + } else { + #expect(freq.days != nil) + #expect(freq.days! > 0) + } + } + } +} + +// MARK: - PersonTag Tests + +@Suite("PersonTag") +struct PersonTagTests { + + @Test("alle Tags haben ein nicht-leeres Icon") + func allTagsHaveIcons() { + for tag in PersonTag.allCases { + #expect(!tag.icon.isEmpty) + } + } + + @Test("family hat house-Icon") + func familyIconIsHouse() { + #expect(PersonTag.family.icon == "house") + } + + @Test("rawValue round-trip") + func rawValueRoundTrip() { + for tag in PersonTag.allCases { + let parsed = PersonTag(rawValue: tag.rawValue) + #expect(parsed == tag) + } + } +} + +// MARK: - MomentType Tests + +@Suite("MomentType") +struct MomentTypeTests { + + @Test("alle MomentTypes haben ein nicht-leeres Icon") + func allTypesHaveIcons() { + for type_ in MomentType.allCases { + #expect(!type_.icon.isEmpty) + } + } + + @Test("rawValue round-trip") + func rawValueRoundTrip() { + for type_ in MomentType.allCases { + let parsed = MomentType(rawValue: type_.rawValue) + #expect(parsed == type_) + } + } +} + +// MARK: - MomentSource Tests + +@Suite("MomentSource") +struct MomentSourceTests { + + @Test("alle Sources haben ein nicht-leeres Icon") + func allSourcesHaveIcons() { + for source in MomentSource.allCases { + #expect(!source.icon.isEmpty) + } + } +} + +// MARK: - Person Computed Properties Tests +// Person ist ein @Model – kann ohne Context instanziiert werden, +// solange nur non-Relationship-Properties getestet werden. + +@Suite("Person – Computed Properties") +struct PersonComputedPropertyTests { + + @Test("initials aus Vor- und Nachname") + func initialsFromFullName() { + let p = Person(name: "Max Mustermann") + #expect(p.initials == "MM") + } + + @Test("initials aus einem Wort (2 Zeichen)") + func initialsFromSingleWord() { + let p = Person(name: "Max") + #expect(p.initials == "MA") + } + + @Test("initials aus drei Wörtern nimmt erste zwei") + func initialsFromThreeWords() { + let p = Person(name: "Max Karl Mustermann") + #expect(p.initials == "MK") + } + + @Test("initials sind uppercase") + func initialsAreUppercase() { + let p = Person(name: "anna bach") + #expect(p.initials == p.initials.uppercased()) + } + + @Test("firstName aus vollem Namen") + func firstNameFromFullName() { + let p = Person(name: "Max Mustermann") + #expect(p.firstName == "Max") + } + + @Test("firstName bei einem Wort") + func firstNameFromSingleWord() { + let p = Person(name: "Max") + #expect(p.firstName == "Max") + } + + @Test("tag computed property round-trip") + func tagRoundTrip() { + let p = Person(name: "Test", tag: .family) + #expect(p.tag == .family) + p.tag = .work + #expect(p.tag == .work) + #expect(p.tagRaw == PersonTag.work.rawValue) + } + + @Test("nudgeFrequency computed property round-trip") + func nudgeFrequencyRoundTrip() { + let p = Person(name: "Test", nudgeFrequency: .weekly) + #expect(p.nudgeFrequency == .weekly) + p.nudgeFrequency = .quarterly + #expect(p.nudgeFrequency == .quarterly) + } + + @Test("needsAttention mit .never ist immer false") + func needsAttentionNeverIsAlwaysFalse() { + let p = Person(name: "Test", nudgeFrequency: .never) + #expect(!p.needsAttention) + } + + @Test("needsAttention kurz nach Erstellung ist false") + func needsAttentionJustCreatedIsFalse() { + let p = Person(name: "Test", nudgeFrequency: .weekly) + p.createdAt = Date() // gerade erstellt + #expect(!p.needsAttention) + } + + @Test("needsAttention nach abgelaufener Nudge-Periode ist true") + func needsAttentionAfterPeriodIsTrue() { + let p = Person(name: "Test", nudgeFrequency: .weekly) + // createdAt auf 10 Tage in der Vergangenheit setzen + p.createdAt = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + #expect(p.needsAttention) + } + + @Test("touch() aktualisiert updatedAt") + func touchUpdatesTimestamp() throws { + let p = Person(name: "Test") + let before = p.updatedAt + // Kurze Pause um unterschiedliche Timestamps zu garantieren + Thread.sleep(forTimeInterval: 0.01) + p.touch() + #expect(p.updatedAt > before) + } + + @Test("currentPhotoData bevorzugt photo gegenüber photoData") + func currentPhotoDataFallback() { + let p = Person(name: "Test") + #expect(p.currentPhotoData == nil) // Beide nil + + let legacyData = Data([0x01, 0x02]) + p.photoData = legacyData + #expect(p.currentPhotoData == legacyData) // Fallback auf legacy + } + + @Test("hasBirthdayWithin(0) gibt immer false") + func birthdayWithinZeroDays() { + let p = Person(name: "Test", birthday: Date()) + #expect(!p.hasBirthdayWithin(days: 0)) + } + + @Test("hasBirthdayWithin erkennt heutigen Geburtstag") + func birthdayWithinDetectsTodaysBirthday() { + // Geburtstag auf heute setzen (Jahr egal, nur Monat+Tag zählt) + let today = Date() + let cal = Calendar.current + var bdc = cal.dateComponents([.month, .day], from: today) + bdc.year = 1990 // beliebiges vergangenes Jahr + let birthday = cal.date(from: bdc)! + let p = Person(name: "Test", birthday: birthday) + #expect(p.hasBirthdayWithin(days: 1)) + } + + @Test("hasBirthdayWithin gibt false wenn Geburtstag außerhalb des Fensters") + func birthdayOutsideWindowReturnsFalse() { + let cal = Calendar.current + // Geburtstag auf gestern setzen + let yesterday = cal.date(byAdding: .day, value: -1, to: Date())! + var bdc = cal.dateComponents([.month, .day], from: yesterday) + bdc.year = 1990 + let birthday = cal.date(from: bdc)! + let p = Person(name: "Test", birthday: birthday) + // 1-Tage-Fenster ab heute: gestern liegt nicht darin + #expect(!p.hasBirthdayWithin(days: 1)) + } +} + +// MARK: - Moment Tests + +@Suite("Moment – Computed Properties") +struct MomentComputedPropertyTests { + + @Test("type computed property round-trip") + func typeRoundTrip() { + let m = Moment(text: "Test", type: .meeting) + #expect(m.type == .meeting) + m.type = .thought + #expect(m.type == .thought) + #expect(m.typeRaw == MomentType.thought.rawValue) + } + + @Test("source computed property round-trip") + func sourceRoundTrip() { + let m = Moment(text: "Test", source: .whatsapp) + #expect(m.source == .whatsapp) + m.source = .signal + #expect(m.source == .signal) + } + + @Test("source nil round-trip") + func sourceNilRoundTrip() { + let m = Moment(text: "Test", source: nil) + #expect(m.source == nil) + #expect(m.sourceRaw == nil) + } + + @Test("isImportant startet als false") + func isImportantDefaultsFalse() { + let m = Moment(text: "Test") + #expect(!m.isImportant) + } +} + +// MARK: - LogEntry Tests + +@Suite("LogEntry – Computed Properties") +struct LogEntryComputedPropertyTests { + + @Test("type computed property round-trip") + func typeRoundTrip() { + let entry = LogEntry(type: .call, title: "Test") + #expect(entry.type == .call) + entry.type = .nextStep + #expect(entry.type == .nextStep) + #expect(entry.typeRaw == LogEntryType.nextStep.rawValue) + } + + @Test("alle LogEntryTypes haben ein nicht-leeres Icon und color") + func allTypesHaveIconAndColor() { + let types: [LogEntryType] = [.nextStep, .calendarEvent, .call] + for type_ in types { + #expect(!type_.icon.isEmpty) + #expect(!type_.color.isEmpty) + } + } +} diff --git a/nahbar/nahbarTests/PersonRowTests.swift b/nahbar/nahbarTests/PersonRowTests.swift new file mode 100644 index 0000000..fb3916a --- /dev/null +++ b/nahbar/nahbarTests/PersonRowTests.swift @@ -0,0 +1,65 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - formatLastMoment Tests +// +// Testet die formatLastMoment-Hilfsfunktion aus PeopleListView. +// `now` wird injiziert, damit Zeitangaben deterministisch sind. + +@Suite("PersonRow – formatLastMoment") +struct PersonRowTests { + + private let now = Date(timeIntervalSinceReferenceDate: 0) + + @Test("Ausgabe beginnt immer mit 'Zuletzt '") + func outputStartsWithZuletzt() { + let date = now.addingTimeInterval(-3600) // 1 Stunde zuvor + #expect(formatLastMoment(date, relativeTo: now).hasPrefix("Zuletzt ")) + } + + @Test("Ausgabe ist nicht leer") + func outputIsNotEmpty() { + let date = now.addingTimeInterval(-86400) + #expect(!formatLastMoment(date, relativeTo: now).isEmpty) + } + + @Test("3 Tage zuvor enthält 'vor 3 Tagen'") + func threeDaysAgoContainsCorrectText() { + let date = now.addingTimeInterval(-(3 * 86400)) + let result = formatLastMoment(date, relativeTo: now) + #expect(result.contains("3 Tagen"), "Erwartet '3 Tagen' in '\(result)'") + } + + @Test("7 Tage zuvor enthält Wochenangabe") + func sevenDaysAgoContainsWeek() { + let date = now.addingTimeInterval(-(7 * 86400)) + let result = formatLastMoment(date, relativeTo: now) + // "vor einer Woche" oder "vor 7 Tagen" je nach OS-Version + let hasWeekOrDays = result.contains("Woche") || result.contains("Tagen") || result.contains("Tag") + #expect(hasWeekOrDays, "Erwartet Wochen- oder Tagesangabe in '\(result)'") + } + + @Test("30 Tage zuvor enthält Wochen- oder Monatsangabe") + func thirtyDaysAgoContainsWeekOrMonth() { + let date = now.addingTimeInterval(-(30 * 86400)) + let result = formatLastMoment(date, relativeTo: now) + let hasUnit = result.contains("Woche") || result.contains("Monat") + #expect(hasUnit, "Erwartet 'Woche' oder 'Monat' in '\(result)'") + } + + @Test("400 Tage zuvor enthält Jahresangabe") + func fourHundredDaysAgoContainsYear() { + let date = now.addingTimeInterval(-(400 * 86400)) + let result = formatLastMoment(date, relativeTo: now) + #expect(result.contains("Jahr"), "Erwartet 'Jahr' in '\(result)'") + } + + @Test("Zukünftiges Datum enthält 'in' (Zukunft)") + func futureDateContainsIn() { + let date = now.addingTimeInterval(86400) + let result = formatLastMoment(date, relativeTo: now) + #expect(result.contains("in") || result.contains("morgen"), + "Erwartet Zukunftsform in '\(result)'") + } +} diff --git a/nahbar/nahbarTests/README.md b/nahbar/nahbarTests/README.md new file mode 100644 index 0000000..90f4d01 --- /dev/null +++ b/nahbar/nahbarTests/README.md @@ -0,0 +1,37 @@ +# nahbar Unit Tests + +Swift Testing-basierte Regressionstests für die Kernlogik von nahbar. + +## Test-Target einrichten (einmalig) + +1. In Xcode: **File → New → Target** +2. Wähle **Unit Testing Bundle** +3. Name: `nahbarTests`, Host Application: `nahbar` +4. **Finish** + +5. Rechtsklick auf die erstellten Dateien im Finder → **Add Files to "nahbar"** + Alternativ: In Xcode die Dateien aus dem `nahbarTests/`-Ordner auf das neue Target ziehen. + +6. Sicherstellen, dass alle Dateien als **Target Membership** `nahbarTests` haben. + +## Tests ausführen + +`Cmd + U` oder **Product → Test** + +## Test-Dateien + +| Datei | Testet | +|-------|--------| +| `ModelTests.swift` | `Person`, `Moment`, `LogEntry` computed properties, `NudgeFrequency`, `PersonTag`, `MomentType` | +| `AppGroupTests.swift` | JSON-Serialisierung der Pending-Moments-Queue und cachedPeople | +| `UserProfileStoreTests.swift` | Initials-Logik, isEmpty-Logik | +| `CallWindowManagerTests.swift` | Zeitfenster-Berechnung, windowDescription, selectPerson-Filterlogik | +| `AppEventLogTests.swift` | Ring-Buffer-Struktur, Export-Format, Schema-Regressionswächter | + +## Wichtige Regressionstests + +Die Tests in `AppEventLogTests.swift` → Suite "Schema – Regressionswächter" stellen sicher, dass: +- Die Schema-Versionen V1/V2/V3 korrekte Versionsnummern haben +- Der Migrationsplan genau 3 Schemas und 2 Stages enthält + +Diese Tests **schlagen fehl**, wenn die Migration versehentlich geändert wird – genau das ist der Sinn. diff --git a/nahbar/nahbarTests/StoreTests.swift b/nahbar/nahbarTests/StoreTests.swift new file mode 100644 index 0000000..deb121c --- /dev/null +++ b/nahbar/nahbarTests/StoreTests.swift @@ -0,0 +1,163 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - SubscriptionTier Tests + +@Suite("SubscriptionTier – Enum") +struct SubscriptionTierTests { + + @Test("Genau 2 Tiers vorhanden") + func allCasesCount() { + #expect(SubscriptionTier.allCases.count == 2) + } + + @Test("Pro productID ist 'profeatures'") + func proProductID() { + #expect(SubscriptionTier.pro.productID == "profeatures") + } + + @Test("Max productID ist 'maxfeatures'") + func maxProductID() { + #expect(SubscriptionTier.max.productID == "maxfeatures") + } + + @Test("Pro displayName ist 'Pro'") + func proDisplayName() { + #expect(SubscriptionTier.pro.displayName == "Pro") + } + + @Test("Max displayName ist 'Max'") + func maxDisplayName() { + #expect(SubscriptionTier.max.displayName == "Max") + } + + @Test("productIDs sind einzigartig") + func productIDsAreUnique() { + let ids = SubscriptionTier.allCases.map { $0.productID } + #expect(Set(ids).count == SubscriptionTier.allCases.count) + } + + @Test("displayNames sind einzigartig") + func displayNamesAreUnique() { + let names = SubscriptionTier.allCases.map { $0.displayName } + #expect(Set(names).count == SubscriptionTier.allCases.count) + } + + @Test("productIDs sind nicht leer") + func productIDsNotEmpty() { + for tier in SubscriptionTier.allCases { + #expect(!tier.productID.isEmpty) + } + } +} + +// MARK: - AI Free Query Counter Tests + +// Tests laufen serialisiert, da alle auf UserDefaults.standard mit gleichem Key schreiben +@Suite("AIAnalysisService – Gratis-Abfragen", .serialized) +struct AIFreeQueryTests { + + init() { + AIAnalysisService.shared.freeQueriesUsed = 0 + } + + @Test("Limit ist genau 3") + func limitIsThree() { + #expect(AIAnalysisService.freeQueryLimit == 3) + } + + @Test("Initial: Abfragen verfügbar, remaining = 3") + func initialState() { + AIAnalysisService.shared.freeQueriesUsed = 0 + #expect(AIAnalysisService.shared.hasFreeQueriesLeft == true) + #expect(AIAnalysisService.shared.freeQueriesRemaining == 3) + } + + @Test("consumeFreeQuery erhöht Zähler um 1") + func consumeIncrementsCounter() { + AIAnalysisService.shared.freeQueriesUsed = 0 + AIAnalysisService.shared.consumeFreeQuery() + #expect(AIAnalysisService.shared.freeQueriesUsed == 1) + #expect(AIAnalysisService.shared.freeQueriesRemaining == 2) + } + + @Test("Nach 3 Verbrauchungen: hasFreeQueriesLeft == false") + func afterThreeConsumed() { + AIAnalysisService.shared.freeQueriesUsed = 0 + AIAnalysisService.shared.consumeFreeQuery() + AIAnalysisService.shared.consumeFreeQuery() + AIAnalysisService.shared.consumeFreeQuery() + #expect(AIAnalysisService.shared.hasFreeQueriesLeft == false) + #expect(AIAnalysisService.shared.freeQueriesRemaining == 0) + } + + @Test("Zähler über Limit: remaining bleibt 0, nicht negativ") + func remainingNotNegative() { + AIAnalysisService.shared.freeQueriesUsed = 10 + #expect(AIAnalysisService.shared.freeQueriesRemaining == 0) + #expect(AIAnalysisService.shared.hasFreeQueriesLeft == false) + } + + @Test("Zähler = limit - 1: noch genau 1 Abfrage verfügbar") + func oneQueryLeft() { + AIAnalysisService.shared.freeQueriesUsed = AIAnalysisService.freeQueryLimit - 1 + #expect(AIAnalysisService.shared.hasFreeQueriesLeft == true) + #expect(AIAnalysisService.shared.freeQueriesRemaining == 1) + } + + @Test("Zähler = limit: keine Abfragen mehr verfügbar") + func atLimit() { + AIAnalysisService.shared.freeQueriesUsed = AIAnalysisService.freeQueryLimit + #expect(AIAnalysisService.shared.hasFreeQueriesLeft == false) + } + + @Test("freeQueriesUsed + freeQueriesRemaining = limit (solange unter limit)") + func usedPlusRemainingEqualsLimit() { + for used in 0...AIAnalysisService.freeQueryLimit { + AIAnalysisService.shared.freeQueriesUsed = used + let remaining = AIAnalysisService.shared.freeQueriesRemaining + #expect(used + remaining == AIAnalysisService.freeQueryLimit) + } + } +} + +// MARK: - AppGroup Pro-Status Tests + +// Tests laufen serialisiert, da alle dieselbe UserDefaults-Suite nutzen +@Suite("AppGroup – Pro-Status", .serialized) +struct AppGroupProStatusTests { + + private let testDefaults = UserDefaults(suiteName: "nahbar.test.proStatus")! + + init() { + testDefaults.removeObject(forKey: "isPro") + testDefaults.synchronize() + } + + @Test("Pro-Status initial false wenn nicht gesetzt") + func proStatusInitiallyFalse() { + testDefaults.removeObject(forKey: "isPro") + #expect(testDefaults.bool(forKey: "isPro") == false) + } + + @Test("Pro-Status round-trip: true") + func proStatusRoundTripTrue() { + testDefaults.set(true, forKey: "isPro") + #expect(testDefaults.bool(forKey: "isPro") == true) + } + + @Test("Pro-Status round-trip: false") + func proStatusRoundTripFalse() { + testDefaults.set(true, forKey: "isPro") + testDefaults.set(false, forKey: "isPro") + #expect(testDefaults.bool(forKey: "isPro") == false) + } + + @Test("Pro-Status nach removeObject ist false") + func proStatusAfterRemoveIsFalse() { + testDefaults.set(true, forKey: "isPro") + testDefaults.removeObject(forKey: "isPro") + #expect(testDefaults.bool(forKey: "isPro") == false) + } +} diff --git a/nahbar/nahbarTests/UserProfileStoreTests.swift b/nahbar/nahbarTests/UserProfileStoreTests.swift new file mode 100644 index 0000000..4cccf3f --- /dev/null +++ b/nahbar/nahbarTests/UserProfileStoreTests.swift @@ -0,0 +1,161 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - UserProfileStore Tests +// +// Testet die reine Logik des UserProfileStore (Initials, isEmpty, Persistenz). +// Nutzt einen isolierten UserDefaults-Key um Produktionsdaten nicht zu überschreiben. + +@Suite("UserProfileStore – Initials") +struct UserProfileStoreInitialsTests { + + // Wir testen die Initialen-Logik isoliert, ohne den Store zu instanziieren. + // Die Logik ist: Vorname[0] + Nachname[0] aus split by " " + // Bei einem Wort: prefix(2).uppercased() + // Bei leerem String: "?" + + private func initials(from name: String) -> String { + let parts = name.split(separator: " ") + if parts.count >= 2 { + return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased() + } + return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() + } + + @Test("Initials aus Vor- und Nachname") + func initialsFromFullName() { + #expect(initials(from: "Max Mustermann") == "MM") + } + + @Test("Initials aus drei Wörtern") + func initialsFromThreeWords() { + #expect(initials(from: "Anna Maria Schmidt") == "AM") + } + + @Test("Initials aus einem Wort (2 Buchstaben)") + func initialsFromOneWord() { + #expect(initials(from: "Max") == "MA") + } + + @Test("Initials aus kurzem Namen (1 Buchstabe)") + func initialsFromOneLetterName() { + #expect(initials(from: "A") == "A") + } + + @Test("Initials aus leerem String ist ?") + func initialsFromEmptyStringIsQuestionMark() { + #expect(initials(from: "") == "?") + } + + @Test("Initials sind immer uppercase") + func initialsAreAlwaysUppercase() { + let names = ["anna bach", "max mustermann", "tim", ""] + for name in names { + let result = initials(from: name) + #expect(result == result.uppercased(), "Initials für '\(name)' sollten uppercase sein") + } + } +} + +// MARK: - UserProfileStore isEmpty Tests + +@Suite("UserProfileStore – isEmpty") +struct UserProfileStoreIsEmptyTests { + + // isEmpty = name.isEmpty && occupation.isEmpty && location.isEmpty + // && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + + private func isEmpty(name: String = "", occupation: String = "", + location: String = "", likes: String = "", + dislikes: String = "", socialStyle: String = "") -> Bool { + name.isEmpty && occupation.isEmpty && location.isEmpty + && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + } + + @Test("Alle Felder leer → isEmpty ist true") + func allFieldsEmptyIsTrue() { + #expect(isEmpty()) + } + + @Test("Nur Name gesetzt → isEmpty ist false") + func onlyNameSetIsFalse() { + #expect(!isEmpty(name: "Max")) + } + + @Test("Nur Beruf gesetzt → isEmpty ist false") + func onlyOccupationSetIsFalse() { + #expect(!isEmpty(occupation: "Ingenieur")) + } + + @Test("Whitespace-only Name gilt als leer") + func whitespaceNameIsEmpty() { + // Der Store trimmt beim Speichern, also gilt " " als leer + #expect(isEmpty(name: "")) + } + + @Test("Nur likes gesetzt → isEmpty ist false") + func onlyLikesSetIsFalse() { + #expect(!isEmpty(likes: "Kaffee, Sport")) + } + + @Test("Nur dislikes gesetzt → isEmpty ist false") + func onlyDislikesSetIsFalse() { + #expect(!isEmpty(dislikes: "Lärm")) + } + + @Test("Nur socialStyle gesetzt → isEmpty ist false") + func onlySocialStyleSetIsFalse() { + #expect(!isEmpty(socialStyle: "Introvertiert")) + } + + @Test("Alle Vorlieben-Felder leer + Rest leer → isEmpty ist true") + func allVorliebFieldsEmptyStillEmpty() { + #expect(isEmpty(likes: "", dislikes: "", socialStyle: "")) + } +} + +// MARK: - UserProfileStore Neue Felder Tests + +@Suite("UserProfileStore – Neue Felder") +struct UserProfileStoreNewFieldsTests { + + @Test("socialStyleOptions enthält genau 5 Einträge") + func socialStyleOptionsCount() { + let options = ["Introvertiert", "Eher introvertiert", "Ausgeglichen", "Eher extrovertiert", "Extrovertiert"] + #expect(options.count == 5) + } + + @Test("socialStyleOptions sind alle einzigartig") + func socialStyleOptionsUnique() { + let options = ["Introvertiert", "Eher introvertiert", "Ausgeglichen", "Eher extrovertiert", "Extrovertiert"] + #expect(Set(options).count == options.count) + } + + @Test("Likes-Parsing: Komma-getrennte Einträge werden korrekt aufgeteilt") + func likesParsing() { + let likes = "Kaffee, Sport, Natur" + let items = likes.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + #expect(items == ["Kaffee", "Sport", "Natur"]) + } + + @Test("Likes-Parsing: Leerzeichen um Kommas werden getrimmt") + func likesParsingTrimsWhitespace() { + let likes = " Kaffee , Sport " + let items = likes.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + #expect(items == ["Kaffee", "Sport"]) + } + + @Test("Likes-Parsing: Leere Einträge werden gefiltert") + func likesParsingFiltersEmpty() { + let likes = "Kaffee,,Sport," + let items = likes.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + #expect(items == ["Kaffee", "Sport"]) + } + + @Test("Leerer Likes-String ergibt keine Einträge") + func emptyLikesYieldsNoItems() { + let items = "".split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + #expect(items.isEmpty) + } +} diff --git a/nahbar/nahbarTests/VisitRatingTests.swift b/nahbar/nahbarTests/VisitRatingTests.swift new file mode 100644 index 0000000..7c8d09e --- /dev/null +++ b/nahbar/nahbarTests/VisitRatingTests.swift @@ -0,0 +1,358 @@ +import Testing +import Foundation +import SwiftData +@testable import nahbar + +// MARK: - RatingCategory Tests + +@Suite("RatingCategory – Enum") +struct RatingCategoryTests { + + @Test("Alle 4 Kategorien vorhanden") + func allCasesCount() { + #expect(RatingCategory.allCases.count == 4) + } + + @Test("rawValues sind nicht leer") + func rawValuesNotEmpty() { + for cat in RatingCategory.allCases { + #expect(!cat.rawValue.isEmpty) + } + } + + @Test("icons sind nicht leer") + func iconsNotEmpty() { + for cat in RatingCategory.allCases { + #expect(!cat.icon.isEmpty) + } + } + + @Test("Nachwirkung ist isAftermath-Kategorie") + func nachwirkungIsAftermathCategory() { + let aftermathCategories = RatingQuestion.aftermath.map { $0.category } + #expect(aftermathCategories.allSatisfy { $0 == .nachwirkung }) + } +} + +// MARK: - VisitStatus Tests + +@Suite("VisitStatus – Enum") +struct VisitStatusTests { + + @Test("rawValues können round-trip-parsed werden") + func rawValueRoundTrip() { + let statuses: [VisitStatus] = [.immediateCompleted, .awaitingAftermath, .completed] + for status in statuses { + let parsed = VisitStatus(rawValue: status.rawValue) + #expect(parsed == status) + } + } + + @Test("awaitingAftermath rawValue ist 'warte_nachwirkung'") + func awaitingAftermathRawValue() { + #expect(VisitStatus.awaitingAftermath.rawValue == "warte_nachwirkung") + } +} + +// MARK: - RatingQuestion Tests + +@Suite("RatingQuestion – statische Fragen") +struct RatingQuestionTests { + + @Test("Genau 9 Fragen insgesamt") + func totalQuestionCount() { + #expect(RatingQuestion.all.count == 9) + } + + @Test("Genau 5 Sofort-Fragen") + func immediateQuestionCount() { + #expect(RatingQuestion.immediate.count == 5) + } + + @Test("Genau 4 Nachwirkungs-Fragen") + func aftermathQuestionCount() { + #expect(RatingQuestion.aftermath.count == 4) + } + + @Test("immediate (5) + aftermath (4) = all (9)") + func immediatePlusAftermathEqualsAll() { + #expect(RatingQuestion.immediate.count + RatingQuestion.aftermath.count == RatingQuestion.all.count) + #expect(RatingQuestion.all.count == 9) + } + + @Test("Alle Fragen haben nicht-leere Texte und Pole") + func allQuestionsHaveContent() { + for q in RatingQuestion.all { + #expect(!q.text.isEmpty) + #expect(!q.negativePole.isEmpty) + #expect(!q.positivePole.isEmpty) + } + } + + @Test("Sofort-Fragen decken 3 Kategorien ab") + func immediateQuestionsSpan3Categories() { + let categories = Set(RatingQuestion.immediate.map { $0.category }) + #expect(categories.count == 3) + #expect(categories.contains(.selbst)) + #expect(categories.contains(.beziehung)) + #expect(categories.contains(.gespraech)) + } + + @Test("Nachwirkungs-Fragen haben alle isAftermath == true") + func aftermathFlagsCorrect() { + for q in RatingQuestion.aftermath { + #expect(q.isAftermath == true) + } + for q in RatingQuestion.immediate { + #expect(q.isAftermath == false) + } + } +} + +// MARK: - Rating Tests + +@Suite("Rating – Bewertungs-Logik") +struct RatingTests { + + @Test("value nil bedeutet übersprungen") + func nilValueMeansSkipped() { + let r = Rating(category: .selbst, questionIndex: 0, value: nil, isAftermath: false) + #expect(r.value == nil) + } + + @Test("value -2 ist gültig") + func minusTwo() { + let r = Rating(category: .selbst, questionIndex: 0, value: -2, isAftermath: false) + #expect(r.value == -2) + } + + @Test("value +2 ist gültig") + func plusTwo() { + let r = Rating(category: .selbst, questionIndex: 0, value: 2, isAftermath: false) + #expect(r.value == 2) + } + + @Test("category round-trip via rawValue") + func categoryRoundTrip() { + let r = Rating(category: .beziehung, questionIndex: 1, value: 1, isAftermath: false) + #expect(r.category == .beziehung) + #expect(r.categoryRaw == RatingCategory.beziehung.rawValue) + } +} + +// MARK: - AftermathDelayOption Tests + +@Suite("AftermathDelayOption – Einstellungen") +struct AftermathDelayOptionTests { + + @Test("Alle 3 Optionen vorhanden") + func allCasesCount() { + #expect(AftermathDelayOption.allCases.count == 3) + } + + @Test("36h ist der Standard") + func defaultIs36h() { + let defaults = UserDefaults(suiteName: "nahbar.test.aftermathDelay")! + defaults.removeObject(forKey: "aftermathDelayOption") + // Simuliert fehlenden Wert → Fallback auf .hours36 + let raw = defaults.string(forKey: "aftermathDelayOption") ?? AftermathDelayOption.hours36.rawValue + let opt = AftermathDelayOption(rawValue: raw) + #expect(opt == .hours36) + } + + @Test("Sekunden sind korrekt") + func secondsAreCorrect() { + #expect(AftermathDelayOption.hours24.seconds == 24 * 3600) + #expect(AftermathDelayOption.hours36.seconds == 36 * 3600) + #expect(AftermathDelayOption.hours48.seconds == 48 * 3600) + } + + @Test("rawValue round-trip") + func rawValueRoundTrip() { + for opt in AftermathDelayOption.allCases { + #expect(AftermathDelayOption(rawValue: opt.rawValue) == opt) + } + } +} + +// MARK: - AppLanguage Tests + +@Suite("AppLanguage – KI-Spracheinstellung") +struct AppLanguageTests { + + @Test("Genau 2 Sprachen vorhanden") + func allCasesCount() { + #expect(AppLanguage.allCases.count == 2) + } + + @Test("rawValue round-trip") + func rawValueRoundTrip() { + for lang in AppLanguage.allCases { + #expect(AppLanguage(rawValue: lang.rawValue) == lang) + } + } + + @Test("german.rawValue ist 'de', english.rawValue ist 'en'") + func rawValues() { + #expect(AppLanguage.german.rawValue == "de") + #expect(AppLanguage.english.rawValue == "en") + } + + @Test("displayName ist nicht leer") + func displayNamesNotEmpty() { + for lang in AppLanguage.allCases { + #expect(!lang.displayName.isEmpty) + } + } + + @Test("systemPrompt ist nicht leer") + func systemPromptsNotEmpty() { + for lang in AppLanguage.allCases { + #expect(!lang.systemPrompt.isEmpty) + } + } + + // Regressionswächter: Parse-Tokens dürfen nie aus den Instructions verschwinden, + // sonst bricht parseResult() im AIAnalysisService still. + @Test("analysisInstruction enthält alle 3 Parse-Tokens") + func analysisInstructionContainsParseTokens() { + for lang in AppLanguage.allCases { + #expect(lang.analysisInstruction.contains("MUSTER:"), + "MUSTER: fehlt in analysisInstruction (\(lang.rawValue))") + #expect(lang.analysisInstruction.contains("BEZIEHUNG:"), + "BEZIEHUNG: fehlt in analysisInstruction (\(lang.rawValue))") + #expect(lang.analysisInstruction.contains("EMPFEHLUNG:"), + "EMPFEHLUNG: fehlt in analysisInstruction (\(lang.rawValue))") + } + } + + @Test("giftInstruction enthält alle 3 IDEE-Parse-Tokens") + func giftInstructionContainsParseTokens() { + for lang in AppLanguage.allCases { + #expect(lang.giftInstruction.contains("IDEE 1:"), + "IDEE 1: fehlt in giftInstruction (\(lang.rawValue))") + #expect(lang.giftInstruction.contains("IDEE 2:"), + "IDEE 2: fehlt in giftInstruction (\(lang.rawValue))") + #expect(lang.giftInstruction.contains("IDEE 3:"), + "IDEE 3: fehlt in giftInstruction (\(lang.rawValue))") + } + } + + @Test("current gibt einen der unterstützten Werte zurück") + func currentIsValid() { + // AppLanguage.current liest Locale.current – Ergebnis ist immer ein gültiger Case + let lang = AppLanguage.current + #expect(AppLanguage.allCases.contains(lang)) + } + + @Test("unbekannte Sprachcodes fallen auf .german zurück") + func unknownCodeFallsBackToGerman() { + let lang = AppLanguage(rawValue: "zz") ?? .german + #expect(lang == .german) + } +} + +// MARK: - Lokalisierungs-Regressionswächter + +// Fragen-Texte, Pol-Labels und Kategorie-rawValues werden per LocalizedStringKey() +// als Schlüssel in Localizable.xcstrings nachgeschlagen. Jede Änderung eines Texts +// würde die englische Übersetzung still brechen – diese Tests schützen dagegen. + +@Suite("VisitRating – Lokalisierungs-Schlüssel") +struct VisitRatingLocalizationKeyTests { + + @Test("Sofort-Fragen-Texte sind stabile Schlüssel") + func immediateQuestionTextsAreStable() { + let q = RatingQuestion.immediate + #expect(q[0].text == "Wie hast du dich während des Treffens gefühlt?") + #expect(q[1].text == "Wie ist dein Energielevel nach dem Treffen?") + #expect(q[2].text == "Fühlt sich die Beziehung gestärkt an?") + #expect(q[3].text == "War das Treffen ausgeglichen (Geben/Nehmen)?") + #expect(q[4].text == "Wie tiefgehend waren die Gespräche?") + } + + @Test("Nachwirkungs-Fragen-Texte sind stabile Schlüssel") + func aftermathQuestionTextsAreStable() { + let q = RatingQuestion.aftermath + #expect(q[0].text == "Möchtest du die Person bald wiedersehen?") + #expect(q[1].text == "Wie denkst du jetzt über das Treffen?") + #expect(q[2].text == "Hat sich deine Sicht auf die Person verändert?") + #expect(q[3].text == "Würdest du ein ähnliches Treffen wiederholen?") + } + + @Test("Pol-Labels der Sofort-Fragen sind stabile Schlüssel") + func immediatePoleLabelsAreStable() { + let q = RatingQuestion.immediate + #expect(q[0].negativePole == "Unwohl"); #expect(q[0].positivePole == "Sehr wohl") + #expect(q[1].negativePole == "Erschöpft"); #expect(q[1].positivePole == "Energiegeladen") + #expect(q[2].negativePole == "Distanzierter"); #expect(q[2].positivePole == "Viel näher") + #expect(q[3].negativePole == "Sehr einseitig"); #expect(q[3].positivePole == "Perfekt ausgeglichen") + #expect(q[4].negativePole == "Nur Smalltalk"); #expect(q[4].positivePole == "Sehr tiefgründig") + } + + @Test("Pol-Labels der Nachwirkungs-Fragen sind stabile Schlüssel") + func aftermathPoleLabelsAreStable() { + let q = RatingQuestion.aftermath + #expect(q[0].negativePole == "Eher nicht"); #expect(q[0].positivePole == "Unbedingt") + #expect(q[1].negativePole == "Eher negativ"); #expect(q[1].positivePole == "Sehr positiv") + #expect(q[2].negativePole == "Zum Schlechteren"); #expect(q[2].positivePole == "Zum Besseren") + #expect(q[3].negativePole == "Eher nicht"); #expect(q[3].positivePole == "Sofort wieder") + } + + @Test("Kategorie-rawValues sind stabile Schlüssel") + func categoryRawValuesAreStable() { + #expect(RatingCategory.selbst.rawValue == "Selbst") + #expect(RatingCategory.beziehung.rawValue == "Beziehung") + #expect(RatingCategory.gespraech.rawValue == "Gespräch") + #expect(RatingCategory.nachwirkung.rawValue == "Nachwirkung") + } +} + +// MARK: - AftermathNotificationManager – Konstanten + +@Suite("AftermathNotificationManager – Konstanten") +struct AftermathNotificationManagerTests { + + @Test("categoryID ist unveränderlich") + func categoryID() { + #expect(AftermathNotificationManager.categoryID == "AFTERMATH_RATING") + } + + @Test("actionID ist unveränderlich") + func actionID() { + #expect(AftermathNotificationManager.actionID == "RATE_NOW") + } + + @Test("visitIDKey ist unveränderlich") + func visitIDKey() { + #expect(AftermathNotificationManager.visitIDKey == "visitID") + } + + @Test("personNameKey ist unveränderlich") + func personNameKey() { + #expect(AftermathNotificationManager.personNameKey == "personName") + } +} + +// MARK: - Schema-Regressionswächter (V4) + +@Suite("Schema – Regressionswächter V4") +struct SchemaV4RegressionTests { + + @Test("NahbarSchemaV4 hat Version 4.0.0") + func schemaV4HasCorrectVersion() { + #expect(NahbarSchemaV4.versionIdentifier.major == 4) + #expect(NahbarSchemaV4.versionIdentifier.minor == 0) + #expect(NahbarSchemaV4.versionIdentifier.patch == 0) + } + + @Test("Migrationsplan enthält genau 4 Schemas") + func migrationPlanHasFourSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 4) + } + + @Test("Migrationsplan enthält genau 3 Stages") + func migrationPlanHasThreeStages() { + #expect(NahbarMigrationPlan.stages.count == 3) + } +} diff --git a/nahbar/nahbarTests/nahbarTests.swift b/nahbar/nahbarTests/nahbarTests.swift new file mode 100644 index 0000000..ec0580d --- /dev/null +++ b/nahbar/nahbarTests/nahbarTests.swift @@ -0,0 +1,18 @@ +// +// nahbarTests.swift +// nahbarTests +// +// Created by Sven Hanold on 18.04.26. +// + +import Testing + +struct nahbarTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +} diff --git a/nahbar/profeatures.storekit b/nahbar/profeatures.storekit index 52bfd80..5e9c5b9 100644 --- a/nahbar/profeatures.storekit +++ b/nahbar/profeatures.storekit @@ -61,14 +61,44 @@ ], "localizations" : [ { - "description" : "Zusätzliche Themes, KI-Empfehlungen, nützliche Analysen", - "displayName" : "Pro Features freischalten", + "description" : "Unbegrenzte Kontakte, Teilen-Funktion & alle Themes", + "displayName" : "nahbar Pro", "locale" : "de" } ], "productID" : "profeatures", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "Pro Features freischalten", + "referenceName" : "nahbar Pro", + "subscriptionGroupID" : "22038114", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "9.99", + "familyShareable" : true, + "groupNumber" : 2, + "internalID" : "6762459002", + "introductoryOffers" : [ + + ], + "localizations" : [ + { + "description" : "Alles aus Pro plus unbegrenzte KI-Analysen & Geschenkideen", + "displayName" : "nahbar Max", + "locale" : "de" + } + ], + "productID" : "maxfeatures", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "nahbar Max", "subscriptionGroupID" : "22038114", "type" : "RecurringSubscription", "winbackOffers" : [