Fix profile picker on tvOS
This commit is contained in:
@@ -25,6 +25,7 @@ struct ProfilePickerSheet: View {
|
|||||||
@State private var alert: AlertState?
|
@State private var alert: AlertState?
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@FocusState private var focusedProfileID: Int64?
|
@FocusState private var focusedProfileID: Int64?
|
||||||
|
@State private var movingProfileID: Int64?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private var isEditingActive: Bool {
|
private var isEditingActive: Bool {
|
||||||
@@ -96,6 +97,18 @@ struct ProfilePickerSheet: View {
|
|||||||
listContent
|
listContent
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
TVToolbarButton(title: editMode.isEditing ? "Done" : "Edit") {
|
||||||
|
withAnimation {
|
||||||
|
if editMode.isEditing {
|
||||||
|
movingProfileID = nil
|
||||||
|
}
|
||||||
|
editMode = editMode.isEditing ? .inactive : .active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.navigationDestination(item: $profileToEdit) { profile in
|
.navigationDestination(item: $profileToEdit) { profile in
|
||||||
EditProfileView()
|
EditProfileView()
|
||||||
.environmentObject(profile)
|
.environmentObject(profile)
|
||||||
@@ -158,6 +171,7 @@ struct ProfilePickerSheet: View {
|
|||||||
profile: profile,
|
profile: profile,
|
||||||
isSelected: profile.id == selectedProfileID,
|
isSelected: profile.id == selectedProfileID,
|
||||||
isEditing: isEditingActive,
|
isEditing: isEditingActive,
|
||||||
|
isMoving: movingProfileID == profile.id,
|
||||||
alert: $alert,
|
alert: $alert,
|
||||||
focusedProfileID: $focusedProfileID,
|
focusedProfileID: $focusedProfileID,
|
||||||
onSelect: {
|
onSelect: {
|
||||||
@@ -169,6 +183,20 @@ struct ProfilePickerSheet: View {
|
|||||||
},
|
},
|
||||||
onUpdate: {
|
onUpdate: {
|
||||||
await updateProfile(profile)
|
await updateProfile(profile)
|
||||||
|
},
|
||||||
|
onDelete: {
|
||||||
|
if let index = profileList.firstIndex(where: { $0.id == profile.id }) {
|
||||||
|
deleteProfile(at: IndexSet(integer: index))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleMoving: {
|
||||||
|
withAnimation {
|
||||||
|
if movingProfileID == profile.id {
|
||||||
|
movingProfileID = nil
|
||||||
|
} else {
|
||||||
|
movingProfileID = profile.id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
@@ -179,6 +207,28 @@ struct ProfilePickerSheet: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
focusedProfileID = selectedProfileID
|
focusedProfileID = selectedProfileID
|
||||||
}
|
}
|
||||||
|
.onMoveCommand { direction in
|
||||||
|
guard let movingID = movingProfileID,
|
||||||
|
let currentIndex = profileList.firstIndex(where: { $0.id == movingID })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let newIndex: Int
|
||||||
|
switch direction {
|
||||||
|
case .up:
|
||||||
|
guard currentIndex > 0 else { return }
|
||||||
|
newIndex = currentIndex - 1
|
||||||
|
case .down:
|
||||||
|
guard currentIndex < profileList.count - 1 else { return }
|
||||||
|
newIndex = currentIndex + 1
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
moveProfile(from: IndexSet(integer: currentIndex), to: newIndex > currentIndex ? newIndex + 1 : newIndex)
|
||||||
|
}
|
||||||
|
focusedProfileID = movingID
|
||||||
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
List {
|
List {
|
||||||
ForEach(profileList, id: \.id) { profile in
|
ForEach(profileList, id: \.id) { profile in
|
||||||
@@ -417,6 +467,9 @@ private struct ProfilePickerRow: View {
|
|||||||
#if os(tvOS) || os(macOS)
|
#if os(tvOS) || os(macOS)
|
||||||
let isEditing: Bool
|
let isEditing: Bool
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
let isMoving: Bool
|
||||||
|
#endif
|
||||||
@Binding var alert: AlertState?
|
@Binding var alert: AlertState?
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
var focusedProfileID: FocusState<Int64?>.Binding
|
var focusedProfileID: FocusState<Int64?>.Binding
|
||||||
@@ -424,9 +477,12 @@ private struct ProfilePickerRow: View {
|
|||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
let onEdit: () -> Void
|
let onEdit: () -> Void
|
||||||
let onUpdate: () async -> Void
|
let onUpdate: () async -> Void
|
||||||
#if os(macOS)
|
#if os(tvOS) || os(macOS)
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
let onToggleMoving: () -> Void
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var isEditing: Bool {
|
private var isEditing: Bool {
|
||||||
@@ -451,12 +507,28 @@ private struct ProfilePickerRow: View {
|
|||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private var tvOSBody: some View {
|
private var tvOSBody: some View {
|
||||||
|
Group {
|
||||||
|
if isEditing {
|
||||||
|
tvOSEditingBody
|
||||||
|
} else {
|
||||||
|
tvOSNormalBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showQRCode) {
|
||||||
|
if let remoteURL = profile.remoteURL {
|
||||||
|
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tvOSNormalBody: some View {
|
||||||
Button(action: onSelect) {
|
Button(action: onSelect) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 24, weight: .semibold))
|
.font(.system(size: 24, weight: .semibold))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.opacity(isSelected ? 1 : 0)
|
.opacity(isSelected ? 1 : 0)
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(profile.name)
|
Text(profile.name)
|
||||||
@@ -468,17 +540,14 @@ private struct ProfilePickerRow: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if !isEditing {
|
Color.clear.frame(width: 70)
|
||||||
Color.clear.frame(width: 44)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
.focused(focusedProfileID, equals: profile.id)
|
.focused(focusedProfileID, equals: profile.id)
|
||||||
.disabled(isEditing || isUpdating)
|
.disabled(isUpdating)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
if !isEditing {
|
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
onEdit()
|
onEdit()
|
||||||
@@ -517,11 +586,45 @@ private struct ProfilePickerRow: View {
|
|||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showQRCode) {
|
|
||||||
if let remoteURL = profile.remoteURL {
|
private var tvOSEditingBody: some View {
|
||||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
HStack(spacing: 12) {
|
||||||
|
Color.clear.frame(width: 28)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(profile.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
profileInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onToggleMoving()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.actionButtonStyle()
|
||||||
|
.focused(focusedProfileID, equals: profile.id)
|
||||||
|
}
|
||||||
|
.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
|
#endif
|
||||||
|
|
||||||
@@ -850,6 +953,46 @@ private struct ProfilePickerRow: View {
|
|||||||
|
|
||||||
// MARK: - Legacy iOS ProfilePickerRow (iOS < 26)
|
// 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)
|
#if os(iOS)
|
||||||
private struct LegacyProfilePickerRow: View {
|
private struct LegacyProfilePickerRow: View {
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
|||||||
Reference in New Issue
Block a user