From 7f0d6e0f01e5f35e49a90e0f44b35fb51fddc91c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sat, 11 Jul 2026 21:53:17 -0700 Subject: [PATCH] add detailed statistics on ios --- BitDream/BitDreamApp.swift | 2 +- BitDream/Formatting.swift | 14 + BitDream/Views/Shared/ContentView.swift | 101 ++++--- BitDream/Views/iOS/iOSContentView.swift | 46 +++- BitDream/Views/iOS/iOSStatisticsView.swift | 246 ++++++++++++++++++ BitDream/Views/iOS/iOSTorrentListRow.swift | 2 +- BitDream/Views/macOS/macOSContentDetail.swift | 6 +- BitDream/Views/macOS/macOSContentView.swift | 5 +- .../Formatting/FormattingTests.swift | 29 +++ 9 files changed, 398 insertions(+), 53 deletions(-) create mode 100644 BitDream/Views/iOS/iOSStatisticsView.swift diff --git a/BitDream/BitDreamApp.swift b/BitDream/BitDreamApp.swift index 5c96c9a..d6db813 100644 --- a/BitDream/BitDreamApp.swift +++ b/BitDream/BitDreamApp.swift @@ -282,7 +282,7 @@ private extension BitDreamApp { .accentColor(themeManager.accentColor) .environmentObject(themeManager) .immediateTheme(manager: themeManager) - .frame(minWidth: 420, idealWidth: 460, maxWidth: 600, minHeight: 320, idealHeight: 360, maxHeight: 800) + .frame(minWidth: 420, idealWidth: 460, maxWidth: 600, minHeight: 320, idealHeight: 720, maxHeight: 800) } .windowResizability(.contentSize) .modelContainer(persistenceController.container) diff --git a/BitDream/Formatting.swift b/BitDream/Formatting.swift index 8f1ce34..bbeeb06 100644 --- a/BitDream/Formatting.swift +++ b/BitDream/Formatting.swift @@ -27,6 +27,20 @@ func formatSpeed(_ bytesPerSecond: Int64) -> String { return "\(base)/s" } +func formatTransferRatio(uploadedBytes: Int64, downloadedBytes: Int64) -> String { + let ratio = downloadedBytes > 0 ? Double(uploadedBytes) / Double(downloadedBytes) : 0 + return ratio.formatted(.number.precision(.fractionLength(2))) +} + +func formatActiveDuration(_ seconds: Int64) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute, .second] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropLeading + + return formatter.string(from: TimeInterval(max(0, seconds))) ?? "0s" +} + func formatCompactByteCount(_ bytes: Int64) -> CompactByteCountComponents { let formatted = formattedByteCount(bytes) var value = "" diff --git a/BitDream/Views/Shared/ContentView.swift b/BitDream/Views/Shared/ContentView.swift index 6dfecf9..83a532f 100644 --- a/BitDream/Views/Shared/ContentView.swift +++ b/BitDream/Views/Shared/ContentView.swift @@ -181,6 +181,7 @@ func makeRatioSummarySnapshot(store: TransmissionStore, displayMode: RatioDispla struct StatsHeaderView: View { @EnvironmentObject private var themeManager: ThemeManager @ObservedObject var store: TransmissionStore + let onShowStatistics: () -> Void @AppStorage(UserDefaultsKeys.ratioDisplayMode) private var ratioDisplayModeRaw: String = AppDefaults.ratioDisplayMode.rawValue // MARK: - Computed totals and ratio @@ -196,6 +197,21 @@ struct StatsHeaderView: View { ratioSummary.ratio } + private var downloadSpeed: Int64 { + store.sessionStats?.downloadSpeed ?? 0 + } + + private var uploadSpeed: Int64 { + store.sessionStats?.uploadSpeed ?? 0 + } + + private var accessibilityValue: String { + let mode = ratioDisplayMode == .cumulative ? "Total ratio" : "Session ratio" + let ratio = overallRatio.formatted(.number.precision(.fractionLength(2))) + return "\(mode) \(ratio), download speed \(formatSpeed(downloadSpeed)), " + + "upload speed \(formatSpeed(uploadSpeed))" + } + private var ratioTooltip: String { let mode = ratioDisplayMode == .cumulative ? "Total Ratio" : "Session Ratio" let uploaded = formatByteCount(ratioSummary.uploaded) @@ -204,47 +220,54 @@ struct StatsHeaderView: View { } var body: some View { - HStack(spacing: 12) { - RatioChip( - ratio: overallRatio, - size: .compact, - helpText: ratioTooltip - ) - .contextMenu { - Button(action: { ratioDisplayModeRaw = RatioDisplayMode.cumulative.rawValue }, label: { - HStack { - if ratioDisplayMode == .cumulative { Image(systemName: "checkmark") } - Text("Total Ratio") - } - }) - Button(action: { ratioDisplayModeRaw = RatioDisplayMode.current.rawValue }, label: { - HStack { - if ratioDisplayMode == .current { Image(systemName: "checkmark") } - Text("Session Ratio") - } - }) - } - - Spacer() - - HStack(spacing: 8) { - SpeedChip( - speed: store.sessionStats?.downloadSpeed ?? 0, - direction: .download, - style: .chip, - size: .compact - ) - - SpeedChip( - speed: store.sessionStats?.uploadSpeed ?? 0, - direction: .upload, - style: .chip, - size: .compact + Button(action: onShowStatistics) { + HStack(spacing: 12) { + RatioChip( + ratio: overallRatio, + size: .compact, + helpText: ratioTooltip ) + .contextMenu { + Button(action: { ratioDisplayModeRaw = RatioDisplayMode.cumulative.rawValue }, label: { + HStack { + if ratioDisplayMode == .cumulative { Image(systemName: "checkmark") } + Text("Total Ratio") + } + }) + Button(action: { ratioDisplayModeRaw = RatioDisplayMode.current.rawValue }, label: { + HStack { + if ratioDisplayMode == .current { Image(systemName: "checkmark") } + Text("Session Ratio") + } + }) + } + + Spacer() + + HStack(spacing: 8) { + SpeedChip( + speed: downloadSpeed, + direction: .download, + style: .chip, + size: .compact + ) + + SpeedChip( + speed: uploadSpeed, + direction: .upload, + style: .chip, + size: .compact + ) + } } + .padding(.horizontal) + .padding(.vertical, 8) + .contentShape(.rect) } - .padding(.horizontal) - .padding(.vertical, 8) + .buttonStyle(.plain) + .accessibilityLabel("Statistics") + .accessibilityValue(accessibilityValue) + .accessibilityHint("Shows detailed session statistics") } } @@ -257,7 +280,7 @@ struct StatsHeaderView: View { #Preview("Statistics Header", traits: .sizeThatFitsLayout) { PreviewContainer { environment in - StatsHeaderView(store: environment.store) + StatsHeaderView(store: environment.store, onShowStatistics: { }) .padding() } } diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index c997f54..ae62e87 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -2,12 +2,16 @@ import SwiftUI import Foundation #if os(iOS) +enum iOSNavigationRoute: Hashable { + case torrent(Int) +} + struct iOSContentView: View { let hosts: [Host] @ObservedObject var store: TransmissionStore private let userDefaults: UserDefaults - @State private var torrentPath: [Int] = [] + @State private var navigationPath: [iOSNavigationRoute] = [] @State private var sortProperty: SortProperty @State private var sortOrder: SortOrder @@ -15,6 +19,7 @@ struct iOSContentView: View { @State private var labelFilter = TorrentLabelFilter() @AppStorage(UserDefaultsKeys.showContentTypeIcons) private var showContentTypeIcons = AppDefaults.showContentTypeIcons @State private var searchText: String = "" + @State private var isStatisticsPresented = false @State private var showPrefs: Bool = false @State private var serverToEdit: Host? @@ -53,7 +58,7 @@ struct iOSContentView: View { } .onChange(of: store.host?.serverID) { _, _ in labelFilter.clear() - torrentPath.removeAll() + navigationPath.removeAll() } .onChange(of: store.torrents.map(\.id)) { _, torrentIDs in reconcileNavigationPath(with: torrentIDs) @@ -77,6 +82,12 @@ struct iOSContentView: View { .sheet(isPresented: $store.showSettings, content: { SettingsView(store: store) }) + .sheet(isPresented: $isStatisticsPresented) { + NavigationStack { + iOSStatisticsView(store: store) + } + .presentationDragIndicator(.visible) + } } } @@ -119,11 +130,14 @@ private extension iOSContentView { } func mainContent(drawerWidth: CGFloat, progress: CGFloat) -> some View { - NavigationStack(path: $torrentPath) { + NavigationStack(path: $navigationPath) { torrentListScreen - .navigationDestination(for: Int.self) { torrentID in - if let torrent = store.torrents.first(where: { $0.id == torrentID }) { - TorrentDetail(store: store, torrent: torrent) + .navigationDestination(for: iOSNavigationRoute.self) { route in + switch route { + case .torrent(let torrentID): + if let torrent = store.torrents.first(where: { $0.id == torrentID }) { + TorrentDetail(store: store, torrent: torrent) + } } } } @@ -144,7 +158,7 @@ private extension iOSContentView { } } .overlay(alignment: .leading) { - if !isSidebarOpen && torrentPath.isEmpty { + if !isSidebarOpen && navigationPath.isEmpty { Color.clear .frame(width: 20) .contentShape(.rect) @@ -217,7 +231,7 @@ private extension iOSContentView { private extension iOSContentView { var torrentListScreen: some View { VStack(spacing: 0) { - StatsHeaderView(store: store) + statisticsButton Group { if store.host != nil, store.connectionStatus != .connected { @@ -250,6 +264,15 @@ private extension iOSContentView { } } + var statisticsButton: some View { + StatsHeaderView( + store: store, + onShowStatistics: { + isStatisticsPresented = true + } + ) + } + var displayedTorrents: [Torrent] { filterAndSortTorrents( store.torrents, @@ -389,7 +412,12 @@ private extension iOSContentView { func reconcileNavigationPath(with torrentIDs: [Int]) { let availableTorrentIDs = Set(torrentIDs) - torrentPath.removeAll { !availableTorrentIDs.contains($0) } + navigationPath.removeAll { route in + switch route { + case .torrent(let torrentID): + !availableTorrentIDs.contains(torrentID) + } + } } var hasActiveFilters: Bool { diff --git a/BitDream/Views/iOS/iOSStatisticsView.swift b/BitDream/Views/iOS/iOSStatisticsView.swift new file mode 100644 index 0000000..564ac04 --- /dev/null +++ b/BitDream/Views/iOS/iOSStatisticsView.swift @@ -0,0 +1,246 @@ +import SwiftUI + +#if os(iOS) +struct iOSStatisticsView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var store: TransmissionStore + + var body: some View { + Group { + if let statistics = store.sessionStats { + Form { + iOSStatisticsLiveSection(statistics: statistics) + iOSStatisticsPeriodSection( + title: "Current Session", + statistics: statistics.currentStats, + showsSessionCount: false + ) + iOSStatisticsPeriodSection( + title: "Total", + statistics: statistics.cumulativeStats, + showsSessionCount: true + ) + } + .formStyle(.grouped) + } else { + ContentUnavailableView( + "No Data", + systemImage: "chart.xyaxis.line", + description: Text("Session statistics will appear once a server is connected.") + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Statistics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } +} + +private struct iOSStatisticsLiveSection: View { + let statistics: SessionStats + + var body: some View { + Section("Live") { + ViewThatFits(in: .horizontal) { + HStack { + Text("Torrents") + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 16) + horizontalTorrentCounts + } + + VStack(alignment: .leading, spacing: 12) { + Text("Torrents") + + ViewThatFits(in: .horizontal) { + horizontalTorrentCounts + verticalTorrentCounts + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "Torrents, \(statistics.activeTorrentCount.formatted()) active, " + + "\(statistics.pausedTorrentCount.formatted()) paused, " + + "\(statistics.torrentCount.formatted()) total" + ) + + ViewThatFits(in: .horizontal) { + HStack { + Text("Speed") + Spacer(minLength: 16) + speedChips + } + + VStack(alignment: .leading, spacing: 12) { + Text("Speed") + speedChips + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + } + + private var horizontalTorrentCounts: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + iOSTorrentCountValue(count: statistics.activeTorrentCount, label: "Active") + iOSTorrentCountSeparator() + iOSTorrentCountValue(count: statistics.pausedTorrentCount, label: "Paused") + iOSTorrentCountSeparator() + iOSTorrentCountValue(count: statistics.torrentCount, label: "Total") + } + .fixedSize(horizontal: true, vertical: false) + } + + private var verticalTorrentCounts: some View { + VStack(spacing: 8) { + iOSStatisticsMetricRow(label: "Active", value: statistics.activeTorrentCount.formatted()) + iOSStatisticsMetricRow(label: "Paused", value: statistics.pausedTorrentCount.formatted()) + iOSStatisticsMetricRow(label: "Total", value: statistics.torrentCount.formatted()) + } + } + + private var speedChips: some View { + HStack(spacing: 8) { + SpeedChip( + speed: statistics.downloadSpeed, + direction: .download, + style: .plain, + size: .regular + ) + SpeedChip( + speed: statistics.uploadSpeed, + direction: .upload, + style: .plain, + size: .regular + ) + } + .fixedSize(horizontal: true, vertical: false) + } +} + +private struct iOSTorrentCountValue: View { + let count: Int + let label: String + + var body: some View { + VStack(spacing: 2) { + Text(count.formatted()) + .font(.system(.body, design: .monospaced)) + .monospacedDigit() + Text(label) + .font(.caption) + } + .foregroundStyle(.secondary) + } +} + +private struct iOSTorrentCountSeparator: View { + var body: some View { + Text("•") + .foregroundStyle(.secondary.opacity(0.6)) + .accessibilityHidden(true) + } +} + +private struct iOSStatisticsPeriodSection: View { + let title: String + let statistics: TransmissionCumulativeStats? + let showsSessionCount: Bool + + var body: some View { + Section(title) { + if let statistics { + iOSStatisticsMetricRow( + label: "Downloaded", + value: formatByteCount(statistics.downloadedBytes) + ) + iOSStatisticsMetricRow( + label: "Uploaded", + value: formatByteCount(statistics.uploadedBytes) + ) + iOSStatisticsMetricRow( + label: "Upload Ratio", + value: formatTransferRatio( + uploadedBytes: statistics.uploadedBytes, + downloadedBytes: statistics.downloadedBytes + ) + ) + iOSStatisticsMetricRow( + label: "Files Added", + value: statistics.filesAdded.formatted() + ) + iOSStatisticsMetricRow( + label: "Active Time", + value: formatActiveDuration(statistics.secondsActive) + ) + + if showsSessionCount { + iOSStatisticsMetricRow( + label: "Session Count", + value: statistics.sessionCount.formatted() + ) + } + } else { + iOSStatisticsMetricRow(label: "Unavailable", value: "—") + } + } + } +} + +private struct iOSStatisticsMetricRow: View { + let label: String + let value: String + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack { + Text(label) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 16) + valueText + .fixedSize(horizontal: true, vertical: false) + } + + VStack(alignment: .leading, spacing: 4) { + Text(label) + valueText + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + private var valueText: some View { + Text(value) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } +} + +#if DEBUG +#Preview("iOS Statistics — Connected") { + PreviewContainer { environment in + NavigationStack { + iOSStatisticsView(store: environment.store) + } + } +} + +#Preview("iOS Statistics — Accessibility Text") { + PreviewContainer { environment in + NavigationStack { + iOSStatisticsView(store: environment.store) + } + .environment(\.dynamicTypeSize, .accessibility3) + } +} +#endif +#endif diff --git a/BitDream/Views/iOS/iOSTorrentListRow.swift b/BitDream/Views/iOS/iOSTorrentListRow.swift index d378a94..26d145a 100644 --- a/BitDream/Views/iOS/iOSTorrentListRow.swift +++ b/BitDream/Views/iOS/iOSTorrentListRow.swift @@ -19,7 +19,7 @@ struct iOSTorrentListRow: View { @State private var errorMessage = "" var body: some View { - NavigationLink(value: torrent.id) { + NavigationLink(value: iOSNavigationRoute.torrent(torrent.id)) { paddedRowContent } .swipeActions(edge: .trailing) { diff --git a/BitDream/Views/macOS/macOSContentDetail.swift b/BitDream/Views/macOS/macOSContentDetail.swift index e38eac2..d83a811 100644 --- a/BitDream/Views/macOS/macOSContentDetail.swift +++ b/BitDream/Views/macOS/macOSContentDetail.swift @@ -19,10 +19,11 @@ struct macOSContentDetail: View { @Binding var isDropTargeted: Bool @Binding var draggedTorrentInfo: [TorrentInfo] let focusedTarget: FocusState.Binding + let onShowStatistics: () -> Void var body: some View { VStack(spacing: 0) { - StatsHeaderView(store: store) + StatsHeaderView(store: store, onShowStatistics: onShowStatistics) if store.host != nil, store.connectionStatus != .connected { macOSConnectionBannerView(store: store) @@ -354,7 +355,8 @@ private struct macOSContentDetailPreviewHost: View { accentColor: .blue, isDropTargeted: $isDropTargeted, draggedTorrentInfo: $draggedTorrentInfo, - focusedTarget: $focusedTarget + focusedTarget: $focusedTarget, + onShowStatistics: { } ) } } diff --git a/BitDream/Views/macOS/macOSContentView.swift b/BitDream/Views/macOS/macOSContentView.swift index 2a4f11b..cf322bb 100644 --- a/BitDream/Views/macOS/macOSContentView.swift +++ b/BitDream/Views/macOS/macOSContentView.swift @@ -232,7 +232,10 @@ struct macOSContentView: View { accentColor: themeManager.accentColor, isDropTargeted: $isDropTargeted, draggedTorrentInfo: $draggedTorrentInfo, - focusedTarget: $focusedTarget + focusedTarget: $focusedTarget, + onShowStatistics: { + openWindow(id: "statistics") + } ) .navigationTitle(sidebarSelection.rawValue) .navigationSubtitle(navigationSubtitle) diff --git a/BitDreamTests/Formatting/FormattingTests.swift b/BitDreamTests/Formatting/FormattingTests.swift index b2f2a86..3546d3a 100644 --- a/BitDreamTests/Formatting/FormattingTests.swift +++ b/BitDreamTests/Formatting/FormattingTests.swift @@ -64,4 +64,33 @@ final class FormattingTests: XCTestCase { XCTAssertTrue(formatSpeed(33_000).hasSuffix("/s")) XCTAssertTrue(formatSpeed(33_000).hasPrefix(formatByteCount(33_000))) } + + // MARK: - Statistics formatting + + func testTransferRatioUsesTwoFractionDigits() { + let separator = Locale.current.decimalSeparator ?? "." + + XCTAssertEqual( + formatTransferRatio(uploadedBytes: 3, downloadedBytes: 2), + "1\(separator)50" + ) + } + + func testTransferRatioIsZeroWithoutDownloadedBytes() { + let separator = Locale.current.decimalSeparator ?? "." + + XCTAssertEqual( + formatTransferRatio(uploadedBytes: 10, downloadedBytes: 0), + "0\(separator)00" + ) + } + + func testActiveDurationClampsNegativeValuesToZero() { + XCTAssertEqual(formatActiveDuration(-1), "0s") + XCTAssertEqual(formatActiveDuration(0), "0s") + } + + func testActiveDurationPreservesAbbreviatedDateComponentsStyle() { + XCTAssertEqual(formatActiveDuration(90_061), "1d 1h 1m 1s") + } }