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.
This commit is contained in:
Arkadiusz Fal
2026-04-20 20:51:24 +02:00
parent e0e1e8cbd7
commit 508069cecf
6 changed files with 370 additions and 1 deletions

View File

@@ -34,6 +34,9 @@ struct AddLocalFolderView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
#if os(macOS)
macOSBody
#else
Form { Form {
nameSection nameSection
folderSection folderSection
@@ -53,8 +56,64 @@ struct AddLocalFolderView: View {
} }
} }
#endif #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 // MARK: - Sections
private var nameSection: some View { private var nameSection: some View {

View File

@@ -172,12 +172,38 @@ struct AddRemoteServerView: View {
if isFieldsRevealed { if isFieldsRevealed {
serverConfigurationFields serverConfigurationFields
#if !os(macOS)
actionSection actionSection
#endif
} }
} }
#if os(iOS) #if os(iOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
#endif #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 // MARK: - URL Entry Section
@@ -207,6 +233,35 @@ struct AddRemoteServerView: View {
.buttonStyle(TVSettingsButtonStyle()) .buttonStyle(TVSettingsButtonStyle())
.disabled(urlString.isEmpty || uiState == .detecting) .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 #else
TextField(String(localized: "sources.placeholder.urlOrAddress"), text: $urlString) TextField(String(localized: "sources.placeholder.urlOrAddress"), text: $urlString)
.textContentType(.URL) .textContentType(.URL)
@@ -277,6 +332,16 @@ struct AddRemoteServerView: View {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) 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 #else
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username) .textContentType(.username)
@@ -328,6 +393,10 @@ struct AddRemoteServerView: View {
Section { Section {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name) 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 #else
TextField(String(localized: "sources.field.nameOptional"), text: $name) TextField(String(localized: "sources.field.nameOptional"), text: $name)
#endif #endif
@@ -381,6 +450,16 @@ struct AddRemoteServerView: View {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) 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 #else
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username) .textContentType(.username)

View File

@@ -39,6 +39,9 @@ struct AddSMBView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
#if os(macOS)
macOSBody
#else
Form { Form {
nameSection nameSection
serverSection serverSection
@@ -68,8 +71,100 @@ struct AddSMBView: View {
name = prefillName 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 // MARK: - Sections
private var nameSection: some View { private var nameSection: some View {

View File

@@ -40,6 +40,9 @@ struct AddWebDAVView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
#if os(macOS)
macOSBody
#else
Form { Form {
nameSection nameSection
serverSection serverSection
@@ -72,8 +75,100 @@ struct AddWebDAVView: View {
allowInvalidCertificates = true 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 // MARK: - Sections
private var nameSection: some View { private var nameSection: some View {

View File

@@ -89,7 +89,7 @@ struct AddSourceView: View {
} }
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 500, minHeight: 450) .frame(minWidth: 560, minHeight: 560)
#endif #endif
.sheet(isPresented: $showingNetworkDiscovery) { .sheet(isPresented: $showingNetworkDiscovery) {
NetworkShareDiscoverySheet(filterType: selectedShareType) { share in NetworkShareDiscoverySheet(filterType: selectedShareType) { share in

View File

@@ -118,6 +118,11 @@ private struct EditRemoteServerContent: View {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name) TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled) 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 #else
TextField(String(localized: "sources.field.name"), text: $name) TextField(String(localized: "sources.field.name"), text: $name)
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
@@ -128,6 +133,16 @@ private struct EditRemoteServerContent: View {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) 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 #else
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username) .textContentType(.username)
@@ -321,6 +336,9 @@ private struct EditRemoteServerContent: View {
#if os(iOS) #if os(iOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
#endif #endif
#if os(macOS)
.formStyle(.grouped)
#endif
.confirmationDialog( .confirmationDialog(
String(localized: "sources.delete.confirmation.single \(instance.displayName)"), String(localized: "sources.delete.confirmation.single \(instance.displayName)"),
isPresented: $showingDeleteConfirmation, isPresented: $showingDeleteConfirmation,
@@ -564,6 +582,11 @@ private struct EditFileSourceContent: View {
#if os(tvOS) #if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name) TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled) 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 #else
TextField(String(localized: "sources.field.name"), text: $name) TextField(String(localized: "sources.field.name"), text: $name)
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled) Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
@@ -615,6 +638,21 @@ private struct EditFileSourceContent: View {
text: $password, text: $password,
isSecure: true 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 #else
TextField(String(localized: "sources.field.username"), text: $username) TextField(String(localized: "sources.field.username"), text: $username)
.textContentType(.username) .textContentType(.username)
@@ -718,6 +756,9 @@ private struct EditFileSourceContent: View {
#if os(iOS) #if os(iOS)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
#endif #endif
#if os(macOS)
.formStyle(.grouped)
#endif
.confirmationDialog( .confirmationDialog(
String(localized: "sources.delete.confirmation.single \(source.name)"), String(localized: "sources.delete.confirmation.single \(source.name)"),
isPresented: $showingDeleteConfirmation, isPresented: $showingDeleteConfirmation,