From 508069cecfa119d4c3a111757c3dbdc9e37c7d73 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 20 Apr 2026 20:51:24 +0200 Subject: [PATCH] Make source add/edit forms feel native on macOS Use grouped Form style with LabeledContent rows and move primary actions into the sheet toolbar for SMB, WebDAV, Local Folder, Remote Server and the Edit sheet. iOS and tvOS branches unchanged. --- .../AddSource/AddLocalFolderView.swift | 59 ++++++++++++ .../AddSource/AddRemoteServerView.swift | 79 +++++++++++++++ .../Views/Settings/AddSource/AddSMBView.swift | 95 +++++++++++++++++++ .../Settings/AddSource/AddWebDAVView.swift | 95 +++++++++++++++++++ Yattee/Views/Settings/AddSourceView.swift | 2 +- Yattee/Views/Settings/EditSourceView.swift | 41 ++++++++ 6 files changed, 370 insertions(+), 1 deletion(-) diff --git a/Yattee/Views/Settings/AddSource/AddLocalFolderView.swift b/Yattee/Views/Settings/AddSource/AddLocalFolderView.swift index 64b7fd28..5f8d6102 100644 --- a/Yattee/Views/Settings/AddSource/AddLocalFolderView.swift +++ b/Yattee/Views/Settings/AddSource/AddLocalFolderView.swift @@ -34,6 +34,9 @@ struct AddLocalFolderView: View { // MARK: - Body var body: some View { + #if os(macOS) + macOSBody + #else Form { nameSection folderSection @@ -53,8 +56,64 @@ struct AddLocalFolderView: View { } } #endif + #endif } + #if os(macOS) + private var macOSBody: some View { + Form { + Section { + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name) + } + } footer: { + Text(String(localized: "sources.footer.displayName")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent(String(localized: "sources.header.folder")) { + HStack { + if let url = selectedFolderURL { + Text(url.path) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .font(.system(.body, design: .monospaced)) + } else { + Text(String(localized: "sources.selectFolder")) + .foregroundStyle(.secondary) + } + Button(String(localized: "sources.selectFolder")) { + selectFolderMacOS() + } + } + } + } footer: { + Text(String(localized: "sources.footer.folder")) + .font(.callout) + .foregroundStyle(.secondary) + } + + if let result = testResult { + SourceTestResultSection(result: result) + } + } + .formStyle(.grouped) + .navigationTitle(String(localized: "sources.addLocalFolder")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "sources.addSource")) { + addSource() + } + .disabled(!canAdd) + .keyboardShortcut(.defaultAction) + } + } + } + #endif + // MARK: - Sections private var nameSection: some View { diff --git a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift index 8d31e80c..187a22f6 100644 --- a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift +++ b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift @@ -172,12 +172,38 @@ struct AddRemoteServerView: View { if isFieldsRevealed { serverConfigurationFields + #if !os(macOS) actionSection + #endif } } #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif + #if os(macOS) + .formStyle(.grouped) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if isFieldsRevealed { + Button { + addSource() + } label: { + if isValidatingCredentials { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sources.validatingCredentials")) + } + } else { + Text(String(localized: "sources.addSource")) + } + } + .disabled(!canAdd || isValidatingCredentials) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("addRemoteServer.actionButton") + } + } + } + #endif } // MARK: - URL Entry Section @@ -207,6 +233,35 @@ struct AddRemoteServerView: View { .buttonStyle(TVSettingsButtonStyle()) .disabled(urlString.isEmpty || uiState == .detecting) } + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.url")) { + TextField("", text: $urlString, prompt: Text(String(localized: "sources.placeholder.urlOrAddress"))) + .textContentType(.URL) + .autocorrectionDisabled() + .accessibilityIdentifier("addRemoteServer.urlField") + .onChange(of: urlString) { _, _ in + handleURLChange() + } + } + + if !isFieldsRevealed && !isAwaitingBasicAuth { + HStack { + Spacer() + Button { + startDetection() + } label: { + HStack(spacing: 6) { + if case .detecting = uiState { + ProgressView() + .controlSize(.small) + } + Text(String(localized: "sources.detect")) + } + } + .disabled(urlString.isEmpty || uiState == .detecting) + .accessibilityIdentifier("addRemoteServer.detectButton") + } + } #else TextField(String(localized: "sources.placeholder.urlOrAddress"), text: $urlString) .textContentType(.URL) @@ -277,6 +332,16 @@ struct AddRemoteServerView: View { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.username")) { + TextField("", text: $basicAuthUsername) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.password")) { + SecureField("", text: $basicAuthPassword) + .textContentType(.password) + } #else TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) .textContentType(.username) @@ -328,6 +393,10 @@ struct AddRemoteServerView: View { Section { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name, prompt: Text(String(localized: "sources.field.nameOptional"))) + } #else TextField(String(localized: "sources.field.nameOptional"), text: $name) #endif @@ -381,6 +450,16 @@ struct AddRemoteServerView: View { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.username")) { + TextField("", text: $basicAuthUsername) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.password")) { + SecureField("", text: $basicAuthPassword) + .textContentType(.password) + } #else TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) .textContentType(.username) diff --git a/Yattee/Views/Settings/AddSource/AddSMBView.swift b/Yattee/Views/Settings/AddSource/AddSMBView.swift index 15d0b30a..3cd5ffb7 100644 --- a/Yattee/Views/Settings/AddSource/AddSMBView.swift +++ b/Yattee/Views/Settings/AddSource/AddSMBView.swift @@ -39,6 +39,9 @@ struct AddSMBView: View { // MARK: - Body var body: some View { + #if os(macOS) + macOSBody + #else Form { nameSection serverSection @@ -68,8 +71,100 @@ struct AddSMBView: View { name = prefillName } } + #endif } + #if os(macOS) + private var macOSBody: some View { + Form { + Section { + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name) + } + } footer: { + Text(String(localized: "sources.footer.displayName")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent(String(localized: "sources.placeholder.smbServer")) { + TextField("", text: $server) + .autocorrectionDisabled() + } + } footer: { + Text(String(localized: "sources.footer.smb")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent(String(localized: "sources.field.usernameOptional")) { + TextField("", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.passwordOptional")) { + SecureField("", text: $password) + .textContentType(.password) + } + } header: { + Text(String(localized: "sources.header.auth")) + } footer: { + Text(String(localized: "sources.footer.auth")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + Picker(String(localized: "sources.field.smbProtocol"), selection: $protocolVersion) { + ForEach(SMBProtocol.allCases, id: \.self) { proto in + Text(proto.displayName).tag(proto) + } + } + } header: { + Text(String(localized: "sources.header.advanced")) + } footer: { + Text(String(localized: "sources.footer.smbProtocol")) + .font(.callout) + .foregroundStyle(.secondary) + } + + if let result = testResult { + SourceTestResultSection(result: result) + } + } + .formStyle(.grouped) + .navigationTitle(String(localized: "sources.addSMB")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + addSource() + } label: { + if isTesting { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(testProgress ?? String(localized: "sources.testing")) + } + } else { + Text(String(localized: "sources.addSource")) + } + } + .disabled(!canAdd || isTesting) + .keyboardShortcut(.defaultAction) + } + } + .onAppear { + if let prefillServer { + server = prefillServer + } + if let prefillName, name.isEmpty { + name = prefillName + } + } + } + #endif + // MARK: - Sections private var nameSection: some View { diff --git a/Yattee/Views/Settings/AddSource/AddWebDAVView.swift b/Yattee/Views/Settings/AddSource/AddWebDAVView.swift index e137e543..3211a568 100644 --- a/Yattee/Views/Settings/AddSource/AddWebDAVView.swift +++ b/Yattee/Views/Settings/AddSource/AddWebDAVView.swift @@ -40,6 +40,9 @@ struct AddWebDAVView: View { // MARK: - Body var body: some View { + #if os(macOS) + macOSBody + #else Form { nameSection serverSection @@ -72,8 +75,100 @@ struct AddWebDAVView: View { allowInvalidCertificates = true } } + #endif } + #if os(macOS) + private var macOSBody: some View { + Form { + Section { + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name) + } + } footer: { + Text(String(localized: "sources.footer.displayName")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent(String(localized: "sources.placeholder.webdavUrl")) { + TextField("", text: $urlString) + .textContentType(.URL) + .autocorrectionDisabled() + } + } footer: { + Text(String(localized: "sources.footer.webdav")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent(String(localized: "sources.field.usernameOptional")) { + TextField("", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.passwordOptional")) { + SecureField("", text: $password) + .textContentType(.password) + } + } header: { + Text(String(localized: "sources.header.auth")) + } footer: { + Text(String(localized: "sources.footer.auth")) + .font(.callout) + .foregroundStyle(.secondary) + } + + Section { + Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates) + } header: { + Text(String(localized: "sources.header.security")) + } footer: { + Text(String(localized: "sources.footer.allowInvalidCertificates")) + .font(.callout) + .foregroundStyle(.secondary) + } + + if let result = testResult { + SourceTestResultSection(result: result) + } + } + .formStyle(.grouped) + .navigationTitle(String(localized: "sources.addWebDAV")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + addSource() + } label: { + if isTesting { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(testProgress ?? String(localized: "sources.testing")) + } + } else { + Text(String(localized: "sources.addSource")) + } + } + .disabled(!canAdd || isTesting) + .keyboardShortcut(.defaultAction) + } + } + .onAppear { + if let url = prefillURL { + urlString = url.absoluteString + } + if let prefillName, name.isEmpty { + name = prefillName + } + if prefillAllowInvalidCertificates { + allowInvalidCertificates = true + } + } + } + #endif + // MARK: - Sections private var nameSection: some View { diff --git a/Yattee/Views/Settings/AddSourceView.swift b/Yattee/Views/Settings/AddSourceView.swift index 221ef84b..57cb7d6c 100644 --- a/Yattee/Views/Settings/AddSourceView.swift +++ b/Yattee/Views/Settings/AddSourceView.swift @@ -89,7 +89,7 @@ struct AddSourceView: View { } } #if os(macOS) - .frame(minWidth: 500, minHeight: 450) + .frame(minWidth: 560, minHeight: 560) #endif .sheet(isPresented: $showingNetworkDiscovery) { NetworkShareDiscoverySheet(filterType: selectedShareType) { share in diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index e2d20c7c..3b2d2478 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -118,6 +118,11 @@ private struct EditRemoteServerContent: View { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name) TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name) + } + Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) #else TextField(String(localized: "sources.field.name"), text: $name) Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) @@ -128,6 +133,16 @@ private struct EditRemoteServerContent: View { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.username")) { + TextField("", text: $basicAuthUsername) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.password")) { + SecureField("", text: $basicAuthPassword) + .textContentType(.password) + } #else TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) .textContentType(.username) @@ -321,6 +336,9 @@ private struct EditRemoteServerContent: View { #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif + #if os(macOS) + .formStyle(.grouped) + #endif .confirmationDialog( String(localized: "sources.delete.confirmation.single \(instance.displayName)"), isPresented: $showingDeleteConfirmation, @@ -564,6 +582,11 @@ private struct EditFileSourceContent: View { #if os(tvOS) TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name) TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.name")) { + TextField("", text: $name) + } + Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) #else TextField(String(localized: "sources.field.name"), text: $name) Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) @@ -615,6 +638,21 @@ private struct EditFileSourceContent: View { text: $password, isSecure: true ) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.username")) { + TextField("", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.password")) { + SecureField( + hasExistingPassword + ? String(localized: "sources.field.passwordKeep") + : String(localized: "sources.field.passwordRequired"), + text: $password + ) + .textContentType(.password) + } #else TextField(String(localized: "sources.field.username"), text: $username) .textContentType(.username) @@ -718,6 +756,9 @@ private struct EditFileSourceContent: View { #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif + #if os(macOS) + .formStyle(.grouped) + #endif .confirmationDialog( String(localized: "sources.delete.confirmation.single \(source.name)"), isPresented: $showingDeleteConfirmation,