Files
yattee/Yattee/Views/Settings/AddSource/AddSMBView.swift
Arkadiusz Fal 508069cecf 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.
2026-04-20 20:51:24 +02:00

321 lines
9.9 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
}
// 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)
}
}
.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 {
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)
}
}