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
This commit is contained in:
Arkadiusz Fal
2026-07-19 19:23:36 +02:00
parent 9c97036684
commit 283c2cb9cb
4 changed files with 136 additions and 35 deletions

View File

@@ -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")
}

View File

@@ -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:

View File

@@ -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(