mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Redesign the Storage screen for clarity: an "On this device" usage header with a manual Refresh control, symbol-led action buttons, and a caption under each action spelling out exactly what it does (Free up space = temp + trash, non-destructive; Delete all transfers = clears history + cached share content, keeps received files). Fix the summary sticking on "Calculating…": it loaded only on .onAppear and bailed when opened before the core finished its async launch, leaving the loading branch showing with nothing running (only a manual refresh recovered it). loadStorageUsage now waits for the core to become ready before reading usage, distinguishes a real failure (retry) from loading, loads via .task, and can be refreshed on demand. Use plain button styling with explicit tints so pressing an action no longer flips the label to the white selection highlight.
357 lines
12 KiB
Swift
357 lines
12 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
|
|
|
|
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 = 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)
|
|
}
|
|
}
|
|
}
|