feat(apple): streamline the Storage screen and fix stuck usage

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.
This commit is contained in:
2026-07-24 14:07:31 +02:00
parent 677c3ce47f
commit e22e395efc
12 changed files with 224 additions and 39 deletions

View File

@@ -55,6 +55,7 @@ struct SettingsState: Equatable {
var bugLogPreviewBytes = 0
var storage: StorageBreakdown?
var isCalculatingStorage = false
var storageLoadFailed = false
var isDeletingTransfers = false
var isCleaningStorage = false
@@ -72,6 +73,7 @@ struct SettingsState: Equatable {
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
&& lhs.storageLoadFailed == rhs.storageLoadFailed
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
&& lhs.isCleaningStorage == rhs.isCleaningStorage
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
@@ -265,17 +267,28 @@ final class SettingsModel: ObservableObject {
// MARK: - Storage
/// Recomputes the on-disk usage breakdown off the main actor.
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
/// before the core is ready: it keeps the spinner up and waits for the core to
/// finish initializing (it starts asynchronously at launch) rather than bailing.
func loadStorageUsage() {
if state.isCalculatingStorage { return }
state.isCalculatingStorage = true
state.storageLoadFailed = false
let tempDir = NSTemporaryDirectory()
Task {
// The core initializes asynchronously at launch; poll briefly so opening
// Storage early doesn't leave the summary stuck.
var attempts = 0
while !repository.state.isInitialized && attempts < 100 {
try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1
}
let coreResult = await repository.storageUsage()
let artifactsResult = await repository.receivedArtifacts()
guard case .success(let core) = coreResult,
case .success(let artifacts) = artifactsResult else {
state.isCalculatingStorage = false
state.storageLoadFailed = true
return
}
let diskSizes = await Task.detached {

View File

@@ -78,61 +78,71 @@ 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 {
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()
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 {
model.freeUpSpace()
} label: {
HStack {
Text(model.state.isCleaningStorage
? String(localized: L10n.Storage.cleaning)
: String(localized: L10n.Storage.freeUpSpace))
if model.state.isCleaningStorage {
Spacer()
ProgressView()
}
}
Button(action: model.freeUpSpace) {
actionLabel(
title: L10n.Storage.freeUpSpace,
busyTitle: L10n.Storage.cleaning,
isBusy: model.state.isCleaningStorage,
symbol: .sparkles,
tint: .accentColor
)
}
.disabled(model.state.isCleaningStorage)
// `.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(role: .destructive) {
Button {
showDeleteConfirmation = true
} label: {
HStack {
Text(model.state.isDeletingTransfers
? String(localized: L10n.Storage.deleting)
: String(localized: L10n.Storage.deleteTransfers))
if model.state.isDeletingTransfers {
Spacer()
ProgressView()
}
}
actionLabel(
title: L10n.Storage.deleteTransfers,
busyTitle: L10n.Storage.deleting,
isBusy: model.state.isDeletingTransfers,
symbol: .trash,
tint: .red
)
}
.disabled(model.state.isDeletingTransfers)
.buttonStyle(.plain)
.disabled(isBusy)
} footer: {
Text(String(localized: L10n.Storage.deleteTransfersCaption))
}
.onAppear { model.loadStorageUsage() }
.task { model.loadStorageUsage() }
.confirmationDialog(
Text(String(localized: L10n.Storage.deleteTransfers)),
isPresented: $showDeleteConfirmation,
@@ -146,6 +156,53 @@ struct StorageSettings: View {
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 {