From 34a12eae758f63081d62317d599988b39b69c283 Mon Sep 17 00:00:00 2001 From: Marcin Czachursk Date: Fri, 6 Jan 2023 13:05:21 +0100 Subject: [PATCH] Haptic feedback --- Vernissage.xcodeproj/project.pbxproj | 32 ++++++--- Vernissage/Formatters/HTMLFotmattedText.swift | 6 +- Vernissage/Haptics/HapticService.swift | 70 +++++++++++++++++++ Vernissage/Models/ApplicationState.swift | 3 +- Vernissage/Services/CacheAvatarService.swift | 17 ++++- Vernissage/Services/UserFeedbackService.swift | 18 ----- Vernissage/VernissageApp.swift | 6 ++ Vernissage/Views/FollowersView.swift | 60 ++++++++-------- Vernissage/Views/FollowingView.swift | 62 ++++++++-------- Vernissage/Views/HomeFeedView.swift | 8 +-- Vernissage/Views/MainView.swift | 10 +-- .../{DetailsView.swift => StatusView.swift} | 14 ++-- Vernissage/Views/UserProfileView.swift | 58 +++++++-------- Vernissage/Widgets/CommentsSection.swift | 19 +++-- Vernissage/Widgets/InteractionRow.swift | 19 ++--- Vernissage/Widgets/LoadingIndicator.swift | 22 ++++++ Vernissage/Widgets/UserAvatar.swift | 47 +++++++++++++ Vernissage/Widgets/UsernameRow.swift | 33 +++------ 18 files changed, 325 insertions(+), 179 deletions(-) create mode 100644 Vernissage/Haptics/HapticService.swift delete mode 100644 Vernissage/Services/UserFeedbackService.swift rename Vernissage/Views/{DetailsView.swift => StatusView.swift} (93%) create mode 100644 Vernissage/Widgets/LoadingIndicator.swift create mode 100644 Vernissage/Widgets/UserAvatar.swift diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 7028453..efe85b4 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE42966E160001D9973 /* Color+SystemColors.swift */; }; F8210DE72966E1D1001D9973 /* Color+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE62966E1D1001D9973 /* Color+Assets.swift */; }; F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */; }; - F8210DEC2966F30C001D9973 /* UserFeedbackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DEB2966F30C001D9973 /* UserFeedbackService.swift */; }; F8341F90295C636C009C8EE6 /* UIImage+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F8F295C636C009C8EE6 /* UIImage+Exif.swift */; }; F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8341F91295C63BB009C8EE6 /* ImageStatus.swift */; }; F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83901A5295D8EC000456AE2 /* LabelIcon.swift */; }; @@ -57,7 +56,7 @@ F88C2473295C37BB0006098B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F88C2472295C37BB0006098B /* Preview Assets.xcassets */; }; F88C2475295C37BB0006098B /* CoreDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2474295C37BB0006098B /* CoreDataHandler.swift */; }; F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F88C2476295C37BB0006098B /* Vernissage.xcdatamodeld */; }; - F88C2482295C3A4F0006098B /* DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2481295C3A4F0006098B /* DetailsView.swift */; }; + F88C2482295C3A4F0006098B /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2481295C3A4F0006098B /* StatusView.swift */; }; F88C2486295C48030006098B /* HTMLFotmattedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88C2485295C48030006098B /* HTMLFotmattedText.swift */; }; F88FAD21295F3944009B20C9 /* HomeFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD20295F3944009B20C9 /* HomeFeedView.swift */; }; F88FAD23295F3FC4009B20C9 /* LocalFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD22295F3FC4009B20C9 /* LocalFeedView.swift */; }; @@ -67,6 +66,9 @@ F88FAD2B295F43B8009B20C9 /* AccountData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD29295F43B8009B20C9 /* AccountData+CoreDataProperties.swift */; }; F88FAD2D295F4AD7009B20C9 /* ApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD2C295F4AD7009B20C9 /* ApplicationState.swift */; }; F88FAD32295F5029009B20C9 /* RemoteFileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88FAD31295F5029009B20C9 /* RemoteFileService.swift */; }; + F897978829681B9C00B22335 /* UserAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978729681B9C00B22335 /* UserAvatar.swift */; }; + F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89797892968314A00B22335 /* LoadingIndicator.swift */; }; + F897978D2968369600B22335 /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978C2968369600B22335 /* HapticService.swift */; }; F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; }; F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; }; F8A93D822965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */; }; @@ -87,7 +89,6 @@ F8210DE42966E160001D9973 /* Color+SystemColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+SystemColors.swift"; sourceTree = ""; }; F8210DE62966E1D1001D9973 /* Color+Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Assets.swift"; sourceTree = ""; }; F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatePlaceholderModifier.swift; sourceTree = ""; }; - F8210DEB2966F30C001D9973 /* UserFeedbackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFeedbackService.swift; sourceTree = ""; }; F8341F8F295C636C009C8EE6 /* UIImage+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Exif.swift"; sourceTree = ""; }; F8341F91295C63BB009C8EE6 /* ImageStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStatus.swift; sourceTree = ""; }; F83901A5295D8EC000456AE2 /* LabelIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelIcon.swift; sourceTree = ""; }; @@ -121,7 +122,7 @@ F88C2472295C37BB0006098B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; F88C2474295C37BB0006098B /* CoreDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHandler.swift; sourceTree = ""; }; F88C2477295C37BB0006098B /* Vernissage.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Vernissage.xcdatamodel; sourceTree = ""; }; - F88C2481295C3A4F0006098B /* DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsView.swift; sourceTree = ""; }; + F88C2481295C3A4F0006098B /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; F88C2485295C48030006098B /* HTMLFotmattedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFotmattedText.swift; sourceTree = ""; }; F88FAD20295F3944009B20C9 /* HomeFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedView.swift; sourceTree = ""; }; F88FAD22295F3FC4009B20C9 /* LocalFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedView.swift; sourceTree = ""; }; @@ -131,6 +132,9 @@ F88FAD29295F43B8009B20C9 /* AccountData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountData+CoreDataProperties.swift"; sourceTree = ""; }; F88FAD2C295F4AD7009B20C9 /* ApplicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationState.swift; sourceTree = ""; }; F88FAD31295F5029009B20C9 /* RemoteFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFileService.swift; sourceTree = ""; }; + F897978729681B9C00B22335 /* UserAvatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAvatar.swift; sourceTree = ""; }; + F89797892968314A00B22335 /* LoadingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingIndicator.swift; sourceTree = ""; }; + F897978C2968369600B22335 /* HapticService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticService.swift; sourceTree = ""; }; F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = ""; }; F8A93D812965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonClientAuthenticated+Account.swift"; sourceTree = ""; }; @@ -162,7 +166,7 @@ F8341F93295C63E2009C8EE6 /* Views */ = { isa = PBXGroup; children = ( - F88C2481295C3A4F0006098B /* DetailsView.swift */, + F88C2481295C3A4F0006098B /* StatusView.swift */, F88C246D295C37B80006098B /* MainView.swift */, F88FAD20295F3944009B20C9 /* HomeFeedView.swift */, F88FAD22295F3FC4009B20C9 /* LocalFeedView.swift */, @@ -243,6 +247,8 @@ F85D497A29640C8200751DF7 /* UsernameRow.swift */, F85D497C29640D5900751DF7 /* InteractionRow.swift */, F85D497E296416C800751DF7 /* CommentsSection.swift */, + F897978729681B9C00B22335 /* UserAvatar.swift */, + F89797892968314A00B22335 /* LoadingIndicator.swift */, ); path = Widgets; sourceTree = ""; @@ -267,6 +273,7 @@ isa = PBXGroup; children = ( F866F6A829604FFF002E8F88 /* Info.plist */, + F897978B2968367E00B22335 /* Haptics */, F8210DE82966E4D8001D9973 /* Modifiers */, F88FAD30295F5010009B20C9 /* Services */, F83901A2295D863B00456AE2 /* Widgets */, @@ -300,12 +307,19 @@ F85D4974296407F100751DF7 /* TimelineService.swift */, F8A93D7F2965FED4001D8331 /* AccountService.swift */, F8210DE02966D0C4001D9973 /* StatusService.swift */, - F8210DEB2966F30C001D9973 /* UserFeedbackService.swift */, F85DBF92296760790069BF89 /* CacheAvatarService.swift */, ); path = Services; sourceTree = ""; }; + F897978B2968367E00B22335 /* Haptics */ = { + isa = PBXGroup; + children = ( + F897978C2968369600B22335 /* HapticService.swift */, + ); + path = Haptics; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -391,6 +405,7 @@ F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */, F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */, F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */, + F897978A2968314A00B22335 /* LoadingIndicator.swift in Sources */, F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */, F85DBF93296760790069BF89 /* CacheAvatarService.swift in Sources */, F88FAD23295F3FC4009B20C9 /* LocalFeedView.swift in Sources */, @@ -405,6 +420,7 @@ F8A93D822965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift in Sources */, F85D49872964334100751DF7 /* String+Date.swift in Sources */, F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */, + F897978829681B9C00B22335 /* UserAvatar.swift in Sources */, F8210DDD2966CF17001D9973 /* StatusData+Status.swift in Sources */, F8210DCF2966B600001D9973 /* ImageRowAsync.swift in Sources */, F85D498329642FAC00751DF7 /* AttachmentData+Comperable.swift in Sources */, @@ -416,12 +432,13 @@ F85DBF912967385F0069BF89 /* FollowingView.swift in Sources */, F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */, F80048032961850500E6868A /* AttachmentData+CoreDataClass.swift in Sources */, + F897978D2968369600B22335 /* HapticService.swift in Sources */, F8341F90295C636C009C8EE6 /* UIImage+Exif.swift in Sources */, F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */, F85D4981296417F700751DF7 /* MastodonClientAuthenticated+Context.swift in Sources */, F88C246E295C37B80006098B /* MainView.swift in Sources */, F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */, - F88C2482295C3A4F0006098B /* DetailsView.swift in Sources */, + F88C2482295C3A4F0006098B /* StatusView.swift in Sources */, F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */, F85D497F296416C800751DF7 /* CommentsSection.swift in Sources */, F88C2486295C48030006098B /* HTMLFotmattedText.swift in Sources */, @@ -442,7 +459,6 @@ F8A93D802965FED4001D8331 /* AccountService.swift in Sources */, F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */, F85D4973296406E700751DF7 /* BottomRight.swift in Sources */, - F8210DEC2966F30C001D9973 /* UserFeedbackService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Vernissage/Formatters/HTMLFotmattedText.swift b/Vernissage/Formatters/HTMLFotmattedText.swift index 481fb60..4bae636 100644 --- a/Vernissage/Formatters/HTMLFotmattedText.swift +++ b/Vernissage/Formatters/HTMLFotmattedText.swift @@ -26,7 +26,7 @@ struct HTMLFormattedText: UIViewRepresentable { textView.isUserInteractionEnabled = false textView.translatesAutoresizingMaskIntoConstraints = false textView.isScrollEnabled = false - textView.backgroundColor = UIColor(Color.clear) + textView.backgroundColor = UIColor(.clear) return textView } @@ -48,12 +48,12 @@ struct HTMLFormattedText: UIViewRepresentable { let largeAttributes = [ NSAttributedString.Key.font: UIFont.systemFont(ofSize: CGFloat(self.fontSize)), - NSAttributedString.Key.foregroundColor: UIColor(Color.mainTextColor) + NSAttributedString.Key.foregroundColor: UIColor(.mainTextColor) ] let linkAttributes = [ NSAttributedString.Key.font: UIFont.systemFont(ofSize: CGFloat(self.fontSize)), - NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor) + NSAttributedString.Key.foregroundColor: UIColor(.accentColor) ] if let attributedString = try? NSMutableAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) { diff --git a/Vernissage/Haptics/HapticService.swift b/Vernissage/Haptics/HapticService.swift new file mode 100644 index 0000000..48ee58e --- /dev/null +++ b/Vernissage/Haptics/HapticService.swift @@ -0,0 +1,70 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import CoreHaptics + +public final class HapticService: ObservableObject { + public static let shared = HapticService() + + private let hapticEngine: CHHapticEngine? + private var needsToRestart = false + + /// Fires a transient haptic event with the given intensity and sharpness (0-1). + public func touch(intensity: Float = 0.75, sharpness: Float = 0.5) { + do { + let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), + CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness) + ], + relativeTime: 0) + + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try hapticEngine?.makePlayer(with: pattern) + + if needsToRestart { + try? start() + } + + try player?.start(atTime: CHHapticTimeImmediate) + } catch { + print("Error \(error.localizedDescription)") + } + } + + private init() { + hapticEngine = try? CHHapticEngine() + hapticEngine?.resetHandler = resetHandler + hapticEngine?.stoppedHandler = restartHandler + hapticEngine?.playsHapticsOnly = true + try? start() + } + + /// Stops the internal CHHapticEngine. Should be called when your app enters the background. + public func stop(completionHandler: CHHapticEngine.CompletionHandler? = nil) { + hapticEngine?.stop(completionHandler: completionHandler) + } + + /// Starts the internal CHHapticEngine. Should be called when your app enters the foreground. + public func start() throws { + try hapticEngine?.start() + needsToRestart = false + } + + private func resetHandler() { + do { + try start() + } catch { + needsToRestart = true + } + } + + private func restartHandler(_ reasonForStopping: CHHapticEngine.StoppedReason? = nil) { + resetHandler() + } + +} diff --git a/Vernissage/Models/ApplicationState.swift b/Vernissage/Models/ApplicationState.swift index 462541f..9896adf 100644 --- a/Vernissage/Models/ApplicationState.swift +++ b/Vernissage/Models/ApplicationState.swift @@ -9,7 +9,8 @@ import Foundation public class ApplicationState: ObservableObject { public static let shared = ApplicationState() - + private init() { } + @Published var accountData: AccountData? } diff --git a/Vernissage/Services/CacheAvatarService.swift b/Vernissage/Services/CacheAvatarService.swift index 7ac5588..8faad89 100644 --- a/Vernissage/Services/CacheAvatarService.swift +++ b/Vernissage/Services/CacheAvatarService.swift @@ -13,13 +13,28 @@ public class CacheAvatarService { private init() { } private var cache: Dictionary = [:] - + func addImage(for id: String, data: Data) { if let uiImage = UIImage(data: data) { self.cache[id] = uiImage } } + func downloadImage(for accountId: String?, avatarUrl: URL?) async { + guard let accountId, let avatarUrl else { + return + } + + do { + let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) + if let avatarData { + CacheAvatarService.shared.addImage(for: accountId, data: avatarData) + } + } catch { + print("Error \(error.localizedDescription)") + } + } + func getImage(for id: String) -> UIImage? { return self.cache[id] } diff --git a/Vernissage/Services/UserFeedbackService.swift b/Vernissage/Services/UserFeedbackService.swift deleted file mode 100644 index f0bf352..0000000 --- a/Vernissage/Services/UserFeedbackService.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// https://mczachurski.dev -// Copyright © 2023 Marcin Czachurski and the repository contributors. -// Licensed under the MIT License. -// - -import SwiftUI -import AVFoundation - -public class UserFeedbackService { - public static let shared = UserFeedbackService() - private init() { } - - func send() { - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - // AudioServicesPlaySystemSound(1016) - } -} diff --git a/Vernissage/VernissageApp.swift b/Vernissage/VernissageApp.swift index bc5ef54..f444eac 100644 --- a/Vernissage/VernissageApp.swift +++ b/Vernissage/VernissageApp.swift @@ -49,6 +49,12 @@ struct VernissageApp: App { URLCache.shared.diskCapacity = 1_000_000_000 // ~1GB disk cache space } .navigationViewStyle(.stack) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + try? HapticService.shared.start() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in + HapticService.shared.stop() + } } } } diff --git a/Vernissage/Views/FollowersView.swift b/Vernissage/Views/FollowersView.swift index 6a2aa5f..85e2602 100644 --- a/Vernissage/Views/FollowersView.swift +++ b/Vernissage/Views/FollowersView.swift @@ -14,25 +14,27 @@ struct FollowersView: View { @State private var accounts: [Account] = [] @State private var page = 1 @State private var allItemsLoaded = false + @State private var firstLoadFinished = false var body: some View { - List(accounts, id: \.id) { account in - NavigationLink(destination: UserProfileView( - accountId: account.id, - accountDisplayName: account.displayName, - accountUserName: account.acct) - .environmentObject(applicationState)) { - UsernameRow(accountAvatar: account.avatar, - accountDisplayName: account.displayName, - accountUsername: account.acct, - cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) - } - - if allItemsLoaded == false && accounts.last?.id == account.id { + List { + ForEach(accounts, id: \.id) { account in + NavigationLink(destination: UserProfileView( + accountId: account.id, + accountDisplayName: account.displayName, + accountUserName: account.acct) + .environmentObject(applicationState)) { + UsernameRow(accountAvatar: account.avatar, + accountDisplayName: account.displayName, + accountUsername: account.acct, + cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) + } + } + + if allItemsLoaded == false && firstLoadFinished { HStack(alignment: .center) { Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + LoadingIndicator() .onAppear { Task { self.page = self.page + 1 @@ -42,6 +44,10 @@ struct FollowersView: View { Spacer() } } + }.overlay { + if firstLoadFinished == false { + LoadingIndicator() + } } .navigationBarTitle("Followers") .listStyle(PlainListStyle()) @@ -51,6 +57,7 @@ struct FollowersView: View { } await self.loadAccounts(page: self.page) + self.firstLoadFinished = true } } @@ -66,25 +73,20 @@ struct FollowersView: View { return } - for account in accountsFromApi { - guard let avatarUrl = account.avatar else { - continue - } - - do { - if let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) { - CacheAvatarService.shared.addImage(for: account.id, data: avatarData) - } - } catch { - print("Error \(error.localizedDescription)") - } - } - + await self.downloadAvatars(accounts: accountsFromApi) self.accounts.append(contentsOf: accountsFromApi) } catch { print("Error \(error.localizedDescription)") } } + + func downloadAvatars(accounts: [Account]) async { + await withTaskGroup(of: Void.self) { group in + for account in accounts { + group.addTask { await CacheAvatarService.shared.downloadImage(for: account.id, avatarUrl: account.avatar) } + } + } + } } struct FollowersView_Previews: PreviewProvider { diff --git a/Vernissage/Views/FollowingView.swift b/Vernissage/Views/FollowingView.swift index e0aa06d..dd173a8 100644 --- a/Vernissage/Views/FollowingView.swift +++ b/Vernissage/Views/FollowingView.swift @@ -14,25 +14,27 @@ struct FollowingView: View { @State private var accounts: [Account] = [] @State private var page = 1 @State private var allItemsLoaded = false + @State private var firstLoadFinished = false var body: some View { - List(accounts, id: \.id) { account in - NavigationLink(destination: UserProfileView( - accountId: account.id, - accountDisplayName: account.displayName, - accountUserName: account.acct) - .environmentObject(applicationState)) { - UsernameRow(accountAvatar: account.avatar, - accountDisplayName: account.displayName, - accountUsername: account.acct, - cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) - } - - if allItemsLoaded == false && accounts.last?.id == account.id { + List { + ForEach(accounts, id: \.id) { account in + NavigationLink(destination: UserProfileView( + accountId: account.id, + accountDisplayName: account.displayName, + accountUserName: account.acct) + .environmentObject(applicationState)) { + UsernameRow(accountAvatar: account.avatar, + accountDisplayName: account.displayName, + accountUsername: account.acct, + cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) + } + } + + if allItemsLoaded == false && firstLoadFinished { HStack(alignment: .center) { Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + LoadingIndicator() .onAppear { Task { self.page = self.page + 1 @@ -42,6 +44,10 @@ struct FollowingView: View { Spacer() } } + }.overlay { + if firstLoadFinished == false { + LoadingIndicator() + } } .navigationBarTitle("Following") .listStyle(PlainListStyle()) @@ -51,6 +57,7 @@ struct FollowingView: View { } await self.loadAccounts(page: self.page) + self.firstLoadFinished = true } } @@ -66,29 +73,24 @@ struct FollowingView: View { return } - for account in accountsFromApi { - guard let avatarUrl = account.avatar else { - continue - } - - do { - if let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) { - CacheAvatarService.shared.addImage(for: account.id, data: avatarData) - } - } catch { - print("Error \(error.localizedDescription)") - } - } - + await self.downloadAvatars(accounts: accountsFromApi) self.accounts.append(contentsOf: accountsFromApi) } catch { print("Error \(error.localizedDescription)") } } + + func downloadAvatars(accounts: [Account]) async { + await withTaskGroup(of: Void.self) { group in + for account in accounts { + group.addTask { await CacheAvatarService.shared.downloadImage(for: account.id, avatarUrl: account.avatar) } + } + } + } } struct FollowingView_Previews: PreviewProvider { static var previews: some View { - FollowingView(accountId: "") + FollowersView(accountId: "") } } diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index d374244..3fea099 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -22,14 +22,13 @@ struct HomeFeedView: View { ScrollView { LazyVGrid(columns: gridColumns) { ForEach(dbStatuses, id: \.self) { item in - NavigationLink(destination: DetailsView(statusId: item.id) + NavigationLink(destination: StatusView(statusId: item.id) .environmentObject(applicationState)) { ImageRow(attachments: item.attachments()) } } - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + LoadingIndicator() .onAppear { Task { do { @@ -45,8 +44,7 @@ struct HomeFeedView: View { } if showLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + LoadingIndicator() } } .refreshable { diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index ebe150e..0e49a48 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -47,7 +47,7 @@ struct MainView: View { if let accountData = self.applicationState.accountData { UserProfileView(accountId: accountData.id, accountDisplayName: accountData.displayName, - accountUserName: accountData.username) + accountUserName: accountData.acct) } case .notifications: NotificationsView() @@ -112,7 +112,7 @@ struct MainView: View { .font(.subheadline) } .frame(width: 150) - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) } } } @@ -126,10 +126,10 @@ struct MainView: View { // TODO: Switch accounts. } label: { HStack { - Text(self.applicationState.accountData?.displayName ?? self.applicationState.accountData?.username ?? "") + Text(self.applicationState.accountData?.displayName ?? self.applicationState.accountData?.acct ?? "") Image(systemName: "person.circle.fill") .resizable() - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) } } @@ -153,7 +153,7 @@ struct MainView: View { Image(systemName: "person.circle") .resizable() .frame(width: 32.0, height: 32.0) - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) } } } diff --git a/Vernissage/Views/DetailsView.swift b/Vernissage/Views/StatusView.swift similarity index 93% rename from Vernissage/Views/DetailsView.swift rename to Vernissage/Views/StatusView.swift index cc4331a..2f9e7c4 100644 --- a/Vernissage/Views/DetailsView.swift +++ b/Vernissage/Views/StatusView.swift @@ -8,7 +8,7 @@ import SwiftUI import MastodonSwift import AVFoundation -struct DetailsView: View { +struct StatusView: View { @EnvironmentObject var applicationState: ApplicationState @State var statusId: String @@ -41,7 +41,7 @@ struct DetailsView: View { LabelIcon(iconName: "calendar", value: "2 Oct 2022") } .padding(.bottom, 2) - .foregroundColor(Color.lightGrayColor) + .foregroundColor(.lightGrayColor) HStack { Text("Uploaded") @@ -51,7 +51,7 @@ struct DetailsView: View { Text("via \(applicationName)") } } - .foregroundColor(Color.lightGrayColor) + .foregroundColor(.lightGrayColor) .font(.footnote) InteractionRow(statusData: statusData) @@ -72,10 +72,10 @@ struct DetailsView: View { accountUsername: "@username") Text("Lorem ispum text something") - .foregroundColor(Color.lightGrayColor) + .foregroundColor(.lightGrayColor) .font(.footnote) Text("Lorem ispum text something sdf sdfsdf sdfdsfsdfsdf") - .foregroundColor(Color.lightGrayColor) + .foregroundColor(.lightGrayColor) .font(.footnote) LabelIcon(iconName: "camera", value: "SONY ILCE-7M3") @@ -114,8 +114,8 @@ struct DetailsView: View { } } -struct DetailsView_Previews: PreviewProvider { +struct StatusView_Previews: PreviewProvider { static var previews: some View { - DetailsView(statusId: "123") + StatusView(statusId: "123") } } diff --git a/Vernissage/Views/UserProfileView.swift b/Vernissage/Views/UserProfileView.swift index 27e5b2a..404ac26 100644 --- a/Vernissage/Views/UserProfileView.swift +++ b/Vernissage/Views/UserProfileView.swift @@ -8,13 +8,15 @@ import SwiftUI import MastodonSwift struct UserProfileView: View { - @EnvironmentObject var applicationState: ApplicationState + @EnvironmentObject private var applicationState: ApplicationState + @State public var accountId: String @State public var accountDisplayName: String? @State public var accountUserName: String @State private var account: Account? = nil @State private var relationship: Relationship? = nil @State private var statuses: [Status] = [] + @State private var isDuringRelationshipAction = false private static let initialColumns = 1 @State private var gridColumns = Array(repeating: GridItem(.flexible()), count: initialColumns) @@ -24,17 +26,7 @@ struct UserProfileView: View { if let account = self.account { VStack(alignment: .leading) { HStack(alignment: .center) { - AsyncImage(url: account.avatar) { image in - image - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fit) - } placeholder: { - Image(systemName: "person.circle") - .resizable() - .foregroundColor(Color.mainTextColor) - } - .frame(width: 96.0, height: 96.0) + UserAvatar(accountAvatar: account.avatar, width: 96, height: 96) Spacer() @@ -58,7 +50,7 @@ struct UserProfileView: View { .font(.subheadline) .opacity(0.6) } - }.foregroundColor(Color.mainTextColor) + }.foregroundColor(.mainTextColor) Spacer() @@ -72,17 +64,17 @@ struct UserProfileView: View { .font(.subheadline) .opacity(0.6) } - }.foregroundColor(Color.mainTextColor) + }.foregroundColor(.mainTextColor) } HStack (alignment: .center) { VStack(alignment: .leading) { - Text(account.displayName ?? account.username) - .foregroundColor(Color.mainTextColor) + Text(account.displayName ?? account.acct) + .foregroundColor(.mainTextColor) .font(.footnote) .fontWeight(.bold) - Text("@\(account.username)") - .foregroundColor(Color.lightGrayColor) + Text("@\(account.acct)") + .foregroundColor(.lightGrayColor) .font(.footnote) } @@ -91,12 +83,17 @@ struct UserProfileView: View { if self.applicationState.accountData?.id != self.accountId { Button { Task { + Task { @MainActor in + self.isDuringRelationshipAction = false + } + + HapticService.shared.touch() + self.isDuringRelationshipAction = true do { if let relationship = try await AccountService.shared.follow( forAccountId: self.accountId, andContext: self.applicationState.accountData ) { - UserFeedbackService.shared.send() self.relationship = relationship } } catch { @@ -104,13 +101,18 @@ struct UserProfileView: View { } } } label: { - HStack { - Image(systemName: relationship?.following == true ? "person.badge.minus" : "person.badge.plus") - Text(relationship?.following == true ? "Unfollow" : (relationship?.followedBy == true ? "Follow back" : "Follow")) + if isDuringRelationshipAction { + LoadingIndicator() + } else { + HStack { + Image(systemName: relationship?.following == true ? "person.badge.minus" : "person.badge.plus") + Text(relationship?.following == true ? "Unfollow" : (relationship?.followedBy == true ? "Follow back" : "Follow")) + } } } + .disabled(isDuringRelationshipAction) .buttonStyle(.borderedProminent) - .tint(relationship?.following == true ? Color.dangerColor : .accentColor) + .tint(relationship?.following == true ? .dangerColor : .accentColor) } } @@ -121,7 +123,7 @@ struct UserProfileView: View { } Text("Joined \(account.createdAt.toRelative(.isoDateTimeMilliSec))") - .foregroundColor(Color.lightGrayColor.opacity(0.5)) + .foregroundColor(.lightGrayColor.opacity(0.5)) .font(.footnote) } @@ -129,7 +131,7 @@ struct UserProfileView: View { LazyVGrid(columns: gridColumns) { ForEach(self.statuses, id: \.id) { item in - NavigationLink(destination: DetailsView(statusId: item.id) + NavigationLink(destination: StatusView(statusId: item.id) .environmentObject(applicationState)) { ImageRowAsync(attachments: item.mediaAttachments) } @@ -137,8 +139,7 @@ struct UserProfileView: View { } } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + LoadingIndicator() } } .navigationBarTitle(self.accountDisplayName ?? self.accountUserName) @@ -147,7 +148,8 @@ struct UserProfileView: View { do { async let relationshipTask = AccountService.shared.getRelationship(withId: self.accountId, forUser: self.applicationState.accountData) async let accountTask = AccountService.shared.getAccount(withId: self.accountId, and: self.applicationState.accountData) - + + // Wait for download account and relationships. (self.relationship, self.account) = try await (relationshipTask, accountTask) self.statuses = try await AccountService.shared.getStatuses(forAccountId: self.accountId, andContext: self.applicationState.accountData) diff --git a/Vernissage/Widgets/CommentsSection.swift b/Vernissage/Widgets/CommentsSection.swift index 97345c8..8f006b3 100644 --- a/Vernissage/Widgets/CommentsSection.swift +++ b/Vernissage/Widgets/CommentsSection.swift @@ -34,7 +34,7 @@ struct CommentsSection: View { NavigationLink(destination: UserProfileView( accountId: account.id, accountDisplayName: account.displayName, - accountUserName: account.username) + accountUserName: account.acct) .environmentObject(applicationState)) { AsyncImage(url: account.avatar) { image in image @@ -44,7 +44,7 @@ struct CommentsSection: View { } placeholder: { Image(systemName: "person.circle") .resizable() - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) } .frame(width: 32.0, height: 32.0) } @@ -52,23 +52,20 @@ struct CommentsSection: View { VStack (alignment: .leading) { HStack (alignment: .top) { - Text(status.account?.displayName ?? status.account?.username ?? "") - .foregroundColor(Color.mainTextColor) + Text(status.account?.displayName ?? status.account?.acct ?? "") + .foregroundColor(.mainTextColor) .font(.footnote) .fontWeight(.bold) - Text("@\(status.account?.username ?? "")") - .foregroundColor(Color.lightGrayColor) - .font(.footnote) Spacer() Text(status.createdAt.toRelative(.isoDateTimeMilliSec)) - .foregroundColor(Color.lightGrayColor.opacity(0.5)) + .foregroundColor(.lightGrayColor.opacity(0.5)) .font(.footnote) } HTMLFormattedText(status.content, withFontSize: 14, andWidth: contentWidth) - .padding(.top, -10) + .padding(.top, -16) .padding(.leading, -4) if status.mediaAttachments.count > 0 { @@ -81,14 +78,14 @@ struct CommentsSection: View { .frame(minWidth: 0, maxWidth: .infinity) .frame(height: status.mediaAttachments.count == 1 ? 200 : 100) .cornerRadius(10) - .shadow(color: Color.mainTextColor.opacity(0.3), radius: 2) + .shadow(color: .mainTextColor.opacity(0.3), radius: 2) } placeholder: { Image(systemName: "photo") .resizable() .scaledToFit() .frame(minWidth: 0, maxWidth: .infinity) .frame(height: status.mediaAttachments.count == 1 ? 200 : 100) - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) .opacity(0.05) } } diff --git a/Vernissage/Widgets/InteractionRow.swift b/Vernissage/Widgets/InteractionRow.swift index 39d155c..887611a 100644 --- a/Vernissage/Widgets/InteractionRow.swift +++ b/Vernissage/Widgets/InteractionRow.swift @@ -9,12 +9,12 @@ import SwiftUI struct InteractionRow: View { @EnvironmentObject var applicationState: ApplicationState @ObservedObject public var statusData: StatusData - + var body: some View { HStack (alignment: .top) { Button { // TODO: Reply. - UserFeedbackService.shared.send() + HapticService.shared.touch() } label: { HStack(alignment: .center) { Image(systemName: "message") @@ -27,6 +27,8 @@ struct InteractionRow: View { Button { Task { + HapticService.shared.touch() + do { let status = self.statusData.reblogged ? try await StatusService.shared.unboost(statusId: self.statusData.id, accountData: self.applicationState.accountData) @@ -38,7 +40,6 @@ struct InteractionRow: View { : Int32(status.reblogsCount) self.statusData.reblogged = status.reblogged - UserFeedbackService.shared.send() } } catch { print("Error \(error.localizedDescription)") @@ -56,6 +57,8 @@ struct InteractionRow: View { Button { Task { + HapticService.shared.touch() + do { let status = self.statusData.favourited ? try await StatusService.shared.unfavourite(statusId: self.statusData.id, accountData: self.applicationState.accountData) @@ -67,7 +70,6 @@ struct InteractionRow: View { : Int32(status.favouritesCount) self.statusData.favourited = status.favourited - UserFeedbackService.shared.send() } } catch { print("Error \(error.localizedDescription)") @@ -85,13 +87,14 @@ struct InteractionRow: View { Button { Task { + HapticService.shared.touch() + do { - let status = self.statusData.bookmarked + _ = self.statusData.bookmarked ? try await StatusService.shared.unbookmark(statusId: self.statusData.id, accountData: self.applicationState.accountData) : try await StatusService.shared.bookmark(statusId: self.statusData.id, accountData: self.applicationState.accountData) self.statusData.bookmarked.toggle() - UserFeedbackService.shared.send() } catch { print("Error \(error.localizedDescription)") } @@ -104,14 +107,14 @@ struct InteractionRow: View { Button { // TODO: Share. - UserFeedbackService.shared.send() + HapticService.shared.touch() } label: { Image(systemName: "square.and.arrow.up") } } .font(.title3) .fontWeight(.semibold) - .foregroundColor(Color.accentColor) + .foregroundColor(.accentColor) } } diff --git a/Vernissage/Widgets/LoadingIndicator.swift b/Vernissage/Widgets/LoadingIndicator.swift new file mode 100644 index 0000000..0b79574 --- /dev/null +++ b/Vernissage/Widgets/LoadingIndicator.swift @@ -0,0 +1,22 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + + +import SwiftUI + +struct LoadingIndicator: View { + var body: some View { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(.mainTextColor) + } +} + +struct LoadingIndicator_Previews: PreviewProvider { + static var previews: some View { + LoadingIndicator() + } +} diff --git a/Vernissage/Widgets/UserAvatar.swift b/Vernissage/Widgets/UserAvatar.swift new file mode 100644 index 0000000..0118959 --- /dev/null +++ b/Vernissage/Widgets/UserAvatar.swift @@ -0,0 +1,47 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + + +import SwiftUI + +struct UserAvatar: View { + @State public var accountAvatar: URL? + @State public var cachedAvatar: UIImage? + @State public var width = 48.0 + @State public var height = 48.0 + + var body: some View { + if let cachedAvatar { + Image(uiImage: cachedAvatar) + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + .frame(width: self.width, height: self.height) + } + else { + AsyncImage(url: self.accountAvatar) { image in + image + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + } placeholder: { + Image(systemName: "person.circle") + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + .foregroundColor(.mainTextColor) + } + .frame(width: self.width, height: self.height) + } + } +} + +struct UserAvatar_Previews: PreviewProvider { + static var previews: some View { + UserAvatar() + .previewLayout(.fixed(width: 128, height: 128)) + } +} diff --git a/Vernissage/Widgets/UsernameRow.swift b/Vernissage/Widgets/UsernameRow.swift index 076c9b2..adaed6c 100644 --- a/Vernissage/Widgets/UsernameRow.swift +++ b/Vernissage/Widgets/UsernameRow.swift @@ -15,34 +15,16 @@ struct UsernameRow: View { var body: some View { HStack (alignment: .center) { - if let cachedAvatar { - Image(uiImage: cachedAvatar) - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fit) - .frame(width: 48.0, height: 48.0) - } - else { - AsyncImage(url: accountAvatar) { image in - image - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fit) - } placeholder: { - Image(systemName: "person.circle") - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fit) - .foregroundColor(Color.mainTextColor) - } - .frame(width: 48.0, height: 48.0) - } + UserAvatar(accountAvatar: accountAvatar, + cachedAvatar: cachedAvatar, + width: 48, + height: 48) VStack (alignment: .leading) { Text(accountDisplayName ?? accountUsername) - .foregroundColor(Color.mainTextColor) + .foregroundColor(.mainTextColor) Text("@\(accountUsername)") - .foregroundColor(Color.lightGrayColor) + .foregroundColor(.lightGrayColor) .font(.footnote) } .padding(.leading, 8) @@ -52,6 +34,7 @@ struct UsernameRow: View { struct UsernameRow_Previews: PreviewProvider { static var previews: some View { - UsernameRow(accountUsername: "") + UsernameRow(accountDisplayName: "John Doe", accountUsername: "johndoe@mastodon.xx") + .previewLayout(.fixed(width: 320, height: 64)) } }