import SwiftUI import SFSafeSymbols /// 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: L10n.Field.username)) { TextField(String(localized: L10n.Field.username), text: Binding(get: { model.state.username }, set: { model.setUsername($0) })) } if model.state.supportsCustomReceiveFolders { Section(String(localized: L10n.Preferences.receiveFolderTitle)) { LabeledContent { Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder) } label: { Label { Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable)) .lineLimit(1) .truncationMode(.middle) } icon: { Image(systemSymbol: .folder) } } if !model.isUsingDefaultReceiveFolder { Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder) } } } } } struct AppearanceSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section { Picker(String(localized: L10n.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 { Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary) switch model.state.notificationPermission { case .notDetermined: Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications) case .granted: // Allowed — the OS Settings app is where you disable or fine-tune. Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary) Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings) case .denied: Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary) Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings) case .unsupported: Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary) } } .onAppear { model.refreshNotificationPermission() } } } struct NetworkSettings: View { @ObservedObject var model: SettingsModel var body: some View { Section { Picker( "", selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) }) ) { ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in Text(relayModeLabel(mode)).tag(mode) } } .pickerStyle(.inline) .labelsHidden() .disabled(model.state.isApplyingRelayConfiguration) } header: { Text(String(localized: L10n.Settings.networkTitle)) } footer: { Text(String(localized: relayModeDescription(model.state.relayMode))) } Section { Label { Text(String(localized: L10n.Relay.privacyDescription)) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemSymbol: .lockShield) } .foregroundStyle(.secondary) } if let endpointId = model.state.endpointId, !endpointId.isEmpty { Section { Text(L10n.Approval.endpointId(deviceId: endpointId)) .font(.footnote.monospaced()) .textSelection(.enabled) } } if model.state.relayMode.usesCustomRelayURLs { Section { if model.state.relayMode == .strictCustom { Label { Text(String(localized: L10n.Relay.strictWarning)) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemSymbol: .exclamationmarkShieldFill) } .foregroundStyle(.orange) } ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in VStack(alignment: .leading, spacing: 6) { HStack { TextField( "", text: Binding( get: { model.state.relayURLs.indices.contains(index) ? model.state.relayURLs[index] : "" }, set: { model.setRelayURL($0, at: index) } ), // `Text(verbatim:)` avoids macOS markdown-linkifying the // URL-shaped placeholder into a purple link. prompt: Text(verbatim: "https://relay.example.com") ) .labelsHidden() #if os(iOS) .keyboardType(.URL) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() .disabled(model.state.isApplyingRelayConfiguration) Button(role: .destructive) { model.removeRelayURL(at: index) } label: { Image(systemSymbol: .minusCircleFill) } .buttonStyle(.borderless) .accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl))) .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: L10n.Relay.addUrl), systemSymbol: .plusCircle) } .disabled( model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount || model.state.isApplyingRelayConfiguration ) } header: { Text(String(localized: L10n.Relay.customUrlsLabel)) } footer: { Text(String(localized: L10n.Relay.customUrlsHelp)) } } if let error = model.state.relayValidationError, error.urlIndex == nil { Section { Label { Text(relayValidationMessage(error)) } icon: { Image(systemSymbol: .exclamationmarkTriangleFill) } .foregroundStyle(.red) } } if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil { Section { Label { Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed))) } icon: { Image(systemSymbol: .exclamationmarkTriangleFill) } .foregroundStyle(.red) } } Section { Button(action: model.applyRelayConfiguration) { HStack { Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply)) if model.state.isApplyingRelayConfiguration { Spacer() ProgressView() } } } .disabled( !model.state.relayConfigurationIsDirty || model.state.isApplyingRelayConfiguration || model.state.hasActiveNetworkWork ) } footer: { Text(String(localized: L10n.Relay.applyRestartDescription)) } } } private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String { switch error { case .missingURL: return String(localized: L10n.Relay.validationMissingUrl) case .tooManyURLs: return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount) case .httpsRequired(let index): return L10n.Relay.validationHttpsRequired(line: index + 1) case .invalidURL(let index): return L10n.Relay.validationInvalidUrl(line: index + 1) case .duplicateURL(let index): return L10n.Relay.validationDuplicateUrl(line: index + 1) } } struct StorageSettings: View { @ObservedObject var model: SettingsModel @State private var showDeleteConfirmation = false private var isBusy: Bool { model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers } var body: some View { Section { usageContent } header: { HStack { Text(String(localized: L10n.Storage.usageHeader)) Spacer() if model.state.isCalculatingStorage { ProgressView().controlSize(.small) } else { Button(action: model.loadStorageUsage) { Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise) .labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(isBusy) .help(String(localized: L10n.Storage.refresh)) } } } footer: { Text(String(localized: L10n.Storage.footer)) } // Reclaim reversible junk (temp + trash) — non-destructive to history. Section { Button(action: model.freeUpSpace) { actionLabel( title: L10n.Storage.freeUpSpace, busyTitle: L10n.Storage.cleaning, isBusy: model.state.isCleaningStorage, symbol: .sparkles, tint: .accentColor ) } // `.plain` so pressing the row dims the label instead of flipping it to // the white selection-highlight that the default form button style uses. .buttonStyle(.plain) .disabled(isBusy) } footer: { Text(String(localized: L10n.Storage.freeUpSpaceCaption)) } // Destructive: clears transfer history + cached share content. Section { Button { showDeleteConfirmation = true } label: { actionLabel( title: L10n.Storage.deleteTransfers, busyTitle: L10n.Storage.deleting, isBusy: model.state.isDeletingTransfers, symbol: .trash, tint: .red ) } .buttonStyle(.plain) .disabled(isBusy) } footer: { Text(String(localized: L10n.Storage.deleteTransfersCaption)) } .task { model.loadStorageUsage() } .confirmationDialog( Text(String(localized: L10n.Storage.deleteTransfers)), isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) { model.deleteAllTransfers() } Button(String(localized: L10n.Button.cancel), role: .cancel) {} } message: { Text(String(localized: L10n.Storage.deleteTransfersDescription)) } } @ViewBuilder private var usageContent: some View { if let storage = model.state.storage { LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles)) LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache)) LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData)) LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary)) LabeledContent(String(localized: L10n.Storage.total)) { Text(formatBytes(storage.total)).fontWeight(.semibold) } } else if model.state.storageLoadFailed { // Genuine failure (core reported an error) — offer a retry. Button(action: model.loadStorageUsage) { Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise) .foregroundStyle(.secondary) } .buttonStyle(.plain) } else { // Loading, or waiting for the core to finish starting. HStack { Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary) Spacer() ProgressView().controlSize(.small) } } } /// A tinted, full-width button label with a leading symbol and a trailing /// spinner while busy. `.contentShape` keeps the whole row tappable. private func actionLabel( title: String.LocalizationValue, busyTitle: String.LocalizationValue, isBusy: Bool, symbol: SFSymbol, tint: Color ) -> some View { HStack { Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol) Spacer() if isBusy { ProgressView().controlSize(.small) } } .foregroundStyle(tint) .contentShape(Rectangle()) } } struct AboutSettings: View { @ObservedObject var model: SettingsModel private static let privacyPolicyURL = AppConfig.privacyPolicyURL var body: some View { Section { Text(String(localized: L10n.About.tagline)).font(.headline) Text(String(localized: L10n.About.description)).foregroundStyle(.secondary) } Section(String(localized: L10n.About.isTitle)) { AboutPoint(L10n.About.isDirect, .paperplane) AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark) AboutPoint(L10n.About.isInControl, .checkmarkShield) AboutPoint(L10n.About.isEncrypted, .lock) AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight) } Section(String(localized: L10n.About.isntTitle)) { AboutPoint(L10n.About.isntCloud, .icloudSlash) AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath) AboutPoint(L10n.About.isntPublic, .megaphone) } Section(String(localized: L10n.About.privacyTitle)) { AboutPoint(L10n.About.privacyCapability, .qrcode) AboutPoint(L10n.About.privacyDeny, .handRaised) AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight) AboutPoint(L10n.About.privacyLocal, .internaldrive) } Section(String(localized: L10n.About.title)) { LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion) if let device = model.state.deviceInfo { LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "—") LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem) } LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0") Link(destination: Self.privacyPolicyURL) { Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised) } } } } /// 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(String(localized: L10n.About.bugReport))) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: L10n.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.LocalizationValue let symbol: SFSymbol init(_ key: String.LocalizationValue, _ symbol: SFSymbol) { self.key = key self.symbol = symbol } var body: some View { Label { Text(String(localized: key)) .font(.subheadline) .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemSymbol: symbol).foregroundStyle(.tint) } } } struct BugReportSettings: View { @ObservedObject var model: SettingsModel var onSubmitted: () -> Void = {} var body: some View { Section(String(localized: L10n.Bug.reportWhatLabel)) { TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }), prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: L10n.Bug.reportExpectedLabel)) { TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }), prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: L10n.Bug.reportStepsLabel)) { TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }), prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } Section(String(localized: L10n.Bug.reportContactLabel)) { TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }), prompt: Text(String(localized: L10n.Bug.reportContactHint))) .labelsHidden() } Section { Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { Text(String(localized: L10n.Bug.reportIncludeLogs)) } Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) { Text(model.state.isSubmittingBugReport ? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit)) } .disabled(model.state.isSubmittingBugReport) } } }