Presets + Custom-Moods + Sequenzen sind über CSSearchable im Spotlight + Siri-Vorschlägen auffindbar. Tippen routet via `uniqueIdentifier` (Format `mood:<id>` / `sequence:<id>`) in RootView zurück → öffnet MoodPlayerView (Moods) oder springt in Sequenzen-Tab. Sources/Core/Search/SpotlightIndexer.swift mit zwei Domains (`ev.mana.moodlit.moods`, `ev.mana.moodlit.sequences`). Domain- basiertes `deleteSearchableItems` clearen beim Logout — kein Eintrag des abgemeldeten Kontos bleibt im system-weiten Index. MoodStore-Integration: - `refreshSpotlightIndex()` läuft initial im RootView-`task` (Presets sofort findbar ohne Login) und nach jedem `loadAll`. - `clearSpotlightIndex()` beim Auth-Wechsel signedOut. RootView: - `onContinueUserActivity(CSSearchableItemActionType)` parsed den uniqueIdentifier mit prefix-Check, setzt deepLinkMoodId oder selectedTab. - `authStatusKey()`-Helper für Equatable-onChange auf AuthClient.Status (Cases mit assoziierten Werten sind nicht direkt Equatable). xcodebuild iOS-Sim + macOS BUILD SUCCEEDED; 11/11 Unit-Tests grün. ShareExt (Photo→Palette→Mood) wäre der natürliche moodlit-Use- Case, ist aber 2-3h Vision/CoreImage-Arbeit für sich — deferred auf eigenen Sprint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
6.7 KiB
Swift
226 lines
6.7 KiB
Swift
import Foundation
|
|
import ManaCore
|
|
import OSLog
|
|
import WidgetKit
|
|
|
|
/// Server-authoritativer Cache für Moods, Sequenzen und Preferences.
|
|
/// Lädt nach Login, refresht on-scenePhase-active.
|
|
///
|
|
/// Pendant zum Web-Store in
|
|
/// `Code/moodlit/apps/web/src/lib/modules/store.svelte.ts`.
|
|
/// Presets kommen aus `DefaultMoods.all`; Custom-Moods + Sequences +
|
|
/// Preferences kommen aus moodlit-api.
|
|
@Observable
|
|
@MainActor
|
|
public final class MoodStore {
|
|
public private(set) var customMoods: [Mood] = []
|
|
public private(set) var sequences: [MoodSequence] = []
|
|
public private(set) var preferences: Preferences?
|
|
public private(set) var isLoading = false
|
|
public private(set) var lastError: String?
|
|
|
|
/// Presets + Custom in der UI-Reihenfolge (Presets zuerst, dann
|
|
/// neueste Custom oben). Frontend dedupliziert nach `id` —
|
|
/// kollidiert sollte nichts, weil Presets stable slugs nutzen
|
|
/// und Custom UUIDs.
|
|
public var allMoods: [Mood] {
|
|
DefaultMoods.all + customMoods.sorted { $0.createdAt > $1.createdAt }
|
|
}
|
|
|
|
public var favoriteIds: [String] {
|
|
preferences?.favoriteIds ?? readLocalFavorites()
|
|
}
|
|
|
|
public func isFavorite(_ moodId: String) -> Bool {
|
|
favoriteIds.contains(moodId)
|
|
}
|
|
|
|
/// 0.0...1.0 — entspricht dem Brightness-Schieber in Settings.
|
|
/// Default 1.0 (volle Helligkeit), darunter dimt der Player.
|
|
public var playerBrightness: Double {
|
|
guard let b = preferences?.brightness else { return 1.0 }
|
|
return Double(max(20, min(100, b))) / 100.0
|
|
}
|
|
|
|
/// Zeit-Multiplikator für `AnimatedMoodView`-TimelineView.
|
|
/// `slow = 0.5`, `normal = 1.0`, `fast = 1.6`.
|
|
public var playerSpeedMultiplier: Double {
|
|
switch preferences?.animationSpeed ?? .normal {
|
|
case .slow: return 0.5
|
|
case .normal: return 1.0
|
|
case .fast: return 1.6
|
|
}
|
|
}
|
|
|
|
public func moodById(_ id: String) -> Mood? {
|
|
allMoods.first { $0.id == id }
|
|
}
|
|
|
|
private let api: MoodlitAPI
|
|
private let auth: AuthClient
|
|
private let log = Logger(subsystem: AppConfig.bundleIdentifier, category: "moods")
|
|
|
|
public init(auth: AuthClient) {
|
|
self.auth = auth
|
|
self.api = MoodlitAPI(auth: auth)
|
|
}
|
|
|
|
public func loadAll() async {
|
|
guard case .signedIn = auth.status else {
|
|
customMoods = []
|
|
sequences = []
|
|
preferences = nil
|
|
return
|
|
}
|
|
isLoading = true
|
|
lastError = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
async let m = api.listMoods()
|
|
async let s = api.listSequences()
|
|
async let p = api.getPreferences()
|
|
let (moods, seqs, prefs) = try await (m, s, p)
|
|
self.customMoods = moods
|
|
self.sequences = seqs
|
|
self.preferences = prefs
|
|
refreshWidgetSnapshot()
|
|
refreshSpotlightIndex()
|
|
} catch {
|
|
lastError = "\(error)"
|
|
log.error("loadAll failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
/// Indiziert Presets + Custom-Moods + Sequenzen in CSSearchable.
|
|
/// Wird nach jedem `loadAll` aufgerufen + initial im App-Boot,
|
|
/// damit Presets sofort suchbar sind (auch ohne Login).
|
|
public func refreshSpotlightIndex() {
|
|
#if os(iOS)
|
|
SpotlightIndexer.index(moods: allMoods, sequences: sequences)
|
|
#endif
|
|
}
|
|
|
|
/// Beim Logout: alle moodlit-Einträge aus dem Spotlight-Index
|
|
/// löschen, damit das nächste Nutzungs-Konto keine fremden Daten
|
|
/// findet.
|
|
public func clearSpotlightIndex() {
|
|
#if os(iOS)
|
|
SpotlightIndexer.clearAll()
|
|
#endif
|
|
}
|
|
|
|
/// Schreibt die Favoriten + Last-Played in den App-Group-Container
|
|
/// und pingt WidgetCenter, damit der nächste Refresh den Snapshot
|
|
/// liest. Idempotent — beim Logout (preferences=nil + customMoods=[])
|
|
/// wird ein leerer Snapshot geschrieben, damit das Widget den
|
|
/// Empty-State zeigt.
|
|
public func refreshWidgetSnapshot(lastPlayed: Mood? = nil) {
|
|
let favs = favoriteIds.compactMap(moodById).map { mood in
|
|
WidgetMood(
|
|
id: mood.id,
|
|
name: mood.name,
|
|
colors: mood.colors,
|
|
animation: mood.animation.rawValue
|
|
)
|
|
}
|
|
let last = lastPlayed.map { mood in
|
|
WidgetMood(
|
|
id: mood.id,
|
|
name: mood.name,
|
|
colors: mood.colors,
|
|
animation: mood.animation.rawValue
|
|
)
|
|
}
|
|
WidgetSnapshotStore.write(WidgetSnapshot(favorites: favs, lastPlayed: last))
|
|
#if os(iOS)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
#endif
|
|
}
|
|
|
|
@discardableResult
|
|
public func createMood(name: String, colors: [String], animation: AnimationType) async throws -> Mood {
|
|
let mood = try await api.createMood(
|
|
MoodlitAPI.CreateMoodInput(name: name, colors: colors, animation: animation)
|
|
)
|
|
customMoods.append(mood)
|
|
return mood
|
|
}
|
|
|
|
public func deleteMood(id: String) async throws {
|
|
try await api.deleteMood(id: id)
|
|
customMoods.removeAll { $0.id == id }
|
|
if favoriteIds.contains(id) {
|
|
await toggleFavorite(moodId: id)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
public func createSequence(name: String, moodIds: [String], durationSec: Int = 30) async throws -> MoodSequence {
|
|
let seq = try await api.createSequence(
|
|
MoodlitAPI.CreateSequenceInput(name: name, moodIds: moodIds, durationSec: durationSec)
|
|
)
|
|
sequences.append(seq)
|
|
return seq
|
|
}
|
|
|
|
public func deleteSequence(id: String) async throws {
|
|
try await api.deleteSequence(id: id)
|
|
sequences.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// Atomarer Preferences-PATCH (Brightness/Animation-Speed/Auto-
|
|
/// Timer-Default/Auto-Mood-Switch). Favoriten gehen über
|
|
/// `toggleFavorite`, weil sie noch die lokalen-Guest-Defaults
|
|
/// brauchen.
|
|
public func updatePreferences(_ input: MoodlitAPI.UpdatePreferencesInput) async throws {
|
|
guard case .signedIn = auth.status else { return }
|
|
let updated = try await api.updatePreferences(input)
|
|
preferences = updated
|
|
refreshWidgetSnapshot()
|
|
}
|
|
|
|
/// Setzt eine extern berechnete `Preferences` (z.B. SettingsView
|
|
/// ruft `api.updatePreferences` direkt). Trigger für UI-Update.
|
|
public func applyPreferences(_ prefs: Preferences) {
|
|
preferences = prefs
|
|
refreshWidgetSnapshot()
|
|
}
|
|
|
|
public func toggleFavorite(moodId: String) async {
|
|
let current = favoriteIds
|
|
let next = current.contains(moodId)
|
|
? current.filter { $0 != moodId }
|
|
: current + [moodId]
|
|
|
|
if case .signedIn = auth.status {
|
|
do {
|
|
let updated = try await api.updatePreferences(
|
|
MoodlitAPI.UpdatePreferencesInput(favoriteIds: next)
|
|
)
|
|
preferences = updated
|
|
} catch {
|
|
log.error("toggleFavorite failed: \(error.localizedDescription, privacy: .public)")
|
|
lastError = "\(error)"
|
|
}
|
|
} else {
|
|
writeLocalFavorites(next)
|
|
}
|
|
refreshWidgetSnapshot()
|
|
}
|
|
|
|
// MARK: - Guest-Mode-Favoriten (UserDefaults, App-Group)
|
|
|
|
private var favoritesDefaults: UserDefaults? {
|
|
UserDefaults(suiteName: AppConfig.appGroup) ?? .standard
|
|
}
|
|
private let favoritesKey = "moodlit.favorites.guest"
|
|
|
|
private func readLocalFavorites() -> [String] {
|
|
(favoritesDefaults?.array(forKey: favoritesKey) as? [String]) ?? []
|
|
}
|
|
|
|
private func writeLocalFavorites(_ ids: [String]) {
|
|
favoritesDefaults?.set(ids, forKey: favoritesKey)
|
|
}
|
|
}
|