From 992beab4384dbb0bcdb4ced61394b49deb046e17 Mon Sep 17 00:00:00 2001 From: David Rodger Date: Sun, 13 Sep 2026 13:20:25 -0400 Subject: [PATCH] Replace iPad-broken confirmation dialogs with menus and alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iPad a confirmationDialog is presented as a popover, and on iPadOS 26 — with the UIDesignRequiresCompatibility opt-out this app ships — that popover renders as an empty panel with no readable content. Verified by A/B on an iPad (A16) / iPadOS 26.1 simulator: with the key present the dialog shows nothing; remove the key and the same dialog renders. The song-title long press now opens a context menu instead. That is the native match for a long press, draws its own chrome, and anchors to the title rather than floating over the tab bar. It also says why the refresh is unavailable when the song is already queued, where the old gesture was silently a no-op. The five remaining dialogs are all tap-triggered confirmations carrying a message, so they become alerts, which present as a centred modal on iPad and are unaffected by the opt-out. Two get `presenting:` so their message can no longer render empty, and the MusicBrainz search dialog gains a real two-way isPresented binding in place of a `.constant`. Alerts only take buttons, so its "View on MusicBrainz" link opens through the environment. Alert buttons inherit the tab bar's brand tint, which was navy on near-black whenever the device was in dark mode. ApproachNoteTheme is a single hardcoded light palette, so the app's own screens are light either way and only the system surfaces we don't draw were drifting. Declaring the app light-only keeps them in step, as the Mac app already does with .preferredColorScheme(.light). Each of the six was driven to on screen on the iPad simulator. Co-Authored-By: Claude Opus 5 --- apps/iOS/App/Info.plist | 11 ++++ .../Views/AuthorityRecommendationView.swift | 36 ++++++------- apps/iOS/Views/MusicBrainzSearchSheet.swift | 53 +++++++++++-------- .../Views/RecordingContributionEditView.swift | 10 ++-- apps/iOS/Views/SongDetailView.swift | 44 +++++++-------- apps/iOS/Views/YouTubeImportView.swift | 10 ++-- 6 files changed, 91 insertions(+), 73 deletions(-) diff --git a/apps/iOS/App/Info.plist b/apps/iOS/App/Info.plist index 08465ae..d488f6e 100644 --- a/apps/iOS/App/Info.plist +++ b/apps/iOS/App/Info.plist @@ -8,6 +8,17 @@ system bar no longer applies. --> UIDesignRequiresCompatibility + + + UIUserInterfaceStyle + Light + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/apps/iOS/Views/AuthorityRecommendationView.swift b/apps/iOS/Views/AuthorityRecommendationView.swift index 518a302..faa0728 100644 --- a/apps/iOS/Views/AuthorityRecommendationView.swift +++ b/apps/iOS/Views/AuthorityRecommendationView.swift @@ -151,41 +151,37 @@ struct AuthorityRecommendationsView: View { showingAddSheet = false } } - .confirmationDialog( + // Alerts rather than confirmationDialogs: on iPad a dialog is presented + // as a popover, and on iPadOS 26 (with the app's + // UIDesignRequiresCompatibility opt-out) that popover renders with no + // readable content. Alerts are unaffected. + .alert( "Delete Authority Reference", isPresented: $showingDeleteConfirmation, - titleVisibility: .visible - ) { + presenting: deleteTarget + ) { authority in Button("Delete", role: .destructive) { - if let authority = deleteTarget { - Task { await deleteAuthority(authority) } - } + Task { await deleteAuthority(authority) } } Button("Cancel", role: .cancel) { deleteTarget = nil } - } message: { - if let authority = deleteTarget { - Text("Remove this \(authority.sourceDisplayName) reference from the recording?") - } + } message: { authority in + Text("Remove this \(authority.sourceDisplayName) reference from the recording?") } - .confirmationDialog( + .alert( "Link to This Recording", isPresented: $showingLinkConfirmation, - titleVisibility: .visible - ) { + presenting: linkTarget + ) { authority in Button("Link to \(albumTitle)") { - if let authority = linkTarget { - Task { await linkAuthority(authority) } - } + Task { await linkAuthority(authority) } } Button("Cancel", role: .cancel) { linkTarget = nil } - } message: { - if let authority = linkTarget { - Text("Link \"\(authority.artistName ?? "Unknown") - \(authority.albumTitle ?? "Unknown")\" to this recording?") - } + } message: { authority in + Text("Link \"\(authority.artistName ?? "Unknown") - \(authority.albumTitle ?? "Unknown")\" to this recording?") } .overlay { if linkingInProgress { diff --git a/apps/iOS/Views/MusicBrainzSearchSheet.swift b/apps/iOS/Views/MusicBrainzSearchSheet.swift index 1bf153e..0ac599f 100644 --- a/apps/iOS/Views/MusicBrainzSearchSheet.swift +++ b/apps/iOS/Views/MusicBrainzSearchSheet.swift @@ -12,6 +12,7 @@ struct MusicBrainzSearchSheet: View { let onSongImported: () -> Void @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @EnvironmentObject var authManager: AuthenticationManager @StateObject private var musicBrainzService = MusicBrainzService() @@ -73,31 +74,39 @@ struct MusicBrainzSearchSheet: View { } message: { Text(resultMessage) } - .confirmationDialog( - "Request Song", - isPresented: .constant(selectedWork != nil && !isSubmitting), - titleVisibility: .visible - ) { - if let work = selectedWork { - Button("Request \"\(work.title)\"") { - Task { - await requestSong(work) - } - } - - if let url = URL(string: work.musicbrainzUrl) { - Link("View on MusicBrainz", destination: url) - } - - Button("Cancel", role: .cancel) { - selectedWork = nil - } + } + // An alert rather than a confirmationDialog: on iPad a dialog is presented + // as a popover, and on iPadOS 26 (with the app's + // UIDesignRequiresCompatibility opt-out) that popover renders with no + // readable content. Alerts are unaffected, but SwiftUI only honours one + // alert per view, so this one hangs off the NavigationStack while the + // result alert above stays on its content. Alerts also only take buttons, + // so the MusicBrainz link opens through the environment. + .alert( + "Request Song", + isPresented: Binding( + get: { selectedWork != nil && !isSubmitting }, + set: { if !$0 { selectedWork = nil } } + ), + presenting: selectedWork + ) { work in + Button("Request \"\(work.title)\"") { + Task { + await requestSong(work) } - } message: { - if let work = selectedWork { - Text("Request that \"\(work.title)\" by \(work.composerDisplay) be added? We'll review it and add the song if it's a good fit.") + } + + if let url = URL(string: work.musicbrainzUrl) { + Button("View on MusicBrainz") { + openURL(url) } } + + Button("Cancel", role: .cancel) { + selectedWork = nil + } + } message: { work in + Text("Request that \"\(work.title)\" by \(work.composerDisplay) be added? We'll review it and add the song if it's a good fit.") } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) diff --git a/apps/iOS/Views/RecordingContributionEditView.swift b/apps/iOS/Views/RecordingContributionEditView.swift index c28254f..84fb1a9 100644 --- a/apps/iOS/Views/RecordingContributionEditView.swift +++ b/apps/iOS/Views/RecordingContributionEditView.swift @@ -149,11 +149,11 @@ struct RecordingContributionEditView: View { } message: { Text(errorMessage) } - .confirmationDialog( - "Delete Contribution", - isPresented: $showDeleteConfirmation, - titleVisibility: .visible - ) { + // An alert rather than a confirmationDialog: on iPad a dialog is + // presented as a popover, and on iPadOS 26 (with the app's + // UIDesignRequiresCompatibility opt-out) that popover renders with no + // readable content. Alerts are unaffected. + .alert("Delete Contribution", isPresented: $showDeleteConfirmation) { Button("Delete", role: .destructive) { deleteContribution() } diff --git a/apps/iOS/Views/SongDetailView.swift b/apps/iOS/Views/SongDetailView.swift index 0593d3f..a095eee 100644 --- a/apps/iOS/Views/SongDetailView.swift +++ b/apps/iOS/Views/SongDetailView.swift @@ -31,9 +31,6 @@ struct SongDetailView: View { @State private var alertMessage = "" @State private var isAddingToRepertoire = false - // Song refresh management - @State private var showRefreshConfirmation = false - // NEW: Toast notification @State private var toast: ToastItem? @@ -140,9 +137,29 @@ struct SongDetailView: View { .font(ApproachNoteTheme.largeTitle(weight: .regular)) .foregroundColor(ApproachNoteTheme.textSecondary) ) - .onLongPressGesture { - if canQueueForRefresh { - showRefreshConfirmation = true + // Long-press the title to re-research the song. This used to be a + // confirmationDialog, but on iPad that presents as a popover, and + // on iPadOS 26 (with the app's UIDesignRequiresCompatibility + // opt-out) the popover renders as an empty panel over the tab bar + // with no readable content. A context menu draws its own chrome, + // anchors to the title, and is the natural match for a long press. + .contextMenu { + Section("Refresh Song Data") { + if canQueueForRefresh { + Button { + refreshSongData(forceRefresh: false) + } label: { + Label("Quick Refresh", systemImage: "arrow.clockwise") + } + Button { + refreshSongData(forceRefresh: true) + } label: { + Label("Full Refresh", systemImage: "arrow.triangle.2.circlepath") + } + Text("Quick uses cached data. Full re-fetches everything.") + } else { + Text("This song is already queued for research.") + } } } @@ -346,21 +363,6 @@ struct SongDetailView: View { } message: { Text(alertMessage) } - .confirmationDialog( - "Refresh Song Data", - isPresented: $showRefreshConfirmation, - titleVisibility: .visible - ) { - Button("Quick Refresh") { - refreshSongData(forceRefresh: false) - } - Button("Full Refresh") { - refreshSongData(forceRefresh: true) - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Quick refresh uses cached data for faster results. Full refresh re-fetches everything from external sources.") - } .toast($toast) .onDisappear { viewModel.stopResearchStatusPolling() diff --git a/apps/iOS/Views/YouTubeImportView.swift b/apps/iOS/Views/YouTubeImportView.swift index 8978031..46423fb 100644 --- a/apps/iOS/Views/YouTubeImportView.swift +++ b/apps/iOS/Views/YouTubeImportView.swift @@ -94,11 +94,11 @@ struct YouTubeImportView: View { ) } } - .confirmationDialog( - "Link to Recording?", - isPresented: $showingRecordingChoice, - titleVisibility: .visible - ) { + // An alert rather than a confirmationDialog: on iPad a dialog is presented + // as a popover, and on iPadOS 26 (with the app's + // UIDesignRequiresCompatibility opt-out) that popover renders with no + // readable content. Alerts are unaffected. + .alert("Link to Recording?", isPresented: $showingRecordingChoice) { Button("Pick a Recording") { showingRecordingPicker = true }