Umfassende Erweiterung, Lokalisierung, Besuchsbewertung

This commit is contained in:
2026-04-18 20:30:48 +02:00
parent 0b35403096
commit e75141d23c
54 changed files with 9332 additions and 378 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+101
View File
@@ -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)"
}
}
+112
View File
@@ -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 }
}
}
+184
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
}
}
+73
View File
@@ -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()
}
+80
View File
@@ -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
)
}
+150
View File
@@ -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()
}
}
+171
View File
@@ -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
}
}
}
+172
View File
@@ -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 }
}
}
+141
View File
@@ -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
}
}
}
+181 -1
View File
@@ -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 = "<group>"; };
26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncMonitor.swift; sourceTree = "<group>"; };
26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStore.swift; sourceTree = "<group>"; };
26B2CAB52F93B55F0039BA3B /* IchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IchView.swift; sourceTree = "<group>"; };
26B2CAB72F93B7570039BA3B /* NahbarLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarLogger.swift; sourceTree = "<group>"; };
26B2CAB92F93B76E0039BA3B /* LogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExportView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
26B2CAE22F93C0180039BA3B /* RatingQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingQuestionView.swift; sourceTree = "<group>"; };
26B2CAE42F93C02B0039BA3B /* AftermathNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathNotificationManager.swift; sourceTree = "<group>"; };
26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitRatingFlowView.swift; sourceTree = "<group>"; };
26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathRatingFlowView.swift; sourceTree = "<group>"; };
26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitSummaryView.swift; sourceTree = "<group>"; };
26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = "<group>"; };
26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = "<group>"; };
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
@@ -111,6 +147,11 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
26B2CAC02F93B96C0039BA3B /* nahbarTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = nahbarTests;
sourceTree = "<group>";
};
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 = "<group>";
};
@@ -154,6 +213,7 @@
children = (
265F92202F9109B500CE0A5C /* nahbar.app */,
26BB85CA2F926A9700889312 /* nahbarShareExtension.appex */,
26B2CABF2F93B96C0039BA3B /* nahbarTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -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 = "<group>";
@@ -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 = (
@@ -29,6 +29,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "26B2CABE2F93B96C0039BA3B"
BuildableName = "nahbarTests.xctest"
BlueprintName = "nahbarTests"
ReferencedContainer = "container:nahbar.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
+44 -17
View File
@@ -97,7 +97,25 @@ class AIAnalysisService {
static let shared = AIAnalysisService()
private init() {}
// MARK: - Rate Limiting (max 3 pro Stunde)
// MARK: - Gratis-Abfragen (3 Lifetime für alle Nutzer)
static let freeQueryLimit = 3
private let freeQueryCountKey = "ai_free_queries_used"
var freeQueriesUsed: Int {
get { UserDefaults.standard.integer(forKey: freeQueryCountKey) }
set { UserDefaults.standard.set(newValue, forKey: freeQueryCountKey) }
}
var hasFreeQueriesLeft: Bool { freeQueriesUsed < AIAnalysisService.freeQueryLimit }
var freeQueriesRemaining: Int { max(0, AIAnalysisService.freeQueryLimit - freeQueriesUsed) }
/// Verbraucht eine Gratis-Abfrage. Nur aufrufen nach erfolgreicher API-Antwort.
func consumeFreeQuery() {
freeQueriesUsed += 1
}
// MARK: - Rate Limiting (max 3 pro Stunde, serverseitiger Schutz)
private var maxRequestsPerHour: Int { AIConfig.load().rateLimitPerHour }
private let rateLimitKey = "ai_rate_timestamps"
@@ -156,13 +174,13 @@ class AIAnalysisService {
throw URLError(.badURL)
}
let prompt = buildPrompt(for: person, template: config.userPromptTemplate)
let prompt = buildPrompt(for: person)
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]
]
]
@@ -197,10 +215,14 @@ class AIAnalysisService {
// MARK: - Prompt Builder
private func buildPrompt(for person: Person, template: String) -> 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]
]
]
+4 -3
View File
@@ -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
+24 -4
View File
@@ -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)
+61 -33
View File
@@ -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")
}
}
+7 -7
View File
@@ -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 }
+2
View File
@@ -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)
+185
View File
@@ -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)
}
}
+114 -21
View File
@@ -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<Person>(
predicate: #Predicate<Person> { 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)
}
+517
View File
@@ -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<Content: View>(_ 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<String>) -> 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()
}
}
File diff suppressed because it is too large Load Diff
+137 -12
View File
@@ -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<Content: View>: 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()
}
}
+283 -11
View File
@@ -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
}
}
+84 -14
View File
@@ -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)
}
}
}
+238 -38
View File
@@ -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)
}
}
+208 -106
View File
@@ -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")
}
}
}
+38 -5
View File
@@ -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 <relativer Zeitausdruck>" 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)
}
+159 -4
View File
@@ -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)
+215 -20
View File
@@ -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 {
+6 -3
View File
@@ -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)
+83 -23
View File
@@ -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
}
+56 -20
View File
@@ -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<Void, Never>? = 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<Void, Never> {
Task(priority: .background) {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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"
+75 -27
View File
@@ -4,6 +4,11 @@ import SwiftData
struct TodayView: View {
@Environment(\.nahbarTheme) var theme
@Query private var people: [Person]
@Query(filter: #Predicate<Visit> { $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<Content: View>: View {
@Environment(\.nahbarTheme) var theme
let title: String
let title: LocalizedStringKey
let icon: String
@ViewBuilder let content: Content
+130
View File
@@ -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)")
}
}
+2 -2
View File
@@ -12,10 +12,10 @@
<true/>
</dict>
</dict>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -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()
}
}
@@ -0,0 +1 @@
+175
View File
@@ -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.count {
#expect(levels[i].priority > 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)
}
}
+138
View File
@@ -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)
}
}
@@ -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" }))
}
}
+299
View File
@@ -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)
}
}
}
+65
View File
@@ -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)'")
}
}
+37
View File
@@ -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.
+163
View File
@@ -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)
}
}
@@ -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)
}
}
+358
View File
@@ -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)
}
}
+18
View File
@@ -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
}
}
+33 -3
View File
@@ -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" : [