Add storage usage stats settings view

This commit implements a new Storage settings view that displays storage
usage statistics for NostrDB, snapshot database, and Kingfisher image cache.

Key features:
- Interactive pie chart visualization (iOS 17+) with tap-to-select functionality
- Pull-to-refresh gesture to recalculate storage
- Categorized list showing each storage type with size and percentage
- Total storage sum displayed at bottom
- Conditional compilation for iOS 16/17+ compatibility
- All calculations run on background thread to avoid blocking main thread
- NostrDB storage breakdown

Changelog-Added: Storage usage statistics view in Settings
Changelog-Changed: Moved clear cache button to storage settings
Closes: https://github.com/damus-io/damus/issues/3649
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-02-25 15:45:37 -08:00
parent 65e767b774
commit 795fce1b65
11 changed files with 1860 additions and 70 deletions
@@ -0,0 +1,154 @@
//
// StorageStatsManager.swift
// damus
//
// Created by Daniel DAquino on 2026-02-20.
//
import Foundation
import Kingfisher
/// Storage statistics for various Damus data stores
struct StorageStats: Hashable {
/// Detailed breakdown of NostrDB storage by kind, indices, and other
let nostrdbDetails: NdbStats?
/// Size of the main NostrDB database file in bytes (total)
let nostrdbSize: UInt64
/// Size of the snapshot NostrDB database file in bytes
let snapshotSize: UInt64
/// Size of the Kingfisher image cache in bytes
let imageCacheSize: UInt64
/// Total storage used across all data stores
var totalSize: UInt64 {
return nostrdbSize + snapshotSize + imageCacheSize
}
/// Calculate the percentage of total storage used by a specific size
/// - Parameter size: The size to calculate percentage for
/// - Returns: Percentage value between 0.0 and 100.0
func percentage(for size: UInt64) -> Double {
guard totalSize > 0 else { return 0.0 }
return Double(size) / Double(totalSize) * 100.0
}
}
/// Manager for calculating storage statistics across Damus data stores
struct StorageStatsManager {
static let shared = StorageStatsManager()
private init() {}
/// Calculate storage statistics for all Damus data stores
///
/// This method runs all file operations on a background thread to avoid blocking
/// the main thread. It calculates:
/// - NostrDB database file size
/// - Detailed NostrDB breakdown (if ndb instance provided)
/// - Snapshot database file size
/// - Kingfisher image cache size
///
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
/// - Returns: StorageStats containing all calculated sizes
/// - Throws: Error if critical file operations fail
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
// Run all file operations on background thread
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let nostrdbSize = self.getNostrDBSize()
let snapshotSize = self.getSnapshotDBSize()
// Get detailed NostrDB stats if ndb instance provided
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
// Kingfisher cache size requires async callback
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
let imageCacheSize: UInt64
switch result {
case .success(let size):
imageCacheSize = UInt64(size)
case .failure(let error):
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
imageCacheSize = 0
}
let stats = StorageStats(
nostrdbDetails: nostrdbDetails,
nostrdbSize: nostrdbSize,
snapshotSize: snapshotSize,
imageCacheSize: imageCacheSize
)
continuation.resume(returning: stats)
}
} catch {
continuation.resume(throwing: error)
}
}
}
}
/// Get the size of the main NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getNostrDBSize() -> UInt64 {
guard let dbPath = Ndb.db_path else {
Log.error("Failed to get NostrDB path", for: .storage)
return 0
}
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "NostrDB")
}
/// Get the size of the snapshot NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getSnapshotDBSize() -> UInt64 {
guard let snapshotPath = Ndb.snapshot_db_path else {
Log.error("Failed to get snapshot DB path", for: .storage)
return 0
}
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "Snapshot DB")
}
/// Get the size of a file at the specified path
/// - Parameters:
/// - path: Full path to the file
/// - description: Human-readable description for logging
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getFileSize(at path: String, description: String) -> UInt64 {
guard FileManager.default.fileExists(atPath: path) else {
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
return 0
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let fileSize = attributes[.size] as? UInt64 else {
Log.error("Failed to get size attribute for %@", for: .storage, description)
return 0
}
return fileSize
} catch {
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
return 0
}
}
/// Format bytes into a human-readable string
/// - Parameter bytes: Number of bytes
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytes))
}
}
@@ -0,0 +1,169 @@
//
// StorageStatsViewHelper.swift
// damus
//
// Created by Daniel D'Aquino on 2026-02-25.
//
import Foundation
import SwiftUI
/// Shared helper functions for storage statistics views
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
enum StorageStatsViewHelper {
// MARK: - Category Ranges
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
/// - Parameter categories: Array of storage categories
/// - Returns: Array of tuples containing category ID and cumulative range
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
var total: UInt64 = 0
return categories.map { category in
let newTotal = total + category.size
let result = (category: category.id, range: Double(total)..<Double(newTotal))
total = newTotal
return result
}
}
// MARK: - Storage Stats Loading
/// Load storage statistics asynchronously
/// - Parameter ndb: The NostrDB instance
/// - Returns: Calculated storage statistics
/// - Throws: Error if storage calculation fails
@concurrent
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
}
// MARK: - Export Preparation
/// Prepare export text for storage statistics on background thread
/// - Parameters:
/// - stats: The storage statistics to export
/// - formatter: Closure that formats the stats into text
/// - Returns: Formatted text ready for export
@concurrent
static func prepareExportText(
stats: StorageStats,
formatter: @escaping @concurrent (StorageStats) async -> String
) async -> String {
return await formatter(stats)
}
// MARK: - Text Formatting
/// Format storage statistics as exportable text
/// - Parameter stats: The storage statistics to format
/// - Returns: Formatted text representation of storage stats
@concurrent
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
// Build categories list
let categories = [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
var text = "Damus Storage Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
// Top-level Categories
text += "Storage Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
for category in categories {
let percentage = stats.percentage(for: category.size)
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
}
text += String(repeating: "-", count: 50) + "\n"
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
// Add NostrDB detailed breakdown if available
if let details = stats.nostrdbDetails {
text += await formatNostrDBDetails(details: details)
}
return text
}
/// Format NostrDB statistics as exportable text
/// - Parameter stats: The storage statistics containing NostrDB details
/// - Returns: Formatted text representation of NostrDB stats breakdown
@concurrent
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
guard let details = stats.nostrdbDetails else {
return "NostrDB details not available"
}
var text = "Damus NostrDB Detailed Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
text += await formatNostrDBDetails(details: details)
return text
}
// MARK: - Private Helpers
/// Format NostrDB details section
/// - Parameter details: The NostrDB statistics details
/// - Returns: Formatted text representation of NostrDB details
@concurrent
private static func formatNostrDBDetails(details: NdbStats) async -> String {
var text = String(repeating: "=", count: 50) + "\n\n"
text += "NostrDB Detailed Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
// Per-database breakdown (sorted by size, already done in getStats)
if !details.databaseStats.isEmpty {
text += "\nDatabases:\n"
for dbStat in details.databaseStats {
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
// Only show keys/values breakdown if both exist
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
}
}
}
text += "\n" + String(repeating: "-", count: 50) + "\n"
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
return text
}
}
@@ -7,16 +7,6 @@
import SwiftUI
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
/// A simple type to keep track of the cache clearing state
fileprivate enum CacheClearingState {
case not_cleared
case clearing
case cleared
}
struct ResizedEventPreview: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@@ -59,8 +49,6 @@ struct AppearanceSettingsView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
@State var showing_cache_clear_alert: Bool = false
@State var showing_enable_animation_alert: Bool = false
@State var enable_animation_toggle_is_user_initiated: Bool = true
@@ -142,8 +130,6 @@ struct AppearanceSettingsView: View {
.tag(uploader.model.tag)
}
}
self.ClearCacheButton
}
// MARK: - GIFs
@@ -192,30 +178,6 @@ struct AppearanceSettingsView: View {
}
}
func clear_cache_button_action() {
cache_clearing_state = .clearing
let group = DispatchGroup()
group.enter()
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
group.leave()
})
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
group.enter()
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
group.leave()
}
group.notify(queue: .main) {
cache_clearing_state = .cleared
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
cache_clearing_state = .not_cleared
}
}
}
var EnableAnimationsToggle: some View {
Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation)
.toggleStyle(.switch)
@@ -231,7 +193,9 @@ struct AppearanceSettingsView: View {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?", comment: "Message explaining consequences of changing the 'enable animation' setting"),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
Task.detached(priority: .utility, operation: {
await DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {})
})
},
secondaryButton: .cancel() {
// Toggle back if user cancels action
@@ -241,33 +205,6 @@ struct AppearanceSettingsView: View {
)
}
}
var ClearCacheButton: some View {
Button(action: { self.showing_cache_clear_alert = true }, label: {
HStack(spacing: 6) {
switch cache_clearing_state {
case .not_cleared:
Text("Clear Cache", comment: "Button to clear image cache.")
case .clearing:
ProgressView()
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
case .cleared:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
}
}
})
.disabled(self.cache_clearing_state != .not_cleared)
.alert(isPresented: $showing_cache_clear_alert) {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
},
secondaryButton: .cancel())
}
}
}
@@ -32,6 +32,7 @@ struct ConfigView: View {
private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings")
private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings")
private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings")
private let storageTitle = NSLocalizedString("Storage", comment: "Section header for storage usage statistics")
private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings")
private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.")
private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.")
@@ -104,6 +105,12 @@ struct ConfigView: View {
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
}
}
// Storage
if showSettingsButton(title: storageTitle){
NavigationLink(value: Route.StorageSettings(settings: settings)){
IconLabel(storageTitle, img_name: "disk", color: .gray)
}
}
//First Aid
if showSettingsButton(title: firstAidTitle){
NavigationLink(value: Route.FirstAidSettings(settings: settings)){
@@ -0,0 +1,245 @@
//
// NostrDBDetailView.swift
// damus
//
// Created by Daniel D'Aquino on 2026-02-23.
//
import SwiftUI
import Charts
/// Detail view displaying NostrDB storage breakdown by kind, indices, and other categories
struct NostrDBDetailView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
let initialStats: StorageStats
@State private var stats: StorageStats
@State private var isLoading: Bool = false
@State private var error: String?
@State private var selectedAngle: Double?
@State private var showShareSheet: Bool = false
@State private var exportText: String?
@State private var isPreparingExport: Bool = false
@Environment(\.dismiss) var dismiss
init(damus_state: DamusState, settings: UserSettingsStore, stats: StorageStats) {
self.damus_state = damus_state
self.settings = settings
self.initialStats = stats
self._stats = State(initialValue: stats)
}
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard stats.nostrdbDetails != nil else { return [] }
return StorageStatsViewHelper.computeCategoryRanges(for: detailedCategories)
}
/// Selected storage category based on pie chart interaction (iOS 17+)
private var selectedCategory: StorageCategory? {
guard let selectedAngle = selectedAngle else { return nil }
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
return detailedCategories[selectedIndex]
}
return nil
}
/// Detailed categories showing per-database breakdown
private var detailedCategories: [StorageCategory] {
guard let details = stats.nostrdbDetails else { return [] }
var result: [StorageCategory] = []
// Per-database categories (sorted by size descending in getStats)
for dbStat in details.databaseStats {
result.append(StorageCategory(
id: dbStat.database.id,
title: dbStat.database.displayName,
icon: dbStat.database.icon,
color: dbStat.database.color,
size: dbStat.totalSize
))
}
return result
}
var body: some View {
Form {
// Chart Section (iOS 17+ only)
if stats.nostrdbDetails != nil {
if #available(iOS 17.0, *) {
Section {
StoragePieChart(
categories: detailedCategories,
selectedAngle: $selectedAngle,
selectedCategory: selectedCategory,
totalSize: stats.nostrdbDetails?.totalSize ?? stats.nostrdbSize
)
.frame(height: 300)
.padding(.vertical)
}
}
// Detailed Categories List
Section {
ForEach(detailedCategories) { category in
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: percentageOfNostrDB(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: percentageOfNostrDB(for: category.size),
isSelected: false
)
}
}
}
// NostrDB Total
Section {
HStack {
Text("NostrDB Total", comment: "Label for total NostrDB storage")
.font(.headline)
Spacer()
Text(StorageStatsManager.formatBytes(stats.nostrdbSize))
.foregroundColor(.secondary)
.font(.headline)
}
}
}
// Loading state
if isLoading {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
// Error state
if let error = error {
Section {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
.padding(.bottom, 50)
.navigationTitle(NSLocalizedString("NostrDB Details", comment: "Navigation title for NostrDB detail view"))
.toolbar {
if stats.nostrdbDetails != nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await prepareExport() } }) {
if isPreparingExport {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(isPreparingExport)
}
}
}
.sheet(isPresented: $showShareSheet) {
if let exportText = exportText {
TextShareSheet(activityItems: [exportText])
}
}
.refreshable {
await loadStorageStatsAsync()
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
}
/// Prepare export text on background thread before showing share sheet
@concurrent
private func prepareExport() async {
// Atomically check/export all needed @State on MainActor
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
let hasDetails = stats.nostrdbDetails != nil
let notAlreadyPreparing = !isPreparingExport
if hasDetails && notAlreadyPreparing {
isPreparingExport = true
return (true, stats)
} else {
return (false, nil)
}
}
guard shouldProceed, let statsSnapshot else { return }
// Format text off-main
let text = await StorageStatsViewHelper.formatNostrDBStatsAsText(statsSnapshot)
// Update UI on main thread
await MainActor.run {
self.exportText = text
self.isPreparingExport = false
self.showShareSheet = true
}
}
/// Calculate percentage of NostrDB size
private func percentageOfNostrDB(for size: UInt64) -> Double {
guard stats.nostrdbSize > 0 else { return 0.0 }
return Double(size) / Double(stats.nostrdbSize) * 100.0
}
/// Load storage statistics asynchronously (for refreshable)
private func loadStorageStatsAsync() async {
await MainActor.run {
isLoading = true
error = nil
}
do {
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
await MainActor.run {
self.stats = calculatedStats
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
self.isLoading = false
}
}
}
}
// MARK: - Preview
#Preview("NostrDB Detail") {
NavigationStack {
NostrDBDetailView(
damus_state: test_damus_state,
settings: test_damus_state.settings,
stats: StorageStats(
nostrdbDetails: NdbStats(
databaseStats: [
NdbDatabaseStats(database: .other, keySize: 0, valueSize: 2000000000),
NdbDatabaseStats(database: .note, keySize: 50000, valueSize: 200000),
NdbDatabaseStats(database: .noteBlocks, keySize: 100000, valueSize: 50000),
NdbDatabaseStats(database: .profile, keySize: 25000, valueSize: 100000),
NdbDatabaseStats(database: .noteId, keySize: 75000, valueSize: 75000)
]
),
nostrdbSize: 2500000000,
snapshotSize: 100000,
imageCacheSize: 5000000
)
)
}
}
@@ -0,0 +1,457 @@
//
// StorageSettingsView.swift
// damus
//
// Created by Daniel DAquino on 2026-02-20.
//
import SwiftUI
import Charts
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
/// A simple type to keep track of the cache clearing state
fileprivate enum CacheClearingState {
case not_cleared
case clearing
case cleared
}
/// Storage category for display in list and chart
struct StorageCategory: Identifiable {
let id: String
let title: String
let icon: String
let color: Color
let size: UInt64
var range: Range<Double> {
return 0..<Double(size)
}
}
/// Settings view displaying storage usage statistics for Damus data stores
struct StorageSettingsView: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss
@State private var stats: StorageStats?
@State private var isLoading: Bool = false
@State private var error: String?
@State private var selectedAngle: Double?
@State private var showShareSheet: Bool = false
@State private var exportText: String?
@State private var isPreparingExport: Bool = false
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
@State var showing_cache_clear_alert: Bool = false
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
private var categoryRanges: [(category: String, range: Range<Double>)] {
guard let stats = stats else { return [] }
return StorageStatsViewHelper.computeCategoryRanges(for: categories)
}
/// Selected storage category based on pie chart interaction (iOS 17+)
private var selectedCategory: StorageCategory? {
guard let selectedAngle = selectedAngle else { return nil }
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
return categories[selectedIndex]
}
return nil
}
/// All storage categories for display (top-level view)
private var categories: [StorageCategory] {
guard let stats = stats else { return [] }
return [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
}
var body: some View {
Form {
// Chart Section (iOS 17+ only)
if let stats = stats {
if #available(iOS 17.0, *) {
Section {
StoragePieChart(
categories: categories,
selectedAngle: $selectedAngle,
selectedCategory: selectedCategory,
totalSize: stats.totalSize
)
.frame(height: 300)
.padding(.vertical)
}
}
// Categories List
Section {
ForEach(categories) { category in
if category.id == "nostrdb", stats.nostrdbDetails != nil {
// NostrDB is drillable when we have detailed stats
NavigationLink(value: Route.NostrDBStorageDetail(stats: stats)) {
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
} else {
// Other categories are not drillable
if #available(iOS 17.0, *) {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: selectedCategory?.id == category.id
)
} else {
StorageCategoryRow(
category: category,
percentage: stats.percentage(for: category.size),
isSelected: false
)
}
}
}
}
// Total at bottom
Section {
HStack {
Text("Total Storage", comment: "Label for total storage used")
.font(.headline)
Spacer()
Text(StorageStatsManager.formatBytes(stats.totalSize))
.foregroundColor(.secondary)
.font(.headline)
}
}
// Clear Cache Section
Section {
self.ClearCacheButton
}
}
// Loading state
if isLoading {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
// Error state
if let error = error {
Section {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
}
}
.padding(.bottom, 50)
.navigationTitle(NSLocalizedString("Storage", comment: "Navigation title for storage settings"))
.toolbar {
if stats != nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { Task { await prepareExport() } }) {
if isPreparingExport {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
.disabled(isPreparingExport)
}
}
}
.sheet(isPresented: $showShareSheet) {
if let exportText = exportText {
TextShareSheet(activityItems: [exportText])
}
}
.refreshable {
await loadStorageStatsAsync()
}
.onAppear {
if stats == nil {
loadStorageStats()
}
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
}
/// Prepare export text on background thread before showing share sheet
@concurrent
private func prepareExport() async {
// Capture all relevant @State in one MainActor.run
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
let hasStats = stats != nil
let notAlreadyPreparing = !isPreparingExport
if hasStats && notAlreadyPreparing {
isPreparingExport = true
return (true, stats)
} else {
return (false, nil)
}
}
guard shouldProceed, let statsSnapshot else { return }
// Format text on background thread using shared helper
let text = await StorageStatsViewHelper.formatStorageStatsAsText(statsSnapshot)
// Update UI on main thread
await MainActor.run {
self.exportText = text
self.isPreparingExport = false
self.showShareSheet = true
}
}
/// Load storage statistics on a background thread (for onAppear)
private func loadStorageStats() {
guard !isLoading else { return }
isLoading = true
error = nil
Task {
await loadStorageStatsAsync()
}
}
/// Load storage statistics asynchronously (for refreshable)
@concurrent
private func loadStorageStatsAsync() async {
await MainActor.run {
isLoading = true
error = nil
}
do {
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
await MainActor.run {
self.stats = calculatedStats
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
self.isLoading = false
}
}
}
/// Clear cache button action with loading state management
func clear_cache_button_action() {
cache_clearing_state = .clearing
let group = DispatchGroup()
group.enter()
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
group.leave()
})
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
group.enter()
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
group.leave()
}
group.notify(queue: .main) {
cache_clearing_state = .cleared
// Refresh storage stats after clearing cache
loadStorageStats()
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
cache_clearing_state = .not_cleared
}
}
}
/// Clear cache button view with confirmation dialog
var ClearCacheButton: some View {
Button(action: { self.showing_cache_clear_alert = true }, label: {
HStack(spacing: 6) {
switch cache_clearing_state {
case .not_cleared:
Text("Clear Cache", comment: "Button to clear image cache.")
case .clearing:
ProgressView()
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
case .cleared:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
}
}
})
.disabled(self.cache_clearing_state != .not_cleared)
.alert(isPresented: $showing_cache_clear_alert) {
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
self.clear_cache_button_action()
},
secondaryButton: .cancel())
}
}
}
/// Pie chart displaying storage usage distribution (iOS 17+)
@available(iOS 17.0, *)
struct StoragePieChart: View {
let categories: [StorageCategory]
@Binding var selectedAngle: Double?
let selectedCategory: StorageCategory?
let totalSize: UInt64
var body: some View {
Chart(categories) { category in
SectorMark(
angle: .value("Size", category.size),
innerRadius: .ratio(0.618),
angularInset: 1.5
)
.cornerRadius(4)
.foregroundStyle(category.color)
.opacity(selectedCategory == nil || selectedCategory?.id == category.id ? 1.0 : 0.5)
}
.chartAngleSelection(value: $selectedAngle)
.chartBackground { chartProxy in
GeometryReader { geometry in
if let anchor = chartProxy.plotFrame {
let frame = geometry[anchor]
centerLabel
.position(x: frame.midX, y: frame.midY)
}
}
}
.chartLegend(.hidden)
}
/// Center label showing selected category or total
private var centerLabel: some View {
VStack(spacing: 4) {
if let selected = selectedCategory {
Image(systemName: selected.icon)
.font(.title2)
.foregroundColor(selected.color)
Text(selected.title)
.font(.headline)
.multilineTextAlignment(.center)
Text(StorageStatsManager.formatBytes(selected.size))
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Total", comment: "Label for total storage in pie chart center")
.font(.headline)
Text(StorageStatsManager.formatBytes(totalSize))
.font(.title2)
.bold()
}
}
.frame(maxWidth: 120)
}
}
/// Row displaying a storage category with icon, name, size, and percentage
struct StorageCategoryRow: View {
let category: StorageCategory
let percentage: Double
let isSelected: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: category.icon)
.foregroundColor(category.color)
.frame(width: 24)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text(category.title)
.font(.body)
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(StorageStatsManager.formatBytes(category.size))
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
.opacity(isSelected ? 1.0 : 0.9)
}
}
/// Text-based ShareSheet wrapper for SwiftUI
struct TextShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: activityItems,
applicationActivities: nil
)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}
// MARK: - Preview
#Preview("Storage Settings") {
NavigationStack {
StorageSettingsView(
damus_state: test_damus_state,
settings: test_damus_state.settings
)
}
}
+11
View File
@@ -32,6 +32,8 @@ enum Route: Hashable {
case SearchSettings(settings: UserSettingsStore)
case DeveloperSettings(settings: UserSettingsStore)
case FirstAidSettings(settings: UserSettingsStore)
case StorageSettings(settings: UserSettingsStore)
case NostrDBStorageDetail(stats: StorageStats)
case Thread(thread: ThreadModel)
case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference)
case Reposts(reposts: EventsModel)
@@ -100,6 +102,10 @@ enum Route: Hashable {
DeveloperSettingsView(settings: settings, damus_state: damusState)
case .FirstAidSettings(settings: let settings):
FirstAidSettingsView(damus_state: damusState, settings: settings)
case .StorageSettings(settings: let settings):
StorageSettingsView(damus_state: damusState, settings: settings)
case .NostrDBStorageDetail(stats: let stats):
NostrDBDetailView(damus_state: damusState, settings: damusState.settings, stats: stats)
case .Thread(let thread):
ChatroomThreadView(damus: damusState, thread: thread)
//ThreadView(state: damusState, thread: thread)
@@ -204,6 +210,11 @@ enum Route: Hashable {
hasher.combine("developerSettings")
case .FirstAidSettings:
hasher.combine("firstAidSettings")
case .StorageSettings:
hasher.combine("storageSettings")
case .NostrDBStorageDetail(let stats):
hasher.combine("nostrDBStorageDetail")
hasher.combine(stats)
case .Thread(let threadModel):
hasher.combine("thread")
hasher.combine(threadModel.original_event.id)