mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
435 lines
13 KiB
Swift
435 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|