moodlit-native/Sources/Features/Moods/MoodListView.swift
till 9fe686780d feat: Favorit-Toggle + SequenceRow-Kontextmenü
MoodCard-Kontextmenü um Favorit/Aus-Favoriten erweitert (store.
toggleFavorite), an beiden Grid-Aufrufstellen verdrahtet. SequenceRow
bekommt ein Kontextmenü (Abspielen + Löschen) parallel zum Swipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:24:16 +02:00

237 lines
6.4 KiB
Swift

import ManaAuthUI
import ManaCore
import SwiftUI
/// Übersicht aller Moods Presets immer sichtbar, Custom-Moods nur
/// nach Login. Klick öffnet `MoodPlayerView` als fullScreenCover.
public struct MoodListView: View {
@Environment(MoodStore.self) private var store
@Environment(ManaAuthGate.self) private var gate
@Environment(PresenceClient.self) private var presence
@State private var presentedMood: Mood?
@State private var isCreating = false
private let columns: [GridItem] = [
GridItem(.adaptive(minimum: 160, maximum: 240), spacing: 12)
]
public init() {}
public var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
if let remote = presence.remote, presentedMood == nil {
remoteSessionBanner(remote)
}
section(title: "Voreinstellungen", subtitle: "\(DefaultMoods.all.count) Moods") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(DefaultMoods.all) { mood in
MoodCard(
mood: mood,
isFavorite: store.isFavorite(mood.id),
onTap: { presentedMood = mood },
onToggleFavorite: {
Task { await store.toggleFavorite(moodId: mood.id) }
}
)
}
}
}
customSection
}
.padding(16)
}
.background(MoodlitTheme.background.ignoresSafeArea())
.navigationTitle("Moodlit")
#if os(iOS)
.navigationBarTitleDisplayMode(.large)
#endif
.fullScreenCoverIfAvailable(item: $presentedMood) { mood in
MoodPlayerView(
mood: mood,
isFavorite: store.isFavorite(mood.id),
brightness: store.playerBrightness,
speedMultiplier: store.playerSpeedMultiplier,
onClose: { presentedMood = nil },
onFavoriteToggle: {
Task { await store.toggleFavorite(moodId: mood.id) }
}
)
}
.onChange(of: presence.remote?.payload.moodId) { _, newMoodId in
// Symmetrischer Auto-Switch: wenn lokaler Player offen ist
// und ein anderes Gerät desselben Users den Mood wechselt,
// folgt dieses Gerät automatisch. Mirror-Verhalten analog
// zum Web-Client.
guard let presentedId = presentedMood?.id,
let newMoodId,
presentedId != newMoodId,
let target = resolveMood(byId: newMoodId)
else { return }
presentedMood = target
}
.sheet(isPresented: $isCreating) {
CreateMoodSheet { name, colors, animation in
Task {
do {
try await store.createMood(name: name, colors: colors, animation: animation)
isCreating = false
} catch {
// Fehler stehen in store.lastError; Sheet schließt nicht.
}
}
}
}
.task { await store.loadAll() }
.refreshable { await store.loadAll() }
}
@ViewBuilder
private func remoteSessionBanner(_ remote: PresenceSession) -> some View {
let mood = resolveMood(for: remote)
Button {
if let mood {
presentedMood = mood
}
} label: {
HStack(spacing: 12) {
Circle()
.fill(LinearGradient(
colors: (mood?.colors ?? remote.payload.colors).map { Color(hex: $0) },
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Läuft auf einem anderen Gerät")
.font(.caption2.weight(.semibold))
.foregroundStyle(MoodlitTheme.mutedForeground)
.textCase(.uppercase)
Text(mood?.name ?? remote.payload.moodId)
.font(.subheadline.weight(.semibold))
.foregroundStyle(MoodlitTheme.foreground)
}
Spacer()
if mood != nil {
Text("Spiegeln")
.font(.caption.weight(.medium))
.foregroundStyle(MoodlitTheme.primary)
}
}
.padding(12)
.background(MoodlitTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(MoodlitTheme.primary.opacity(0.4), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(mood == nil)
}
private func resolveMood(for remote: PresenceSession) -> Mood? {
resolveMood(byId: remote.payload.moodId)
}
private func resolveMood(byId id: String) -> Mood? {
store.moodById(id) ?? DefaultMoods.byId(id)
}
@ViewBuilder
private func section<Content: View>(
title: String,
subtitle: String? = nil,
@ViewBuilder _ content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(title.uppercased())
.font(.caption.weight(.semibold))
.tracking(0.5)
.foregroundStyle(MoodlitTheme.mutedForeground)
if let subtitle {
Spacer()
Text(subtitle)
.font(.caption2)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
content()
}
}
private var customSection: some View {
section(title: "Eigene Moods") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Spacer()
Button {
gate.require(reason: "create-mood") {
isCreating = true
}
} label: {
Label("Neues Mood", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.tint(MoodlitTheme.primary)
}
if store.customMoods.isEmpty {
emptyState
} else {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(store.customMoods) { mood in
MoodCard(
mood: mood,
isFavorite: store.isFavorite(mood.id),
onTap: { presentedMood = mood },
onToggleFavorite: {
Task { await store.toggleFavorite(moodId: mood.id) }
},
onDelete: {
Task { try? await store.deleteMood(id: mood.id) }
}
)
}
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("Noch keine eigenen Moods")
.font(.body.weight(.medium))
Text("Erstelle ein eigenes Mood mit eigenen Farben und Animation.")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(MoodlitTheme.border, style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
)
}
}
// MARK: - fullScreenCover Helper
private extension View {
/// macOS hat kein `fullScreenCover` auf macOS via `sheet`.
@ViewBuilder
func fullScreenCoverIfAvailable<Item: Identifiable, Content: View>(
item: Binding<Item?>,
@ViewBuilder content: @escaping (Item) -> Content
) -> some View {
#if os(iOS)
self.fullScreenCover(item: item, content: content)
#else
self.sheet(item: item, content: content)
#endif
}
}