diff --git a/Yattee/Views/Settings/AccountsListView.swift b/Yattee/Views/Settings/AccountsListView.swift index 1ace6f42..dd6604c0 100644 --- a/Yattee/Views/Settings/AccountsListView.swift +++ b/Yattee/Views/Settings/AccountsListView.swift @@ -38,6 +38,12 @@ struct AccountsListView: View { @State private var pendingRemoval: PendingAccountRemoval? @State private var renameRequest: AccountRenameRequest? + #if os(tvOS) + /// The account whose actions dialog is up. tvOS only: everywhere else the + /// row grows a context menu instead of a press target. + @State private var actionsAccountID: UUID? + #endif + /// Bumped whenever a credential is replaced, purely to re-read the login /// identifier the rows display. /// @@ -255,6 +261,38 @@ struct AccountsListView: View { @ViewBuilder private func accountRow(_ account: Account, instance: Instance?) -> some View { + #if os(tvOS) + // One focusable row per account: pressing it raises the actions as a + // dialog. Spelling them out as sibling rows made every account four + // rows tall, which is a lot of clicking past on a remote. + Button { + actionsAccountID = account.id + } label: { + rowContent(account, instance: instance) + } + .buttonStyle(TVSettingsButtonStyle()) + .confirmationDialog( + title(for: account, instance: instance), + isPresented: Binding( + // By ID: a label edit arriving from another device while the + // dialog is up would otherwise close it. + get: { actionsAccountID == account.id }, + set: { if !$0 { actionsAccountID = nil } } + ), + titleVisibility: .visible + ) { + rowActions(account, instance: instance) + Button(String(localized: "common.cancel"), role: .cancel) {} + } + #else + rowContent(account, instance: instance) + .contextMenu { + rowActions(account, instance: instance) + } + #endif + } + + private func rowContent(_ account: Account, instance: Instance?) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { // The same checkmark the switcher marks the selected context @@ -289,17 +327,6 @@ struct AccountsListView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - #if !os(tvOS) - .contextMenu { - rowActions(account, instance: instance) - } - #endif - - #if os(tvOS) - // A context menu is not reachable on tvOS, and a Form row is a single - // focusable unit — so the actions are rows of their own. - rowActions(account, instance: instance) - #endif } @ViewBuilder @@ -311,9 +338,6 @@ struct AccountsListView: View { } label: { Label(String(localized: "accounts.useThisAccount"), systemImage: "checkmark.circle") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif } Button { @@ -325,9 +349,6 @@ struct AccountsListView: View { } label: { Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif } // Offered on orphans too: with no source to fall back to, a label of @@ -337,9 +358,6 @@ struct AccountsListView: View { } label: { Label(String(localized: "accounts.rename"), systemImage: "pencil") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif Button(role: .destructive) { pendingRemoval = PendingAccountRemoval( @@ -353,9 +371,6 @@ struct AccountsListView: View { systemImage: instance == nil ? "trash" : "rectangle.portrait.and.arrow.forward" ) } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif } private func loginSheet(_ login: AccountsListLogin) -> some View { diff --git a/Yattee/Views/Settings/InstanceAccountsSection.swift b/Yattee/Views/Settings/InstanceAccountsSection.swift index a62692d3..1ba2a7af 100644 --- a/Yattee/Views/Settings/InstanceAccountsSection.swift +++ b/Yattee/Views/Settings/InstanceAccountsSection.swift @@ -49,6 +49,12 @@ struct InstanceAccountsSection: View { /// the section rather than the row, and matched back to a row by ID. @State private var renameRequest: AccountRenameRequest? + #if os(tvOS) + /// The account whose actions dialog is up. tvOS only: everywhere else the + /// row grows a context menu instead of a press target. + @State private var actionsAccountID: UUID? + #endif + private var accounts: [Account] { appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? [] } @@ -88,6 +94,76 @@ struct InstanceAccountsSection: View { @ViewBuilder private func accountRow(_ account: Account) -> some View { + rowBase(account) + // Attached to the row that raised it rather than to the section: a + // presentation modifier on a `Section` inside a `Form` has no view of + // its own to present from. + .confirmationDialog( + String(localized: "accounts.remove.confirm.title \(displayName(for: account))"), + isPresented: Binding( + // By ID: a label edit arriving from another device while the + // dialog is up would otherwise close it. + get: { pendingSignOut?.id == account.id }, + set: { if !$0 { pendingSignOut = nil } } + ), + titleVisibility: .visible + ) { + Button(String(localized: "common.remove"), role: .destructive) { + confirmSignOut() + } + Button(String(localized: "common.cancel"), role: .cancel) { + pendingSignOut = nil + } + } message: { + Text(String(localized: "accounts.remove.confirm.message")) + } + #if !os(tvOS) + .presentationCompactAdaptation(.sheet) + #endif + // Attached to the row for the same reason the dialog above it is, and + // narrowed to the row that raised it so only one alert is ever live. + .accountRenameAlert( + Binding( + get: { renameRequest?.id == account.id ? renameRequest : nil }, + set: { if $0 == nil { renameRequest = nil } } + ) + ) { accountID, name in + appEnvironment?.accountsManager.updateDisplayName(name, for: accountID) + } + } + + @ViewBuilder + private func rowBase(_ account: Account) -> some View { + #if os(tvOS) + // One focusable row per account: pressing it raises the actions as a + // dialog. Spelling them out as sibling rows made every account four + // rows tall, which is a lot of clicking past on a remote. + Button { + actionsAccountID = account.id + } label: { + rowContent(account) + } + .buttonStyle(TVSettingsButtonStyle()) + .confirmationDialog( + displayName(for: account), + isPresented: Binding( + get: { actionsAccountID == account.id }, + set: { if !$0 { actionsAccountID = nil } } + ), + titleVisibility: .visible + ) { + rowActions(account) + Button(String(localized: "common.cancel"), role: .cancel) {} + } + #else + rowContent(account) + .contextMenu { + rowActions(account) + } + #endif + } + + private func rowContent(_ account: Account) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { // The same checkmark the switcher marks the selected context @@ -107,54 +183,6 @@ struct InstanceAccountsSection: View { AccountStatusBadge(status: status(for: account)) } .frame(maxWidth: .infinity, alignment: .leading) - #if !os(tvOS) - .contextMenu { - rowActions(account) - } - #endif - // Attached to the row that raised it rather than to the section: a - // presentation modifier on a `Section` inside a `Form` has no view of - // its own to present from. - .confirmationDialog( - String(localized: "accounts.remove.confirm.title \(displayName(for: account))"), - isPresented: Binding( - // By ID: a label edit arriving from another device while the - // dialog is up would otherwise close it. - get: { pendingSignOut?.id == account.id }, - set: { if !$0 { pendingSignOut = nil } } - ), - titleVisibility: .visible - ) { - Button(String(localized: "common.remove"), role: .destructive) { - confirmSignOut() - } - Button(String(localized: "common.cancel"), role: .cancel) { - pendingSignOut = nil - } - } message: { - Text(String(localized: "accounts.remove.confirm.message")) - } - #if !os(tvOS) - .presentationCompactAdaptation(.sheet) - #endif - // Attached to the row for the same reason the dialog above it is, and - // narrowed to the row that raised it so only one alert is ever live. - .accountRenameAlert( - Binding( - get: { renameRequest?.id == account.id ? renameRequest : nil }, - set: { if $0 == nil { renameRequest = nil } } - ) - ) { accountID, name in - appEnvironment?.accountsManager.updateDisplayName(name, for: accountID) - } - - #if os(tvOS) - // A context menu is not reachable on tvOS, and a Form row is a single - // focusable unit — so the actions are rows of their own. Without them - // the whole account is unreachable here: directional focus walks - // straight past a row that is only text. - rowActions(account) - #endif } @ViewBuilder @@ -167,9 +195,6 @@ struct InstanceAccountsSection: View { } label: { Label(String(localized: "accounts.useThisAccount"), systemImage: "checkmark.circle") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif } // tvOS never syncs app Keychain items, so an account that arrived from @@ -191,27 +216,18 @@ struct InstanceAccountsSection: View { Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key") #endif } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif Button { renameRequest = makeRenameRequest(for: account) } label: { Label(String(localized: "accounts.rename"), systemImage: "pencil") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif Button(role: .destructive) { pendingSignOut = account } label: { Label(String(localized: "login.signOutAndRemove"), systemImage: "rectangle.portrait.and.arrow.forward") } - #if os(tvOS) - .buttonStyle(TVSettingsButtonStyle()) - #endif } // MARK: - Presentation