diff --git a/Mlem/App State.swift b/Mlem/App State.swift index d03aa5a60..209476936 100644 --- a/Mlem/App State.swift +++ b/Mlem/App State.swift @@ -10,14 +10,43 @@ import Foundation import SwiftUI class AppState: ObservableObject { + @Dependency(\.accountsTracker) var accountsTracker @Dependency(\.apiClient) var apiClient @AppStorage("defaultAccountId") var defaultAccountId: Int? + @AppStorage("profileTabLabel") var profileTabLabel: ProfileTabLabel = .username + @AppStorage("showUserAvatarOnProfileTab") var showUserAvatar: Bool = true @Published private(set) var currentActiveAccount: SavedAccount? - @Published private(set) var currentNickname: String? - /// A method to set the current active account + /// A variable representing how the current account should be displayed in the tab bar + var tabDisplayName: String { + guard let currentActiveAccount else { + return "Profile" + } + + switch profileTabLabel { + case .username: + return currentActiveAccount.username + case .instance: + return currentActiveAccount.hostName ?? "Instance" + case .nickname: + return currentActiveAccount.nickname + case .anonymous: + return "Profile" + } + } + + /// A variable representing the remote location to load the user profile avatar from if applicable + var profileTabRemoteSymbolUrl: URL? { + guard profileTabLabel != .anonymous, showUserAvatar else { + return nil + } + + return currentActiveAccount?.avatarUrl + } + + /// A method to set the current active account, any changes to the account will be propogated to the persistence layer /// - Important: If you wish to _clear_ the current active account please use the `\.setAppFlow` method available via the environment to reset to our `.onboarding` flow /// - Parameter account: The `SavedAccount` which should become the active account func setActiveAccount(_ account: SavedAccount) { @@ -25,15 +54,14 @@ class AppState: ObservableObject { // we configure the client here to ensure any updated session tokens are updated apiClient.configure(for: .account(account)) currentActiveAccount = account - currentNickname = account.nickname defaultAccountId = account.id + accountsTracker.update(with: account) } /// A method to clear the currentlly active account /// - Important: It is unlikely you will want to call this method directly but instead use the `\.setAppFlow` method available via the environment func clearActiveAccount() { currentActiveAccount = nil - currentNickname = nil } func isCurrentAccountId(_ id: Int) -> Bool { diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index 98ae226f4..63061076e 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -32,7 +32,6 @@ struct ContentView: View { @AppStorage("showInboxUnreadBadge") var showInboxUnreadBadge: Bool = true @AppStorage("homeButtonExists") var homeButtonExists: Bool = false - @AppStorage("profileTabLabel") var profileTabLabel: ProfileTabLabel = .username var accessibilityFont: Bool { UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory } @@ -43,8 +42,7 @@ struct ContentView: View { .fancyTabItem(tag: TabSelection.feeds) { FancyTabBarLabel( tag: TabSelection.feeds, - symbolName: "scroll", - activeSymbolName: "scroll.fill" + symbolConfiguration: .feed ) } @@ -56,8 +54,7 @@ struct ContentView: View { .fancyTabItem(tag: TabSelection.inbox) { FancyTabBarLabel( tag: TabSelection.inbox, - symbolName: "mail.stack", - activeSymbolName: "mail.stack.fill", + symbolConfiguration: .inbox, badgeCount: showInboxUnreadBadge ? unreadTracker.total : 0 ) } @@ -66,9 +63,12 @@ struct ContentView: View { .fancyTabItem(tag: TabSelection.profile) { FancyTabBarLabel( tag: TabSelection.profile, - customText: computeUsername(account: account), - symbolName: "person.circle", - activeSymbolName: "person.circle.fill" + customText: appState.tabDisplayName, + symbolConfiguration: .init( + symbol: FancyTabBarLabel.SymbolConfiguration.profile.symbol, + activeSymbol: FancyTabBarLabel.SymbolConfiguration.profile.activeSymbol, + remoteSymbolUrl: appState.profileTabRemoteSymbolUrl + ) ) .simultaneousGesture(accountSwitchLongPress) } @@ -78,8 +78,7 @@ struct ContentView: View { .fancyTabItem(tag: TabSelection.search) { FancyTabBarLabel( tag: TabSelection.search, - symbolName: "magnifyingglass", - activeSymbolName: "text.magnifyingglass" + symbolConfiguration: .search ) } @@ -87,7 +86,7 @@ struct ContentView: View { .fancyTabItem(tag: TabSelection.settings) { FancyTabBarLabel( tag: TabSelection.settings, - symbolName: "gear" + symbolConfiguration: .settings ) } } @@ -152,15 +151,6 @@ struct ContentView: View { } } - func computeUsername(account: SavedAccount) -> String { - switch profileTabLabel { - case .username: return account.username - case .instance: return account.hostName ?? account.username - case .nickname: return appState.currentNickname ?? account.username - case .anonymous: return "Profile" - } - } - func showAccountSwitcherDragCallback() { if !homeButtonExists { isPresentingAccountSwitcher = true diff --git a/Mlem/Custom Tab Bar/FancyTabBarLabel.swift b/Mlem/Custom Tab Bar/FancyTabBarLabel.swift index c5325e212..38198165e 100644 --- a/Mlem/Custom Tab Bar/FancyTabBarLabel.swift +++ b/Mlem/Custom Tab Bar/FancyTabBarLabel.swift @@ -9,14 +9,25 @@ import Foundation import SwiftUI struct FancyTabBarLabel: View { + struct SymbolConfiguration { + let symbol: String + let activeSymbol: String + let remoteSymbolUrl: URL? + + static var feed: Self { .init(symbol: "scroll", activeSymbol: "scroll.fill", remoteSymbolUrl: nil) } + static var inbox: Self { .init(symbol: "mail.stack", activeSymbol: "mail.stack.fill", remoteSymbolUrl: nil) } + static var profile: Self { .init(symbol: "person.circle", activeSymbol: "person.circle.fill", remoteSymbolUrl: nil) } + static var search: Self { .init(symbol: "magnifyingglass", activeSymbol: "text.magnifyingglass", remoteSymbolUrl: nil) } + static var settings: Self { .init(symbol: "gear", activeSymbol: "gear", remoteSymbolUrl: nil) } + } + @Environment(\.tabSelectionHashValue) private var selectedTagHashValue @AppStorage("showTabNames") var showTabNames: Bool = true let tabIconSize: CGFloat = 24 let tagHash: Int - let symbolName: String? - let activeSymbolName: String? + let symbolConfiguration: SymbolConfiguration let labelText: String? let color: Color let activeColor: Color @@ -37,15 +48,13 @@ struct FancyTabBarLabel: View { init( tag: any FancyTabBarSelection, customText: String? = nil, - symbolName: String? = nil, - activeSymbolName: String? = nil, + symbolConfiguration: SymbolConfiguration, customColor: Color = Color.primary, activeColor: Color = .accentColor, badgeCount: Int? = nil ) { self.tagHash = tag.hashValue - self.symbolName = symbolName - self.activeSymbolName = activeSymbolName + self.symbolConfiguration = symbolConfiguration self.labelText = customText ?? tag.labelText self.color = customColor self.activeColor = activeColor @@ -66,10 +75,26 @@ struct FancyTabBarLabel: View { .animation(.linear(duration: 0.1), value: active) } + @ViewBuilder var labelDisplay: some View { VStack(spacing: 4) { - if let symbolName { - Image(systemName: active ? activeSymbolName ?? symbolName : symbolName) + if let remoteSymbolUrl = symbolConfiguration.remoteSymbolUrl { + CachedImage( + url: remoteSymbolUrl, + shouldExpand: false, + fixedSize: .init(width: tabIconSize, height: tabIconSize), + imageNotFound: { defaultTabIcon(for: symbolConfiguration) }, + errorBackgroundColor: .clear, + contentMode: .fill + ) + .frame(width: tabIconSize, height: tabIconSize) + .clipShape(Circle()) + .overlay(Circle() + .stroke(.gray.opacity(0.3), lineWidth: 1)) + .opacity(active ? 1 : 0.7) + .accessibilityHidden(true) + } else { + Image(systemName: active ? symbolConfiguration.activeSymbol : symbolConfiguration.symbol) .resizable() .scaledToFit() .frame(width: tabIconSize, height: tabIconSize) @@ -81,4 +106,12 @@ struct FancyTabBarLabel: View { } } } + + private func defaultTabIcon(for configuration: SymbolConfiguration) -> AnyView { + AnyView(Image(systemName: active ? configuration.activeSymbol : configuration.symbol) + .resizable() + .scaledToFill() + .frame(width: tabIconSize, height: tabIconSize) + ) + } } diff --git a/Mlem/Models/Saved Account.swift b/Mlem/Models/Saved Account.swift index 2119e61d4..324639e36 100644 --- a/Mlem/Models/Saved Account.swift +++ b/Mlem/Models/Saved Account.swift @@ -13,32 +13,37 @@ struct SavedAccount: Identifiable, Codable, Equatable, Hashable { let accessToken: String let username: String let storedNickname: String? + let avatarUrl: URL? init( id: Int, instanceLink: URL, accessToken: String, username: String, - storedNickname: String? = nil + storedNickname: String? = nil, + avatarUrl: URL? = nil ) { self.id = id self.instanceLink = instanceLink self.accessToken = accessToken self.username = username self.storedNickname = storedNickname + self.avatarUrl = avatarUrl } // Convenience initializer to create an equal copy with different non-identifying properties. init( from account: SavedAccount, accessToken: String? = nil, - storedNickname: String? = nil + storedNickname: String? = nil, + avatarUrl: URL? ) { self.id = account.id self.instanceLink = account.instanceLink self.accessToken = accessToken ?? account.accessToken self.username = account.username self.storedNickname = storedNickname ?? account.storedNickname + self.avatarUrl = avatarUrl } // convenience @@ -55,6 +60,7 @@ struct SavedAccount: Identifiable, Codable, Equatable, Hashable { try container.encode("redacted", forKey: .accessToken) try container.encode(username, forKey: .username) try container.encode(storedNickname, forKey: .storedNickname) + try container.encode(avatarUrl, forKey: .avatarUrl) } static func == (lhs: SavedAccount, rhs: SavedAccount) -> Bool { diff --git a/Mlem/Repositories/PersistenceRepository.swift b/Mlem/Repositories/PersistenceRepository.swift index 379034650..1c3fdfd90 100644 --- a/Mlem/Repositories/PersistenceRepository.swift +++ b/Mlem/Repositories/PersistenceRepository.swift @@ -75,7 +75,7 @@ class PersistenceRepository { return nil } - return SavedAccount(from: account, accessToken: token) + return SavedAccount(from: account, accessToken: token, avatarUrl: account.avatarUrl) } } diff --git a/Mlem/Views/Shared/Accounts/Add Account View.swift b/Mlem/Views/Shared/Accounts/Add Account View.swift index 7828a383c..60f393761 100644 --- a/Mlem/Views/Shared/Accounts/Add Account View.swift +++ b/Mlem/Views/Shared/Accounts/Add Account View.swift @@ -314,11 +314,13 @@ struct AddSavedInstanceView: View { viewState = .success } - let newAccount = try await SavedAccount( - id: getUserID(authToken: response.jwt, instanceURL: instanceURL), + let user = try await loadUser(authToken: response.jwt, instanceURL: instanceURL) + let newAccount = SavedAccount( + id: user.id, instanceLink: instanceURL, accessToken: response.jwt, - username: username + username: username, + avatarUrl: user.avatar ) // MARK: - Save the account's credentials into the keychain @@ -336,14 +338,13 @@ struct AddSavedInstanceView: View { } } - private func getUserID(authToken: String, instanceURL: URL) async throws -> Int { + private func loadUser(authToken: String, instanceURL: URL) async throws -> APIPerson { // create a session to use for this request, since we're in the process of creating the account... let session = APISession.authenticated(instanceURL, authToken) do { return try await apiClient.getPersonDetails(session: session, username: username) .personView .person - .id } catch { print("getUserId Error info: \(error)") throw UserIDRetrievalError.couldNotFetchUserInformation diff --git a/Mlem/Views/Shared/Cached Image.swift b/Mlem/Views/Shared/Cached Image.swift index 95814c56f..be2447928 100644 --- a/Mlem/Views/Shared/Cached Image.swift +++ b/Mlem/Views/Shared/Cached Image.swift @@ -27,6 +27,7 @@ struct CachedImage: View { let screenWidth: CGFloat let contentMode: ContentMode let cornerRadius: CGFloat + let errorBackgroundColor: Color // Optional callback triggered when the quicklook preview is dismissed let dismissCallback: (() -> Void)? @@ -37,6 +38,7 @@ struct CachedImage: View { maxHeight: CGFloat = .infinity, fixedSize: CGSize? = nil, imageNotFound: @escaping () -> AnyView = imageNotFoundDefault, + errorBackgroundColor: Color = Color(uiColor: .systemGray4), contentMode: ContentMode = .fit, dismissCallback: (() -> Void)? = nil, cornerRadius: CGFloat? = nil @@ -45,6 +47,7 @@ struct CachedImage: View { self.shouldExpand = shouldExpand self.maxHeight = maxHeight self.imageNotFound = imageNotFound + self.errorBackgroundColor = errorBackgroundColor self.contentMode = contentMode self.dismissCallback = dismissCallback self.cornerRadius = cornerRadius ?? 0 @@ -140,7 +143,7 @@ struct CachedImage: View { // Indicates an error imageNotFound() .frame(idealWidth: size.width, maxHeight: size.height) - .background(Color(uiColor: .systemGray4)) + .background(errorBackgroundColor) } else { ProgressView() // Acts as a placeholder .frame(idealWidth: size.width, maxHeight: size.height) diff --git a/Mlem/Views/Shared/TokenRefreshView.swift b/Mlem/Views/Shared/TokenRefreshView.swift index 8032ef55a..50a2534be 100644 --- a/Mlem/Views/Shared/TokenRefreshView.swift +++ b/Mlem/Views/Shared/TokenRefreshView.swift @@ -245,7 +245,7 @@ struct TokenRefreshView: View { try? await Task.sleep(for: .seconds(0.5)) await MainActor.run { - refreshedAccount(.init(from: account, accessToken: newToken)) + refreshedAccount(.init(from: account, accessToken: newToken, avatarUrl: account.avatarUrl)) dismiss() } } diff --git a/Mlem/Views/Tabs/Profile/User View.swift b/Mlem/Views/Tabs/Profile/User View.swift index 027b05ea0..7d9912cc0 100644 --- a/Mlem/Views/Tabs/Profile/User View.swift +++ b/Mlem/Views/Tabs/Profile/User View.swift @@ -8,6 +8,8 @@ import Dependencies import SwiftUI +// swiftlint:disable file_length + /// View for showing user profiles /// Accepts the following parameters: /// - **userID**: Non-optional ID of the user @@ -237,7 +239,25 @@ struct UserView: View { } private func loadUser(savedItems: Bool) async throws -> GetPersonDetailsResponse { - try await apiClient.getPersonDetails(for: userID, limit: 20, savedOnly: savedItems) + let response = try await apiClient.getPersonDetails(for: userID, limit: 20, savedOnly: savedItems) + + if isShowingOwnProfile(), let currentAccount = appState.currentActiveAccount { + // take this opportunity to update the users avatar url to catch changes + // we should be able to shift this down to the repository layer in the future so that we + // catch anytime the app loads the signed in users details from any location in the app 🤞 + let url = response.personView.person.avatar + let updatedAccount = SavedAccount( + id: currentAccount.id, + instanceLink: currentAccount.instanceLink, + accessToken: currentAccount.accessToken, + username: currentAccount.username, + storedNickname: currentAccount.storedNickname, + avatarUrl: url + ) + appState.setActiveAccount(updatedAccount) + } + + return response } } @@ -382,3 +402,5 @@ struct UserViewPreview: PreviewProvider { ).environmentObject(AppState()) } } + +// swiftlint:enable file_length diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift index 15f3a77bf..87a9cccdd 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift @@ -9,11 +9,10 @@ import Dependencies import SwiftUI struct TabBarSettingsView: View { - @Dependency(\.accountsTracker) var accountsTracker - @AppStorage("profileTabLabel") var profileTabLabel: ProfileTabLabel = .username @AppStorage("showTabNames") var showTabNames: Bool = true @AppStorage("showInboxUnreadBadge") var showInboxUnreadBadge: Bool = true + @AppStorage("showUserAvatarOnProfileTab") var showUserAvatar: Bool = true @EnvironmentObject var appState: AppState @@ -33,7 +32,7 @@ struct TabBarSettingsView: View { if profileTabLabel == .nickname { Label { - TextField(text: $textFieldEntry, prompt: Text(appState.currentNickname ?? "")) { + TextField(text: $textFieldEntry, prompt: Text(appState.currentActiveAccount?.nickname ?? "")) { Text("Nickname") } .autocorrectionDisabled(true) @@ -44,9 +43,15 @@ struct TabBarSettingsView: View { return } - let newAccount = SavedAccount(from: existingAccount, storedNickname: textFieldEntry) + // disallow blank nicknames + let acceptedNickname = textFieldEntry.trimmed.isEmpty ? existingAccount.username : textFieldEntry + + let newAccount = SavedAccount( + from: existingAccount, + storedNickname: acceptedNickname, + avatarUrl: existingAccount.avatarUrl + ) appState.setActiveAccount(newAccount) - accountsTracker.update(with: newAccount) } } icon: { Image(systemName: "rectangle.and.pencil.and.ellipsis") @@ -67,6 +72,14 @@ struct TabBarSettingsView: View { settingName: "Show Unread Count", isTicked: $showInboxUnreadBadge ) + + SwitchableSettingsItem( + settingPictureSystemName: "person.fill.questionmark", + settingName: "Show User Avatar", + // if `.anonymous` is selected the toggle here should always be false + isTicked: profileTabLabel == .anonymous ? .constant(false) : $showUserAvatar + ) + .disabled(profileTabLabel == .anonymous) } } .fancyTabScrollCompatible()