mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(apple): storage screen, About content, and fixes
- Settings: add Storage screen (size breakdown + delete-all-transfers) and expand About (what it is/isn't, privacy & security, license/source) - Move Report a bug to a toolbar sheet (cancel-only unless empty) - Send progress: derive the list-row bar from receiver delivery status so it clears once every receiver completes - Fixes: iPad orientations, onChange(of:) iOS 17 API, weak-self captures, invalid SF Symbol, macOS bug-report form labels; bump core build target to match the app (18.2/15.0)
This commit is contained in:
@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
|
||||
case preferences
|
||||
case appearance
|
||||
case notifications
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
|
||||
@@ -16,12 +17,21 @@ enum SettingsSection: Hashable {
|
||||
case .preferences: return "preferences_title"
|
||||
case .appearance: return "appearance_title"
|
||||
case .notifications: return "notifications_title"
|
||||
case .storage: return "storage_title"
|
||||
case .about: return "about_title"
|
||||
case .bugReport: return "about_bug_report"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk usage breakdown for the Storage screen.
|
||||
struct StorageBreakdown: Equatable {
|
||||
var receivedFiles: UInt64 = 0
|
||||
var transferData: UInt64 = 0
|
||||
var temporary: UInt64 = 0
|
||||
var total: UInt64 { receivedFiles + transferData + temporary }
|
||||
}
|
||||
|
||||
struct SettingsState: Equatable {
|
||||
var selectedSection: SettingsSection = .overview
|
||||
var username = ""
|
||||
@@ -43,6 +53,9 @@ struct SettingsState: Equatable {
|
||||
var bugIncludeLogs = true
|
||||
var isSubmittingBugReport = false
|
||||
var bugLogPreviewBytes = 0
|
||||
var storage: StorageBreakdown?
|
||||
var isCalculatingStorage = false
|
||||
var isDeletingTransfers = false
|
||||
|
||||
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||
@@ -57,6 +70,8 @@ struct SettingsState: Equatable {
|
||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||
}
|
||||
}
|
||||
@@ -70,6 +85,7 @@ final class SettingsModel: ObservableObject {
|
||||
private let environment: PlatformEnvironment
|
||||
private let deviceInfoProvider: DeviceInfoProvider
|
||||
private let fileSystemService: FileSystemService
|
||||
private let repository: CoreRepository
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let notifications: LocalNotificationService
|
||||
private let messages: UiMessageController
|
||||
@@ -85,6 +101,7 @@ final class SettingsModel: ObservableObject {
|
||||
environment: PlatformEnvironment,
|
||||
deviceInfoProvider: DeviceInfoProvider,
|
||||
fileSystemService: FileSystemService,
|
||||
repository: CoreRepository,
|
||||
preferences: AppPreferencesRepository,
|
||||
notifications: LocalNotificationService,
|
||||
messages: UiMessageController,
|
||||
@@ -94,6 +111,7 @@ final class SettingsModel: ObservableObject {
|
||||
self.environment = environment
|
||||
self.deviceInfoProvider = deviceInfoProvider
|
||||
self.fileSystemService = fileSystemService
|
||||
self.repository = repository
|
||||
self.preferences = preferences
|
||||
self.notifications = notifications
|
||||
self.messages = messages
|
||||
@@ -170,7 +188,7 @@ final class SettingsModel: ObservableObject {
|
||||
text: .resource(key),
|
||||
tone: .warning,
|
||||
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
||||
onAction: permission == .denied ? { [weak self] in self?.openNotificationSettings() } : nil
|
||||
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -193,7 +211,7 @@ final class SettingsModel: ObservableObject {
|
||||
func setBugContact(_ value: String) { state.bugContact = value }
|
||||
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
|
||||
|
||||
func submitBugReport() {
|
||||
func submitBugReport(onSuccess: @escaping () -> Void = {}) {
|
||||
if state.isSubmittingBugReport { return }
|
||||
Task {
|
||||
let snapshot = state
|
||||
@@ -224,7 +242,7 @@ final class SettingsModel: ObservableObject {
|
||||
state.bugContact = ""
|
||||
state.bugIncludeLogs = true
|
||||
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
||||
selectSection(.about)
|
||||
onSuccess()
|
||||
case .failure:
|
||||
state.isSubmittingBugReport = false
|
||||
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
||||
@@ -262,6 +280,60 @@ final class SettingsModel: ObservableObject {
|
||||
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// Recomputes the on-disk usage breakdown off the main actor.
|
||||
func loadStorageUsage() {
|
||||
if state.isCalculatingStorage { return }
|
||||
state.isCalculatingStorage = true
|
||||
let coreDir = environment.defaultCoreDataDir
|
||||
let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil
|
||||
let tempDir = NSTemporaryDirectory()
|
||||
Task.detached {
|
||||
let breakdown = StorageBreakdown(
|
||||
receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0,
|
||||
transferData: SettingsModel.directorySize(coreDir),
|
||||
temporary: SettingsModel.directorySize(tempDir)
|
||||
)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.state.storage = breakdown
|
||||
self?.state.isCalculatingStorage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes every send/receive transfer via the core, freeing the imported
|
||||
/// shared-file content and clearing history. Node identity and received files
|
||||
/// are left untouched.
|
||||
func deleteAllTransfers() {
|
||||
if state.isDeletingTransfers { return }
|
||||
state.isDeletingTransfers = true
|
||||
Task {
|
||||
for id in repository.state.transfers.map(\.transferId) {
|
||||
_ = await repository.delete(transferId: id)
|
||||
}
|
||||
_ = await repository.refresh()
|
||||
state.isDeletingTransfers = false
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive size of every regular file under `path` (0 if missing).
|
||||
nonisolated static func directorySize(_ path: String) -> UInt64 {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
||||
) else { return 0 }
|
||||
var total: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey])
|
||||
guard values?.isRegularFile == true else { continue }
|
||||
total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func loadDeviceInfo() {
|
||||
if state.isLoadingDeviceInfo { return }
|
||||
state.isLoadingDeviceInfo = true
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
struct SettingsScreen: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
let windowClass: WindowClass
|
||||
@State private var showBugReport = false
|
||||
|
||||
private var path: Binding<[SettingsSection]> {
|
||||
Binding(
|
||||
@@ -34,6 +35,9 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.notifications) {
|
||||
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.about) {
|
||||
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||
}
|
||||
@@ -42,15 +46,41 @@ struct SettingsScreen: View {
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
||||
.navigationDestination(for: SettingsSection.self) { section in
|
||||
Form {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
||||
sectionForm(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||
let content = Form {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
||||
|
||||
if section == .about {
|
||||
content
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showBugReport = true
|
||||
} label: {
|
||||
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showBugReport) {
|
||||
BugReportSheet(model: model)
|
||||
}
|
||||
} else {
|
||||
content
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +99,8 @@ private struct SettingsSectionContent: View {
|
||||
AppearanceSettings(model: model)
|
||||
case .notifications:
|
||||
NotificationSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .about:
|
||||
AboutSettings(model: model)
|
||||
case .bugReport:
|
||||
|
||||
@@ -57,17 +57,106 @@ struct NotificationSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
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.transferData))
|
||||
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 sourceURL = 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.sourceURL) {
|
||||
Label(String(localized: "about_source_label"), systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
if DiagnosticsBuildConfig.included {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
@@ -78,42 +167,94 @@ struct AboutSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.bugReport) {
|
||||
Text(LocalizedStringKey("about_bug_report"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(String(localized: "bug_report_what_label"),
|
||||
text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), axis: .vertical)
|
||||
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened),
|
||||
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_expected_label")) {
|
||||
TextField(String(localized: "bug_report_expected_label"),
|
||||
text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), axis: .vertical)
|
||||
TextField("", text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected),
|
||||
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_steps_label")) {
|
||||
TextField(String(localized: "bug_report_steps_label"),
|
||||
text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), axis: .vertical)
|
||||
TextField("", text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps),
|
||||
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_contact_label")) {
|
||||
TextField(String(localized: "bug_report_contact_label"),
|
||||
text: Binding(get: { model.state.bugContact }, set: model.setBugContact))
|
||||
TextField("", text: Binding(get: { model.state.bugContact }, set: model.setBugContact),
|
||||
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) {
|
||||
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||
Text(model.state.isSubmittingBugReport
|
||||
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user