Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions BitDream/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum MenuBarSortMode: String, CaseIterable {
enum AppDefaults {
static let accentColor: AccentColorOption = .blue
static let themeMode: ThemeMode = .system
static let hapticFeedbackEnabled: Bool = true
static let showContentTypeIcons: Bool = true
static let menuBarTransferWidgetEnabled: Bool = true
static let menuBarShowActiveCount: Bool = true
Expand Down Expand Up @@ -62,6 +63,7 @@ enum RuntimeDomain {
}

enum UserDefaultsKeys {
static let hapticFeedbackEnabled = "hapticFeedbackEnabled"
static let pollInterval = "pollInterval"
static let torrentListCompactMode = "torrentListCompactMode"
static let showContentTypeIcons = "showContentTypeIcons"
Expand Down
19 changes: 17 additions & 2 deletions BitDream/AppIcon/AppIconManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
import SwiftUI
import UIKit

enum AppIconSelectionOutcome: Sendable, Equatable {
case changed
case unchanged
case failed
}

@MainActor
final class AppIconManager: ObservableObject {
static let shared = AppIconManager()
Expand Down Expand Up @@ -58,16 +64,23 @@ final class AppIconManager: ObservableObject {
currentIconName = currentIconNameProvider()
}

func selectIcon(name: String?) {
func selectIcon(
name: String?,
completion: @escaping @MainActor @Sendable (AppIconSelectionOutcome) -> Void = { _ in }
) {
lastError = nil

guard supportsAlternateIcons else {
lastError = "Alternate icons not supported on this device."
completion(.failed)
return
}

// Avoid triggering the system alert when re-selecting the same icon.
guard currentIconName != name else { return }
guard currentIconName != name else {
completion(.unchanged)
return
}

isChanging = true
setAlternateIconName(name) { [weak self] error in
Expand All @@ -77,9 +90,11 @@ final class AppIconManager: ObservableObject {

if let error = error {
self.lastError = "Failed to change icon: \(error.localizedDescription)"
completion(.failed)
} else {
self.lastError = nil
self.currentIconName = name
completion(.changed)
}
}
}
Expand Down
30 changes: 16 additions & 14 deletions BitDream/BitDreamApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,22 +316,24 @@ private extension BitDreamApp {
#else
var iOSScene: some Scene {
WindowGroup {
ContentView()
.environmentObject(store) // Pass the shared store to the ContentView
.accentColor(themeManager.accentColor) // Apply the accent color to the entire app
.environmentObject(themeManager) // Pass the ThemeManager to all views
.environmentObject(appIconManager)
.immediateTheme(manager: themeManager)
.task {
await HostRepository.shared.bootstrap()
ensureStartupConnectionBehaviorApplied(store: store, modelContext: persistenceController.container.mainContext)
BackgroundRefreshManager.schedule()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
iOSHapticFeedbackHost {
ContentView()
.environmentObject(store) // Pass the shared store to the ContentView
.accentColor(themeManager.accentColor) // Apply the accent color to the entire app
.environmentObject(themeManager) // Pass the ThemeManager to all views
.environmentObject(appIconManager)
.immediateTheme(manager: themeManager)
.task {
await HostRepository.shared.bootstrap()
ensureStartupConnectionBehaviorApplied(store: store, modelContext: persistenceController.container.mainContext)
BackgroundRefreshManager.schedule()
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
BackgroundRefreshManager.schedule()
}
}
}
}
.modelContainer(persistenceController.container)
}
Expand Down
6 changes: 6 additions & 0 deletions BitDream/Models/RefreshOutcome.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
enum RefreshOutcome: Equatable, Sendable {
case succeeded
case unavailable
case failed
case cancelled
}
18 changes: 18 additions & 0 deletions BitDream/Models/TorrentFileStats+Mutation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
extension TorrentFileStats {
func applying(_ mutation: TorrentDetailFileStatsMutation) -> Self {
switch mutation {
case .wanted(let wanted):
TorrentFileStats(
bytesCompleted: bytesCompleted,
wanted: wanted,
priority: priority
)
case .priority(let priority):
TorrentFileStats(
bytesCompleted: bytesCompleted,
wanted: wanted,
priority: priority.rawValue
)
}
}
}
32 changes: 21 additions & 11 deletions BitDream/TransmissionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ final class TransmissionStore: NSObject, ObservableObject {
private var activationFailure: ActivationFailure?
private var currentConnectionGeneration: UUID
private var activationTask: Task<Void, Never>?
private var fullRefreshTask: Task<Void, Never>?
private var fullRefreshTask: Task<RefreshOutcome, Never>?
private var pollTask: Task<Void, Never>?
private var retryTask: Task<Void, Never>?
private var sessionRefreshTask: Task<Void, Never>?
Expand Down Expand Up @@ -680,27 +680,26 @@ extension TransmissionStore {
func requestRefresh() {
Task { @MainActor [weak self] in
guard let self else { return }
await self.refreshNow()
_ = await self.refreshNow()
}
}

func refreshNow() async {
guard let activeConnection else { return }
func refreshNow() async -> RefreshOutcome {
guard let activeConnection else { return .unavailable }

if let existing = fullRefreshTask {
await existing.value
return
return await existing.value
}

cancelPollTask()

let task = Task { @MainActor [weak self] in
guard let self else { return }
guard let self else { return RefreshOutcome.cancelled }
defer { self.fullRefreshTask = nil }
await self.performFullRefresh(for: activeConnection)
return await self.performFullRefresh(for: activeConnection)
}
fullRefreshTask = task
await task.value
return await task.value
}

private func replaceConnection(for host: Host, trigger: ConnectionAttemptReason) {
Expand Down Expand Up @@ -776,17 +775,28 @@ extension TransmissionStore {
}
}

private func performFullRefresh(for connectionState: ActiveConnection) async {
private func performFullRefresh(for connectionState: ActiveConnection) async -> RefreshOutcome {
do {
let snapshot = try await connectionState.connection.fetchAppRefreshSnapshot()
guard isCurrentGeneration(connectionState.generation, hostID: connectionState.hostID) else {
return
return .cancelled
}

apply(snapshot: snapshot, for: connectionState)
startPolling(for: connectionState)
return .succeeded
} catch {
guard !Task.isCancelled,
isCurrentGeneration(connectionState.generation, hostID: connectionState.hostID) else {
return .cancelled
}

let transmissionError = TransmissionErrorResolver.transmissionError(from: error)
if case .cancelled = transmissionError {
return .cancelled
}
handleReadError(error, generation: connectionState.generation)
return .failed
}
}

Expand Down
131 changes: 131 additions & 0 deletions BitDream/Views/Shared/HapticFeedback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import SwiftUI

enum AppHapticFeedback: Sendable, Equatable {
case actionTriggered
case selectionChanged
case operationSucceeded
case operationNeedsAttention
case operationFailed

var sensoryFeedback: SensoryFeedback {
switch self {
case .actionTriggered:
.impact(weight: .light, intensity: 0.7)
case .selectionChanged:
.selection
case .operationSucceeded:
.success
case .operationNeedsAttention:
.warning
case .operationFailed:
.error
}
}
}

struct HapticFeedbackClient: Sendable {
private let playAction: @MainActor @Sendable (AppHapticFeedback) -> Void

init(play: @escaping @MainActor @Sendable (AppHapticFeedback) -> Void) {
self.playAction = play
}

@MainActor
func play(_ feedback: AppHapticFeedback) {
playAction(feedback)
}

static let disabled = Self(play: { _ in })
}

struct HapticFeedbackTriggers: Equatable {
private(set) var action = 0
private(set) var selection = 0
private(set) var success = 0
private(set) var warning = 0
private(set) var error = 0

mutating func play(_ feedback: AppHapticFeedback) {
switch feedback {
case .actionTriggered:
action &+= 1
case .selectionChanged:
selection &+= 1
case .operationSucceeded:
success &+= 1
case .operationNeedsAttention:
warning &+= 1
case .operationFailed:
error &+= 1
}
}
}

extension RefreshOutcome {
var appHapticFeedback: AppHapticFeedback? {
switch self {
case .succeeded:
.operationSucceeded
case .unavailable:
.operationNeedsAttention
case .failed:
.operationFailed
case .cancelled:
nil
}
}
}

extension SessionSettingsSaveState {
var appHapticFeedback: AppHapticFeedback? {
switch self {
case .pending:
.selectionChanged
case .failed:
.operationFailed
case .idle, .saving:
nil
}
}
}

extension SessionFreeSpaceState {
var appHapticFeedback: AppHapticFeedback? {
switch self {
case .result:
.operationSucceeded
case .failed:
.operationFailed
case .idle, .checking:
nil
}
}
}

extension SessionPortTestState {
var appHapticFeedback: AppHapticFeedback? {
switch self {
case .result(.open):
.operationSucceeded
case .result(.closed), .result(.checkerUnavailable):
.operationNeedsAttention
case .failed:
.operationFailed
case .idle, .testing:
nil
}
}
}

extension SessionBlocklistUpdateState {
var appHapticFeedback: AppHapticFeedback? {
switch self {
case .success:
.operationSucceeded
case .failed:
.operationFailed
case .idle, .updating:
nil
}
}
}
3 changes: 3 additions & 0 deletions BitDream/Views/Shared/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ struct SettingsView: View {
userDefaults.set(AppDefaults.dockShowDownloadSpeed, forKey: UserDefaultsKeys.dockShowDownloadSpeed)
userDefaults.set(AppDefaults.dockShowUploadSpeed, forKey: UserDefaultsKeys.dockShowUploadSpeed)
userDefaults.set(AppDefaults.startupConnectionBehavior.rawValue, forKey: UserDefaultsKeys.startupConnectionBehavior)
#if os(iOS)
userDefaults.set(AppDefaults.hapticFeedbackEnabled, forKey: UserDefaultsKeys.hapticFeedbackEnabled)
#endif

// Poll interval via TransmissionStore API
store.updatePollInterval(AppDefaults.pollInterval)
Expand Down
Loading
Loading