Skip to content
Merged
14 changes: 1 addition & 13 deletions BitDream/TransmissionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ final class TransmissionStore: NSObject, ObservableObject {
@Published var lastErrorMessage: String = ""
@Published var nextRetryAt: Date?

@Published var showConnectionErrorAlert: Bool = false

@Published var sessionConfiguration: TransmissionSessionResponseArguments?
@Published private(set) var settingsConnectionGeneration = UUID()
Expand Down Expand Up @@ -502,7 +501,6 @@ extension TransmissionStore {
func clearReconnectPresentationState() {
nextRetryAt = nil
cancelRetryTask()
showConnectionErrorAlert = false
}

func clearPendingRetrySchedule() {
Expand Down Expand Up @@ -561,22 +559,12 @@ extension TransmissionStore {
let remainingDelay = nextRetryAt.timeIntervalSince(now)
scheduleRetryTask(after: remainingDelay, generation: currentConnectionGeneration)
}
#if os(iOS)
showConnectionErrorAlert = true
#else
showConnectionErrorAlert = false
#endif
return
return
}

let scheduledDelay = reconnectBackoff.nextDelay()
scheduleRetryTask(after: scheduledDelay, generation: currentConnectionGeneration)

#if os(iOS)
showConnectionErrorAlert = true
#else
showConnectionErrorAlert = false
#endif
}

// Add a method to update the poll interval and restart the timer
Expand Down
27 changes: 12 additions & 15 deletions BitDream/Views/Shared/PiecesGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,22 @@ struct PiecesGridView: View {
var body: some View {
let bitset = piecesHaveSet

GeometryReader { geometry in
let columnsCount = computeColumns(availableWidth: geometry.size.width, cellSize: cellSize, cellSpacing: cellSpacing)
Canvas { context, size in
let columnsCount = computeColumns(availableWidth: size.width, cellSize: cellSize, cellSpacing: cellSpacing)
let totalCells = max(1, rows * columnsCount)
let buckets = bucketize(bitset: bitset, totalBuckets: totalCells)
let unit = cellSize + cellSpacing

LazyVGrid(
columns: Array(repeating: GridItem(.fixed(cellSize), spacing: cellSpacing, alignment: .leading), count: columnsCount),
spacing: cellSpacing
) {
ForEach(0..<totalCells, id: \.self) { index in
let fraction = index < buckets.count ? buckets[index] : 0
Rectangle()
.fill(colorForFraction(fraction))
.frame(width: cellSize, height: cellSize)
.cornerRadius(1.0)
.accessibilityHidden(true)
}
for index in 0..<totalCells {
let fraction = index < buckets.count ? buckets[index] : 0
let origin = CGPoint(
x: CGFloat(index % columnsCount) * unit,
y: CGFloat(index / columnsCount) * unit
)
let rect = CGRect(origin: origin, size: CGSize(width: cellSize, height: cellSize))
let path = Path(roundedRect: rect, cornerRadius: 1.0)
context.fill(path, with: .color(colorForFraction(fraction)))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: CGFloat(rows) * (cellSize + cellSpacing))
.accessibilityElement(children: .ignore)
Expand Down
115 changes: 115 additions & 0 deletions BitDream/Views/Shared/TorrentDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,36 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable {
piecesHaveCount: piecesHaveCount
)
}

var hasRenderablePieceData: Bool {
pieceCount > 0 && !piecesHaveSet.isEmpty
}
}

internal enum TorrentPiecesSectionState: Equatable {
case loading
case content(TorrentDetailSupplementalPayload)
case empty
case failed

static func resolve(
status: TorrentDetailSupplementalLoadStatus,
payload: TorrentDetailSupplementalPayload,
shouldDisplayPayload: Bool
) -> Self {
guard shouldDisplayPayload else {
return status == .failed ? .failed : .loading
}

switch status {
case .failed:
return payload.hasRenderablePieceData ? .content(payload) : .failed
case .loaded:
return payload.hasRenderablePieceData ? .content(payload) : .empty
case .idle, .loading:
return payload.hasRenderablePieceData ? .content(payload) : .loading
}
}
}

internal struct TorrentDetailSupplementalState: Sendable {
Expand Down Expand Up @@ -199,11 +229,17 @@ internal struct TorrentDetailSupplementalState: Sendable {
@MainActor
internal final class TorrentDetailSupplementalStore: ObservableObject {
@Published private(set) var state = TorrentDetailSupplementalState()
private var managedLoadTask: Task<Void, Never>?
private var managedLoadGeneration = 0

init(state: TorrentDetailSupplementalState = TorrentDetailSupplementalState()) {
self.state = state
}

deinit {
managedLoadTask?.cancel()
}

var payload: TorrentDetailSupplementalPayload {
state.payload
}
Expand All @@ -220,6 +256,47 @@ internal final class TorrentDetailSupplementalStore: ObservableObject {
state.shouldDisplayPayload(for: torrentID)
}

func replaceLoad(
for torrentID: Int,
using store: TransmissionStore,
onError: @escaping @MainActor @Sendable (String) -> Void
) {
managedLoadGeneration += 1
let generation = managedLoadGeneration

managedLoadTask?.cancel()
let requestGeneration = mutateState { state in
state.beginLoading(for: torrentID)
}

managedLoadTask = Task { [weak self] in
let snapshot = await performStructuredTransmissionOperation(
operation: { try await store.loadTorrentDetail(id: torrentID) },
onError: { [weak self] message in
guard let self else { return }
guard self.markFailure(for: torrentID, generation: requestGeneration) else {
return
}
onError(message)
}
)

guard let self else { return }

if let snapshot {
self.applyManagedSnapshot(
snapshot,
for: torrentID,
requestGeneration: requestGeneration
)
} else {
_ = self.markCancellation(for: torrentID, generation: requestGeneration)
}

self.clearManagedLoadTask(ifMatching: generation)
}
}

@discardableResult
func applyCommittedFileStatsMutation(
_ mutation: TorrentDetailFileStatsMutation,
Expand Down Expand Up @@ -293,6 +370,22 @@ internal final class TorrentDetailSupplementalStore: ObservableObject {
)
}

func replaceLoad(
for torrentID: Int,
using store: TransmissionStore,
showingError: Binding<Bool>,
errorMessage: Binding<String>
) {
replaceLoad(
for: torrentID,
using: store,
onError: makeTransmissionBindingErrorHandler(
isPresented: showingError,
message: errorMessage
)
)
}

func loadIfIdle(
for torrentID: Int,
using store: TransmissionStore,
Expand All @@ -319,6 +412,28 @@ internal final class TorrentDetailSupplementalStore: ObservableObject {
mutateState { $0.markCancelled(for: torrentID, generation: generation) }
}

private func applyManagedSnapshot(
_ snapshot: TransmissionTorrentDetailSnapshot,
for torrentID: Int,
requestGeneration: Int
) {
mutateState { state in
_ = state.apply(
snapshot: snapshot,
for: torrentID,
generation: requestGeneration
)
}
}

private func clearManagedLoadTask(ifMatching generation: Int) {
guard managedLoadGeneration == generation else {
return
}

managedLoadTask = nil
}

@discardableResult
private func mutateState<Result>(
_ mutate: (inout TorrentDetailSupplementalState) -> Result
Expand Down
117 changes: 59 additions & 58 deletions BitDream/Views/iOS/Settings/iOSAboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,78 +14,79 @@ struct iOSAboutView: View {
}

var body: some View {
VStack(spacing: 16) {
Spacer()
ScrollView {
VStack(spacing: 16) {
// App Icon
Image("AppIconPreview-Default")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6)

// App Icon
Image("AppIconPreview-Default")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6)

// App Name and Tagline
VStack(spacing: 4) {
Text("BitDream")
.font(.system(size: 24, weight: .bold, design: .monospaced))
.foregroundStyle(.primary)

Text("Remote Control for Transmission")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.secondary)
}

// Description
Text("BitDream is a native and feature-rich remote control client for Transmission web server. It provides a modern interface to manage your Transmission server from anywhere.")
.font(.system(size: 14))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)
// App Name and Tagline
VStack(spacing: 4) {
Text("BitDream")
.font(.system(size: 24, weight: .bold, design: .monospaced))
.foregroundStyle(.primary)

// Version Information
VStack(spacing: 2) {
HStack(spacing: 8) {
Text("Version")
.font(.system(size: 14, weight: .medium))
Text("Remote Control for Transmission")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.secondary)
Text(appVersion)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
}

// Copyright
Text("© \(copyrightYear) Austin Smith")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.top, 12)
}

// Transmission Acknowledgment
VStack(spacing: 8) {
Divider()
// Description
Text("BitDream is a native and feature-rich remote control client for Transmission web server. It provides a modern interface to manage your Transmission server from anywhere.")
.font(.system(size: 14))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)

HStack(spacing: 4) {
Text("Powered by")
.font(.system(size: 14))
// Version Information
VStack(spacing: 2) {
HStack(spacing: 8) {
Text("Version")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text(appVersion)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
}

// Copyright
Text("© \(copyrightYear) Austin Smith")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.top, 12)
}

Button("Transmission") {
if let url = URL(string: "https://transmissionbt.com/") {
openURL(url)
// Transmission Acknowledgment
VStack(spacing: 8) {
Divider()
.padding(.horizontal, 24)

HStack(spacing: 4) {
Text("Powered by")
.font(.system(size: 14))
.foregroundStyle(.tertiary)

Button("Transmission") {
if let url = URL(string: "https://transmissionbt.com/") {
openURL(url)
}
}
.buttonStyle(.plain)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(themeManager.accentColor)
}
.buttonStyle(.plain)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(themeManager.accentColor)
}
}

Spacer()
.frame(maxWidth: .infinity)
.padding(.top, 32)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationTitle("About")
.navigationBarTitleDisplayMode(.inline)
}
Expand Down
Loading