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:
@@ -39,6 +39,7 @@ struct RootView: View {
|
|||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
deviceInfoProvider: dependencies.deviceInfoProvider,
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
repository: graph.coreRepository,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
@@ -67,7 +68,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.platformPickers(settingsModel: settingsModel)
|
.platformPickers(settingsModel: settingsModel)
|
||||||
.task { await consumeExternalInvitations() }
|
.task { await consumeExternalInvitations() }
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
graph.visibility.setForeground(true)
|
graph.visibility.setForeground(true)
|
||||||
@@ -85,7 +86,7 @@ struct RootView: View {
|
|||||||
// A pending approval is a blocking modal; close the sender's detail panel
|
// A pending approval is a blocking modal; close the sender's detail panel
|
||||||
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
// (e.g. the Share/QR sheet) so the approval sheet isn't presented under it
|
||||||
// on macOS.
|
// on macOS.
|
||||||
.onChange(of: approvals.state.current?.id) { id in
|
.onChange(of: approvals.state.current?.id) { _, id in
|
||||||
if id != nil { sendModel.closeDetailPanel() }
|
if id != nil { sendModel.closeDetailPanel() }
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -110,69 +110,6 @@ func progressForReceiver(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send-side progress summary for a transfer's list row. Aggregates only the
|
|
||||||
/// receivers that are *currently downloading* (in-flight), so the bar disappears
|
|
||||||
/// once every receiver has completed/aborted, and its value is deterministic
|
|
||||||
/// (order-independent) rather than "whichever receiver's event arrived last".
|
|
||||||
func sendProgressSummary(
|
|
||||||
events: [CoreEventModel],
|
|
||||||
transferId: UInt64,
|
|
||||||
totalSizeHint: UInt64? = nil
|
|
||||||
) -> TransferProgress? {
|
|
||||||
let endpointIds = events
|
|
||||||
.filter { $0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer" }
|
|
||||||
.compactMap { findString($0.dataJson, key: "endpoint_id") }
|
|
||||||
.reduce(into: [String]()) { acc, id in if !acc.contains(id) { acc.append(id) } }
|
|
||||||
|
|
||||||
// No per-endpoint attribution: fall back to the single connection-scoped
|
|
||||||
// stream, hidden once it completes/aborts.
|
|
||||||
if endpointIds.isEmpty {
|
|
||||||
let relevant = events.filter {
|
|
||||||
$0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer"
|
|
||||||
&& ["started", "progress", "completed", "aborted"].contains($0.kind)
|
|
||||||
}
|
|
||||||
guard let latest = relevant.first else { return nil }
|
|
||||||
if latest.kind == "completed" || latest.kind == "aborted" { return nil }
|
|
||||||
let live = relevant.filter { ["started", "progress"].contains($0.kind) }
|
|
||||||
return TransferProgress(
|
|
||||||
transferId: transferId, phase: "transfer", kind: latest.kind,
|
|
||||||
labelKey: "progress_sending",
|
|
||||||
progress: aggregateReceiverProgress(events: live, totalSizeHint: totalSizeHint),
|
|
||||||
detail: progressDetail(latest)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep only receivers still in flight (latest state started/progress).
|
|
||||||
var activeCount = 0
|
|
||||||
var fractions: [Double] = []
|
|
||||||
var lastActive: TransferProgress?
|
|
||||||
for id in endpointIds {
|
|
||||||
guard let p = progressForReceiver(
|
|
||||||
events: events, transferId: transferId, remoteEndpointId: id, totalSizeHint: totalSizeHint
|
|
||||||
) else { continue }
|
|
||||||
guard p.kind == "progress" || p.kind == "started" else { continue }
|
|
||||||
activeCount += 1
|
|
||||||
lastActive = p
|
|
||||||
if let fraction = p.progress { fractions.append(fraction) }
|
|
||||||
}
|
|
||||||
if activeCount == 0 { return nil }
|
|
||||||
|
|
||||||
// Same total per receiver, so the byte-summed progress is the mean of the
|
|
||||||
// per-receiver fractions.
|
|
||||||
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
|
||||||
if activeCount == 1 {
|
|
||||||
return TransferProgress(
|
|
||||||
transferId: transferId, phase: "transfer", kind: "progress",
|
|
||||||
labelKey: "progress_sending", progress: combined, detail: lastActive?.detail
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return TransferProgress(
|
|
||||||
transferId: transferId, phase: "transfer", kind: "progress",
|
|
||||||
labelKey: "progress_sending", progress: combined,
|
|
||||||
label: String(format: String(localized: "progress_sending_to_count"), activeCount)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBytes(_ size: UInt64) -> String {
|
func formatBytes(_ size: UInt64) -> String {
|
||||||
var scaled = Double(size)
|
var scaled = Double(size)
|
||||||
let units = ["B", "KB", "MB", "GB", "TB"]
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ final class ReceiveModel: ObservableObject {
|
|||||||
text: .resource("receive_completed"),
|
text: .resource("receive_completed"),
|
||||||
tone: .success,
|
tone: .success,
|
||||||
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
||||||
onAction: canReveal ? { [weak self] in self?.revealReceiveFolder(folder) } : nil
|
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
||||||
))
|
))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if error.isUserCancellation {
|
if error.isUserCancellation {
|
||||||
@@ -193,7 +193,7 @@ final class ReceiveModel: ObservableObject {
|
|||||||
text: uiText,
|
text: uiText,
|
||||||
tone: .error,
|
tone: .error,
|
||||||
actionLabel: .resource("button_retry"),
|
actionLabel: .resource("button_retry"),
|
||||||
onAction: { [weak self] in self?.receive() }
|
onAction: { self.receive() }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ struct ReceiveScreen: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if transfers.isEmpty {
|
if transfers.isEmpty {
|
||||||
ScrollView { emptyState }
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
history
|
history
|
||||||
}
|
}
|
||||||
@@ -88,11 +88,11 @@ struct ReceiveScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
EmptyStateView(
|
ContentUnavailableView {
|
||||||
systemImage: "tray.and.arrow.down",
|
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
|
||||||
title: String(localized: "receive_empty_title"),
|
} description: {
|
||||||
message: String(localized: "receive_empty_body")
|
Text(LocalizedStringKey("receive_empty_body"))
|
||||||
) {
|
} actions: {
|
||||||
Button(action: model.openAcquisition) {
|
Button(action: model.openAcquisition) {
|
||||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ final class SendModel: ObservableObject {
|
|||||||
@Published var pendingFilePick = false
|
@Published var pendingFilePick = false
|
||||||
@Published var pendingFolderPick = false
|
@Published var pendingFolderPick = false
|
||||||
|
|
||||||
|
/// Receiver delivery records per active (sharing) transfer, used to decide
|
||||||
|
/// which transfers still have an in-flight receiver. Delivery status is the
|
||||||
|
/// authoritative signal; byte-transfer events alone don't reliably mark a
|
||||||
|
/// small transfer complete.
|
||||||
|
@Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:]
|
||||||
|
|
||||||
private let repository: CoreRepository
|
private let repository: CoreRepository
|
||||||
private let fileSystemService: FileSystemService
|
private let fileSystemService: FileSystemService
|
||||||
private let filePreviewRepository: FilePreviewRepository
|
private let filePreviewRepository: FilePreviewRepository
|
||||||
@@ -72,10 +78,24 @@ final class SendModel: ObservableObject {
|
|||||||
Task { _ = await self.repository.refresh() }
|
Task { _ = await self.repository.refresh() }
|
||||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||||
|
self.refreshReceiverStatuses(for: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Keep receiver delivery records current for every sharing/importing
|
||||||
|
// outgoing transfer (new shares appear here; status transitions arrive via
|
||||||
|
// the receiverHistoryChanged signal above).
|
||||||
|
repository.$state
|
||||||
|
.map { core -> Set<UInt64> in
|
||||||
|
Set(core.transfers
|
||||||
|
.filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) }
|
||||||
|
.map(\.transferId))
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] ids in self?.syncSharingReceivers(ids) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
filePreviewRepository.$previews
|
filePreviewRepository.$previews
|
||||||
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
|
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -299,6 +319,21 @@ final class SendModel: ObservableObject {
|
|||||||
Task { await fileSystemService.discardPickedFiles(files) }
|
Task { await fileSystemService.discardPickedFiles(files) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the receiver records for the sharing set, pruning transfers that are
|
||||||
|
/// no longer active.
|
||||||
|
private func syncSharingReceivers(_ ids: Set<UInt64>) {
|
||||||
|
receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) }
|
||||||
|
for id in ids { refreshReceiverStatuses(for: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshReceiverStatuses(for transferId: UInt64) {
|
||||||
|
Task {
|
||||||
|
if case .success(let requests) = await repository.receiverRequests(transferId: transferId) {
|
||||||
|
receiversByTransfer[transferId] = requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshReceivers(_ transferId: UInt64) {
|
private func refreshReceivers(_ transferId: UInt64) {
|
||||||
state.isLoadingReceivers = true
|
state.isLoadingReceivers = true
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct SendScreen: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if outgoing.isEmpty {
|
if outgoing.isEmpty {
|
||||||
ScrollView { emptyState }
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
catalog
|
catalog
|
||||||
}
|
}
|
||||||
@@ -100,11 +100,11 @@ struct SendScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
EmptyStateView(
|
ContentUnavailableView {
|
||||||
systemImage: "paperplane",
|
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
|
||||||
title: String(localized: "send_empty_title"),
|
} description: {
|
||||||
message: String(localized: "send_empty_body")
|
Text(LocalizedStringKey("send_empty_body"))
|
||||||
) {
|
} actions: {
|
||||||
Button(action: model.openComposer) {
|
Button(action: model.openComposer) {
|
||||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||||
}
|
}
|
||||||
@@ -116,10 +116,30 @@ struct SendScreen: View {
|
|||||||
private func progress(for transfer: Transfer) -> TransferProgress? {
|
private func progress(for transfer: Transfer) -> TransferProgress? {
|
||||||
switch transfer.status {
|
switch transfer.status {
|
||||||
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
||||||
case .sharing: return sendProgressSummary(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize)
|
case .sharing: return sharingProgress(for: transfer)
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Progress for an active share, driven by receivers whose delivery is still
|
||||||
|
/// in flight (`.accepted`). Returns nil when none are downloading, so the bar
|
||||||
|
/// clears once every receiver has completed even if byte events lag.
|
||||||
|
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
||||||
|
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
||||||
|
if active.isEmpty { return nil }
|
||||||
|
let fractions = active.compactMap {
|
||||||
|
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||||
|
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
||||||
|
}
|
||||||
|
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
||||||
|
if active.count == 1 {
|
||||||
|
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||||
|
labelKey: "progress_sending", progress: combined)
|
||||||
|
}
|
||||||
|
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||||
|
labelKey: "progress_sending", progress: combined,
|
||||||
|
label: String(format: String(localized: "progress_sending_to_count"), active.count))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TransferListItem: View {
|
private struct TransferListItem: View {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
|
|||||||
case preferences
|
case preferences
|
||||||
case appearance
|
case appearance
|
||||||
case notifications
|
case notifications
|
||||||
|
case storage
|
||||||
case about
|
case about
|
||||||
case bugReport
|
case bugReport
|
||||||
|
|
||||||
@@ -16,12 +17,21 @@ enum SettingsSection: Hashable {
|
|||||||
case .preferences: return "preferences_title"
|
case .preferences: return "preferences_title"
|
||||||
case .appearance: return "appearance_title"
|
case .appearance: return "appearance_title"
|
||||||
case .notifications: return "notifications_title"
|
case .notifications: return "notifications_title"
|
||||||
|
case .storage: return "storage_title"
|
||||||
case .about: return "about_title"
|
case .about: return "about_title"
|
||||||
case .bugReport: return "about_bug_report"
|
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 {
|
struct SettingsState: Equatable {
|
||||||
var selectedSection: SettingsSection = .overview
|
var selectedSection: SettingsSection = .overview
|
||||||
var username = ""
|
var username = ""
|
||||||
@@ -43,6 +53,9 @@ struct SettingsState: Equatable {
|
|||||||
var bugIncludeLogs = true
|
var bugIncludeLogs = true
|
||||||
var isSubmittingBugReport = false
|
var isSubmittingBugReport = false
|
||||||
var bugLogPreviewBytes = 0
|
var bugLogPreviewBytes = 0
|
||||||
|
var storage: StorageBreakdown?
|
||||||
|
var isCalculatingStorage = false
|
||||||
|
var isDeletingTransfers = false
|
||||||
|
|
||||||
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||||
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||||
@@ -57,6 +70,8 @@ struct SettingsState: Equatable {
|
|||||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||||
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||||
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||||
|
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||||
|
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||||
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +85,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
private let environment: PlatformEnvironment
|
private let environment: PlatformEnvironment
|
||||||
private let deviceInfoProvider: DeviceInfoProvider
|
private let deviceInfoProvider: DeviceInfoProvider
|
||||||
private let fileSystemService: FileSystemService
|
private let fileSystemService: FileSystemService
|
||||||
|
private let repository: CoreRepository
|
||||||
private let preferences: AppPreferencesRepository
|
private let preferences: AppPreferencesRepository
|
||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
@@ -85,6 +101,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
environment: PlatformEnvironment,
|
environment: PlatformEnvironment,
|
||||||
deviceInfoProvider: DeviceInfoProvider,
|
deviceInfoProvider: DeviceInfoProvider,
|
||||||
fileSystemService: FileSystemService,
|
fileSystemService: FileSystemService,
|
||||||
|
repository: CoreRepository,
|
||||||
preferences: AppPreferencesRepository,
|
preferences: AppPreferencesRepository,
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
messages: UiMessageController,
|
messages: UiMessageController,
|
||||||
@@ -94,6 +111,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.deviceInfoProvider = deviceInfoProvider
|
self.deviceInfoProvider = deviceInfoProvider
|
||||||
self.fileSystemService = fileSystemService
|
self.fileSystemService = fileSystemService
|
||||||
|
self.repository = repository
|
||||||
self.preferences = preferences
|
self.preferences = preferences
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
@@ -170,7 +188,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
text: .resource(key),
|
text: .resource(key),
|
||||||
tone: .warning,
|
tone: .warning,
|
||||||
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
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 setBugContact(_ value: String) { state.bugContact = value }
|
||||||
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
|
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
|
||||||
|
|
||||||
func submitBugReport() {
|
func submitBugReport(onSuccess: @escaping () -> Void = {}) {
|
||||||
if state.isSubmittingBugReport { return }
|
if state.isSubmittingBugReport { return }
|
||||||
Task {
|
Task {
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
@@ -224,7 +242,7 @@ final class SettingsModel: ObservableObject {
|
|||||||
state.bugContact = ""
|
state.bugContact = ""
|
||||||
state.bugIncludeLogs = true
|
state.bugIncludeLogs = true
|
||||||
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
||||||
selectSection(.about)
|
onSuccess()
|
||||||
case .failure:
|
case .failure:
|
||||||
state.isSubmittingBugReport = false
|
state.isSubmittingBugReport = false
|
||||||
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
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))
|
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() {
|
private func loadDeviceInfo() {
|
||||||
if state.isLoadingDeviceInfo { return }
|
if state.isLoadingDeviceInfo { return }
|
||||||
state.isLoadingDeviceInfo = true
|
state.isLoadingDeviceInfo = true
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
struct SettingsScreen: View {
|
struct SettingsScreen: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
@State private var showBugReport = false
|
||||||
|
|
||||||
private var path: Binding<[SettingsSection]> {
|
private var path: Binding<[SettingsSection]> {
|
||||||
Binding(
|
Binding(
|
||||||
@@ -34,6 +35,9 @@ struct SettingsScreen: View {
|
|||||||
NavigationLink(value: SettingsSection.notifications) {
|
NavigationLink(value: SettingsSection.notifications) {
|
||||||
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
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) {
|
NavigationLink(value: SettingsSection.about) {
|
||||||
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||||
}
|
}
|
||||||
@@ -42,15 +46,41 @@ struct SettingsScreen: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
||||||
.navigationDestination(for: SettingsSection.self) { section in
|
.navigationDestination(for: SettingsSection.self) { section in
|
||||||
Form {
|
sectionForm(section)
|
||||||
SettingsSectionContent(model: model, section: section)
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
}
|
||||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
|
||||||
|
@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)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +99,8 @@ private struct SettingsSectionContent: View {
|
|||||||
AppearanceSettings(model: model)
|
AppearanceSettings(model: model)
|
||||||
case .notifications:
|
case .notifications:
|
||||||
NotificationSettings(model: model)
|
NotificationSettings(model: model)
|
||||||
|
case .storage:
|
||||||
|
StorageSettings(model: model)
|
||||||
case .about:
|
case .about:
|
||||||
AboutSettings(model: model)
|
AboutSettings(model: model)
|
||||||
case .bugReport:
|
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 {
|
struct AboutSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
|
||||||
|
private static let sourceURL = URL(string: "https://github.com/vnidrop/vnidrop")!
|
||||||
|
|
||||||
var body: some View {
|
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")) {
|
Section(String(localized: "about_title")) {
|
||||||
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
||||||
if let device = model.state.deviceInfo {
|
if let device = model.state.deviceInfo {
|
||||||
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
||||||
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
|
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 {
|
if DiagnosticsBuildConfig.included {
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(
|
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 {
|
struct BugReportSettings: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
var onSubmitted: () -> Void = {}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(String(localized: "bug_report_what_label")) {
|
Section(String(localized: "bug_report_what_label")) {
|
||||||
TextField(String(localized: "bug_report_what_label"),
|
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened),
|
||||||
text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), axis: .vertical)
|
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_expected_label")) {
|
Section(String(localized: "bug_report_expected_label")) {
|
||||||
TextField(String(localized: "bug_report_expected_label"),
|
TextField("", text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected),
|
||||||
text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), axis: .vertical)
|
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_steps_label")) {
|
Section(String(localized: "bug_report_steps_label")) {
|
||||||
TextField(String(localized: "bug_report_steps_label"),
|
TextField("", text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps),
|
||||||
text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), axis: .vertical)
|
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section(String(localized: "bug_report_contact_label")) {
|
Section(String(localized: "bug_report_contact_label")) {
|
||||||
TextField(String(localized: "bug_report_contact_label"),
|
TextField("", text: Binding(get: { model.state.bugContact }, set: model.setBugContact),
|
||||||
text: Binding(get: { model.state.bugContact }, set: model.setBugContact))
|
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
|
||||||
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
||||||
Text(LocalizedStringKey("bug_report_include_logs"))
|
Text(LocalizedStringKey("bug_report_include_logs"))
|
||||||
}
|
}
|
||||||
Button(action: model.submitBugReport) {
|
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||||
Text(model.state.isSubmittingBugReport
|
Text(model.state.isSubmittingBugReport
|
||||||
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,10 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -37,6 +37,196 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"about_description" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "VniDrop moves files and folders straight from one device to another over the network, without uploading them to any file-hosting service. There’s no account to create, and no copy of your transfer is left in the cloud once you’re done."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_title" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "What VniDrop is"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_direct" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "A direct device-to-device transfer — your files go straight to the receiver."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_no_account" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Account-free — there’s nothing to sign up for."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_in_control" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "You decide who receives: approve each request, or open a transfer to anyone holding the invitation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_encrypted" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Connections are authenticated and end-to-end encrypted (via Iroh), and incoming files are verified by their content hash."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_is_open" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Open source, released under the Apache 2.0 license."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_isnt_title" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "What VniDrop isn’t"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_isnt_cloud" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Not cloud storage — no server holds your files, and nothing waits in the cloud after a transfer."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_isnt_sync" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Not a sync or backup service."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_isnt_public" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Not public broadcasting — an invitation is a private access link, not an announcement to everyone nearby."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_privacy_title" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Privacy & security"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_privacy_capability" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Invitations are capabilities. Treat a QR code, NFC tag, or .vnd file like a private access link and share it only with the people you intend — especially with “Anyone with this transfer.”"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_privacy_deny" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Deny by default — VniDrop serves only the content of an active share and rejects unknown requests."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_privacy_relay" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "If two devices can’t connect directly, the encrypted connection may be relayed. Relays forward encrypted packets only; they never store your files."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_privacy_local" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Received files are saved on your device and never silently overwrite an existing file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_license_label" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "License"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_source_label" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Source code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about_tagline" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Send files directly. Stay in control of who receives them."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"about_privacy" : {
|
"about_privacy" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -170,6 +360,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bug_report_contact_hint" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "name@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"bug_report_contact_label" : {
|
"bug_report_contact_label" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -180,6 +380,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bug_report_expected_hint" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Describe what you expected to happen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bug_report_steps_hint" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "List the steps to reproduce the issue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bug_report_what_hint" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Describe what happened"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"bug_report_description" : {
|
"bug_report_description" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1729,6 +1959,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"storage_calculating" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Calculating…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_delete_transfers" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete all transfers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_delete_transfers_description" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_deleting" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Deleting…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_footer" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_received_files" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Received files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_temporary" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Temporary files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_title" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Storage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_total" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Total"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_transfer_data" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Transfer data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage_transfers_deleted" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "All transfers deleted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"status_available" : {
|
"status_available" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -94,30 +94,3 @@ struct Field: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - EmptyStateView
|
|
||||||
|
|
||||||
/// Native-styled empty state (iOS 16 compatible; avoids iOS 17
|
|
||||||
/// `ContentUnavailableView`).
|
|
||||||
struct EmptyStateView<Actions: View>: View {
|
|
||||||
let systemImage: String
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
@ViewBuilder var actions: () -> Actions
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: systemImage)
|
|
||||||
.font(.system(size: 52))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(title).font(.title2).fontWeight(.semibold).multilineTextAlignment(.center)
|
|
||||||
Text(message)
|
|
||||||
.font(.body).foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: 420)
|
|
||||||
actions().padding(.top, 4)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.vertical, 48)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ GEN_DIR="$PKG_DIR/Sources/VnidropCore"
|
|||||||
TARGET_DIR="$REPO_ROOT/target"
|
TARGET_DIR="$REPO_ROOT/target"
|
||||||
BUILD_DIR="$APPLE_DIR/.build-core"
|
BUILD_DIR="$APPLE_DIR/.build-core"
|
||||||
|
|
||||||
# Match the iOS deployment target used by the Gobley config in shared/build.gradle.kts.
|
# Match the SwiftUI app's deployment targets (apple/project.yml) so the static
|
||||||
export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}"
|
# libs are never built for a newer OS than the app links against.
|
||||||
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}"
|
export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-18.2}"
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
|
||||||
|
|
||||||
# The workspace `[profile.dev] strip = "debuginfo"` corrupts host proc-macro
|
# The workspace `[profile.dev] strip = "debuginfo"` corrupts host proc-macro
|
||||||
# dylibs on the current Apple toolchain ("mis-aligned LINKEDIT string pool"),
|
# dylibs on the current Apple toolchain ("mis-aligned LINKEDIT string pool"),
|
||||||
|
|||||||
Reference in New Issue
Block a user