moodlit-native/Sources/Features/Moods/MoodStore.swift
till 206fff422e μ-7.7: Spotlight-Indexing für Moods + Sequenzen
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>
2026-05-18 15:39:42 +02:00

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)
}
}