import SwiftUI /// Settings section detail views, rebuilt as native `Form` content. Each view is /// placed inside a parent `Form`, so it returns `Section`s / rows directly. struct PreferencesSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section(String(localized: "field_username")) { TextField(String(localized: "field_username"), text: Binding(get: { model.state.username }, set: { model.setUsername($0) })) } if model.state.supportsCustomReceiveFolders { Section(String(localized: "preferences_receive_folder_title")) { Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable")) .foregroundStyle(.secondary) Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder) Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder) } } } } struct AppearanceSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section { Picker(String(localized: "appearance_title"), selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) { ForEach(ThemeMode.allCases, id: \.self) { mode in Text(themeModeLabel(mode)).tag(mode) } } .pickerStyle(.inline) .labelsHidden() } } } struct NotificationSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section { Toggle(isOn: Binding( get: { model.state.notificationsEnabled }, set: { model.setNotificationsEnabled($0) } )) { Text(LocalizedStringKey("notifications_local_title")) } if model.state.notificationPermission == .denied { Button(String(localized: "button_open_settings"), action: model.openNotificationSettings) } } } } struct NetworkSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section { Picker( String(localized: "settings_network_title"), selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) }) ) { ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in Text(relayModeLabel(mode)).tag(mode) } } .pickerStyle(.inline) .disabled(model.state.isApplyingRelayConfiguration) } footer: { Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode))) } Section { Label { Text(LocalizedStringKey("relay_privacy_description")) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "lock.shield") } .foregroundStyle(.secondary) } if let endpointId = model.state.endpointId, !endpointId.isEmpty { Section { Text(String(format: String(localized: "approval_endpoint_id"), endpointId)) .font(.footnote.monospaced()) .textSelection(.enabled) } } if model.state.relayMode.usesCustomRelayURLs { Section { if model.state.relayMode == .strictCustom { Label { Text(LocalizedStringKey("relay_strict_warning")) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "exclamationmark.shield.fill") } .foregroundStyle(.orange) } ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in VStack(alignment: .leading, spacing: 6) { HStack { TextField( "https://relay.example.com", text: Binding( get: { model.state.relayURLs.indices.contains(index) ? model.state.relayURLs[index] : "" }, set: { model.setRelayURL($0, at: index) } ) ) #if os(iOS) .keyboardType(.URL) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() .disabled(model.state.isApplyingRelayConfiguration) Button(role: .destructive) { model.removeRelayURL(at: index) } label: { Image(systemName: "minus.circle.fill") } .buttonStyle(.borderless) .accessibilityLabel(Text(LocalizedStringKey("relay_remove_url"))) .disabled(model.state.isApplyingRelayConfiguration) } if let error = model.state.relayValidationError, error.urlIndex == index { Text(relayValidationMessage(error)) .font(.caption) .foregroundStyle(.red) } } } Button(action: model.addRelayURL) { Label(String(localized: "relay_add_url"), systemImage: "plus.circle") } .disabled( model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount || model.state.isApplyingRelayConfiguration ) } header: { Text(LocalizedStringKey("relay_custom_urls_label")) } footer: { Text(LocalizedStringKey("relay_custom_urls_help")) } } if let error = model.state.relayValidationError, error.urlIndex == nil { Section { Label { Text(relayValidationMessage(error)) } icon: { Image(systemName: "exclamationmark.triangle.fill") } .foregroundStyle(.red) } } if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil { Section { Label { Text(LocalizedStringKey( model.state.hasActiveNetworkWork ? "relay_apply_active_transfers" : model.state.relayApplyErrorKey ?? "relay_apply_failed" )) } icon: { Image(systemName: "exclamationmark.triangle.fill") } .foregroundStyle(.red) } } Section { Button(action: model.applyRelayConfiguration) { HStack { Text(LocalizedStringKey( model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply" )) if model.state.isApplyingRelayConfiguration { Spacer() ProgressView() } } } .disabled( !model.state.relayConfigurationIsDirty || model.state.isApplyingRelayConfiguration || model.state.hasActiveNetworkWork ) } footer: { Text(LocalizedStringKey("relay_apply_restart_description")) } } } private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String { switch error { case .missingURL: return String(localized: "relay_validation_missing_url") case .tooManyURLs: return String( format: String(localized: "relay_validation_too_many_urls"), RelayConfigurationValidator.maximumRelayCount ) case .httpsRequired(let index): return String(format: String(localized: "relay_validation_https_required"), index + 1) case .invalidURL(let index): return String(format: String(localized: "relay_validation_invalid_url"), index + 1) case .duplicateURL(let index): return String(format: String(localized: "relay_validation_duplicate_url"), index + 1) } } struct StorageSettings: View { @ObservedObject var model: SettingsModel @State private var showDeleteConfirmation = false var body: some View { Section { if let storage = model.state.storage { LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache)) LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData)) LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) LabeledContent(String(localized: "storage_total")) { Text(formatBytes(storage.total)).fontWeight(.semibold) } } else { HStack { Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary) Spacer() ProgressView() } } } footer: { Text(LocalizedStringKey("storage_footer")) } Section { Button(role: .destructive) { showDeleteConfirmation = true } label: { HStack { Text(model.state.isDeletingTransfers ? String(localized: "storage_deleting") : String(localized: "storage_delete_transfers")) if model.state.isDeletingTransfers { Spacer() ProgressView() } } } .disabled(model.state.isDeletingTransfers) } .onAppear { model.loadStorageUsage() } .confirmationDialog( Text(LocalizedStringKey("storage_delete_transfers")), isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { Button(String(localized: "storage_delete_transfers"), role: .destructive) { model.deleteAllTransfers() } Button(String(localized: "button_cancel"), role: .cancel) {} } message: { Text(LocalizedStringKey("storage_delete_transfers_description")) } } } struct AboutSettings: View { @ObservedObject var model: SettingsModel private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! var body: some View { Section { Text(LocalizedStringKey("about_tagline")).font(.headline) Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary) } Section(String(localized: "about_is_title")) { AboutPoint("about_is_direct", "paperplane") AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark") AboutPoint("about_is_in_control", "checkmark.shield") AboutPoint("about_is_encrypted", "lock") AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right") } Section(String(localized: "about_isnt_title")) { AboutPoint("about_isnt_cloud", "icloud.slash") AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath") AboutPoint("about_isnt_public", "megaphone") } Section(String(localized: "about_privacy_title")) { AboutPoint("about_privacy_capability", "qrcode") AboutPoint("about_privacy_deny", "hand.raised") AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right") AboutPoint("about_privacy_local", "internaldrive") } Section(String(localized: "about_title")) { LabeledContent(String(localized: "version_title"), value: model.state.appVersion) if let device = model.state.deviceInfo { LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—") LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem) } LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0") Link(destination: Self.privacyPolicyURL) { Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised") } } if DiagnosticsBuildConfig.included { Section { Toggle(isOn: Binding( get: { model.state.diagnosticsEnabled }, set: { model.setDiagnosticsEnabled($0) } )) { Text(LocalizedStringKey("diagnostics_title")) } } } } } /// Bug report presented as a sheet from About. Can be dismissed by swipe only /// when empty; otherwise the Cancel button is required. struct BugReportSheet: View { @ObservedObject var model: SettingsModel @Environment(\.dismiss) private var dismiss private var isEmpty: Bool { model.state.bugWhatHappened.isEmpty && model.state.bugExpected.isEmpty && model.state.bugSteps.isEmpty && model.state.bugContact.isEmpty } var body: some View { NavigationStack { Form { BugReportSettings(model: model, onSubmitted: { dismiss() }) } .formStyle(.grouped) .navigationTitle(Text(LocalizedStringKey("about_bug_report"))) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: "button_cancel")) { dismiss() } } } } .interactiveDismissDisabled(!isEmpty) } } /// A bullet-style informational row with an SF Symbol and wrapping localized text. private struct AboutPoint: View { let key: String let symbol: String init(_ key: String, _ symbol: String) { self.key = key self.symbol = symbol } var body: some View { Label { Text(LocalizedStringKey(key)) .font(.subheadline) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: symbol).foregroundStyle(.tint) } } } struct BugReportSettings: View { @ObservedObject var model: SettingsModel var onSubmitted: () -> Void = {} var body: some View { Section(String(localized: "bug_report_what_label")) { TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }), prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: "bug_report_expected_label")) { TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }), prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: "bug_report_steps_label")) { TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }), prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: "bug_report_contact_label")) { TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }), prompt: Text(LocalizedStringKey("bug_report_contact_hint"))) .labelsHidden() } Section { Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { Text(LocalizedStringKey("bug_report_include_logs")) } Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) { Text(model.state.isSubmittingBugReport ? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit")) } .disabled(model.state.isSubmittingBugReport) } } }