Files
yattee/Yattee/Views/Settings/AddSource/AddSMBView.swift
Arkadiusz Fal 37d11b50a8 Render Add Source buttons inline on macOS where toolbar items aren't shown
Pre-macOS 26 doesn't reliably surface a detail-pane NavigationStack's
toolbar confirmation items in the window title bar, leaving the
"Add Source" actions invisible. Gate the toolbar placement behind
macOS 26+ and fall back to an inline form/header button on older
macOS across the add-source views and the sources list.
2026-05-15 07:55:30 +02:00

342 lines
11 KiB
Swift

//
// AddSMBView.swift
// Yattee
//
// View for adding an SMB (Samba) share as a media source.
//
import SwiftUI
struct AddSMBView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
// MARK: - State
@State private var name = ""
@State private var server = ""
@State private var username = ""
@State private var password = ""
@State private var protocolVersion: SMBProtocol = .auto
@State private var isTesting = false
@State private var testResult: SourceTestResult?
@State private var testProgress: String?
// Pre-filled from network discovery
var prefillServer: String?
var prefillName: String?
// Closure to dismiss the parent sheet
var dismissSheet: DismissAction?
// MARK: - Computed Properties
private var canAdd: Bool {
!name.isEmpty && !server.isEmpty
}
/// macOS surfaces toolbar confirmation items from a pushed-in-sheet view
/// reliably only from macOS 26 onward. On older macOS we render the
/// "Add Source" action button inline in the form instead.
private var usesToolbarActionButton: Bool {
#if os(macOS)
if #available(macOS 26, *) {
return true
} else {
return false
}
#else
return false
#endif
}
// MARK: - Body
var body: some View {
#if os(macOS)
macOSBody
#else
Form {
nameSection
serverSection
authSection
protocolSection
if let result = testResult {
SourceTestResultSection(result: result)
}
actionSection
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if !os(tvOS)
.navigationTitle(String(localized: "sources.addSMB"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#endif
.onAppear {
if let prefillServer {
server = prefillServer
}
if let prefillName, name.isEmpty {
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)
}
if !usesToolbarActionButton {
actionSection
}
}
.formStyle(.grouped)
.navigationTitle(String(localized: "sources.addSMB"))
.toolbar {
if usesToolbarActionButton {
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 {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
#else
TextField(String(localized: "sources.field.name"), text: $name)
#endif
} footer: {
Text(String(localized: "sources.footer.displayName"))
}
}
private var serverSection: some View {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.placeholder.smbServer"), text: $server)
#else
TextField(String(localized: "sources.placeholder.smbServer"), text: $server, prompt: Text(String(localized: "sources.placeholder.smbServer")))
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
#endif
} footer: {
Text(String(localized: "sources.footer.smb"))
}
}
private var authSection: some View {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.usernameOptional"), text: $username)
TVSettingsTextField(title: String(localized: "sources.field.passwordOptional"), text: $password, isSecure: true)
#else
TextField(String(localized: "sources.field.usernameOptional"), text: $username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField(String(localized: "sources.field.passwordOptional"), text: $password)
.textContentType(.password)
#endif
} header: {
Text(String(localized: "sources.header.auth"))
} footer: {
Text(String(localized: "sources.footer.auth"))
}
}
private var protocolSection: some View {
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"))
}
}
private var actionSection: some View {
Section {
Button {
addSource()
} label: {
if isTesting {
HStack {
ProgressView()
.controlSize(.small)
Text(testProgress ?? String(localized: "sources.testing"))
}
} else {
Text(String(localized: "sources.addSource"))
}
}
.disabled(!canAdd || isTesting)
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
// MARK: - Actions
private func addSource() {
guard let appEnvironment else { return }
let urlString = "smb://\(server)"
guard let encodedURLString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encodedURLString) else {
testResult = .failure(String(localized: "sources.error.invalidSMBAddress"))
return
}
isTesting = true
testResult = nil
testProgress = String(localized: "sources.testing.connecting")
let source = MediaSource.smb(
name: name,
url: url,
username: username.isEmpty ? nil : username,
protocolVersion: protocolVersion
)
Task {
do {
_ = try await appEnvironment.smbClient.testConnection(
source: source,
password: password.isEmpty ? nil : password
)
await MainActor.run {
if !password.isEmpty {
appEnvironment.mediaSourcesManager.setPassword(password, for: source)
}
appEnvironment.mediaSourcesManager.add(source)
isTesting = false
testProgress = nil
if let dismissSheet {
dismissSheet()
} else {
dismiss()
}
}
} catch {
await MainActor.run {
isTesting = false
testProgress = nil
testResult = .failure(error.localizedDescription)
}
}
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
AddSMBView()
.appEnvironment(.preview)
}
}