mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Delete all transfers only clears core records, and the blob-store cache is reclaimed by the core's own timer. Neither touches the app's temporary directory (leftover picker/staging copies — hundreds of MB on macOS) or the stray .Trash folders that accumulate in app-owned directories and can't be removed via Files/Finder. Add a non-destructive Free up space button that empties the temp directory and removes .Trash folders under the core data dir (and, on iOS, the fixed Documents receive folder), reporting the bytes reclaimed. Guarded against running while a transfer is in flight; never touches received files, the core database, or user-chosen macOS receive folders.
300 lines
9.8 KiB
Swift
300 lines
9.8 KiB
Swift
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 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: 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 {
|
|
HStack {
|
|
Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary)
|
|
Spacer()
|
|
ProgressView()
|
|
}
|
|
}
|
|
} footer: {
|
|
Text(String(localized: L10n.Storage.footer))
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
model.freeUpSpace()
|
|
} label: {
|
|
HStack {
|
|
Text(model.state.isCleaningStorage
|
|
? String(localized: L10n.Storage.cleaning)
|
|
: String(localized: L10n.Storage.freeUpSpace))
|
|
if model.state.isCleaningStorage {
|
|
Spacer()
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(model.state.isCleaningStorage)
|
|
}
|
|
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showDeleteConfirmation = true
|
|
} label: {
|
|
HStack {
|
|
Text(model.state.isDeletingTransfers
|
|
? String(localized: L10n.Storage.deleting)
|
|
: String(localized: L10n.Storage.deleteTransfers))
|
|
if model.state.isDeletingTransfers {
|
|
Spacer()
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(model.state.isDeletingTransfers)
|
|
}
|
|
.onAppear { 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AboutSettings: View {
|
|
@ObservedObject var model: SettingsModel
|
|
|
|
private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
if DiagnosticsBuildConfig.included {
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { model.state.diagnosticsEnabled },
|
|
set: { model.setDiagnosticsEnabled($0) }
|
|
)) {
|
|
Text(String(localized: L10n.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(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)
|
|
}
|
|
}
|
|
}
|