From 283c2cb9cbf0a2d589804f6de3884f2ca29a6047 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 19 Jul 2026 19:23:36 +0200 Subject: [PATCH] Fix tvOS legacy import rows acting as a single Remove button On tvOS a Form row is one focusable unit, so the legacy account/source cards - header, credential fields, and both buttons stacked in a single row - focused as a whole and every click fired the first button (Remove), making credentials and Import unreachable. - Split the tvOS rows into individually focusable rows (info header, fields, Import, Remove), keeping the card layout on iOS/macOS - Wrap the tvOS entry points in TVSidebarDetailContainer and drop the tvOS navigationTitle that rendered as a ghost behind the form - Present the review screen from the first-launch alert as a fullScreenCover on tvOS: sheets render as a small transparent card that cannot fit the sidebar layout (Menu button dismisses the cover) - Dim disabled TVSettingsButtonStyle buttons - Widen the row icon frame for tvOS glyph sizes and skip the caption when it would repeat the host shown as the title --- .../Views/Settings/AdvancedSettingsView.swift | 7 +- .../Views/Settings/LegacyDataImportView.swift | 145 ++++++++++++++---- .../Settings/TVSettingsButtonStyles.swift | 2 + Yattee/YatteeApp.swift | 17 ++ 4 files changed, 136 insertions(+), 35 deletions(-) diff --git a/Yattee/Views/Settings/AdvancedSettingsView.swift b/Yattee/Views/Settings/AdvancedSettingsView.swift index fff0d182..d34af19d 100644 --- a/Yattee/Views/Settings/AdvancedSettingsView.swift +++ b/Yattee/Views/Settings/AdvancedSettingsView.swift @@ -412,7 +412,12 @@ struct AdvancedSettingsView: View { if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true { #if os(tvOS) NavigationLink { - LegacyDataImportView() + TVSidebarDetailContainer( + systemImage: "person.badge.key", + title: String(localized: "migration.accounts.title") + ) { + LegacyDataImportView() + } } label: { Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key") } diff --git a/Yattee/Views/Settings/LegacyDataImportView.swift b/Yattee/Views/Settings/LegacyDataImportView.swift index 9c821ff1..6127a545 100644 --- a/Yattee/Views/Settings/LegacyDataImportView.swift +++ b/Yattee/Views/Settings/LegacyDataImportView.swift @@ -42,7 +42,11 @@ struct LegacyAccountsImportView: View { contentList } } + #if !os(tvOS) + // On tvOS the title comes from the TVSidebarDetailContainer sidebar; + // a navigationTitle would render as a giant ghost behind the form. .navigationTitle(String(localized: "migration.accounts.title")) + #endif #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -255,31 +259,70 @@ private struct LegacyAccountImportRow: View { !state.username.isEmpty && !state.password.isEmpty && !state.isImporting } - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: legacyInstanceIcon(for: item.instanceType)) - .font(.title2) + private var infoHeader: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: legacyInstanceIcon(for: item.instanceType)) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: legacyRowIconWidth) + + VStack(alignment: .leading, spacing: 3) { + Text(item.displayName) + .font(.headline) + + Text(item.instanceDisplayName) + .font(.subheadline) .foregroundStyle(.secondary) - .frame(width: 28) - VStack(alignment: .leading, spacing: 3) { - Text(item.displayName) - .font(.headline) - - Text(item.instanceDisplayName) - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(item.url.host ?? item.url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() + Text(item.url.host ?? item.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } + Spacer() + } + } + + var body: some View { + #if os(tvOS) + // On tvOS a Form row is a single focusable unit, so packing the whole card + // into one row makes the first button (Remove) swallow every click. + // Emit each interactive element as its own row instead. + infoHeader + .padding(.vertical, 8) + + credentialsFields + + if let errorMessage = state.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + + Button(action: onImport) { + if state.isImporting { + HStack(spacing: 6) { + ProgressView() + Text(String(localized: "migration.importing")) + } + } else { + Text(String(localized: "migration.import")) + } + } + .buttonStyle(TVSettingsButtonStyle()) + .disabled(!canImport) + + Button(role: .destructive, action: onRemove) { + Text(String(localized: "common.remove")) + .foregroundStyle(.red) + } + .buttonStyle(TVSettingsButtonStyle()) + .disabled(state.isImporting) + #else + VStack(alignment: .leading, spacing: 12) { + infoHeader + credentialsFields if let errorMessage = state.errorMessage { @@ -314,6 +357,7 @@ private struct LegacyAccountImportRow: View { } } .padding(.vertical, 8) + #endif } @ViewBuilder @@ -351,27 +395,50 @@ private struct LegacyInstanceImportRow: View { let onImport: () -> Void let onRemove: () -> Void - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: legacyInstanceIcon(for: item.instanceType)) - .font(.title2) - .foregroundStyle(.secondary) - .frame(width: 28) + private var infoHeader: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: legacyInstanceIcon(for: item.instanceType)) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: legacyRowIconWidth) - VStack(alignment: .leading, spacing: 3) { - Text(item.instanceDisplayName) - .font(.headline) + VStack(alignment: .leading, spacing: 3) { + Text(item.instanceDisplayName) + .font(.headline) - Text(item.url.host ?? item.url.absoluteString) + // instanceDisplayName falls back to the host for unnamed + // instances; skip the caption instead of repeating it. + if let host = item.url.host, host != item.instanceDisplayName { + Text(host) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } - - Spacer() } + Spacer() + } + } + + var body: some View { + #if os(tvOS) + infoHeader + .padding(.vertical, 8) + + Button(action: onImport) { + Text(String(localized: "migration.import")) + } + .buttonStyle(TVSettingsButtonStyle()) + + Button(role: .destructive, action: onRemove) { + Text(String(localized: "common.remove")) + .foregroundStyle(.red) + } + .buttonStyle(TVSettingsButtonStyle()) + #else + VStack(alignment: .leading, spacing: 12) { + infoHeader + HStack { Button(role: .destructive, action: onRemove) { Text(String(localized: "common.remove")) @@ -388,9 +455,19 @@ private struct LegacyInstanceImportRow: View { } } .padding(.vertical, 8) + #endif } } +/// tvOS renders .title2 glyphs far larger than the 28pt frame used on other platforms. +private var legacyRowIconWidth: CGFloat { + #if os(tvOS) + return 64 + #else + return 28 + #endif +} + private func legacyInstanceIcon(for type: InstanceType) -> String { switch type { case .invidious: diff --git a/Yattee/Views/Settings/TVSettingsButtonStyles.swift b/Yattee/Views/Settings/TVSettingsButtonStyles.swift index 1d2efe0b..858d75d6 100644 --- a/Yattee/Views/Settings/TVSettingsButtonStyles.swift +++ b/Yattee/Views/Settings/TVSettingsButtonStyles.swift @@ -88,9 +88,11 @@ struct TVSettingsTextField: View { /// Button style for settings forms - subtle focus effect without glow struct TVSettingsButtonStyle: ButtonStyle { @Environment(\.isFocused) private var isFocused + @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { configuration.label + .opacity(isEnabled ? 1 : 0.4) .padding(.horizontal, 20) .padding(.vertical, 12) .background( diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 8e58a26e..5e221469 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -184,6 +184,22 @@ struct YatteeApp: App { .appEnvironment(appEnvironment) } #endif + #if os(tvOS) + // tvOS sheets render as a small centered card that cannot fit + // the sidebar-detail layout — use a full screen cover instead. + .fullScreenCover(isPresented: $showingLegacyAccountsImport) { + NavigationStack { + TVSidebarDetailContainer( + systemImage: "person.badge.key", + title: String(localized: "migration.accounts.title") + ) { + LegacyAccountsImportView() + } + .appEnvironment(appEnvironment) + } + .background(Color.black.ignoresSafeArea()) + } + #else .sheet(isPresented: $showingLegacyAccountsImport) { NavigationStack { LegacyAccountsImportView() @@ -193,6 +209,7 @@ struct YatteeApp: App { .frame(minWidth: 560, minHeight: 560) #endif } + #endif #if os(iOS) .sheet(isPresented: $showingSettings) { SettingsView()