diff --git a/ApplicationLibrary/Views/Abstract/TVToolbarButton.swift b/ApplicationLibrary/Views/Abstract/TVToolbarButton.swift new file mode 100644 index 0000000..a0a55cc --- /dev/null +++ b/ApplicationLibrary/Views/Abstract/TVToolbarButton.swift @@ -0,0 +1,42 @@ +#if os(tvOS) + import SwiftUI + import UIKit + + /// Workaround for tvOS SwiftUI bug where toolbar Button text labels fail to render properly. + /// Using UIButton via UIViewRepresentable bypasses this issue. + struct TVToolbarButton: UIViewRepresentable { + let title: String + let action: () -> Void + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .primaryActionTriggered) + return button + } + + func updateUIView(_ uiView: UIButton, context: Context) { + uiView.setTitle(title, for: .normal) + uiView.invalidateIntrinsicContentSize() + uiView.sizeToFit() + context.coordinator.action = action + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + class Coordinator: NSObject { + var action: () -> Void + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func buttonTapped() { + action() + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift index 60cc11a..02d21cf 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -100,6 +100,8 @@ import SwiftUI #if os(tvOS) @MainActor public struct CardManagementView: View { @StateObject private var configuration = DashboardCardConfiguration() + @FocusState private var focusedCard: DashboardCard? + @State private var movingCard: DashboardCard? private let onDisappear: (() -> Void)? public init(onDisappear: (() -> Void)? = nil) { @@ -111,39 +113,115 @@ import SwiftUI if configuration.isLoading { ProgressView() } else { - List { - ForEach(configuration.cardOrder) { card in - CardRow( - card: card, - isEnabled: configuration.isEnabled(card), - onToggle: { - configuration.toggleCard(card) - } - ) - } - .onMove { source, destination in - Task { - await configuration.moveCard(from: source, to: destination) + ScrollView { + LazyVStack(spacing: 12) { + ForEach(configuration.cardOrder) { card in + TVCardRow( + card: card, + isEnabled: configuration.isEnabled(card), + isMoving: movingCard == card, + focusedCard: $focusedCard, + onToggle: { + configuration.toggleCard(card) + }, + onToggleMoving: { + withAnimation { + if movingCard == card { + movingCard = nil + } else { + movingCard = card + } + } + } + ) } } + .padding() } - .applyContentMargins() + .focusSection() } } .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Reset", role: .destructive) { + TVToolbarButton(title: "Reset") { Task { await configuration.resetToDefault() } } } } + .onMoveCommand { direction in + guard let movingCard, + let currentIndex = configuration.cardOrder.firstIndex(of: movingCard) + else { return } + + let newIndex: Int + switch direction { + case .up: + guard currentIndex > 0 else { return } + newIndex = currentIndex - 1 + case .down: + guard currentIndex < configuration.cardOrder.count - 1 else { return } + newIndex = currentIndex + 1 + default: + return + } + + Task { + await configuration.moveCard(from: IndexSet(integer: currentIndex), to: newIndex > currentIndex ? newIndex + 1 : newIndex) + } + focusedCard = movingCard + } .onDisappear { onDisappear?() } } } + + private struct TVCardRow: View { + let card: DashboardCard + let isEnabled: Bool + let isMoving: Bool + var focusedCard: FocusState.Binding + let onToggle: () -> Void + let onToggleMoving: () -> Void + + private var isProfileCard: Bool { + card == .profile + } + + var body: some View { + HStack(spacing: 16) { + Label(card.title, systemImage: card.systemImage) + .font(.headline) + .foregroundStyle(isEnabled ? .primary : .secondary) + + Spacer() + + if !isProfileCard { + Toggle("", isOn: Binding( + get: { isEnabled }, + set: { _ in onToggle() } + )) + .labelsHidden() + } + + Button { + onToggleMoving() + } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 20)) + } + .buttonStyle(.plain) + .actionButtonStyle() + .focused(focusedCard, equals: card) + } + .padding() + .background(Color.secondary.opacity(0.2), in: RoundedRectangle(cornerRadius: 16)) + .scaleEffect(isMoving ? 1.05 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isMoving) + } + } #endif private struct CardRow: View { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift index 1465432..ba70e35 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/ProfilePickerSheet.swift @@ -953,46 +953,6 @@ private struct ProfilePickerRow: View { // MARK: - Legacy iOS ProfilePickerRow (iOS < 26) -#if os(tvOS) - /// Workaround for tvOS SwiftUI bug where toolbar Button text labels fail to render properly. - /// Using UIButton via UIViewRepresentable bypasses this issue. - private struct TVToolbarButton: UIViewRepresentable { - let title: String - let action: () -> Void - - func makeUIView(context: Context) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(title, for: .normal) - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .primaryActionTriggered) - return button - } - - func updateUIView(_ uiView: UIButton, context: Context) { - uiView.setTitle(title, for: .normal) - uiView.invalidateIntrinsicContentSize() - uiView.sizeToFit() - context.coordinator.action = action - } - - func makeCoordinator() -> Coordinator { - Coordinator(action: action) - } - - class Coordinator: NSObject { - var action: () -> Void - init(action: @escaping () -> Void) { - self.action = action - } - - @objc func buttonTapped() { - action() - } - } - } -#endif - #if os(iOS) private struct LegacyProfilePickerRow: View { @EnvironmentObject private var environments: ExtensionEnvironments