diff --git a/BitDream/AppConfig.swift b/BitDream/AppConfig.swift index af175dd..ed7c281 100644 --- a/BitDream/AppConfig.swift +++ b/BitDream/AppConfig.swift @@ -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 @@ -62,6 +63,7 @@ enum RuntimeDomain { } enum UserDefaultsKeys { + static let hapticFeedbackEnabled = "hapticFeedbackEnabled" static let pollInterval = "pollInterval" static let torrentListCompactMode = "torrentListCompactMode" static let showContentTypeIcons = "showContentTypeIcons" diff --git a/BitDream/AppIcon/AppIconManager.swift b/BitDream/AppIcon/AppIconManager.swift index 6965f9d..a346aa7 100644 --- a/BitDream/AppIcon/AppIconManager.swift +++ b/BitDream/AppIcon/AppIconManager.swift @@ -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() @@ -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 @@ -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) } } } diff --git a/BitDream/BitDreamApp.swift b/BitDream/BitDreamApp.swift index d6db813..8fde97b 100644 --- a/BitDream/BitDreamApp.swift +++ b/BitDream/BitDreamApp.swift @@ -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) } diff --git a/BitDream/Models/RefreshOutcome.swift b/BitDream/Models/RefreshOutcome.swift new file mode 100644 index 0000000..93038d2 --- /dev/null +++ b/BitDream/Models/RefreshOutcome.swift @@ -0,0 +1,6 @@ +enum RefreshOutcome: Equatable, Sendable { + case succeeded + case unavailable + case failed + case cancelled +} diff --git a/BitDream/Models/TorrentFileStats+Mutation.swift b/BitDream/Models/TorrentFileStats+Mutation.swift new file mode 100644 index 0000000..1d9b1e7 --- /dev/null +++ b/BitDream/Models/TorrentFileStats+Mutation.swift @@ -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 + ) + } + } +} diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index 3d242ea..6656f66 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -156,7 +156,7 @@ final class TransmissionStore: NSObject, ObservableObject { private var activationFailure: ActivationFailure? private var currentConnectionGeneration: UUID private var activationTask: Task? - private var fullRefreshTask: Task? + private var fullRefreshTask: Task? private var pollTask: Task? private var retryTask: Task? private var sessionRefreshTask: Task? @@ -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) { @@ -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 } } diff --git a/BitDream/Views/Shared/HapticFeedback.swift b/BitDream/Views/Shared/HapticFeedback.swift new file mode 100644 index 0000000..08fd853 --- /dev/null +++ b/BitDream/Views/Shared/HapticFeedback.swift @@ -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 + } + } +} diff --git a/BitDream/Views/Shared/Settings/SettingsView.swift b/BitDream/Views/Shared/Settings/SettingsView.swift index 74d73cd..ce5565b 100644 --- a/BitDream/Views/Shared/Settings/SettingsView.swift +++ b/BitDream/Views/Shared/Settings/SettingsView.swift @@ -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) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index d71b357..9434689 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -254,7 +254,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { @Published private(set) var state = TorrentDetailSupplementalState() private var managedLoadTask: Task? private var managedLoadGeneration = 0 - private var loadQueueTail: Task? + private var loadQueueTail: Task? private var loadQueueGeneration = 0 private var pendingLoadCounts: [TorrentDetailIdentity: Int] = [:] @@ -298,7 +298,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { managedLoadTask?.cancel() managedLoadTask = Task { @MainActor [weak self] in guard let self else { return } - await self.load(for: identity, using: store, onError: onError) + _ = await self.load(for: identity, using: store, onError: onError) self.clearManagedLoadTask(ifMatching: generation) } } @@ -316,14 +316,14 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { for identity: TorrentDetailIdentity, using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void - ) async { + ) async -> RefreshOutcome { guard identity.connectionGeneration == store.torrentDetailRefreshTrigger.connectionGeneration else { - return + return .cancelled } let loadTask = enqueueLoad(for: identity, using: store, onError: onError) - await withTaskCancellationHandler { + return await withTaskCancellationHandler { await loadTask.value } onCancel: { loadTask.cancel() @@ -334,58 +334,69 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { for identity: TorrentDetailIdentity, using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void - ) async { + ) async -> RefreshOutcome { guard identity.connectionGeneration == store.torrentDetailRefreshTrigger.connectionGeneration else { - return + return .cancelled } let requestGeneration = mutateState { state in state.beginLoading(for: identity) } - guard let snapshot = await performStructuredTransmissionOperation( - operation: { try await store.loadTorrentDetail(id: identity.torrentID) }, - onError: { [weak self] message in - guard let self else { return } - guard self.markFailure(for: identity, generation: requestGeneration) else { - return - } - onError(message) + let snapshot: TransmissionTorrentDetailSnapshot + do { + try Task.checkCancellation() + snapshot = try await store.loadTorrentDetail(id: identity.torrentID) + try Task.checkCancellation() + } catch { + let transmissionError = TransmissionErrorResolver.transmissionError(from: error) + if case .cancelled = transmissionError { + _ = markCancellation(for: identity, generation: requestGeneration) + return .cancelled + } + + guard markFailure(for: identity, generation: requestGeneration) else { + return .cancelled } - ) else { + presentTransmissionError(error, onError: onError) + return .failed + } + + guard !Task.isCancelled else { _ = markCancellation(for: identity, generation: requestGeneration) - return + return .cancelled } - mutateState { state in - _ = state.apply( + let didApply = mutateState { state in + state.apply( snapshot: snapshot, for: identity, generation: requestGeneration ) } + return didApply ? .succeeded : .cancelled } private func enqueueLoad( for identity: TorrentDetailIdentity, using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void - ) -> Task { + ) -> Task { let predecessor = loadQueueTail loadQueueGeneration += 1 let generation = loadQueueGeneration pendingLoadCounts[identity, default: 0] += 1 let task = Task { @MainActor [weak self] in - await predecessor?.value - guard let self else { return } + _ = await predecessor?.value + guard let self else { return RefreshOutcome.cancelled } defer { self.finishPendingLoad(for: identity) self.clearLoadQueueTail(ifMatching: generation) } - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return .cancelled } - await self.performLoad(for: identity, using: store, onError: onError) + return await self.performLoad(for: identity, using: store, onError: onError) } loadQueueTail = task @@ -396,7 +407,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { for identity: TorrentDetailIdentity, using store: TransmissionStore, onInitialLoadError: @escaping @MainActor @Sendable (String) -> Void - ) async { + ) async -> RefreshOutcome { await load(for: identity, using: store) { message in guard self.state.shouldReportInitialLoadError(for: identity) else { return @@ -426,7 +437,7 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { var requestedRevision = publishedTrigger.revision while true { - await refresh( + _ = await refresh( for: identity, using: store, onInitialLoadError: onInitialLoadError @@ -448,20 +459,20 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { for identity: TorrentDetailIdentity, using store: TransmissionStore, onError: @escaping @MainActor @Sendable (String) -> Void - ) async { + ) async -> RefreshOutcome { guard pendingLoadCounts[identity, default: 0] == 0 else { - return + return .unavailable } guard !state.shouldDisplayPayload(for: identity) else { - return + return .unavailable } guard state.status == .idle else { - return + return .unavailable } - await load(for: identity, using: store, onError: onError) + return await load(for: identity, using: store, onError: onError) } private func finishPendingLoad(for identity: TorrentDetailIdentity) { @@ -513,25 +524,6 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { } } -private 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 - ) - } - } -} - internal struct TorrentDetailSupplementalPlaceholder: View { let status: TorrentDetailSupplementalLoadStatus let loadingTitle: String diff --git a/BitDream/Views/Shared/TorrentDetailSupplementalStore+Bindings.swift b/BitDream/Views/Shared/TorrentDetailSupplementalStore+Bindings.swift index 7c74470..c961495 100644 --- a/BitDream/Views/Shared/TorrentDetailSupplementalStore+Bindings.swift +++ b/BitDream/Views/Shared/TorrentDetailSupplementalStore+Bindings.swift @@ -7,7 +7,7 @@ extension TorrentDetailSupplementalStore { using store: TransmissionStore, showingError: Binding, errorMessage: Binding - ) async { + ) async -> RefreshOutcome { await load( for: identity, using: store, @@ -23,7 +23,7 @@ extension TorrentDetailSupplementalStore { using store: TransmissionStore, showingInitialLoadError: Binding, errorMessage: Binding - ) async { + ) async -> RefreshOutcome { await refresh( for: identity, using: store, @@ -71,7 +71,7 @@ extension TorrentDetailSupplementalStore { using store: TransmissionStore, showingError: Binding, errorMessage: Binding - ) async { + ) async -> RefreshOutcome { await loadIfIdle( for: identity, using: store, diff --git a/BitDream/Views/iOS/Settings/iOSAboutView.swift b/BitDream/Views/iOS/Settings/iOSAboutView.swift index e495dc1..b3b3ccb 100644 --- a/BitDream/Views/iOS/Settings/iOSAboutView.swift +++ b/BitDream/Views/iOS/Settings/iOSAboutView.swift @@ -4,6 +4,7 @@ import SwiftUI struct iOSAboutView: View { @EnvironmentObject var themeManager: ThemeManager @Environment(\.openURL) var openURL + @Environment(\.hapticFeedback) private var hapticFeedback private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" @@ -72,6 +73,7 @@ struct iOSAboutView: View { .foregroundStyle(.tertiary) Button("Transmission") { + hapticFeedback.play(.actionTriggered) if let url = URL(string: "https://transmissionbt.com/") { openURL(url) } diff --git a/BitDream/Views/iOS/Settings/iOSNetworkSettingsView.swift b/BitDream/Views/iOS/Settings/iOSNetworkSettingsView.swift index 5690a96..d5792a2 100644 --- a/BitDream/Views/iOS/Settings/iOSNetworkSettingsView.swift +++ b/BitDream/Views/iOS/Settings/iOSNetworkSettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct iOSNetworkSettingsView: View { + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore @StateObject private var editModel = SettingsViewModel() @@ -22,6 +23,7 @@ struct iOSNetworkSettingsView: View { } Button("Check Port") { + hapticFeedback.play(.actionTriggered) Task { await editModel.testPort(ipProtocol: nil) } @@ -156,6 +158,7 @@ struct iOSNetworkSettingsView: View { } Button("Update Blocklist") { + hapticFeedback.play(.actionTriggered) Task { await editModel.updateBlocklist() } @@ -187,6 +190,15 @@ struct iOSNetworkSettingsView: View { } .navigationTitle("Network") .bindSettingsViewModel(editModel, to: store) + .onChange(of: editModel.portTestState) { _, state in + playHapticFeedback(for: state.appHapticFeedback) + } + .onChange(of: editModel.blocklistUpdateState) { _, state in + playHapticFeedback(for: state.appHapticFeedback) + } + .onChange(of: editModel.saveState) { _, state in + playHapticFeedback(for: state.appHapticFeedback) + } } else { ContentUnavailableView( "No Server Connected", @@ -196,6 +208,11 @@ struct iOSNetworkSettingsView: View { } } } + + private func playHapticFeedback(for feedback: AppHapticFeedback?) { + guard let feedback else { return } + hapticFeedback.play(feedback) + } } #endif diff --git a/BitDream/Views/iOS/Settings/iOSSettingsView.swift b/BitDream/Views/iOS/Settings/iOSSettingsView.swift index f74be9b..318d390 100644 --- a/BitDream/Views/iOS/Settings/iOSSettingsView.swift +++ b/BitDream/Views/iOS/Settings/iOSSettingsView.swift @@ -7,6 +7,7 @@ typealias PlatformSettingsView = iOSSettingsView struct iOSSettingsView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @Environment(\.appUserDefaults) private var userDefaults @EnvironmentObject private var appIconManager: AppIconManager @EnvironmentObject private var themeManager: ThemeManager @@ -14,6 +15,7 @@ struct iOSSettingsView: View { @ObservedObject var store: TransmissionStore @AppStorage(UserDefaultsKeys.showContentTypeIcons) private var showContentTypeIcons: Bool = AppDefaults.showContentTypeIcons @AppStorage(UserDefaultsKeys.startupConnectionBehavior) private var startupBehaviorRaw: String = AppDefaults.startupConnectionBehavior.rawValue + @AppStorage(UserDefaultsKeys.hapticFeedbackEnabled) private var isHapticFeedbackEnabled = AppDefaults.hapticFeedbackEnabled var body: some View { NavigationView { @@ -25,7 +27,8 @@ struct iOSSettingsView: View { } } - NavigationLink(destination: AccentColorPicker(selection: $themeManager.currentAccentColorOption)) { + NavigationLink(destination: AccentColorPicker(selection: $themeManager.currentAccentColorOption) + .iOSHapticNavigationTransition()) { HStack { Text("Accent Color") Spacer() @@ -37,7 +40,8 @@ struct iOSSettingsView: View { } } - NavigationLink(destination: AppIconPickerView(appIconManager: appIconManager)) { + NavigationLink(destination: AppIconPickerView(appIconManager: appIconManager) + .iOSHapticNavigationTransition()) { HStack { Text("App Icon") Spacer() @@ -55,6 +59,15 @@ struct iOSSettingsView: View { } } + Section { + Toggle("Haptic Feedback", isOn: $isHapticFeedbackEnabled) + .sensoryFeedback(.selection, trigger: isHapticFeedbackEnabled) + } header: { + Text("Interaction") + } footer: { + Text("Provides tactile confirmation for important actions and outcomes.") + } + Section(header: Text("Refresh Settings")) { Picker("Poll Interval", selection: Binding( get: { store.pollInterval }, @@ -68,30 +81,36 @@ struct iOSSettingsView: View { } Section(header: Text("Server Settings")) { - NavigationLink(destination: iOSTorrentsSettingsView(store: store)) { + NavigationLink(destination: iOSTorrentsSettingsView(store: store) + .iOSHapticNavigationTransition()) { Label("Torrents", systemImage: "arrow.down.circle") } - NavigationLink(destination: iOSSpeedLimitsSettingsView(store: store)) { + NavigationLink(destination: iOSSpeedLimitsSettingsView(store: store) + .iOSHapticNavigationTransition()) { Label("Speed Limits", systemImage: "speedometer") } - NavigationLink(destination: iOSNetworkSettingsView(store: store)) { + NavigationLink(destination: iOSNetworkSettingsView(store: store) + .iOSHapticNavigationTransition()) { Label("Network", systemImage: "network") } } Section(header: Text("Reset")) { Button("Reset All Settings") { + hapticFeedback.play(.actionTriggered) SettingsView.resetAllSettings( store: store, themeManager: themeManager, userDefaults: userDefaults ) + hapticFeedback.play(.operationSucceeded) } .foregroundColor(.accentColor) } Section(header: Text("About")) { - NavigationLink(destination: iOSAboutView()) { + NavigationLink(destination: iOSAboutView() + .iOSHapticNavigationTransition()) { HStack { Text("About BitDream") Spacer() @@ -102,9 +121,22 @@ struct iOSSettingsView: View { } } .navigationTitle("Settings") + .onChange(of: themeManager.themeMode) { + hapticFeedback.play(.selectionChanged) + } + .onChange(of: showContentTypeIcons) { + hapticFeedback.play(.selectionChanged) + } + .onChange(of: startupBehaviorRaw) { + hapticFeedback.play(.selectionChanged) + } + .onChange(of: store.pollInterval) { + hapticFeedback.play(.selectionChanged) + } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { + hapticFeedback.play(.actionTriggered) dismiss() } } @@ -114,37 +146,42 @@ struct iOSSettingsView: View { } private struct AccentColorPicker: View { + @Environment(\.hapticFeedback) private var hapticFeedback @EnvironmentObject private var themeManager: ThemeManager @Binding var selection: AccentColorOption var body: some View { List { ForEach(AccentColorOption.allCases) { option in - HStack { - Circle() - .fill(option.color) - .frame(width: 20, height: 20) + Button { + guard selection != option else { return } - Text(option.name) - - Text(option.rawValue) - .font(.system(.caption, design: .monospaced)) - .foregroundColor(.secondary) - - Spacer() - - if selection == option { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .contentShape(Rectangle()) - .onTapGesture { withAnimation(.easeInOut(duration: 0.1)) { selection = option themeManager.setAccentColor(option) } + hapticFeedback.play(.selectionChanged) + } label: { + HStack { + Circle() + .fill(option.color) + .frame(width: 20, height: 20) + + Text(option.name) + + Text(option.rawValue) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + + Spacer() + + if selection == option { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } } + .buttonStyle(.plain) } } .navigationTitle("Accent Color") @@ -160,26 +197,29 @@ private struct AppIconOption: Identifiable, Equatable { } private struct AppIconPickerView: View { + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var appIconManager: AppIconManager @State private var options: [AppIconOption] = [] var body: some View { List { ForEach(options) { option in - HStack(spacing: 12) { - PreviewThumbnail(name: option.previewAssetName) - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - Text(option.title) - Spacer() - if appIconManager.currentIconName == option.key { - Image(systemName: "checkmark") + Button { + selectIcon(option) + } label: { + HStack(spacing: 12) { + PreviewThumbnail(name: option.previewAssetName) + .frame(width: 44, height: 44) + .clipShape(.rect(cornerRadius: 8, style: .continuous)) + Text(option.title) + Spacer() + if appIconManager.currentIconName == option.key { + Image(systemName: "checkmark") + } } } - .contentShape(Rectangle()) - .onTapGesture { - appIconManager.selectIcon(name: option.key) - } + .buttonStyle(.plain) + .disabled(appIconManager.isChanging) } if let lastError = appIconManager.lastError { @@ -208,6 +248,19 @@ private struct AppIconPickerView: View { ) } } + + private func selectIcon(_ option: AppIconOption) { + appIconManager.selectIcon(name: option.key) { outcome in + switch outcome { + case .changed: + hapticFeedback.play(.operationSucceeded) + case .failed: + hapticFeedback.play(.operationFailed) + case .unchanged: + break + } + } + } } private struct PreviewThumbnail: View { diff --git a/BitDream/Views/iOS/Settings/iOSSpeedLimitsSettingsView.swift b/BitDream/Views/iOS/Settings/iOSSpeedLimitsSettingsView.swift index 26c55bc..50ff0c1 100644 --- a/BitDream/Views/iOS/Settings/iOSSpeedLimitsSettingsView.swift +++ b/BitDream/Views/iOS/Settings/iOSSpeedLimitsSettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI import Foundation struct iOSSpeedLimitsSettingsView: View { + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore @StateObject private var editModel = SettingsViewModel() @@ -142,6 +143,10 @@ struct iOSSpeedLimitsSettingsView: View { } .navigationTitle("Speed Limits") .bindSettingsViewModel(editModel, to: store) + .onChange(of: editModel.saveState) { _, state in + guard let feedback = state.appHapticFeedback else { return } + hapticFeedback.play(feedback) + } } else { ContentUnavailableView( "No Server Connected", diff --git a/BitDream/Views/iOS/Settings/iOSTorrentsSettingsView.swift b/BitDream/Views/iOS/Settings/iOSTorrentsSettingsView.swift index 1eead27..d88f5f8 100644 --- a/BitDream/Views/iOS/Settings/iOSTorrentsSettingsView.swift +++ b/BitDream/Views/iOS/Settings/iOSTorrentsSettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct iOSTorrentsSettingsView: View { + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore @StateObject private var editModel = SettingsViewModel() @@ -20,6 +21,7 @@ struct iOSTorrentsSettingsView: View { } Button("Check Free Space") { + hapticFeedback.play(.actionTriggered) Task { await editModel.checkFreeSpace() } @@ -174,6 +176,12 @@ struct iOSTorrentsSettingsView: View { } .navigationTitle("Torrents") .bindSettingsViewModel(editModel, to: store) + .onChange(of: editModel.freeSpaceState) { _, state in + playHapticFeedback(for: state.appHapticFeedback) + } + .onChange(of: editModel.saveState) { _, state in + playHapticFeedback(for: state.appHapticFeedback) + } } else { ContentUnavailableView( "No Server Connected", @@ -183,6 +191,11 @@ struct iOSTorrentsSettingsView: View { } } } + + private func playHapticFeedback(for feedback: AppHapticFeedback?) { + guard let feedback else { return } + hapticFeedback.play(feedback) + } } #endif diff --git a/BitDream/Views/iOS/iOSAddTorrent.swift b/BitDream/Views/iOS/iOSAddTorrent.swift index 045edd1..cb8ce52 100644 --- a/BitDream/Views/iOS/iOSAddTorrent.swift +++ b/BitDream/Views/iOS/iOSAddTorrent.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct iOSAddTorrent: View { // MARK: - Properties @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore @State private var alertInput: String = "" @@ -31,7 +32,10 @@ struct iOSAddTorrent: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel", action: dismiss.callAsFunction) + Button("Cancel") { + hapticFeedback.play(.actionTriggered) + dismiss() + } .disabled(isAdding) } @@ -51,7 +55,9 @@ struct iOSAddTorrent: View { } .interactiveDismissDisabled(isAdding) .alert("Error", isPresented: $showingError, actions: { - Button("OK", role: .cancel) {} + Button("OK", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } }, message: { Text(errorMessage ?? "An unknown error occurred") }) @@ -108,6 +114,7 @@ struct iOSAddTorrent: View { private func submitMagnetTorrent() { guard !isAddDisabled else { return } + hapticFeedback.play(.actionTriggered) isAdding = true alertInput = trimmedInput @@ -121,9 +128,11 @@ struct iOSAddTorrent: View { ) }, onSuccess: { (_: TransmissionTorrentAddOutcome) in + hapticFeedback.play(.operationSucceeded) dismiss() }, onError: { message in + hapticFeedback.play(.operationFailed) presentAddTorrentSheetError( detail: message, errorMessage: $errorMessage, diff --git a/BitDream/Views/iOS/iOSConnectionBannerView.swift b/BitDream/Views/iOS/iOSConnectionBannerView.swift index 656cfbe..873c25d 100644 --- a/BitDream/Views/iOS/iOSConnectionBannerView.swift +++ b/BitDream/Views/iOS/iOSConnectionBannerView.swift @@ -2,6 +2,7 @@ import SwiftUI #if os(iOS) struct iOSConnectionBannerView: View { + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore private var shouldShowLastError: Bool { @@ -42,6 +43,7 @@ struct iOSConnectionBannerView: View { Spacer() Button("Retry") { + hapticFeedback.play(.actionTriggered) store.reconnect() } .buttonStyle(.bordered) diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index ae62e87..ed0da5c 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -7,6 +7,8 @@ enum iOSNavigationRoute: Hashable { } struct iOSContentView: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let hosts: [Host] @ObservedObject var store: TransmissionStore private let userDefaults: UserDefaults @@ -63,6 +65,9 @@ struct iOSContentView: View { .onChange(of: store.torrents.map(\.id)) { _, torrentIDs in reconcileNavigationPath(with: torrentIDs) } + .onChange(of: isSidebarOpen) { + hapticFeedback.play(.actionTriggered) + } .sheet(isPresented: $store.setup, content: { iOSServerEditor(store: store, hosts: hosts, host: nil) }) @@ -110,15 +115,19 @@ private extension iOSContentView { closeSidebarAfterSelection() }, onEditServer: { host in + hapticFeedback.play(.actionTriggered) serverToEdit = host }, onAddServer: { + hapticFeedback.play(.actionTriggered) store.setup = true }, onManageServers: { + hapticFeedback.play(.actionTriggered) store.editServers = true }, onOpenSettings: { + hapticFeedback.play(.actionTriggered) store.showSettings = true } ) @@ -144,6 +153,9 @@ private extension iOSContentView { .onChange(of: sidebarSelection) { _, _ in closeSidebarAfterSelection() } + .onChange(of: navigationPath) { + hapticFeedback.play(.selectionChanged) + } .overlay { if progress > 0 { // White scrim fades the card toward the background; also catches taps/drags to close @@ -166,6 +178,11 @@ private extension iOSContentView { } } .clipShape(RoundedRectangle(cornerRadius: 32 * progress, style: .continuous)) + .overlay( + // Shadows vanish on black backgrounds; this edge keeps the card separated in dark mode + RoundedRectangle(cornerRadius: 32 * progress, style: .continuous) + .strokeBorder(Color(.separator).opacity(Double(progress)), lineWidth: 1) + ) .shadow(color: .black.opacity(0.12 * progress), radius: 12) .offset(x: drawerWidth * progress) } @@ -248,7 +265,11 @@ private extension iOSContentView { .navigationTitle(sidebarSelection.rawValue) .navigationBarTitleDisplayMode(.inline) .refreshable { - await store.refreshNow() + hapticFeedback.play(.actionTriggered) + let outcome = await store.refreshNow() + if let feedback = outcome.appHapticFeedback { + hapticFeedback.play(feedback) + } } .searchable(text: $searchText, prompt: "Search torrents") .toolbar { @@ -268,6 +289,7 @@ private extension iOSContentView { StatsHeaderView( store: store, onShowStatistics: { + hapticFeedback.play(.actionTriggered) isStatisticsPresented = true } ) @@ -342,6 +364,7 @@ private extension iOSContentView { } label: { Image(systemName: "ellipsis.circle") } + .iOSHapticControlActivation() } } @@ -349,6 +372,7 @@ private extension iOSContentView { Group { ToolbarItem(placement: .bottomBar) { Button { + hapticFeedback.play(.actionTriggered) showPrefs.toggle() } label: { Label( @@ -377,6 +401,7 @@ private extension iOSContentView { ToolbarItem(placement: .bottomBar) { Button(action: { + hapticFeedback.play(.actionTriggered) store.isShowingAddAlert.toggle() }, label: { Label("Add Torrent", systemImage: "plus") @@ -386,21 +411,41 @@ private extension iOSContentView { } func pauseAllTorrents() { - performTransmissionDebugAction( + performAllTorrentsAction( .pauseAllTorrents, - store: store, operation: { try await store.pauseAllTorrents() } ) } func resumeAllTorrents() { - performTransmissionDebugAction( + performAllTorrentsAction( .resumeAllTorrents, - store: store, operation: { try await store.resumeAllTorrents() } ) } + func performAllTorrentsAction( + _ context: TransmissionActionFailureContext, + operation: @escaping @MainActor @Sendable () async throws -> Void + ) { + hapticFeedback.play(.actionTriggered) + let errorHandler = makeTransmissionDebugErrorHandler( + store: store, + context: context + ) + + performTransmissionAction( + operation: operation, + onSuccess: { + hapticFeedback.play(.operationSucceeded) + }, + onError: { message in + hapticFeedback.play(.operationFailed) + errorHandler(message) + } + ) + } + func reconcileSelectedLabels(with availableLabels: [String]) { var reconciledFilter = labelFilter reconciledFilter.reconcile(with: availableLabels) diff --git a/BitDream/Views/iOS/iOSFilterAndSortView.swift b/BitDream/Views/iOS/iOSFilterAndSortView.swift index 322d277..a2b512b 100644 --- a/BitDream/Views/iOS/iOSFilterAndSortView.swift +++ b/BitDream/Views/iOS/iOSFilterAndSortView.swift @@ -3,6 +3,7 @@ import SwiftUI #if os(iOS) struct iOSFilterAndSortView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @Binding var labelFilter: TorrentLabelFilter @Binding var sortProperty: SortProperty @@ -24,6 +25,7 @@ struct iOSFilterAndSortView: View { noLabelCount: noLabelCount, onDone: dismiss.callAsFunction ) + .iOSHapticNavigationTransition() } label: { LabeledContent("Labels", value: labelSelectionSummary) } @@ -36,12 +38,18 @@ struct iOSFilterAndSortView: View { } } .pickerStyle(.navigationLink) + .onChange(of: sortProperty) { + hapticFeedback.play(.selectionChanged) + } Picker("Order", selection: $sortOrder) { Text("Ascending").tag(SortOrder.ascending) Text("Descending").tag(SortOrder.descending) } .pickerStyle(.navigationLink) + .onChange(of: sortOrder) { + hapticFeedback.play(.selectionChanged) + } } } .listStyle(.insetGrouped) @@ -49,7 +57,10 @@ struct iOSFilterAndSortView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(id: "filter-and-sort-done", placement: .confirmationAction) { - Button("Done", action: dismiss.callAsFunction) + Button("Done") { + hapticFeedback.play(.actionTriggered) + dismiss() + } } } } @@ -71,6 +82,8 @@ private extension iOSFilterAndSortView { } private struct iOSLabelFilterView: View { + @Environment(\.hapticFeedback) private var hapticFeedback + @Binding var labelFilter: TorrentLabelFilter let availableLabels: [String] @@ -85,6 +98,7 @@ private struct iOSLabelFilterView: View { let rule = labelFilter.rule(for: label) Button { labelFilter.advanceRule(for: label) + hapticFeedback.play(.selectionChanged) } label: { iOSLabelRuleRow( label: label, @@ -103,6 +117,7 @@ private struct iOSLabelFilterView: View { Button { labelFilter.setShowsUnlabeledOnly(!labelFilter.showsUnlabeledOnly) + hapticFeedback.play(.selectionChanged) } label: { iOSNoLabelsFilterRow( count: noLabelCount, @@ -118,6 +133,7 @@ private struct iOSLabelFilterView: View { Section { Button("Clear All Filters") { labelFilter.clear() + hapticFeedback.play(.selectionChanged) } } } @@ -126,7 +142,10 @@ private struct iOSLabelFilterView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(id: "label-filter-done", placement: .confirmationAction) { - Button("Done", action: onDone) + Button("Done") { + hapticFeedback.play(.actionTriggered) + onDone() + } } } } diff --git a/BitDream/Views/iOS/iOSHapticFeedback.swift b/BitDream/Views/iOS/iOSHapticFeedback.swift new file mode 100644 index 0000000..90a803a --- /dev/null +++ b/BitDream/Views/iOS/iOSHapticFeedback.swift @@ -0,0 +1,79 @@ +#if os(iOS) +import SwiftUI + +extension EnvironmentValues { + @Entry var hapticFeedback: HapticFeedbackClient = .disabled +} + +struct iOSHapticFeedbackHost: View { + @AppStorage(UserDefaultsKeys.hapticFeedbackEnabled) + private var isHapticFeedbackEnabled = AppDefaults.hapticFeedbackEnabled + + @State private var triggers = HapticFeedbackTriggers() + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .environment(\.hapticFeedback, HapticFeedbackClient { feedback in + guard isHapticFeedbackEnabled else { return } + triggers.play(feedback) + }) + .sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: triggers.action) + .sensoryFeedback(.selection, trigger: triggers.selection) + .sensoryFeedback(.success, trigger: triggers.success) + .sensoryFeedback(.warning, trigger: triggers.warning) + .sensoryFeedback(.error, trigger: triggers.error) + } +} + +private struct iOSHapticNavigationTransitionModifier: ViewModifier { + @Environment(\.hapticFeedback) private var hapticFeedback + + func body(content: Content) -> some View { + content + .onAppear { + hapticFeedback.play(.actionTriggered) + } + .onDisappear { + hapticFeedback.play(.actionTriggered) + } + } +} + +private struct iOSHapticControlActivationModifier: ViewModifier { + @Environment(\.hapticFeedback) private var hapticFeedback + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content.simultaneousGesture( + TapGesture().onEnded { + guard isEnabled else { return } + hapticFeedback.play(.actionTriggered) + } + ) + } +} + +extension View { + func iOSHapticNavigationTransition() -> some View { + modifier(iOSHapticNavigationTransitionModifier()) + } + + func iOSHapticControlActivation() -> some View { + modifier(iOSHapticControlActivationModifier()) + } +} + +#if DEBUG +#Preview("Haptic Feedback Host") { + iOSHapticFeedbackHost { + Text("Haptic feedback is attached at the scene root.") + .padding() + } +} +#endif +#endif diff --git a/BitDream/Views/iOS/iOSServerEditor.swift b/BitDream/Views/iOS/iOSServerEditor.swift index 6f1101e..0a22ba9 100644 --- a/BitDream/Views/iOS/iOSServerEditor.swift +++ b/BitDream/Views/iOS/iOSServerEditor.swift @@ -12,6 +12,7 @@ private enum iOSServerFormField: Hashable { /// Sheet for adding a new server or editing an existing one. struct iOSServerEditor: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @Environment(\.hostRepositoryProvider) private var hostRepositoryProvider @ObservedObject var store: TransmissionStore let hosts: [Host] @@ -53,9 +54,12 @@ struct iOSServerEditor: View { isPresented: $isConfirmingDiscard ) { Button("Discard Changes", role: .destructive) { + hapticFeedback.play(.actionTriggered) dismiss() } - Button("Keep Editing", role: .cancel) {} + Button("Keep Editing", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } } message: { Text("Your unsaved server changes will be lost.") } @@ -73,7 +77,9 @@ struct iOSServerEditor: View { } ) .alert("Error", isPresented: isPresentingError) { - Button("OK", role: .cancel) {} + Button("OK", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } } message: { Text(errorMessage ?? "") } @@ -93,7 +99,13 @@ struct iOSServerEditor: View { .focused($focusedField, equals: .name) } - Toggle("Default", isOn: $model.values.isDefault) + Toggle("Default", isOn: Binding( + get: { model.values.isDefault }, + set: { value in + model.values.isDefault = value + hapticFeedback.play(.selectionChanged) + } + )) .disabled(!model.canEditDefaultToggle(hostCount: hosts.count)) } footer: { Text("Preferred server when connecting at launch.") @@ -116,7 +128,13 @@ struct iOSServerEditor: View { .focused($focusedField, equals: .port) } - Toggle("Use SSL", isOn: $model.values.isSSL) + Toggle("Use SSL", isOn: Binding( + get: { model.values.isSSL }, + set: { value in + model.values.isSSL = value + hapticFeedback.play(.selectionChanged) + } + )) } header: { Text("Connection") } footer: { @@ -145,6 +163,7 @@ struct iOSServerEditor: View { if !isAddNew { Section { Button("Delete Server", role: .destructive) { + hapticFeedback.play(.actionTriggered) isConfirmingDelete = true } .frame(maxWidth: .infinity) @@ -172,6 +191,7 @@ struct iOSServerEditor: View { } private func save() { + hapticFeedback.play(.actionTriggered) Task { do { switch try await model.save( @@ -179,17 +199,21 @@ struct iOSServerEditor: View { hostRepository: hostRepositoryProvider.resolve() ) { case .validationFailed(let field): + hapticFeedback.play(.operationNeedsAttention) focusedField = focusTarget(for: field) case .saved: + hapticFeedback.play(.operationSucceeded) dismiss() } } catch { + hapticFeedback.play(.operationFailed) errorMessage = userFacingHostPersistenceMessage(error) } } } private func cancel() { + hapticFeedback.play(.actionTriggered) if model.hasUnsavedChanges { isConfirmingDiscard = true } else { @@ -198,6 +222,7 @@ struct iOSServerEditor: View { } private func performDelete(_ host: Host) { + hapticFeedback.play(.actionTriggered) Task { do { try await deleteServer( @@ -206,8 +231,10 @@ struct iOSServerEditor: View { hosts: hosts, hostRepository: hostRepositoryProvider.resolve() ) + hapticFeedback.play(.operationSucceeded) dismiss() } catch { + hapticFeedback.play(.operationFailed) errorMessage = userFacingHostPersistenceMessage(error) } } diff --git a/BitDream/Views/iOS/iOSServerList.swift b/BitDream/Views/iOS/iOSServerList.swift index e7ceba7..8ce6a4d 100644 --- a/BitDream/Views/iOS/iOSServerList.swift +++ b/BitDream/Views/iOS/iOSServerList.swift @@ -4,6 +4,7 @@ import SwiftUI /// Sheet listing the configured servers with add, edit, connect, and delete actions. struct iOSServerList: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @Environment(\.hostRepositoryProvider) private var hostRepositoryProvider let hosts: [Host] @ObservedObject var store: TransmissionStore @@ -55,6 +56,7 @@ struct iOSServerList: View { Section { Button { + hapticFeedback.play(.actionTriggered) presentedEditor = .add } label: { Label("New Server", systemImage: "plus") @@ -69,6 +71,7 @@ struct iOSServerList: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Close", systemImage: "xmark", role: .close) { + hapticFeedback.play(.actionTriggered) dismiss() } } @@ -81,6 +84,7 @@ struct iOSServerList: View { Text("Add a server to get started with BitDream.") } actions: { Button("Add Server") { + hapticFeedback.play(.actionTriggered) presentedEditor = .add } } @@ -100,7 +104,9 @@ struct iOSServerList: View { } ) .alert("Error", isPresented: isPresentingError) { - Button("OK", role: .cancel) {} + Button("OK", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } } message: { Text(errorMessage ?? "") } @@ -122,6 +128,7 @@ struct iOSServerList: View { guard let serverID, let host = hosts.first(where: { $0.serverID == serverID }) else { return } store.setHost(host: host) + hapticFeedback.play(.selectionChanged) } ) } @@ -130,6 +137,7 @@ struct iOSServerList: View { let isConnected = host.serverID == store.host?.serverID return Button { + hapticFeedback.play(.actionTriggered) presentedEditor = .edit(host) } label: { ServerRowLabel(host: host, isConnected: isConnected) @@ -138,10 +146,12 @@ struct iOSServerList: View { .contextMenu { Button("Connect", systemImage: "bolt.fill") { store.setHost(host: host) + hapticFeedback.play(.selectionChanged) } .disabled(isConnected) Button("Edit", systemImage: "square.and.pencil") { + hapticFeedback.play(.actionTriggered) presentedEditor = .edit(host) } @@ -165,11 +175,13 @@ struct iOSServerList: View { } private func promptDelete(_ host: Host) { + hapticFeedback.play(.actionTriggered) serverToDelete = host isConfirmingDelete = true } private func performDelete(_ host: Host) { + hapticFeedback.play(.actionTriggered) Task { do { try await deleteServer( @@ -178,9 +190,11 @@ struct iOSServerList: View { hosts: hosts, hostRepository: hostRepositoryProvider.resolve() ) + hapticFeedback.play(.operationSucceeded) serverToDelete = nil } catch { serverToDelete = nil + hapticFeedback.play(.operationFailed) errorMessage = userFacingHostPersistenceMessage(error) } } diff --git a/BitDream/Views/iOS/iOSSidebarView.swift b/BitDream/Views/iOS/iOSSidebarView.swift index ef23eab..8314cee 100644 --- a/BitDream/Views/iOS/iOSSidebarView.swift +++ b/BitDream/Views/iOS/iOSSidebarView.swift @@ -104,8 +104,10 @@ private struct FooterCircleButton: View { .foregroundStyle(.primary) .frame(width: 44, height: 44) .background( + // Tertiary stays white in light mode but elevates to gray in dark, + // where shadows alone can't separate the circle from the sidebar Circle() - .fill(Color(.systemBackground)) + .fill(Color(.tertiarySystemBackground)) .shadow(color: .black.opacity(0.18), radius: 8, y: 2) ) } diff --git a/BitDream/Views/iOS/iOSStatisticsView.swift b/BitDream/Views/iOS/iOSStatisticsView.swift index 564ac04..23c1db6 100644 --- a/BitDream/Views/iOS/iOSStatisticsView.swift +++ b/BitDream/Views/iOS/iOSStatisticsView.swift @@ -3,6 +3,7 @@ import SwiftUI #if os(iOS) struct iOSStatisticsView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore var body: some View { @@ -36,6 +37,7 @@ struct iOSStatisticsView: View { .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { + hapticFeedback.play(.actionTriggered) dismiss() } } diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index 6011073..b683f2d 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -4,6 +4,7 @@ import SwiftUI #if os(iOS) struct iOSTorrentDetail: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @ObservedObject var store: TransmissionStore var torrent: Torrent @@ -36,6 +37,7 @@ struct iOSTorrentDetail: View { } private func replaceSupplementalLoad() { + hapticFeedback.play(.actionTriggered) supplementalStore.replaceLoad( for: supplementalIdentity, using: store, @@ -59,7 +61,10 @@ struct iOSTorrentDetail: View { piecesSectionState: piecesSectionState, filesDestination: filesDestination, peersDestination: peersDestination, - onDelete: { showingDeleteConfirmation = true }, + onDelete: { + hapticFeedback.play(.actionTriggered) + showingDeleteConfirmation = true + }, onRetryPiecesLoad: { replaceSupplementalLoad() } @@ -84,7 +89,9 @@ struct iOSTorrentDetail: View { Button("Remove from list only") { performDelete(deleteLocalData: false) } - Button("Cancel", role: .cancel) { } + Button("Cancel", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } } message: { Text("Do you want to delete the file(s) from the disk?") } @@ -95,6 +102,7 @@ struct iOSTorrentDetail: View { } private func performDelete(deleteLocalData: Bool) { + hapticFeedback.play(.actionTriggered) performTransmissionAction( operation: { try await store.removeTorrents( @@ -103,12 +111,10 @@ struct iOSTorrentDetail: View { ) }, onSuccess: { + hapticFeedback.play(.operationSucceeded) dismiss() }, - onError: makeTransmissionBindingErrorHandler( - isPresented: $showingError, - message: $errorMessage - ) + onError: presentError ) } @@ -121,12 +127,13 @@ struct iOSTorrentDetail: View { onShowMove: showMoveDialog, onShowRename: showRenameDialog, onShowLabels: showLabelDialog, - onShowDelete: { showingDeleteConfirmation = true }, + onShowDelete: showDeleteDialog, onError: presentError ) } label: { Image(systemName: "ellipsis.circle") } + .iOSHapticControlActivation() } } @@ -167,21 +174,30 @@ struct iOSTorrentDetail: View { } private func showRenameDialog() { + hapticFeedback.play(.actionTriggered) renameInput = torrent.name renameDialog = true } private func showMoveDialog() { + hapticFeedback.play(.actionTriggered) movePath = store.defaultDownloadDir moveDialog = true } private func showLabelDialog() { + hapticFeedback.play(.actionTriggered) labelInput = torrent.labels.joined(separator: ", ") labelDialog = true } + private func showDeleteDialog() { + hapticFeedback.play(.actionTriggered) + showingDeleteConfirmation = true + } + private func presentError(_ error: String) { + hapticFeedback.play(.operationFailed) errorMessage = error showingError = true } @@ -296,6 +312,7 @@ private struct IOSTorrentDetailContent +} + #endif #if os(iOS) && DEBUG diff --git a/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift b/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift index 0a0de73..6501e1a 100644 --- a/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift +++ b/BitDream/Views/iOS/iOSTorrentFileDetailControls.swift @@ -58,6 +58,7 @@ struct BulkActionToolbar: View { Text("Actions") .font(.subheadline) } + .iOSHapticControlActivation() .disabled(selectedCount == 0) } .padding(.horizontal, 16) @@ -82,6 +83,8 @@ struct BulkActionToolbar: View { } struct FileActionButtonsView: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let hasActiveFilters: Bool @Binding var sortProperty: FileSortProperty @Binding var sortOrder: SortOrder @@ -92,6 +95,7 @@ struct FileActionButtonsView: View { var body: some View { HStack(spacing: 12) { Button { + hapticFeedback.play(.actionTriggered) showFilterSheet = true } label: { HStack(spacing: 4) { @@ -103,13 +107,15 @@ struct FileActionButtonsView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(hasActiveFilters ? Color.accentColor : Color.accentColor.opacity(0.1)) - .cornerRadius(16) + .cornerRadius(16) } Menu { ForEach(FileSortProperty.allCases, id: \.self) { property in Button { + guard sortProperty != property else { return } sortProperty = property + hapticFeedback.play(.selectionChanged) } label: { HStack { Text(property.rawValue) @@ -124,7 +130,9 @@ struct FileActionButtonsView: View { Divider() Button { + guard sortOrder != .ascending else { return } sortOrder = .ascending + hapticFeedback.play(.selectionChanged) } label: { HStack { Text("Ascending") @@ -136,7 +144,9 @@ struct FileActionButtonsView: View { } Button { + guard sortOrder != .descending else { return } sortOrder = .descending + hapticFeedback.play(.selectionChanged) } label: { HStack { Text("Descending") @@ -189,6 +199,7 @@ struct FileActionButtonsView: View { struct FilterSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(\.hapticFeedback) private var hapticFeedback @Binding var showWantedFiles: Bool @Binding var showSkippedFiles: Bool @@ -237,6 +248,7 @@ struct FilterSheet: View { showOther = true } .foregroundColor(.accentColor) + .disabled(!hasActiveFilters) } } .navigationTitle("Filters") @@ -244,12 +256,61 @@ struct FilterSheet: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { + hapticFeedback.play(.actionTriggered) dismiss() } } } } + .onChange(of: filterSelection) { + hapticFeedback.play(.selectionChanged) + } + } + + private var filterSelection: FileFilterSelection { + FileFilterSelection( + showsWanted: showWantedFiles, + showsSkipped: showSkippedFiles, + showsComplete: showCompleteFiles, + showsIncomplete: showIncompleteFiles, + showsVideos: showVideos, + showsAudio: showAudio, + showsImages: showImages, + showsDocuments: showDocuments, + showsArchives: showArchives, + showsOther: showOther + ) } + + private var hasActiveFilters: Bool { + filterSelection != .showAll + } +} + +private struct FileFilterSelection: Equatable { + let showsWanted: Bool + let showsSkipped: Bool + let showsComplete: Bool + let showsIncomplete: Bool + let showsVideos: Bool + let showsAudio: Bool + let showsImages: Bool + let showsDocuments: Bool + let showsArchives: Bool + let showsOther: Bool + + static let showAll = Self( + showsWanted: true, + showsSkipped: true, + showsComplete: true, + showsIncomplete: true, + showsVideos: true, + showsAudio: true, + showsImages: true, + showsDocuments: true, + showsArchives: true, + showsOther: true + ) } #if DEBUG diff --git a/BitDream/Views/iOS/iOSTorrentFileRow.swift b/BitDream/Views/iOS/iOSTorrentFileRow.swift index 709f6a3..82516b3 100644 --- a/BitDream/Views/iOS/iOSTorrentFileRow.swift +++ b/BitDream/Views/iOS/iOSTorrentFileRow.swift @@ -58,6 +58,7 @@ struct iOSTorrentFileRow: View { } label: { Image(systemName: "flag") } + .iOSHapticControlActivation() .tint(.orange) Menu { @@ -65,6 +66,7 @@ struct iOSTorrentFileRow: View { } label: { Image(systemName: "ellipsis.circle") } + .iOSHapticControlActivation() } .contextMenu { fullActionSections diff --git a/BitDream/Views/iOS/iOSTorrentListRow.swift b/BitDream/Views/iOS/iOSTorrentListRow.swift index 26d145a..6fca062 100644 --- a/BitDream/Views/iOS/iOSTorrentListRow.swift +++ b/BitDream/Views/iOS/iOSTorrentListRow.swift @@ -3,6 +3,8 @@ import SwiftUI #if os(iOS) struct iOSTorrentListRow: View { + @Environment(\.hapticFeedback) private var hapticFeedback + var torrent: Torrent var store: TransmissionStore var showContentTypeIcons: Bool @@ -38,7 +40,9 @@ struct iOSTorrentListRow: View { Button("Remove from list only") { performDelete(erase: false) } - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + hapticFeedback.play(.actionTriggered) + } } message: { Text("Do you want to delete the file(s) from the disk?") } @@ -96,8 +100,8 @@ struct iOSTorrentListRow: View { store: store, onShowMove: showMoveDialog, onShowRename: showRenameDialog, - onShowLabels: { labelDialog = true }, - onShowDelete: { deleteDialog = true }, + onShowLabels: showLabelDialog, + onShowDelete: showDeleteDialog, onError: presentError ) } @@ -114,6 +118,7 @@ struct iOSTorrentListRow: View { } label: { Image(systemName: "ellipsis.circle") } + .iOSHapticControlActivation() } private func renameSheet() -> some View { @@ -153,30 +158,51 @@ struct iOSTorrentListRow: View { } private func togglePlayback() { + hapticFeedback.play(.actionTriggered) performTransmissionAction( operation: { try await store.toggleTorrentPlayback(torrent) }, + onSuccess: { + hapticFeedback.play(.operationSucceeded) + }, onError: presentError ) } private func performDelete(erase: Bool) { + hapticFeedback.play(.actionTriggered) performTransmissionAction( operation: { try await store.removeTorrents(ids: [torrent.id], deleteLocalData: erase) }, + onSuccess: { + hapticFeedback.play(.operationSucceeded) + }, onError: presentError ) } private func showRenameDialog() { + hapticFeedback.play(.actionTriggered) renameInput = torrent.name renameDialog = true } private func showMoveDialog() { + hapticFeedback.play(.actionTriggered) movePath = store.defaultDownloadDir moveDialog = true } + private func showLabelDialog() { + hapticFeedback.play(.actionTriggered) + labelDialog = true + } + + private func showDeleteDialog() { + hapticFeedback.play(.actionTriggered) + deleteDialog = true + } + private func presentError(_ error: String) { + hapticFeedback.play(.operationFailed) errorMessage = error showingError = true } @@ -184,6 +210,8 @@ struct iOSTorrentListRow: View { @MainActor struct IOSTorrentActionsMenu: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let torrent: Torrent let store: TransmissionStore let onShowMove: () -> Void @@ -204,6 +232,7 @@ struct IOSTorrentActionsMenu: View { Divider() Button("Copy Magnet Link", systemImage: "document.on.document") { copyMagnetLinkToClipboard(torrent.magnetLink) + hapticFeedback.play(.operationSucceeded) } Divider() Button("Ask For More Peers", systemImage: "arrow.left.arrow.right") { @@ -265,66 +294,78 @@ struct IOSTorrentActionsMenu: View { } private func togglePlayback() { - runAction { + runAction(successFeedback: .operationSucceeded) { try await store.toggleTorrentPlayback(torrent) } } private func updatePriority(_ priority: TorrentPriority) { - runAction { + runAction(successFeedback: .selectionChanged) { try await store.updateTorrentPriority(ids: [torrent.id], priority: priority) } } private func queueMoveTopAction() { - runAction { + runAction(successFeedback: .selectionChanged) { try await store.moveTorrentsInQueue(.top, ids: [torrent.id]) } } private func queueMoveUpAction() { - runAction { + runAction(successFeedback: .selectionChanged) { try await store.moveTorrentsInQueue(.upward, ids: [torrent.id]) } } private func queueMoveDownAction() { - runAction { + runAction(successFeedback: .selectionChanged) { try await store.moveTorrentsInQueue(.downward, ids: [torrent.id]) } } private func queueMoveBottomAction() { - runAction { + runAction(successFeedback: .selectionChanged) { try await store.moveTorrentsInQueue(.bottom, ids: [torrent.id]) } } private func verifyTorrentAction() { - runAction { + runAction(successFeedback: .operationSucceeded) { try await store.verifyTorrents(ids: [torrent.id]) } } private func resumeNow() { - runAction { + runAction(successFeedback: .operationSucceeded) { try await store.startTorrentsNow(ids: [torrent.id]) } } private func reannounce() { - runAction { + runAction(successFeedback: .operationSucceeded) { try await store.reannounceTorrents(ids: [torrent.id]) } } - private func runAction(_ operation: @escaping @MainActor () async throws -> Void) { - performTransmissionAction(operation: operation, onError: onError) + private func runAction( + successFeedback: AppHapticFeedback, + operation: @escaping @MainActor @Sendable () async throws -> Void + ) { + hapticFeedback.play(.actionTriggered) + performTransmissionAction( + operation: operation, + onSuccess: { + hapticFeedback.play(successFeedback) + }, + onError: onError + ) } } @MainActor struct IOSTorrentRenameSheet: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let torrent: Torrent let store: TransmissionStore @Binding var renameInput: String @@ -356,7 +397,10 @@ struct IOSTorrentRenameSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { isPresented = false } + Button("Cancel") { + hapticFeedback.play(.actionTriggered) + isPresented = false + } } ToolbarItem(placement: .confirmationAction) { Button("Save") { @@ -369,10 +413,12 @@ struct IOSTorrentRenameSheet: View { private func saveRename() { guard isRenameValid else { return } + hapticFeedback.play(.actionTriggered) let nameToSave = trimmedRenameInput performTransmissionAction( operation: { try await store.renameTorrentRoot(torrent, to: nameToSave) }, onSuccess: { (_: TorrentRenameResponseArgs) in + hapticFeedback.play(.operationSucceeded) isPresented = false }, onError: onError @@ -382,6 +428,8 @@ struct IOSTorrentRenameSheet: View { @MainActor struct IOSTorrentMoveSheet: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let torrent: Torrent let store: TransmissionStore @Binding var movePath: String @@ -408,7 +456,13 @@ struct IOSTorrentMoveSheet: View { } TextField("Destination path", text: $movePath) .textFieldStyle(.roundedBorder) - Toggle(isOn: $moveShouldMove) { + Toggle(isOn: Binding( + get: { moveShouldMove }, + set: { value in + moveShouldMove = value + hapticFeedback.play(.selectionChanged) + } + )) { VStack(alignment: .leading, spacing: 2) { Text("Move files on disk") Text("When enabled, physically moves/renames the torrent's data into this folder on the server. When disabled, does not move files, and instead simply links this torrent to files already in the selected folder.") @@ -422,7 +476,10 @@ struct IOSTorrentMoveSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { isPresented = false } + Button("Cancel") { + hapticFeedback.play(.actionTriggered) + isPresented = false + } } ToolbarItem(placement: .confirmationAction) { Button("Set Location") { @@ -434,6 +491,7 @@ struct IOSTorrentMoveSheet: View { } private func setLocation() { + hapticFeedback.play(.actionTriggered) let location = movePath.trimmingCharacters(in: .whitespacesAndNewlines) performTransmissionAction( @@ -445,6 +503,7 @@ struct IOSTorrentMoveSheet: View { ) }, onSuccess: { + hapticFeedback.play(.operationSucceeded) isPresented = false }, onError: onError @@ -453,6 +512,8 @@ struct IOSTorrentMoveSheet: View { } struct iOSLabelEditView: View { + @Environment(\.hapticFeedback) private var hapticFeedback + @Binding var labelInput: String let existingLabels: [String] @State private var workingLabels: Set @@ -479,6 +540,7 @@ struct iOSLabelEditView: View { } private func saveAndDismiss() { + hapticFeedback.play(.actionTriggered) if addNewTag(from: &newTagInput, to: &workingLabels) { labelInput = workingLabels.joined(separator: ", ") } @@ -493,9 +555,11 @@ struct iOSLabelEditView: View { ]) }, onSuccess: { + hapticFeedback.play(.operationSucceeded) dismiss() }, onError: { message in + hapticFeedback.play(.operationFailed) errorMessage = message showingError = true } @@ -516,6 +580,7 @@ struct iOSLabelEditView: View { LabelTag(label: label) { workingLabels.remove(label) labelInput = workingLabels.joined(separator: ", ") + hapticFeedback.play(.selectionChanged) } } } @@ -529,6 +594,7 @@ struct iOSLabelEditView: View { .onSubmit { if addNewTag(from: &newTagInput, to: &workingLabels) { labelInput = workingLabels.joined(separator: ", ") + hapticFeedback.play(.selectionChanged) } } @@ -536,6 +602,7 @@ struct iOSLabelEditView: View { Button(action: { if addNewTag(from: &newTagInput, to: &workingLabels) { labelInput = workingLabels.joined(separator: ", ") + hapticFeedback.play(.selectionChanged) } }, label: { Image(systemName: "plus.circle.fill") @@ -559,6 +626,7 @@ struct iOSLabelEditView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { + hapticFeedback.play(.actionTriggered) dismiss() } } @@ -573,6 +641,7 @@ struct iOSLabelEditView: View { newTagInput = newValue.replacingOccurrences(of: ",", with: "") if addNewTag(from: &newTagInput, to: &workingLabels) { labelInput = workingLabels.joined(separator: ", ") + hapticFeedback.play(.selectionChanged) } } } diff --git a/BitDream/Views/iOS/iOSTorrentPeerDetail.swift b/BitDream/Views/iOS/iOSTorrentPeerDetail.swift index 9af9ca7..078587f 100644 --- a/BitDream/Views/iOS/iOSTorrentPeerDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentPeerDetail.swift @@ -3,12 +3,14 @@ import Foundation #if os(iOS) struct iOSTorrentPeerDetail: View { + @Environment(\.hapticFeedback) private var hapticFeedback + let torrentName: String let torrentId: Int let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: @MainActor () async -> Void + let onRefresh: @MainActor () async -> RefreshOutcome let onDone: () -> Void @State private var searchText: String = "" @@ -30,7 +32,9 @@ struct iOSTorrentPeerDetail: View { Text(peers.isEmpty ? "No peers yet" : "No results") .foregroundColor(.secondary) Button { - Task { await onRefresh() } + Task { + await refreshPeers() + } } label: { Label("Refresh", systemImage: "arrow.clockwise") } @@ -42,11 +46,16 @@ struct iOSTorrentPeerDetail: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { - Task { await onRefresh() } + Task { + await refreshPeers() + } } label: { Image(systemName: "arrow.clockwise") } - Button("Done", action: onDone) + Button("Done") { + hapticFeedback.play(.actionTriggered) + onDone() + } } } } else { @@ -72,23 +81,37 @@ struct iOSTorrentPeerDetail: View { } } } - .refreshable { await onRefresh() } + .refreshable { + await refreshPeers() + } .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) .searchable(text: $searchText, prompt: "Search peers") .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { - Task { await onRefresh() } + Task { + await refreshPeers() + } } label: { Image(systemName: "arrow.clockwise") } - Button("Done", action: onDone) + Button("Done") { + hapticFeedback.play(.actionTriggered) + onDone() + } } } } } } + + private func refreshPeers() async { + hapticFeedback.play(.actionTriggered) + let outcome = await onRefresh() + guard let feedback = outcome.appHapticFeedback else { return } + hapticFeedback.play(feedback) + } } private struct PeerRowCard: View { @@ -163,7 +186,7 @@ struct iOSTorrentPeerDetail: View { let store: TransmissionStore let peers: [Peer] let peersFrom: PeersFrom? - let onRefresh: @MainActor () async -> Void + let onRefresh: @MainActor () async -> RefreshOutcome let onDone: () -> Void var body: some View { EmptyView() } } @@ -178,7 +201,7 @@ struct iOSTorrentPeerDetail: View { store: environment.store, peers: PreviewFixtures.peers, peersFrom: PreviewFixtures.peersFrom, - onRefresh: {}, + onRefresh: { .succeeded }, onDone: {} ) } diff --git a/BitDreamTests/Transmission/TransmissionTestSupport.swift b/BitDreamTests/Transmission/TransmissionTestSupport.swift index 168a9d1..d75533e 100644 --- a/BitDreamTests/Transmission/TransmissionTestSupport.swift +++ b/BitDreamTests/Transmission/TransmissionTestSupport.swift @@ -186,6 +186,7 @@ actor HostMethodScriptedSender: TransmissionRPCRequestSending { enum Step { case http(statusCode: Int, body: String, headers: [String: String] = [:]) case blocked(id: String, statusCode: Int, body: String, headers: [String: String] = [:]) + case blockedError(id: String, error: any Error) case error(any Error) } @@ -229,6 +230,11 @@ actor HostMethodScriptedSender: TransmissionRPCRequestSending { Data(body.utf8), makeHTTPResponse(for: url, statusCode: statusCode, headers: headers) ) + case let .blockedError(id, error): + await withCheckedContinuation { continuation in + continuations[id] = continuation + } + throw error case let .error(error): throw error } diff --git a/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift b/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift index 16d856d..db4285d 100644 --- a/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift +++ b/BitDreamTests/TransmissionStore/TransmissionStoreFullRefreshDegradationTests.swift @@ -4,6 +4,14 @@ import XCTest @MainActor final class TransmissionStoreFullRefreshTests: XCTestCase { + func testRefreshWithoutActiveConnectionReportsUnavailable() async { + let store = makeStore(sender: MethodQueueSender(stepsByMethod: [:])) + + let outcome = await store.refreshNow() + + XCTAssertEqual(outcome, .unavailable) + } + func testSuccessfulRefreshAdvancesTorrentDetailRefreshTrigger() async throws { let sender = MethodQueueSender(stepsByMethod: [ "session-stats": [ @@ -27,15 +35,93 @@ final class TransmissionStoreFullRefreshTests: XCTestCase { XCTAssertTrue(didConnect) let connectedTrigger = store.torrentDetailRefreshTrigger - await store.refreshNow() + let outcome = await store.refreshNow() let refreshedTrigger = store.torrentDetailRefreshTrigger + XCTAssertEqual(outcome, .succeeded) XCTAssertNotEqual(connectedTrigger.connectionGeneration, initialTrigger.connectionGeneration) XCTAssertEqual(connectedTrigger.revision, 1) XCTAssertEqual(refreshedTrigger.connectionGeneration, connectedTrigger.connectionGeneration) XCTAssertEqual(refreshedTrigger.revision, 2) } + func testManualRefreshReportsRequestFailure() async throws { + let sender = MethodQueueSender(stepsByMethod: [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .error(TestError.offline) + ], + "torrent-get": [ + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")), + .http(statusCode: 200, body: try loadTransmissionFixture(named: "torrent-get.response.json")) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads", version: "4.0.0")) + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "server-1", server: "example.com")) + let didConnect = await waitUntil { store.connectionStatus == .connected } + XCTAssertTrue(didConnect) + + let outcome = await store.refreshNow() + + XCTAssertEqual(outcome, .failed) + } + + func testSupersededManualRefreshReportsCancellationWhenTransportFails() async throws { + let torrentSummary = try loadTransmissionFixture(named: "torrent-get.response.json") + let sender = HostMethodScriptedSender(stepsByHostAndMethod: [ + "old.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody), + .blockedError(id: "stale-refresh", error: TestError.offline) + ], + "torrent-get": [ + .http(statusCode: 200, body: torrentSummary), + .http(statusCode: 200, body: torrentSummary) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/old", version: "4.0.0")), + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/old", version: "4.0.0")) + ] + ], + "new.example.com": [ + "session-stats": [ + .http(statusCode: 200, body: successStatsBody) + ], + "torrent-get": [ + .http(statusCode: 200, body: torrentSummary) + ], + "session-get": [ + .http(statusCode: 200, body: try sessionSettingsBody(downloadDir: "/downloads/new", version: "4.0.0")) + ] + ] + ]) + let store = makeStore(sender: sender) + + store.setHost(host: makeHost(serverID: "old-server", server: "old.example.com")) + let didConnectInitially = await waitUntil { store.defaultDownloadDir == "/downloads/old" } + XCTAssertTrue(didConnectInitially) + + let refreshTask = Task { await store.refreshNow() } + let didStartRefresh = await waitUntil { + await sender.capturedRequests().count == 6 + } + XCTAssertTrue(didStartRefresh) + + store.setHost(host: makeHost(serverID: "new-server", server: "new.example.com")) + await sender.resume(id: "stale-refresh") + + let outcome = await refreshTask.value + XCTAssertEqual(outcome, .cancelled) + + let didSwitch = await waitUntil { store.defaultDownloadDir == "/downloads/new" } + XCTAssertTrue(didSwitch) + } + func testInitialFullRefreshConnectsWhenSessionSettingsFail() async throws { let sender = MethodQueueSender(stepsByMethod: [ "session-stats": [ diff --git a/BitDreamTests/Views/HapticFeedbackTests.swift b/BitDreamTests/Views/HapticFeedbackTests.swift new file mode 100644 index 0000000..d1d596b --- /dev/null +++ b/BitDreamTests/Views/HapticFeedbackTests.swift @@ -0,0 +1,80 @@ +import SwiftUI +import XCTest +@testable import BitDream + +@MainActor +final class HapticFeedbackTests: XCTestCase { + func testSemanticFeedbackUsesAppleSystemPatterns() { + XCTAssertEqual( + AppHapticFeedback.actionTriggered.sensoryFeedback, + .impact(weight: .light, intensity: 0.7) + ) + XCTAssertEqual(AppHapticFeedback.selectionChanged.sensoryFeedback, .selection) + XCTAssertEqual(AppHapticFeedback.operationSucceeded.sensoryFeedback, .success) + XCTAssertEqual(AppHapticFeedback.operationNeedsAttention.sensoryFeedback, .warning) + XCTAssertEqual(AppHapticFeedback.operationFailed.sensoryFeedback, .error) + } + + func testTriggersIncrementTheMatchingFeedbackOnly() { + var triggers = HapticFeedbackTriggers() + + triggers.play(.actionTriggered) + triggers.play(.selectionChanged) + triggers.play(.operationSucceeded) + triggers.play(.operationSucceeded) + triggers.play(.operationNeedsAttention) + triggers.play(.operationFailed) + + XCTAssertEqual(triggers.action, 1) + XCTAssertEqual(triggers.selection, 1) + XCTAssertEqual(triggers.success, 2) + XCTAssertEqual(triggers.warning, 1) + XCTAssertEqual(triggers.error, 1) + } + + func testDisabledClientIsInert() { + HapticFeedbackClient.disabled.play(.operationFailed) + } + + func testRefreshOutcomeFeedbackOnlyReportsTerminalResults() { + XCTAssertEqual(RefreshOutcome.succeeded.appHapticFeedback, .operationSucceeded) + XCTAssertEqual(RefreshOutcome.unavailable.appHapticFeedback, .operationNeedsAttention) + XCTAssertEqual(RefreshOutcome.failed.appHapticFeedback, .operationFailed) + XCTAssertNil(RefreshOutcome.cancelled.appHapticFeedback) + } + + func testSettingsSaveFeedbackReportsEditsAndFailures() { + let error = TransmissionErrorPresentation(title: "Error", message: "Failed") + XCTAssertNil(SessionSettingsSaveState.idle.appHapticFeedback) + XCTAssertEqual(SessionSettingsSaveState.pending.appHapticFeedback, .selectionChanged) + XCTAssertNil(SessionSettingsSaveState.saving.appHapticFeedback) + XCTAssertEqual(SessionSettingsSaveState.failed(error).appHapticFeedback, .operationFailed) + } + + func testFreeSpaceFeedbackReportsTerminalOutcome() { + let summary = SessionFreeSpaceSummary(freeSpace: "10 GB", totalSpace: "20 GB", percentUsed: "50%") + let error = TransmissionErrorPresentation(title: "Error", message: "Failed") + XCTAssertNil(SessionFreeSpaceState.idle.appHapticFeedback) + XCTAssertNil(SessionFreeSpaceState.checking(previous: nil).appHapticFeedback) + XCTAssertEqual(SessionFreeSpaceState.result(summary).appHapticFeedback, .operationSucceeded) + XCTAssertEqual(SessionFreeSpaceState.failed(error).appHapticFeedback, .operationFailed) + } + + func testPortFeedbackDistinguishesSuccessWarningAndFailure() { + let error = TransmissionErrorPresentation(title: "Error", message: "Failed") + XCTAssertNil(SessionPortTestState.idle.appHapticFeedback) + XCTAssertNil(SessionPortTestState.testing.appHapticFeedback) + XCTAssertEqual(SessionPortTestState.result(.open(protocolName: "IPv4")).appHapticFeedback, .operationSucceeded) + XCTAssertEqual(SessionPortTestState.result(.closed(protocolName: "IPv4")).appHapticFeedback, .operationNeedsAttention) + XCTAssertEqual(SessionPortTestState.result(.checkerUnavailable).appHapticFeedback, .operationNeedsAttention) + XCTAssertEqual(SessionPortTestState.failed(error).appHapticFeedback, .operationFailed) + } + + func testBlocklistFeedbackReportsTerminalOutcome() { + let error = TransmissionErrorPresentation(title: "Error", message: "Failed") + XCTAssertNil(SessionBlocklistUpdateState.idle.appHapticFeedback) + XCTAssertNil(SessionBlocklistUpdateState.updating.appHapticFeedback) + XCTAssertEqual(SessionBlocklistUpdateState.success(ruleCount: 42).appHapticFeedback, .operationSucceeded) + XCTAssertEqual(SessionBlocklistUpdateState.failed(error).appHapticFeedback, .operationFailed) + } +} diff --git a/BitDreamTests/Views/PreviewFixturesTests.swift b/BitDreamTests/Views/PreviewFixturesTests.swift index bb207f6..68c4801 100644 --- a/BitDreamTests/Views/PreviewFixturesTests.swift +++ b/BitDreamTests/Views/PreviewFixturesTests.swift @@ -108,12 +108,50 @@ final class PreviewFixturesTests: XCTestCase { func testInertAppIconManagerUpdatesOnlyItsLocalState() async { let manager = AppIconManager.inert() - manager.selectIcon(name: "Blue") - await Task.yield() + let outcome = await withCheckedContinuation { continuation in + manager.selectIcon(name: "Blue") { outcome in + continuation.resume(returning: outcome) + } + } + + XCTAssertEqual(outcome, .changed) + XCTAssertEqual(manager.currentIconName, "Blue") + XCTAssertNil(manager.lastError) + } + + func testInertAppIconManagerReportsUnchangedSelection() async { + let manager = AppIconManager.inert(currentIconName: "Blue") + + let outcome = await withCheckedContinuation { continuation in + manager.selectIcon(name: "Blue") { outcome in + continuation.resume(returning: outcome) + } + } + XCTAssertEqual(outcome, .unchanged) XCTAssertEqual(manager.currentIconName, "Blue") XCTAssertNil(manager.lastError) } + + func testResetAllSettingsRestoresHapticFeedbackDefault() throws { + let suiteName = "BitDreamTests.haptics.reset.\(UUID().uuidString)" + let userDefaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + let store = PreviewFixtures.makeStore(userDefaults: userDefaults) + let themeManager = ThemeManager(userDefaults: userDefaults) + userDefaults.set(false, forKey: UserDefaultsKeys.hapticFeedbackEnabled) + + SettingsView.resetAllSettings( + store: store, + themeManager: themeManager, + userDefaults: userDefaults + ) + + XCTAssertEqual( + userDefaults.bool(forKey: UserDefaultsKeys.hapticFeedbackEnabled), + AppDefaults.hapticFeedbackEnabled + ) + } #endif } #endif diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift index a9149d4..ede1f0d 100644 --- a/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift +++ b/BitDreamTests/Views/TorrentDetailSupplementalStoreTests.swift @@ -103,14 +103,22 @@ final class TorrentDetailSupplementalStoreTests: XCTestCase { XCTAssertTrue(didConnect) let identity = makeIdentity(torrentID: 42, store: transmissionStore) - await supplementalStore.loadIfIdle(for: identity, using: transmissionStore) { errors.append($0) } + let failedOutcome = await supplementalStore.loadIfIdle( + for: identity, + using: transmissionStore + ) { errors.append($0) } XCTAssertEqual(supplementalStore.status, .failed) - await supplementalStore.load(for: identity, using: transmissionStore) { errors.append($0) } + let recoveredOutcome = await supplementalStore.load( + for: identity, + using: transmissionStore + ) { errors.append($0) } let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 3) XCTAssertEqual(errors, ["detail load failed"]) + XCTAssertEqual(failedOutcome, .failed) + XCTAssertEqual(recoveredOutcome, .succeeded) XCTAssertEqual(supplementalStore.status, .loaded) XCTAssertTrue(supplementalStore.shouldDisplayPayload(for: identity)) XCTAssertEqual(supplementalStore.payload(for: identity).files.map(\.name), ["Ubuntu.iso"]) @@ -304,11 +312,15 @@ final class TorrentDetailIdentityStoreTests: XCTestCase { XCTAssertTrue(didConnect) let staleIdentity = makeTestTorrentDetailIdentity(42, connectionGeneration: UUID()) - await supplementalStore.load(for: staleIdentity, using: transmissionStore) { errors.append($0) } + let outcome = await supplementalStore.load( + for: staleIdentity, + using: transmissionStore + ) { errors.append($0) } let methods = try await sender.capturedRequests().map { try requestMethod(from: $0.asURLRequest()) } XCTAssertEqual(methods.filter { $0 == "torrent-get" }.count, 1) XCTAssertEqual(errors, []) + XCTAssertEqual(outcome, .cancelled) XCTAssertEqual(supplementalStore.status, .idle) XCTAssertEqual(supplementalStore.payload(for: staleIdentity), .empty) }