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>
167 lines
4.4 KiB
Swift
167 lines
4.4 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import SwiftUI
|
|
|
|
/// Liste der Sequenzen + Play-Button. **Pure-Native-Vorteil**:
|
|
/// Sequenz-Playback ist in der Web-App nicht implementiert (Store-
|
|
/// Methoden gab's, UI war nie verkabelt). Hier liefern wir's nativ.
|
|
public struct SequenceListView: View {
|
|
@Environment(MoodStore.self) private var store
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(ManaAuthGate.self) private var gate
|
|
@State private var playing: PlayingSequence?
|
|
@State private var isCreating = false
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
List {
|
|
if store.sequences.isEmpty {
|
|
emptyState
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color.clear)
|
|
} else {
|
|
ForEach(store.sequences) { seq in
|
|
SequenceRow(
|
|
sequence: seq,
|
|
moodLookup: store.moodById,
|
|
onPlay: { play(seq) },
|
|
onDelete: {
|
|
Task { try? await store.deleteSequence(id: seq.id) }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.background(MoodlitTheme.background.ignoresSafeArea())
|
|
.navigationTitle("Sequenzen")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
gate.require(reason: "create-sequence") {
|
|
isCreating = true
|
|
}
|
|
} label: {
|
|
Label("Neu", systemImage: "plus")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $isCreating) {
|
|
CreateSequenceSheet { name, moodIds, durationSec in
|
|
Task {
|
|
do {
|
|
try await store.createSequence(name: name, moodIds: moodIds, durationSec: durationSec)
|
|
isCreating = false
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
.fullScreenCoverIfAvailable(item: $playing) { p in
|
|
SequencePlayerView(
|
|
sequence: p.sequence,
|
|
moods: p.moods,
|
|
onClose: { playing = nil }
|
|
)
|
|
}
|
|
.task { await store.loadAll() }
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "list.triangle")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
Text("Noch keine Sequenzen")
|
|
.font(.body.weight(.medium))
|
|
Text("Verkettete Mood-Abfolgen mit Wechsel-Zeit. Wähle 2 oder mehr Moods.")
|
|
.font(.caption)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 40)
|
|
}
|
|
|
|
private func play(_ sequence: MoodSequence) {
|
|
let moods = sequence.moodIds.compactMap(store.moodById)
|
|
guard !moods.isEmpty else { return }
|
|
playing = PlayingSequence(sequence: sequence, moods: moods)
|
|
}
|
|
}
|
|
|
|
private struct PlayingSequence: Identifiable {
|
|
let sequence: MoodSequence
|
|
let moods: [Mood]
|
|
var id: String { sequence.id }
|
|
}
|
|
|
|
private struct SequenceRow: View {
|
|
let sequence: MoodSequence
|
|
let moodLookup: (String) -> Mood?
|
|
let onPlay: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(sequence.name)
|
|
.font(.headline)
|
|
HStack(spacing: 6) {
|
|
ForEach(sequence.moodIds.prefix(6), id: \.self) { id in
|
|
Text(moodLookup(id)?.name ?? "?")
|
|
.font(.caption2.weight(.medium))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(MoodlitTheme.primary.opacity(0.20), in: Capsule())
|
|
.foregroundStyle(MoodlitTheme.primary)
|
|
}
|
|
if sequence.moodIds.count > 6 {
|
|
Text("+\(sequence.moodIds.count - 6)")
|
|
.font(.caption2)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
}
|
|
}
|
|
Text("\(sequence.durationSec)s je Mood · \(sequence.transitionSec)s Übergang")
|
|
.font(.caption2)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
}
|
|
Spacer()
|
|
Button(action: onPlay) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 32))
|
|
.foregroundStyle(MoodlitTheme.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive, action: onDelete) {
|
|
Label("Löschen", systemImage: "trash")
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button(action: onPlay) {
|
|
Label("Abspielen", systemImage: "play.fill")
|
|
}
|
|
Divider()
|
|
Button(role: .destructive, action: onDelete) {
|
|
Label("Löschen", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
@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
|
|
}
|
|
}
|