Guter Zwischenstand
This commit is contained in:
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -187,7 +187,7 @@ struct AddMomentView: View {
|
||||
|
||||
let moment = Moment(text: trimmed, type: selectedType, person: person)
|
||||
modelContext.insert(moment)
|
||||
person.moments.append(moment)
|
||||
person.moments?.append(moment)
|
||||
|
||||
guard addToCalendar else {
|
||||
dismiss()
|
||||
@@ -201,7 +201,7 @@ struct AddMomentView: View {
|
||||
person: person
|
||||
)
|
||||
modelContext.insert(calEntry)
|
||||
person.logEntries.append(calEntry)
|
||||
person.logEntries?.append(calEntry)
|
||||
|
||||
// Kein async/await — Callback-API vermeidet "unsafeForcedSync"
|
||||
createCalendarEvent(notes: trimmed)
|
||||
|
||||
@@ -2,21 +2,75 @@ import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension.
|
||||
/// Die App-Group-ID muss in beiden Targets als Capability eingetragen sein.
|
||||
enum AppGroup {
|
||||
static let identifier = "group.nahbar.shared"
|
||||
|
||||
/// URL des geteilten Containers. Fällt auf das Documents-Verzeichnis zurück,
|
||||
/// falls die App Group noch nicht eingerichtet ist (z.B. in frühen Dev-Builds).
|
||||
static var containerURL: URL {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
/// Shared UserDefaults für die Kommunikation zwischen Hauptapp und Extension.
|
||||
static var userDefaults: UserDefaults {
|
||||
UserDefaults(suiteName: identifier) ?? .standard
|
||||
}
|
||||
|
||||
static func makeModelContainer() throws -> ModelContainer {
|
||||
// MARK: - Hauptapp: Standard-Store (Daten bleiben erhalten)
|
||||
|
||||
/// 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 storeURL = containerURL.appendingPathComponent("nahbar.store")
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
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: - 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) {
|
||||
var queue = pendingMoments
|
||||
queue.append(["personName": personName, "text": text, "type": type,
|
||||
"createdAt": ISO8601DateFormatter().string(from: Date())])
|
||||
if let data = try? JSONSerialization.data(withJSONObject: queue) {
|
||||
userDefaults.set(data, forKey: "pendingMoments")
|
||||
}
|
||||
}
|
||||
|
||||
static var pendingMoments: [[String: String]] {
|
||||
guard let data = userDefaults.data(forKey: "pendingMoments"),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]]
|
||||
else { return [] }
|
||||
return array
|
||||
}
|
||||
|
||||
static func clearPendingMoments() {
|
||||
userDefaults.removeObject(forKey: "pendingMoments")
|
||||
}
|
||||
|
||||
// MARK: - Personenliste für Extension
|
||||
|
||||
/// Hauptapp schreibt beim Start die Personenliste in UserDefaults,
|
||||
/// damit die Extension sie ohne Store-Zugriff lesen kann.
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
static var cachedPeople: [[String: String]] {
|
||||
guard let data = userDefaults.data(forKey: "cachedPeople"),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]]
|
||||
else { return [] }
|
||||
return array
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,13 @@ struct ContentView: View {
|
||||
suggestionDateStr = ISO8601DateFormatter().string(from: Date())
|
||||
let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person)
|
||||
modelContext.insert(entry)
|
||||
person.logEntries.append(entry)
|
||||
person.logEntries?.append(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
if !onboardingDone {
|
||||
showingOnboarding = true
|
||||
} else {
|
||||
@@ -66,7 +68,14 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { checkCallWindow() }
|
||||
if phase == .active {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
checkCallWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: persons) { _, _ in
|
||||
syncPeopleCache()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +92,32 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt die aktuelle Personenliste in den App-Group-Cache für die Share Extension.
|
||||
private func syncPeopleCache() {
|
||||
AppGroup.savePeopleList(persons)
|
||||
}
|
||||
|
||||
/// Importiert Momente, die über die Share Extension eingereiht wurden.
|
||||
private func importPendingMoments() {
|
||||
let pending = AppGroup.pendingMoments
|
||||
guard !pending.isEmpty else { return }
|
||||
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
|
||||
if let person = persons.first(where: { $0.name == name }) {
|
||||
let moment = Moment(text: text, type: type_, person: person)
|
||||
modelContext.insert(moment)
|
||||
person.moments?.append(moment)
|
||||
}
|
||||
}
|
||||
if !pending.isEmpty {
|
||||
try? modelContext.save()
|
||||
AppGroup.clearPendingMoments()
|
||||
}
|
||||
}
|
||||
|
||||
private var suggestionShownToday: Bool {
|
||||
guard !suggestionDateStr.isEmpty,
|
||||
let date = ISO8601DateFormatter().date(from: suggestionDateStr) else { return false }
|
||||
|
||||
+19
-20
@@ -59,23 +59,23 @@ enum MomentType: String, CaseIterable, Codable {
|
||||
|
||||
@Model
|
||||
class Person {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var tagRaw: String
|
||||
var id: UUID = UUID()
|
||||
var name: String = ""
|
||||
var tagRaw: String = PersonTag.other.rawValue
|
||||
var birthday: Date?
|
||||
var occupation: String?
|
||||
var location: String?
|
||||
var interests: String?
|
||||
var generalNotes: String?
|
||||
var nudgeFrequencyRaw: String
|
||||
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
||||
var photoData: Data?
|
||||
var nextStep: String?
|
||||
var nextStepCompleted: Bool
|
||||
var nextStepCompleted: Bool = false
|
||||
var nextStepReminderDate: Date?
|
||||
var lastSuggestedForCall: Date?
|
||||
var createdAt: Date
|
||||
@Relationship(deleteRule: .cascade) var moments: [Moment]
|
||||
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]
|
||||
var createdAt: Date = Date()
|
||||
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
|
||||
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
|
||||
|
||||
init(
|
||||
name: String,
|
||||
@@ -117,7 +117,7 @@ class Person {
|
||||
}
|
||||
|
||||
var lastMomentDate: Date? {
|
||||
moments.sorted { $0.createdAt > $1.createdAt }.first?.createdAt
|
||||
(moments ?? []).sorted { $0.createdAt > $1.createdAt }.first?.createdAt
|
||||
}
|
||||
|
||||
var needsAttention: Bool {
|
||||
@@ -125,7 +125,6 @@ class Person {
|
||||
if let last = lastMomentDate {
|
||||
return Date().timeIntervalSince(last) > Double(days * 86400)
|
||||
}
|
||||
// Never had a moment – show if added more than `days` ago
|
||||
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
||||
}
|
||||
|
||||
@@ -156,11 +155,11 @@ class Person {
|
||||
}
|
||||
|
||||
var sortedMoments: [Moment] {
|
||||
moments.sorted { $0.createdAt > $1.createdAt }
|
||||
(moments ?? []).sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var sortedLogEntries: [LogEntry] {
|
||||
logEntries.sorted { $0.loggedAt > $1.loggedAt }
|
||||
(logEntries ?? []).sorted { $0.loggedAt > $1.loggedAt }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +191,10 @@ enum LogEntryType: String, Codable {
|
||||
|
||||
@Model
|
||||
class LogEntry {
|
||||
var id: UUID
|
||||
var typeRaw: String
|
||||
var title: String
|
||||
var loggedAt: Date
|
||||
var id: UUID = UUID()
|
||||
var typeRaw: String = LogEntryType.nextStep.rawValue
|
||||
var title: String = ""
|
||||
var loggedAt: Date = Date()
|
||||
var person: Person?
|
||||
|
||||
init(type: LogEntryType, title: String, person: Person? = nil) {
|
||||
@@ -216,10 +215,10 @@ class LogEntry {
|
||||
|
||||
@Model
|
||||
class Moment {
|
||||
var id: UUID
|
||||
var text: String
|
||||
var typeRaw: String
|
||||
var createdAt: Date
|
||||
var id: UUID = UUID()
|
||||
var text: String = ""
|
||||
var typeRaw: String = MomentType.conversation.rawValue
|
||||
var createdAt: Date = Date()
|
||||
var person: Person?
|
||||
|
||||
init(text: String, type: MomentType = .conversation, person: Person? = nil) {
|
||||
|
||||
@@ -43,7 +43,7 @@ struct NahbarApp: App {
|
||||
.onAppear { applyTabBarAppearance(activeTheme) }
|
||||
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) }
|
||||
}
|
||||
.modelContainer(try! AppGroup.makeModelContainer())
|
||||
.modelContainer(AppGroup.makeMainContainer())
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .background {
|
||||
appLockManager.lockIfEnabled()
|
||||
|
||||
@@ -164,7 +164,7 @@ struct PersonDetailView: View {
|
||||
if let step = person.nextStep {
|
||||
let entry = LogEntry(type: .nextStep, title: step, person: person)
|
||||
modelContext.insert(entry)
|
||||
person.logEntries.append(entry)
|
||||
person.logEntries?.append(entry)
|
||||
}
|
||||
person.nextStepCompleted = true
|
||||
cancelReminder(for: person)
|
||||
|
||||
@@ -8,6 +8,7 @@ struct SettingsView: View {
|
||||
@EnvironmentObject private var appLockManager: AppLockManager
|
||||
|
||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
||||
@AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL
|
||||
@AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey
|
||||
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
|
||||
@@ -289,6 +290,47 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// iCloud
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "iCloud", icon: "icloud")
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("iCloud-Backup")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text(icloudSyncEnabled
|
||||
? "Daten werden mit iCloud synchronisiert"
|
||||
: "Daten werden nur lokal gespeichert")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $icloudSyncEnabled)
|
||||
.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)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// About
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Über nahbar", icon: "info.circle")
|
||||
@@ -297,8 +339,6 @@ struct SettingsView: View {
|
||||
VStack(spacing: 0) {
|
||||
SettingsInfoRow(label: "Version", value: "1.0 Draft")
|
||||
RowDivider()
|
||||
SettingsInfoRow(label: "Daten", value: "Lokal + iCloud")
|
||||
RowDivider()
|
||||
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.Team.nahbar</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.nahbar.shared</string>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Einfache Personen-Darstellung für die Share Extension (aus UserDefaults-Cache).
|
||||
struct CachedPerson: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let tag: String
|
||||
}
|
||||
|
||||
struct ShareExtensionView: View {
|
||||
let sharedText: String
|
||||
@@ -7,8 +13,8 @@ struct ShareExtensionView: View {
|
||||
|
||||
@State private var text: String
|
||||
@State private var momentType: MomentType = .conversation
|
||||
@State private var people: [Person] = []
|
||||
@State private var selectedPerson: Person?
|
||||
@State private var people: [CachedPerson] = []
|
||||
@State private var selectedPerson: CachedPerson?
|
||||
@State private var searchText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
@@ -19,7 +25,7 @@ struct ShareExtensionView: View {
|
||||
self._text = State(initialValue: sharedText)
|
||||
}
|
||||
|
||||
private var filteredPeople: [Person] {
|
||||
private var filteredPeople: [CachedPerson] {
|
||||
searchText.isEmpty ? people : people.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
@@ -46,14 +52,15 @@ struct ShareExtensionView: View {
|
||||
|
||||
Section("Kontakt") {
|
||||
if people.isEmpty {
|
||||
Text("Keine Kontakte gefunden")
|
||||
Text("Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: 14))
|
||||
} else {
|
||||
TextField("Suchen…", text: $searchText)
|
||||
ForEach(filteredPeople) { person in
|
||||
PersonPickerRow(
|
||||
person: person,
|
||||
name: person.name,
|
||||
tag: person.tag,
|
||||
isSelected: selectedPerson?.id == person.id
|
||||
) {
|
||||
selectedPerson = (selectedPerson?.id == person.id) ? nil : person
|
||||
@@ -88,7 +95,8 @@ struct ShareExtensionView: View {
|
||||
// MARK: - Subviews
|
||||
|
||||
struct PersonPickerRow: View {
|
||||
let person: Person
|
||||
let name: String
|
||||
let tag: String
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@@ -96,18 +104,12 @@ struct ShareExtensionView: View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(person.name)
|
||||
.foregroundStyle(.primary)
|
||||
if let tag = PersonTag(rawValue: person.tagRaw) {
|
||||
Text(tag.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(name).foregroundStyle(.primary)
|
||||
Text(tag).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,41 +120,19 @@ struct ShareExtensionView: View {
|
||||
// MARK: - Data
|
||||
|
||||
private func loadPeople() {
|
||||
do {
|
||||
let container = try AppGroup.makeModelContainer()
|
||||
let context = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<Person>(sortBy: [SortDescriptor(\.name)])
|
||||
people = (try? context.fetch(descriptor)) ?? []
|
||||
} catch {
|
||||
errorMessage = "Kontakte konnten nicht geladen werden."
|
||||
people = AppGroup.cachedPeople.compactMap { dict in
|
||||
guard let idString = dict["id"], let id = UUID(uuidString: idString),
|
||||
let name = dict["name"], let tag = dict["tag"] else { return nil }
|
||||
return CachedPerson(id: id, name: name, tag: tag)
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let person = selectedPerson else { return }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let container = try AppGroup.makeModelContainer()
|
||||
let context = ModelContext(container)
|
||||
let personID = person.id
|
||||
let descriptor = FetchDescriptor<Person>(predicate: #Predicate { $0.id == personID })
|
||||
guard let target = try context.fetch(descriptor).first else {
|
||||
errorMessage = "Kontakt nicht gefunden."
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let moment = Moment(
|
||||
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: momentType,
|
||||
person: target
|
||||
)
|
||||
context.insert(moment)
|
||||
try context.save()
|
||||
AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue)
|
||||
onDismiss()
|
||||
} catch {
|
||||
errorMessage = "Speichern fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user