Umfassende Erweiterung, Lokalisierung, Besuchsbewertung
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
BIN
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)'")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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" : [
|
||||
|
||||
Reference in New Issue
Block a user