diff --git a/CoreData/AccountDataHandler.swift b/CoreData/AccountDataHandler.swift index 155de86..1477732 100644 --- a/CoreData/AccountDataHandler.swift +++ b/CoreData/AccountDataHandler.swift @@ -63,8 +63,8 @@ class AccountDataHandler { } } - func createAccountDataEntity() -> AccountData { - let context = CoreDataHandler.shared.container.viewContext + func createAccountDataEntity(viewContext: NSManagedObjectContext? = nil) -> AccountData { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext return AccountData(context: context) } } diff --git a/CoreData/ApplicationSettingsHandler.swift b/CoreData/ApplicationSettingsHandler.swift index 2f4a544..523a0ab 100644 --- a/CoreData/ApplicationSettingsHandler.swift +++ b/CoreData/ApplicationSettingsHandler.swift @@ -5,15 +5,16 @@ // import Foundation +import CoreData class ApplicationSettingsHandler { public static let shared = ApplicationSettingsHandler() private init() { } - func get() -> ApplicationSettings { + func get(viewContext: NSManagedObjectContext? = nil) -> ApplicationSettings { var settingsList: [ApplicationSettings] = [] - let context = CoreDataHandler.shared.container.viewContext + let context = viewContext ?? CoreDataHandler.shared.container.viewContext let fetchRequest = ApplicationSettings.fetchRequest() do { settingsList = try context.fetch(fetchRequest) @@ -24,11 +25,11 @@ class ApplicationSettingsHandler { if let settings = settingsList.first { return settings } else { - let settings = self.createApplicationSettingsEntity() + let settings = self.createApplicationSettingsEntity(viewContext: context) settings.avatarShape = Int32(AvatarShape.circle.rawValue) settings.theme = Int32(Theme.system.rawValue) settings.tintColor = Int32(TintColor.accentColor2.rawValue) - CoreDataHandler.shared.save() + CoreDataHandler.shared.save(viewContext: context) return settings } @@ -106,8 +107,8 @@ class ApplicationSettingsHandler { CoreDataHandler.shared.save() } - private func createApplicationSettingsEntity() -> ApplicationSettings { - let context = CoreDataHandler.shared.container.viewContext + private func createApplicationSettingsEntity(viewContext: NSManagedObjectContext? = nil) -> ApplicationSettings { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext return ApplicationSettings(context: context) } } diff --git a/CoreData/CoreDataHandler.swift b/CoreData/CoreDataHandler.swift index 833b93b..d6d0999 100644 --- a/CoreData/CoreDataHandler.swift +++ b/CoreData/CoreDataHandler.swift @@ -72,22 +72,24 @@ public class CoreDataHandler { self.container.newBackgroundContext() } - public func save() { - let context = self.container.viewContext + public func save(viewContext: NSManagedObjectContext? = nil) { + let context = viewContext ?? CoreDataHandler.shared.container.viewContext if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. - // You should not use this function in a shipping application, although it may be useful during development. + context.performAndWait { + do { + try context.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. + // You should not use this function in a shipping application, although it may be useful during development. - #if DEBUG - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - #else - CoreDataError.shared.handle(error, message: "An error occurred while writing the data.") - #endif + #if DEBUG + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + #else + CoreDataError.shared.handle(error, message: "An error occurred while writing the data.") + #endif + } } } } diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 93374e1..7b27507 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ F8CC95CE2970761D00C9C2AC /* TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CC95CD2970761D00C9C2AC /* TintColor.swift */; }; F8CEEDF829ABADDD00DBED66 /* UIImage+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEEDF729ABADDD00DBED66 /* UIImage+Size.swift */; }; F8CEEDFA29ABAFD200DBED66 /* ImageFileTranseferable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEEDF929ABAFD200DBED66 /* ImageFileTranseferable.swift */; }; + F8D5444329D4066C002225D6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D5444229D4066C002225D6 /* AppDelegate.swift */; }; F8E6D03329CDD52500416CCA /* EditProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E6D03229CDD52500416CCA /* EditProfileView.swift */; }; F8E6D03529CE161B00416CCA /* UIImage+Jpeg.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E6D03429CE161B00416CCA /* UIImage+Jpeg.swift */; }; F8E9391F29C0BCFA002BB3B8 /* ImageContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E9391E29C0BCFA002BB3B8 /* ImageContextMenu.swift */; }; @@ -424,6 +425,7 @@ F8CC95CD2970761D00C9C2AC /* TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintColor.swift; sourceTree = ""; }; F8CEEDF729ABADDD00DBED66 /* UIImage+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Size.swift"; sourceTree = ""; }; F8CEEDF929ABAFD200DBED66 /* ImageFileTranseferable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFileTranseferable.swift; sourceTree = ""; }; + F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F8E6D03229CDD52500416CCA /* EditProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileView.swift; sourceTree = ""; }; F8E6D03429CE161B00416CCA /* UIImage+Jpeg.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Jpeg.swift"; sourceTree = ""; }; F8E9391E29C0BCFA002BB3B8 /* ImageContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContextMenu.swift; sourceTree = ""; }; @@ -808,6 +810,7 @@ F8341F94295C63FE009C8EE6 /* Extensions */, F8341F93295C63E2009C8EE6 /* Views */, F88C246B295C37B80006098B /* VernissageApp.swift */, + F8D5444229D4066C002225D6 /* AppDelegate.swift */, F88E4D55297EAD6E0057491A /* AppRouteur.swift */, F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */, F866F6A929605AFA002E8F88 /* SceneDelegate.swift */, @@ -1232,6 +1235,7 @@ F873F14C29BDB67300DE72D1 /* UIImage+Rounded.swift in Sources */, F8864CEF29ACE90B0020C534 /* UIFont+Font.swift in Sources */, F8CEEDFA29ABAFD200DBED66 /* ImageFileTranseferable.swift in Sources */, + F8D5444329D4066C002225D6 /* AppDelegate.swift in Sources */, F802884F297AEED5000BDD51 /* DatabaseError.swift in Sources */, F86A4307299AA5E900DF7645 /* ThanksView.swift in Sources */, F89B5CC229D01BF700549F2F /* InstanceView.swift in Sources */, @@ -1304,7 +1308,7 @@ CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 86; + CURRENT_PROJECT_VERSION = 87; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1332,7 +1336,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 86; + CURRENT_PROJECT_VERSION = 87; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1480,7 +1484,7 @@ CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 86; + CURRENT_PROJECT_VERSION = 87; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; @@ -1520,7 +1524,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 86; + CURRENT_PROJECT_VERSION = 87; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; diff --git a/Vernissage/AppDelegate.swift b/Vernissage/AppDelegate.swift new file mode 100644 index 0000000..85ae120 --- /dev/null +++ b/Vernissage/AppDelegate.swift @@ -0,0 +1,19 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation +import SwiftUI + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = SceneDelegate.self + return sceneConfig + } +} diff --git a/Vernissage/Services/AuthorizationService.swift b/Vernissage/Services/AuthorizationService.swift index 5011a2e..699723f 100644 --- a/Vernissage/Services/AuthorizationService.swift +++ b/Vernissage/Services/AuthorizationService.swift @@ -6,6 +6,7 @@ import Foundation import PixelfedKit +import CoreData import AuthenticationServices /// Srvice responsible for login user into the Pixelfed account. @@ -75,7 +76,8 @@ public class AuthorizationService { let account = try await authenticatedClient.verifyCredentials() // Get/create account object in database. - let accountData = self.getAccountData(account: account) + let backgroundContext = CoreDataHandler.shared.newBackgroundContext() + let accountData = self.getAccountData(account: account, backgroundContext: backgroundContext) accountData.id = account.id accountData.username = account.username @@ -113,13 +115,13 @@ public class AuthorizationService { } // Set newly created account as current (only when we create a first account). - let defaultSettings = ApplicationSettingsHandler.shared.get() + let defaultSettings = ApplicationSettingsHandler.shared.get(viewContext: backgroundContext) if defaultSettings.currentAccount == nil { defaultSettings.currentAccount = accountData.id } // Save account/settings data in database. - CoreDataHandler.shared.save() + CoreDataHandler.shared.save(viewContext: backgroundContext) // Return account data. result(accountData) @@ -204,7 +206,8 @@ public class AuthorizationService { accessToken: String, refreshToken: String? ) async { - guard let dbAccount = AccountDataHandler.shared.getAccountData(accountId: accountId) else { + let backgroundContext = CoreDataHandler.shared.newBackgroundContext() + guard let dbAccount = AccountDataHandler.shared.getAccountData(accountId: accountId, viewContext: backgroundContext) else { return } @@ -237,14 +240,14 @@ public class AuthorizationService { } // Save account data in database and in application state. - CoreDataHandler.shared.save() + CoreDataHandler.shared.save(viewContext: backgroundContext) } - private func getAccountData(account: Account) -> AccountData { - if let accountFromDb = AccountDataHandler.shared.getAccountData(accountId: account.id) { + private func getAccountData(account: Account, backgroundContext: NSManagedObjectContext) -> AccountData { + if let accountFromDb = AccountDataHandler.shared.getAccountData(accountId: account.id, viewContext: backgroundContext) { return accountFromDb } - return AccountDataHandler.shared.createAccountDataEntity() + return AccountDataHandler.shared.createAccountDataEntity(viewContext: backgroundContext) } } diff --git a/Vernissage/Services/HomeTimelineService.swift b/Vernissage/Services/HomeTimelineService.swift index 2d7cb7a..f565591 100644 --- a/Vernissage/Services/HomeTimelineService.swift +++ b/Vernissage/Services/HomeTimelineService.swift @@ -28,7 +28,7 @@ public class HomeTimelineService { let newStatuses = try await self.load(for: account, on: backgroundContext, maxId: oldestStatus.id) // Save data into database. - try backgroundContext.save() + CoreDataHandler.shared.save(viewContext: backgroundContext) // Return amount of newly downloaded statuses. return newStatuses.count @@ -43,7 +43,7 @@ public class HomeTimelineService { let lastSeenStatusId = try await self.refresh(for: account, on: backgroundContext) // Save data into database. - try backgroundContext.save() + CoreDataHandler.shared.save(viewContext: backgroundContext) // Return id of last seen status. return lastSeenStatusId @@ -59,7 +59,9 @@ public class HomeTimelineService { } accountDataFromDb.lastSeenStatusId = lastSeenStatusId - try backgroundContext.save() + + // Save data into database. + CoreDataHandler.shared.save(viewContext: backgroundContext) } public func update(status statusData: StatusData, basedOn status: Status, for account: AccountModel) async throws -> StatusData? { @@ -68,7 +70,9 @@ public class HomeTimelineService { // Update status data in database. self.copy(from: status, to: statusData, on: backgroundContext) - try backgroundContext.save() + + // Save data into database. + CoreDataHandler.shared.save(viewContext: backgroundContext) return statusData } @@ -80,6 +84,7 @@ public class HomeTimelineService { attachment.metaImageHeight = Int32(imageHeight) self.setExifProperties(in: attachment, from: imageData) + // Save data into database. CoreDataHandler.shared.save() } diff --git a/Vernissage/VernissageApp.swift b/Vernissage/VernissageApp.swift index 21217f3..4096b8c 100644 --- a/Vernissage/VernissageApp.swift +++ b/Vernissage/VernissageApp.swift @@ -54,29 +54,7 @@ struct VernissageApp: App { .tint(self.tintColor) .preferredColorScheme(self.theme) .task { - UIPageControl.appearance().currentPageIndicatorTintColor = UIColor.white.withAlphaComponent(0.7) - UIPageControl.appearance().pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.4) - - // Set custom configurations for Nuke image/data loaders. - self.setImagePipelines() - - // Load user preferences from database. - self.loadUserPreferences() - - // Refresh other access tokens. - await self.refreshAccessTokens() - - // Verify access token correctness. - let authorizationSession = AuthorizationSession() - let currentAccount = AccountDataHandler.shared.getCurrentAccountData() - await AuthorizationService.shared.verifyAccount(session: authorizationSession, currentAccount: currentAccount) { accountData in - guard let accountData = accountData else { - self.applicationViewMode = .signIn - return - } - - self.setApplicationState(accountData: accountData, checkNewPhotos: true) - } + await self.onApplicationStart() } .navigationViewStyle(.stack) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in @@ -112,6 +90,32 @@ struct VernissageApp: App { } } } + + private func onApplicationStart() async { + UIPageControl.appearance().currentPageIndicatorTintColor = UIColor.white.withAlphaComponent(0.7) + UIPageControl.appearance().pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.4) + + // Set custom configurations for Nuke image/data loaders. + self.setImagePipelines() + + // Load user preferences from database. + self.loadUserPreferences() + + // Refresh other access tokens. + await self.refreshAccessTokens() + + // Verify access token correctness. + let authorizationSession = AuthorizationSession() + let currentAccount = AccountDataHandler.shared.getCurrentAccountData() + await AuthorizationService.shared.verifyAccount(session: authorizationSession, currentAccount: currentAccount) { accountData in + guard let accountData = accountData else { + self.applicationViewMode = .signIn + return + } + + self.setApplicationState(accountData: accountData, checkNewPhotos: true) + } + } private func setApplicationState(accountData: AccountData, checkNewPhotos: Bool = false) { Task { @MainActor in @@ -196,14 +200,3 @@ struct VernissageApp: App { } } } - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) - sceneConfig.delegateClass = SceneDelegate.self - return sceneConfig - } -} diff --git a/Vernissage/Views/AccountsPhotoView.swift b/Vernissage/Views/AccountsPhotoView.swift index a121640..0e76821 100644 --- a/Vernissage/Views/AccountsPhotoView.swift +++ b/Vernissage/Views/AccountsPhotoView.swift @@ -40,32 +40,7 @@ struct AccountsPhotoView: View { if self.accounts.isEmpty { NoDataView(imageSystemName: "person.3.sequence", text: "trendingAccounts.title.noAccounts") } else { - List { - ForEach(self.accounts, id: \.id) { account in - Section { - ImagesGrid(gridType: .account(accountId: account.id, - accountDisplayName: account.displayNameWithoutEmojis, - accountUserName: account.acct)) - } header: { - HStack { - UsernameRow( - accountId: account.id, - accountAvatar: account.avatar, - accountDisplayName: account.displayNameWithoutEmojis, - accountUsername: account.acct) - Spacer() - } - .textCase(.none) - .listRowInsets(EdgeInsets()) - .padding(.vertical, 12) - .onTapGesture { - self.routerPath.navigate(to: .userProfile(accountId: account.id, - accountDisplayName: account.displayNameWithoutEmojis, - accountUserName: account.acct)) - } - } - } - } + self.list() } case .error(let error): ErrorView(error: error) { @@ -78,6 +53,36 @@ struct AccountsPhotoView: View { } } + @ViewBuilder + private func list() -> some View { + List { + ForEach(self.accounts, id: \.id) { account in + Section { + ImagesGrid(gridType: .account(accountId: account.id, + accountDisplayName: account.displayNameWithoutEmojis, + accountUserName: account.acct)) + } header: { + HStack { + UsernameRow( + accountId: account.id, + accountAvatar: account.avatar, + accountDisplayName: account.displayNameWithoutEmojis, + accountUsername: account.acct) + Spacer() + } + .textCase(.none) + .listRowInsets(EdgeInsets()) + .padding(.vertical, 12) + .onTapGesture { + self.routerPath.navigate(to: .userProfile(accountId: account.id, + accountDisplayName: account.displayNameWithoutEmojis, + accountUserName: account.acct)) + } + } + } + } + } + private func loadData() async { do { self.accounts = try await self.loadAccounts() diff --git a/Vernissage/Views/AccountsView.swift b/Vernissage/Views/AccountsView.swift index 2b9f3a7..e704fb0 100644 --- a/Vernissage/Views/AccountsView.swift +++ b/Vernissage/Views/AccountsView.swift @@ -46,34 +46,7 @@ struct AccountsView: View { if self.accounts.isEmpty { NoDataView(imageSystemName: "person.3.sequence", text: "accounts.title.noAccounts") } else { - List { - ForEach(accounts, id: \.id) { account in - NavigationLink(value: RouteurDestinations.userProfile( - accountId: account.id, - accountDisplayName: account.displayNameWithoutEmojis, - accountUserName: account.acct) - ) { - UsernameRow(accountId: account.id, - accountAvatar: account.avatar, - accountDisplayName: account.displayNameWithoutEmojis, - accountUsername: account.acct) - } - } - - if allItemsLoaded == false { - HStack { - Spacer() - LoadingIndicator() - .task { - self.downloadedPage = self.downloadedPage + 1 - await self.loadData(page: self.downloadedPage) - } - Spacer() - } - .listRowSeparator(.hidden) - } - } - .listStyle(.plain) + self.list() } case .error(let error): ErrorView(error: error) { @@ -88,6 +61,38 @@ struct AccountsView: View { } } + @ViewBuilder + private func list() -> some View { + List { + ForEach(accounts, id: \.id) { account in + NavigationLink(value: RouteurDestinations.userProfile( + accountId: account.id, + accountDisplayName: account.displayNameWithoutEmojis, + accountUserName: account.acct) + ) { + UsernameRow(accountId: account.id, + accountAvatar: account.avatar, + accountDisplayName: account.displayNameWithoutEmojis, + accountUsername: account.acct) + } + } + + if allItemsLoaded == false { + HStack { + Spacer() + LoadingIndicator() + .task { + self.downloadedPage = self.downloadedPage + 1 + await self.loadData(page: self.downloadedPage) + } + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + } + private func loadData(page: Int) async { do { try await self.loadAccounts(page: page) diff --git a/Vernissage/Views/EditProfileView.swift b/Vernissage/Views/EditProfileView.swift index f057a14..3293987 100644 --- a/Vernissage/Views/EditProfileView.swift +++ b/Vernissage/Views/EditProfileView.swift @@ -30,6 +30,12 @@ struct EditProfileView: View { private let websiteMaxLength = 120 var body: some View { + self.mainBody() + .navigationTitle("editProfile.navigationBar.title") + } + + @ViewBuilder + private func mainBody() -> some View { switch state { case .loading: LoadingIndicator() @@ -175,7 +181,6 @@ struct EditProfileView: View { .buttonStyle(.borderedProminent) } } - .navigationTitle("editProfile.navigationBar.title") .onAppear { self.displayName = account.displayName ?? String.empty() self.website = account.website ?? String.empty() diff --git a/Vernissage/Views/HashtagsView.swift b/Vernissage/Views/HashtagsView.swift index f3c538c..4198f59 100644 --- a/Vernissage/Views/HashtagsView.swift +++ b/Vernissage/Views/HashtagsView.swift @@ -40,25 +40,7 @@ struct HashtagsView: View { if self.tags.isEmpty { NoDataView(imageSystemName: "person.3.sequence", text: "trendingTags.title.noTags") } else { - List { - ForEach(self.tags, id: \.id) { tag in - Section { - ImagesGrid(gridType: .hashtag(name: tag.hashtag)) - } header: { - HStack { - Text(tag.name).font(.headline) - Spacer() - if let total = tag.total { - Text(String(format: NSLocalizedString("trendingTags.title.amountOfPosts", comment: "Amount of posts"), total)) - .font(.caption) - } - } - .onTapGesture { - self.routerPath.navigate(to: .tag(hashTag: tag.hashtag)) - } - } - } - } + self.list() } case .error(let error): ErrorView(error: error) { @@ -71,6 +53,29 @@ struct HashtagsView: View { } } + @ViewBuilder + private func list() -> some View { + List { + ForEach(self.tags, id: \.id) { tag in + Section { + ImagesGrid(gridType: .hashtag(name: tag.hashtag)) + } header: { + HStack { + Text(tag.name).font(.headline) + Spacer() + if let total = tag.total { + Text(String(format: NSLocalizedString("trendingTags.title.amountOfPosts", comment: "Amount of posts"), total)) + .font(.caption) + } + } + .onTapGesture { + self.routerPath.navigate(to: .tag(hashTag: tag.hashtag)) + } + } + } + } + } + private func loadData() async { do { self.tags = try await self.loadTags() diff --git a/Vernissage/Views/InstanceView.swift b/Vernissage/Views/InstanceView.swift index 3a62980..63b3711 100644 --- a/Vernissage/Views/InstanceView.swift +++ b/Vernissage/Views/InstanceView.swift @@ -17,6 +17,12 @@ struct InstanceView: View { @State private var instance: Instance? var body: some View { + self.mainBody() + .navigationTitle("instance.navigationBar.title") + } + + @ViewBuilder + private func mainBody() -> some View { switch state { case .loading: LoadingIndicator() @@ -101,7 +107,6 @@ struct InstanceView: View { } } } - .navigationTitle("instance.navigationBar.title") } @ViewBuilder diff --git a/Vernissage/Views/NotificationsView/NotificationsView.swift b/Vernissage/Views/NotificationsView/NotificationsView.swift index 0511024..139f3b7 100644 --- a/Vernissage/Views/NotificationsView/NotificationsView.swift +++ b/Vernissage/Views/NotificationsView/NotificationsView.swift @@ -38,29 +38,7 @@ struct NotificationsView: View { if self.notifications.isEmpty { NoDataView(imageSystemName: "bell", text: "notifications.title.noNotifications") } else { - List { - ForEach(notifications, id: \.id) { notification in - NotificationRowView(notification: notification) - } - - if allItemsLoaded == false { - HStack { - Spacer() - LoadingIndicator() - .task { - await self.loadMoreNotifications() - } - Spacer() - } - .listRowSeparator(.hidden) - } - } - .listStyle(PlainListStyle()) - .refreshable { - HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) - await self.loadNewNotifications() - HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) - } + self.list() } case .error(let error): ErrorView(error: error) { @@ -71,6 +49,33 @@ struct NotificationsView: View { } } + @ViewBuilder + private func list() -> some View { + List { + ForEach(notifications, id: \.id) { notification in + NotificationRowView(notification: notification) + } + + if allItemsLoaded == false { + HStack { + Spacer() + LoadingIndicator() + .task { + await self.loadMoreNotifications() + } + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(PlainListStyle()) + .refreshable { + HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) + await self.loadNewNotifications() + HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) + } + } + func loadNotifications() async { do { if let linkable = try await self.client.notifications?.notifications(maxId: maxId, minId: minId, limit: 5) { diff --git a/Vernissage/Views/PaginableStatusesView.swift b/Vernissage/Views/PaginableStatusesView.swift index 5279598..d0a8dc7 100644 --- a/Vernissage/Views/PaginableStatusesView.swift +++ b/Vernissage/Views/PaginableStatusesView.swift @@ -43,37 +43,7 @@ struct PaginableStatusesView: View { if self.statusViewModels.isEmpty { NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "statuses.title.noPhotos") } else { - ScrollView { - LazyVStack(alignment: .center) { - ForEach(self.statusViewModels, id: \.id) { item in - NavigationLink(value: RouteurDestinations.status( - id: item.id, - blurhash: item.mediaAttachments.first?.blurhash, - highestImageUrl: item.mediaAttachments.getHighestImage()?.url, - metaImageWidth: item.getImageWidth(), - metaImageHeight: item.getImageHeight()) - ) { - ImageRowAsync(statusViewModel: item) - } - .buttonStyle(EmptyButtonStyle()) - } - - if allItemsLoaded == false { - HStack { - Spacer() - LoadingIndicator() - .task { - do { - try await self.loadMoreStatuses() - } catch { - ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) - } - } - Spacer() - } - } - } - } + self.list() } case .error(let error): ErrorView(error: error) { @@ -87,6 +57,32 @@ struct PaginableStatusesView: View { } } + @ViewBuilder + private func list() -> some View { + ScrollView { + LazyVStack(alignment: .center) { + ForEach(self.statusViewModels, id: \.id) { item in + ImageRowAsync(statusViewModel: item) + } + + if allItemsLoaded == false { + HStack { + Spacer() + LoadingIndicator() + .task { + do { + try await self.loadMoreStatuses() + } catch { + ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) + } + } + Spacer() + } + } + } + } + } + private func loadData() async { do { try await self.loadStatuses() diff --git a/Vernissage/Views/StatusView/StatusView.swift b/Vernissage/Views/StatusView/StatusView.swift index 032b0a7..ac70f2f 100644 --- a/Vernissage/Views/StatusView/StatusView.swift +++ b/Vernissage/Views/StatusView/StatusView.swift @@ -51,81 +51,8 @@ struct StatusView: View { } case .loaded: if let statusViewModel = self.statusViewModel { - ScrollView { - VStack (alignment: .leading) { - ImagesCarousel(attachments: statusViewModel.mediaAttachments, - selectedAttachment: $selectedAttachmentModel, - exifCamera: $exifCamera, - exifExposure: $exifExposure, - exifCreatedDate: $exifCreatedDate, - exifLens: $exifLens, - description: $description) - .onTapGesture { - withoutAnimation { - self.tappedAttachmentModel = self.selectedAttachmentModel - } - } - - VStack(alignment: .leading) { - self.reblogInformation() - - UsernameRow(accountId: statusViewModel.account.id, - accountAvatar: statusViewModel.account.avatar, - accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, - accountUsername: statusViewModel.account.acct) - .onTapGesture { - self.routerPath.navigate(to: .userProfile(accountId: statusViewModel.account.id, - accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, - accountUserName: statusViewModel.account.acct)) - } - - MarkdownFormattedText(statusViewModel.content.asMarkdown) - .font(.callout) - .environment(\.openURL, OpenURLAction { url in - routerPath.handle(url: url) - }) - - VStack (alignment: .leading) { - if let name = statusViewModel.place?.name, let country = statusViewModel.place?.country { - LabelIcon(iconName: "mappin.and.ellipse", value: "\(name), \(country)") - } - - LabelIcon(iconName: "camera", value: self.exifCamera) - LabelIcon(iconName: "camera.aperture", value: self.exifLens) - LabelIcon(iconName: "timelapse", value: self.exifExposure) - LabelIcon(iconName: "calendar", value: self.exifCreatedDate?.toDate(.isoDateTimeSec)?.formatted()) - - if self.applicationState.showPhotoDescription { - LabelIcon(iconName: "eye.trianglebadge.exclamationmark", value: self.description) - } - } - .padding(.bottom, 2) - .foregroundColor(.lightGrayColor) - - HStack { - Text("status.title.uploaded", comment: "Uploaded") - Text(statusViewModel.createdAt.toRelative(.isoDateTimeMilliSec)) - .padding(.horizontal, -4) - if let applicationName = statusViewModel.application?.name { - Text(String(format: NSLocalizedString("status.title.via", comment: "via"), applicationName)) - } - } - .foregroundColor(.lightGrayColor) - .font(.footnote) - - InteractionRow(statusModel: statusViewModel) { - self.dismiss() - } - .foregroundColor(.accentColor) - .padding(8) - } - .padding(8) - - CommentsSectionView(statusId: statusViewModel.id) - } - } + self.statusView(statusViewModel: statusViewModel) } - case .error(let error): ErrorView(error: error) { self.state = .loading @@ -135,7 +62,85 @@ struct StatusView: View { } } - @ViewBuilder func reblogInformation() -> some View { + @ViewBuilder + private func statusView(statusViewModel: StatusModel) -> some View { + ScrollView { + VStack (alignment: .leading) { + ImagesCarousel(attachments: statusViewModel.mediaAttachments, + selectedAttachment: $selectedAttachmentModel, + exifCamera: $exifCamera, + exifExposure: $exifExposure, + exifCreatedDate: $exifCreatedDate, + exifLens: $exifLens, + description: $description) + .onTapGesture { + withoutAnimation { + self.tappedAttachmentModel = self.selectedAttachmentModel + } + } + + VStack(alignment: .leading) { + self.reblogInformation() + + UsernameRow(accountId: statusViewModel.account.id, + accountAvatar: statusViewModel.account.avatar, + accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, + accountUsername: statusViewModel.account.acct) + .onTapGesture { + self.routerPath.navigate(to: .userProfile(accountId: statusViewModel.account.id, + accountDisplayName: statusViewModel.account.displayNameWithoutEmojis, + accountUserName: statusViewModel.account.acct)) + } + + MarkdownFormattedText(statusViewModel.content.asMarkdown) + .font(.callout) + .environment(\.openURL, OpenURLAction { url in + routerPath.handle(url: url) + }) + + VStack (alignment: .leading) { + if let name = statusViewModel.place?.name, let country = statusViewModel.place?.country { + LabelIcon(iconName: "mappin.and.ellipse", value: "\(name), \(country)") + } + + LabelIcon(iconName: "camera", value: self.exifCamera) + LabelIcon(iconName: "camera.aperture", value: self.exifLens) + LabelIcon(iconName: "timelapse", value: self.exifExposure) + LabelIcon(iconName: "calendar", value: self.exifCreatedDate?.toDate(.isoDateTimeSec)?.formatted()) + + if self.applicationState.showPhotoDescription { + LabelIcon(iconName: "eye.trianglebadge.exclamationmark", value: self.description) + } + } + .padding(.bottom, 2) + .foregroundColor(.lightGrayColor) + + HStack { + Text("status.title.uploaded", comment: "Uploaded") + Text(statusViewModel.createdAt.toRelative(.isoDateTimeMilliSec)) + .padding(.horizontal, -4) + if let applicationName = statusViewModel.application?.name { + Text(String(format: NSLocalizedString("status.title.via", comment: "via"), applicationName)) + } + } + .foregroundColor(.lightGrayColor) + .font(.footnote) + + InteractionRow(statusModel: statusViewModel) { + self.dismiss() + } + .foregroundColor(.accentColor) + .padding(8) + } + .padding(8) + + CommentsSectionView(statusId: statusViewModel.id) + } + } + } + + @ViewBuilder + func reblogInformation() -> some View { if let reblogStatus = self.statusViewModel?.reblogStatus { HStack(alignment: .center, spacing: 4) { UserAvatar(accountAvatar: reblogStatus.account.avatar, size: .mini) diff --git a/Vernissage/Views/StatusesView.swift b/Vernissage/Views/StatusesView.swift index ff63887..83d990e 100644 --- a/Vernissage/Views/StatusesView.swift +++ b/Vernissage/Views/StatusesView.swift @@ -52,46 +52,7 @@ struct StatusesView: View { if self.statusViewModels.isEmpty { NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "statuses.title.noPhotos") } else { - ScrollView { - LazyVStack(alignment: .center) { - ForEach(self.statusViewModels, id: \.id) { item in - NavigationLink(value: RouteurDestinations.status( - id: item.id, - blurhash: item.mediaAttachments.first?.blurhash, - highestImageUrl: item.mediaAttachments.getHighestImage()?.url, - metaImageWidth: item.getImageWidth(), - metaImageHeight: item.getImageHeight()) - ) { - ImageRowAsync(statusViewModel: item) - } - .buttonStyle(EmptyButtonStyle()) - } - - if allItemsLoaded == false { - HStack { - Spacer() - LoadingIndicator() - .task { - do { - try await self.loadMoreStatuses() - } catch { - ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) - } - } - Spacer() - } - } - } - } - .refreshable { - do { - HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) - try await self.loadTopStatuses() - HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) - } catch { - ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) - } - } + self.list() } case .error(let error): ErrorView(error: error) { @@ -102,6 +63,41 @@ struct StatusesView: View { } } + @ViewBuilder + private func list() -> some View { + ScrollView { + LazyVStack(alignment: .center) { + ForEach(self.statusViewModels, id: \.id) { item in + ImageRowAsync(statusViewModel: item) + } + + if allItemsLoaded == false { + HStack { + Spacer() + LoadingIndicator() + .task { + do { + try await self.loadMoreStatuses() + } catch { + ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) + } + } + Spacer() + } + } + } + } + .refreshable { + do { + HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) + try await self.loadTopStatuses() + HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) + } catch { + ErrorService.shared.handle(error, message: "statuses.error.loadingStatusesFailed", showToastr: !Task.isCancelled) + } + } + } + private func loadData() async { do { try await self.loadStatuses() diff --git a/Vernissage/Views/TrendStatusesView.swift b/Vernissage/Views/TrendStatusesView.swift index f06ae1b..f64fbe6 100644 --- a/Vernissage/Views/TrendStatusesView.swift +++ b/Vernissage/Views/TrendStatusesView.swift @@ -68,16 +68,7 @@ struct TrendStatusesView: View { } else { LazyVStack(alignment: .center) { ForEach(self.statusViewModels, id: \.id) { item in - NavigationLink(value: RouteurDestinations.status( - id: item.id, - blurhash: item.mediaAttachments.first?.blurhash, - highestImageUrl: item.mediaAttachments.getHighestImage()?.url, - metaImageWidth: item.getImageWidth(), - metaImageHeight: item.getImageHeight()) - ) { - ImageRowAsync(statusViewModel: item) - } - .buttonStyle(EmptyButtonStyle()) + ImageRowAsync(statusViewModel: item) } } .refreshable { diff --git a/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift b/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift index a19d90f..2857b48 100644 --- a/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift +++ b/Vernissage/Views/UserProfileView/Subviews/UserProfileStatusesView.swift @@ -22,16 +22,7 @@ struct UserProfileStatusesView: View { LazyVStack(alignment: .center) { if firstLoadFinished == true { ForEach(self.statusViewModels, id: \.id) { item in - NavigationLink(value: RouteurDestinations.status( - id: item.id, - blurhash: item.mediaAttachments.first?.blurhash, - highestImageUrl: item.mediaAttachments.getHighestImage()?.url, - metaImageWidth: item.getImageWidth(), - metaImageHeight: item.getImageHeight()) - ) { - ImageRowAsync(statusViewModel: item) - } - .buttonStyle(EmptyButtonStyle()) + ImageRowAsync(statusViewModel: item) } if allItemsLoaded == false && firstLoadFinished == true { diff --git a/Vernissage/Views/UserProfileView/UserProfileView.swift b/Vernissage/Views/UserProfileView/UserProfileView.swift index 416567e..a920112 100644 --- a/Vernissage/Views/UserProfileView/UserProfileView.swift +++ b/Vernissage/Views/UserProfileView/UserProfileView.swift @@ -46,19 +46,7 @@ struct UserProfileView: View { } case .loaded: if let account = self.account { - ScrollView { - UserProfileHeaderView(account: account, relationship: relationship) - .id(self.viewId) - UserProfileStatusesView(accountId: account.id) - } - .onAppear { - if let updatedProfile = self.applicationState.updatedProfile { - self.account = nil - self.account = updatedProfile - self.applicationState.updatedProfile = nil - self.viewId = UUID().uuidString - } - } + self.accountView(account: account) } case .error(let error): ErrorView(error: error) { @@ -69,6 +57,22 @@ struct UserProfileView: View { } } + private func accountView(account: Account) -> some View { + ScrollView { + UserProfileHeaderView(account: account, relationship: relationship) + .id(self.viewId) + UserProfileStatusesView(accountId: account.id) + } + .onAppear { + if let updatedProfile = self.applicationState.updatedProfile { + self.account = nil + self.account = updatedProfile + self.applicationState.updatedProfile = nil + self.viewId = UUID().uuidString + } + } + } + private func loadData() async { do { if self.accountId.isEmpty { diff --git a/Vernissage/Widgets/ContentWarning.swift b/Vernissage/Widgets/ContentWarning.swift index aee8671..bd86b3e 100644 --- a/Vernissage/Widgets/ContentWarning.swift +++ b/Vernissage/Widgets/ContentWarning.swift @@ -6,24 +6,28 @@ import SwiftUI -struct ContentWarning: View { - private let blurhash: String? +struct ContentWarning: View { private let spoilerText: String? - private let content: Content + private let content: () -> Content + private let blurred: () -> Blurred @State private var showSensitive = false - init(blurhash: String?, spoilerText: String?, @ViewBuilder content: () -> Content) { - self.blurhash = blurhash + init(spoilerText: String?, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder blurred: @escaping () -> Blurred) { + self.spoilerText = spoilerText - self.content = content() + self.content = content + self.blurred = blurred } var body: some View { if self.showSensitive { ZStack { - content + content() .transition(.opacity) + VStack(alignment: .trailing) { HStack(alignment: .top) { Spacer() @@ -35,7 +39,8 @@ struct ContentWarning: View { Image(systemName: "eye.slash") .font(.title2) .shadow(color: Color.systemBackground, radius: 0.3) - }.padding() + .padding() + } } Spacer() } @@ -43,7 +48,8 @@ struct ContentWarning: View { } } else { ZStack { - BlurredImage(blurhash: blurhash) + self.blurred() + VStack(alignment: .center) { Spacer() Image(systemName: "eye.slash.fill") @@ -75,11 +81,3 @@ struct ContentWarning: View { } } } - -struct ContentWarning_Previews: PreviewProvider { - static var previews: some View { - ContentWarning(blurhash: nil, spoilerText: "Spoiler") { - Image(systemName: "people") - } - } -} diff --git a/Vernissage/Widgets/ImageRowAsync.swift b/Vernissage/Widgets/ImageRowAsync.swift index 2bb87a9..d22965f 100644 --- a/Vernissage/Widgets/ImageRowAsync.swift +++ b/Vernissage/Widgets/ImageRowAsync.swift @@ -66,6 +66,9 @@ struct ImageRowAsync: View { .tag(attachment.id) } } + .onFirstAppear { + self.selected = statusViewModel.mediaAttachments.first?.id ?? String.empty() + } .onChange(of: selected, perform: { attachmentId in if let attachment = self.statusViewModel.mediaAttachments.first(where: { item in item.id == attachmentId }) { if let size = ImageSizeService.shared.get(for: attachment.url) { diff --git a/Vernissage/Widgets/ImageRowItem.swift b/Vernissage/Widgets/ImageRowItem.swift index 8714ddf..cb0b512 100644 --- a/Vernissage/Widgets/ImageRowItem.swift +++ b/Vernissage/Widgets/ImageRowItem.swift @@ -37,14 +37,19 @@ struct ImageRowItem: View { ZStack { if self.status.sensitive && !self.applicationState.showSensitive { ZStack { - ContentWarning(blurhash: attachmentData.blurhash, spoilerText: self.status.spoilerText) { + ContentWarning(spoilerText: self.status.spoilerText) { self.imageView(uiImage: uiImage) - + if showThumbImage { FavouriteTouch { self.showThumbImage = false } } + } blurred: { + BlurredImage(blurhash: attachmentData.blurhash) + .onTapGesture{ + self.navigateToStatus() + } } } .onAppear { diff --git a/Vernissage/Widgets/ImageRowItemAsync.swift b/Vernissage/Widgets/ImageRowItemAsync.swift index 5b74ff5..1370ecf 100644 --- a/Vernissage/Widgets/ImageRowItemAsync.swift +++ b/Vernissage/Widgets/ImageRowItemAsync.swift @@ -32,8 +32,13 @@ struct ImageRowItemAsync: View { if let image = state.image { if self.statusViewModel.sensitive && !self.applicationState.showSensitive { ZStack { - ContentWarning(blurhash: attachment.blurhash, spoilerText: self.statusViewModel.spoilerText) { + ContentWarning(spoilerText: self.statusViewModel.spoilerText) { self.imageView(image: image) + } blurred: { + BlurredImage(blurhash: attachment.blurhash) + .onTapGesture { + self.navigateToStatus() + } } if showThumbImage { @@ -42,14 +47,15 @@ struct ImageRowItemAsync: View { } } } + .opacity(self.opacity) .onAppear { - withAnimation { - self.opacity = 1.0 - } - if let uiImage = state.imageResponse?.image { self.recalculateSizeOfDownloadedImage(uiImage: uiImage) } + + withAnimation { + self.opacity = 1.0 + } } } else { ZStack { @@ -61,14 +67,15 @@ struct ImageRowItemAsync: View { } } } + .opacity(self.opacity) .onAppear { - withAnimation { - self.opacity = 1.0 - } - if let uiImage = state.imageResponse?.image { self.recalculateSizeOfDownloadedImage(uiImage: uiImage) } + + withAnimation { + self.opacity = 1.0 + } } } } else if state.error != nil { @@ -87,6 +94,9 @@ struct ImageRowItemAsync: View { } else { VStack(alignment: .center) { BlurredImage(blurhash: attachment.blurhash) + .onTapGesture { + self.navigateToStatus() + } } } } @@ -98,7 +108,6 @@ struct ImageRowItemAsync: View { image .resizable() .aspectRatio(contentMode: .fit) - .opacity(self.opacity) .onTapGesture(count: 2) { Task { try? await self.client.statuses?.favourite(statusId: self.statusViewModel.id) @@ -108,17 +117,21 @@ struct ImageRowItemAsync: View { HapticService.shared.fireHaptic(of: .buttonPress) } .onTapGesture { - self.routerPath.navigate(to: .status( - id: statusViewModel.id, - blurhash: statusViewModel.mediaAttachments.first?.blurhash, - highestImageUrl: statusViewModel.mediaAttachments.getHighestImage()?.url, - metaImageWidth: statusViewModel.getImageWidth(), - metaImageHeight: statusViewModel.getImageHeight() - )) + self.navigateToStatus() } .imageContextMenu(client: self.client, statusModel: self.statusViewModel) } + private func navigateToStatus() { + self.routerPath.navigate(to: .status( + id: statusViewModel.id, + blurhash: statusViewModel.mediaAttachments.first?.blurhash, + highestImageUrl: statusViewModel.mediaAttachments.getHighestImage()?.url, + metaImageWidth: statusViewModel.getImageWidth(), + metaImageHeight: statusViewModel.getImageHeight() + )) + } + private func recalculateSizeOfDownloadedImage(uiImage: UIImage) { let size = ImageSizeService.shared.calculate(for: attachment.url, width: uiImage.size.width,