From 08455454171c844413634b132081cf48828a1b7a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sun, 6 Apr 2025 21:58:47 -0400 Subject: [PATCH] Clean Up `SelectUserView` (#1482) * cleanup * fix adaptive layout --- Shared/Components/CenteredLazyVGrid.swift | 194 +++++-- .../ViewExtensions/Backport/Backport.swift | 11 + .../ScrollIfLargerThanContainerModifier.swift | 6 +- .../SwiftfinStore+ServerState.swift | 2 +- Shared/ViewModels/SelectUserViewModel.swift | 5 +- .../Views/SelectUserView/SelectUserView.swift | 71 ++- .../Views/UserSignInView/UserSignInView.swift | 2 +- Swiftfin.xcodeproj/project.pbxproj | 24 +- .../Components/AddUserButton.swift | 111 ---- .../Components/AddUserGridButton.swift | 79 +++ .../Components/AddUserListRow.swift | 92 ++++ .../Components/AddUserRow.swift | 124 ----- .../Components/ServerSelectionMenu.swift | 27 +- .../{UserRow.swift => UserListRow.swift} | 2 +- .../Views/SelectUserView/SelectUserView.swift | 476 ++++++++---------- 15 files changed, 626 insertions(+), 600 deletions(-) delete mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserButton.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift create mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift delete mode 100644 Swiftfin/Views/SelectUserView/Components/AddUserRow.swift rename Swiftfin/Views/SelectUserView/Components/{UserRow.swift => UserListRow.swift} (99%) diff --git a/Shared/Components/CenteredLazyVGrid.swift b/Shared/Components/CenteredLazyVGrid.swift index d666c203..76d5c4c5 100644 --- a/Shared/Components/CenteredLazyVGrid.swift +++ b/Shared/Components/CenteredLazyVGrid.swift @@ -11,40 +11,11 @@ import SwiftUI /// A LazyVGrid that centers its elements, most notably on the last row. struct CenteredLazyVGrid: View { - @State - private var elementSize: CGSize = .zero - - private let columnCount: Int - private let columns: [GridItem] - private let content: (Data.Element) -> Content - private let data: Data - private let id: KeyPath - private let spacing: CGFloat - - /// Calculates the x offset for elements in - /// the last row of the grid to be centered. - private func elementXOffset(for offset: Int) -> CGFloat { - let dataCount = data.count - let lastRowCount = dataCount % columnCount - - guard lastRowCount > 0 else { return 0 } - - let lastRowIndices = (dataCount - lastRowCount ..< dataCount) - - guard lastRowIndices.contains(offset) else { return 0 } - - let lastRowMissingCount = columnCount - lastRowCount - return CGFloat(lastRowMissingCount) * (elementSize.width + spacing) / 2 - } + private let innerContent: () -> any View var body: some View { - LazyVGrid(columns: columns, spacing: spacing) { - ForEach(Array(data.enumerated()), id: \.offset) { offset, element in - content(element) - .trackingSize($elementSize) - .offset(x: elementXOffset(for: offset)) - } - } + innerContent() + .eraseToAnyView() } } @@ -57,13 +28,35 @@ extension CenteredLazyVGrid { spacing: CGFloat = 0, @ViewBuilder content: @escaping (Data.Element) -> Content ) { - self.columnCount = columns - self.content = content - self.data = data - self.id = id - self.spacing = spacing + self.innerContent = { + FixedColumnContentView( + columnCount: columns, + content: content, + data: data, + id: id, + spacing: spacing + ) + } + } - self.columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns) + init( + data: Data, + id: KeyPath, + minimum: CGFloat, + maximum: CGFloat, + spacing: CGFloat = 0, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.innerContent = { + AdaptiveContentView( + content: content, + data: data, + id: id, + maximum: maximum, + minimum: minimum, + spacing: spacing + ) + } } } @@ -83,4 +76,129 @@ extension CenteredLazyVGrid where Data.Element: Identifiable, ID == Data.Element content: content ) } + + init( + data: Data, + minimum: CGFloat, + maximum: CGFloat, + spacing: CGFloat = 0, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.init( + data: data, + id: \.id, + minimum: minimum, + maximum: maximum, + spacing: spacing, + content: content + ) + } +} + +extension CenteredLazyVGrid { + + private struct AdaptiveContentView: View { + + @State + private var contentSize: CGSize = .zero + @State + private var elementSize: CGSize = .zero + + let content: (Data.Element) -> Content + let data: Data + let id: KeyPath + let maximum: CGFloat + let minimum: CGFloat + let spacing: CGFloat + + private var columnCount: Int? { + let elementSizeAndWidth = elementSize.width + spacing + guard elementSizeAndWidth > 0 else { return nil } + + let additionalPadding = data.count >= 1 ? spacing : 0 + + return Int((contentSize.width + additionalPadding) / elementSizeAndWidth) + } + + private func elementXOffset(for offset: Int) -> CGFloat { + guard let columnCount, columnCount > 0 else { return 0 } + let dataCount = data.count + let lastRowCount = dataCount % columnCount + + guard lastRowCount > 0 else { return 0 } + + let lastRowIndices = (dataCount - lastRowCount ..< dataCount) + + guard lastRowIndices.contains(offset) else { return 0 } + + let lastRowMissingCount = columnCount - lastRowCount + return CGFloat(lastRowMissingCount) * (elementSize.width + spacing) / 2 + } + + var body: some View { + let columns: [GridItem] = [GridItem( + .adaptive(minimum: minimum, maximum: maximum), + spacing: spacing + )] + + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(data.enumerated()), id: \.offset) { offset, element in + content(element) + .trackingSize($elementSize) + .offset(x: elementXOffset(for: offset)) + } + } + .trackingSize($contentSize) + } + } +} + +extension CenteredLazyVGrid { + + private struct FixedColumnContentView: View { + + @State + private var elementSize: CGSize = .zero + + let columnCount: Int + let content: (Data.Element) -> Content + let data: Data + let id: KeyPath + let spacing: CGFloat + + /// Calculates the x offset for elements in + /// the last row of the grid to be centered. + private func elementXOffset(for offset: Int) -> CGFloat { + let columnCount = columnCount + let dataCount = data.count + let lastRowCount = dataCount % columnCount + + guard lastRowCount > 0 else { return 0 } + + let lastRowIndices = (dataCount - lastRowCount ..< dataCount) + + guard lastRowIndices.contains(offset) else { return 0 } + + let lastRowMissingCount = columnCount - lastRowCount + return CGFloat(lastRowMissingCount) * (elementSize.width + spacing) / 2 + } + + var body: some View { + let columns = Array( + repeating: GridItem( + .flexible(), + spacing: spacing + ), + count: columnCount + ) + + LazyVGrid(columns: columns, spacing: spacing) { + ForEach(Array(data.enumerated()), id: \.offset) { offset, element in + content(element) + .trackingSize($elementSize) + .offset(x: elementXOffset(for: offset)) + } + } + } + } } diff --git a/Shared/Extensions/ViewExtensions/Backport/Backport.swift b/Shared/Extensions/ViewExtensions/Backport/Backport.swift index 639db398..92fe5a74 100644 --- a/Shared/Extensions/ViewExtensions/Backport/Backport.swift +++ b/Shared/Extensions/ViewExtensions/Backport/Backport.swift @@ -48,6 +48,17 @@ extension Backport where Content: View { } } + @ViewBuilder + func scrollClipDisabled(_ disabled: Bool = true) -> some View { + if #available(iOS 17, *) { + content.scrollClipDisabled(disabled) + } else { + content.introspect(.scrollView, on: .iOS(.v15), .tvOS(.v15)) { scrollView in + scrollView.clipsToBounds = !disabled + } + } + } + @ViewBuilder func scrollDisabled(_ disabled: Bool) -> some View { if #available(iOS 16, tvOS 16, *) { diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift index 1e24968a..3552e18a 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift @@ -9,7 +9,6 @@ import SwiftUI // TODO: both axes -// TODO: add scrollClipDisabled() to iOS when iOS 15 dropped struct ScrollIfLargerThanContainerModifier: ViewModifier { @@ -29,11 +28,10 @@ struct ScrollIfLargerThanContainerModifier: ViewModifier { content .trackingSize($contentSize) } - #if os(tvOS) - .scrollClipDisabled() - #endif .frame(maxHeight: contentSize.height >= layoutSize.height ? .infinity : contentSize.height) .backport + .scrollClipDisabled() + .backport .scrollDisabled(contentSize.height < layoutSize.height) .backport .scrollIndicators(.never) diff --git a/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift b/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift index ca8b0413..f4c50527 100644 --- a/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift +++ b/Shared/SwiftfinStore/SwiftfinStore+ServerState.swift @@ -74,7 +74,7 @@ extension ServerState { return response.value } - func splashScreenImageSource() -> ImageSource { + var splashScreenImageSource: ImageSource { let request = Paths.getSplashscreen() return ImageSource(url: client.fullURL(with: request)) } diff --git a/Shared/ViewModels/SelectUserViewModel.swift b/Shared/ViewModels/SelectUserViewModel.swift index 39429f32..a09037f7 100644 --- a/Shared/ViewModels/SelectUserViewModel.swift +++ b/Shared/ViewModels/SelectUserViewModel.swift @@ -8,7 +8,6 @@ import Combine import CoreStore -import Factory import Foundation import JellyfinAPI import KeychainSwift @@ -26,7 +25,7 @@ final class SelectUserViewModel: ViewModel, Eventful, Stateful { // MARK: Action enum Action: Equatable { - case deleteUsers([UserState]) + case deleteUsers(Set) case getServers case signIn(UserState, pin: String) } @@ -38,7 +37,7 @@ final class SelectUserViewModel: ViewModel, Eventful, Stateful { } @Published - var servers: OrderedDictionary = [:] + private(set) var servers: OrderedDictionary = [:] @Published var state: State = .content diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift index 0349549b..ab5f43a3 100644 --- a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -6,20 +6,15 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import CollectionVGrid import Defaults import Factory import JellyfinAPI import OrderedCollections import SwiftUI -// TODO: user deletion - struct SelectUserView: View { - // MARK: - User Grid Item Enum - - typealias UserGridItem = (user: UserState, server: ServerState) + typealias UserItem = (user: UserState, server: ServerState) // MARK: - Defaults @@ -72,20 +67,20 @@ struct SelectUserView: View { .servers .keys .shuffled() - .map { $0.splashScreenImageSource() } + .map(\.splashScreenImageSource) // need to evaluate server with id selection first case let (.server(id), _), let (.all, .server(id)): - guard let imageSource = viewModel + guard let server = viewModel .servers .keys .first(where: { $0.id == id }) else { return [] } - return [imageSource.splashScreenImageSource()] + return [server.splashScreenImageSource] } } - private var userGridItems: [UserGridItem] { + private var userItems: [UserItem] { switch serverSelection { case .all: return viewModel.servers @@ -95,19 +90,27 @@ struct SelectUserView: View { .flatMap { $0 } .sorted(using: \.user.username) .reversed() - .map { UserGridItem(user: $0.user, server: $0.server) } + .map { UserItem(user: $0.user, server: $0.server) } case let .server(id: id): guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { - assertionFailure("server with ID not found?") return [] } return viewModel.servers[server]! .sorted(using: \.username) - .map { UserGridItem(user: $0, server: server) } + .map { UserItem(user: $0, server: server) } } } + private func addUserSelected(server: ServerState) { + router.route(to: \.userSignIn, server) + } + + private func delete(user: UserState) { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true + } + // MARK: - Select User(s) private func select(user: UserState, needsPin: Bool = true) { @@ -133,7 +136,7 @@ struct SelectUserView: View { @ViewBuilder private var userGrid: some View { CenteredLazyVGrid( - data: userGridItems, + data: userItems, id: \.user.id, columns: 5, spacing: EdgeInsets.edgePadding @@ -155,6 +158,7 @@ struct SelectUserView: View { selectedUsers.insert(user) isPresentingConfirmDeleteUsers = true } + .environment(\.isSelected, selectedUsers.contains(user)) } } @@ -188,7 +192,7 @@ struct SelectUserView: View { .frame(height: 100) Group { - if userGridItems.isEmpty { + if userItems.isEmpty { addUserButtonGrid } else { userGrid @@ -199,6 +203,7 @@ struct SelectUserView: View { .scrollIfLargerThanContainer(padding: 100) .scrollViewOffset($scrollViewOffset) } + .environment(\.isEditing, isEditingUsers) SelectUserBottomBar( isEditing: $isEditingUsers, @@ -206,14 +211,14 @@ struct SelectUserView: View { selectedServer: selectedServer, servers: viewModel.servers.keys, areUsersSelected: selectedUsers.isNotEmpty, - hasUsers: userGridItems.isNotEmpty + hasUsers: userItems.isNotEmpty ) { isPresentingConfirmDeleteUsers = true } toggleAllUsersSelected: { if selectedUsers.isNotEmpty { selectedUsers.removeAll() } else { - selectedUsers.insert(contentsOf: userGridItems.map(\.user)) + selectedUsers.insert(contentsOf: userItems.map(\.user)) } } .focusSection() @@ -237,10 +242,10 @@ struct SelectUserView: View { } } - // MARK: - Empty View + // MARK: - Connect to Server View @ViewBuilder - private var emptyView: some View { + private var connectToServerView: some View { VStack(spacing: 50) { L10n.connectToJellyfinServerStart.text .font(.body) @@ -279,7 +284,7 @@ struct SelectUserView: View { var body: some View { ZStack { if viewModel.servers.isEmpty { - emptyView + connectToServerView } else { contentView } @@ -300,6 +305,22 @@ struct SelectUserView: View { selectedUsers.removeAll() } } + .onChange(of: viewModel.servers.keys) { + let newValue = viewModel.servers.keys + + if case let SelectUserServerSelection.server(id: id) = serverSelection, + !newValue.contains(where: { $0.id == id }) + { + if newValue.count == 1, let firstServer = newValue.first { + let newSelection = SelectUserServerSelection.server(id: firstServer.id) + serverSelection = newSelection + selectUserAllServersSplashscreen = newSelection + } else { + serverSelection = .all + selectUserAllServersSplashscreen = .all + } + } + } .onReceive(viewModel.events) { event in switch event { case let .error(eventError): @@ -323,14 +344,12 @@ struct SelectUserView: View { } .confirmationDialog( Text(L10n.deleteUser), - isPresented: $isPresentingConfirmDeleteUsers, - presenting: selectedUsers - ) { selectedUsers in + isPresented: $isPresentingConfirmDeleteUsers + ) { Button(L10n.delete, role: .destructive) { - viewModel.send(.deleteUsers(Array(selectedUsers))) - isEditingUsers = false + viewModel.send(.deleteUsers(selectedUsers)) } - } message: { selectedUsers in + } message: { if selectedUsers.count == 1, let first = selectedUsers.first { Text(L10n.deleteUserSingleConfirmation(first.username)) } else { diff --git a/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift index 2c683029..3d05283d 100644 --- a/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView/UserSignInView.swift @@ -186,7 +186,7 @@ struct UserSignInView: View { isLoading: viewModel.state == .signingIn, leadingTitle: L10n.signInToServer(viewModel.server.name), trailingTitle: L10n.publicUsers, - backgroundImageSource: viewModel.server.splashScreenImageSource() + backgroundImageSource: viewModel.server.splashScreenImageSource ) { signInSection } trailingContentView: { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 396690f6..6e868841 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -545,8 +545,8 @@ E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */; }; E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; - E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB32BD9803100A92EAF /* UserRow.swift */; }; - E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */; }; + E10B1EB42BD9803100A92EAF /* UserListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB32BD9803100A92EAF /* UserListRow.swift */; }; + E10B1EB62BD98C6600A92EAF /* AddUserListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */; }; E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; E10B1EBF2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; E10B1EC12BD9AD6100A92EAF /* V1UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */; }; @@ -1172,7 +1172,7 @@ E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; }; E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; - E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */; }; + E1DD20412BE1EB8C00C0DE51 /* AddUserGridButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD20402BE1EB8C00C0DE51 /* AddUserGridButton.swift */; }; E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DD55382B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DD95CC2D07876400335494 /* SeparatorVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD95CB2D07876400335494 /* SeparatorVStack.swift */; }; @@ -1705,8 +1705,8 @@ E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = ""; }; E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; - E10B1EB32BD9803100A92EAF /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = ""; }; - E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserRow.swift; sourceTree = ""; }; + E10B1EB32BD9803100A92EAF /* UserListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListRow.swift; sourceTree = ""; }; + E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserListRow.swift; sourceTree = ""; }; E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1ServerModel.swift; sourceTree = ""; }; E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1UserModel.swift; sourceTree = ""; }; E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ServerModel.swift; sourceTree = ""; }; @@ -2088,7 +2088,7 @@ E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = ""; }; E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = ""; }; - E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = ""; }; + E1DD20402BE1EB8C00C0DE51 /* AddUserGridButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserGridButton.swift; sourceTree = ""; }; E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; E1DD95CB2D07876400335494 /* SeparatorVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorVStack.swift; sourceTree = ""; }; E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; @@ -4285,11 +4285,11 @@ E10B1EB02BD9769C00A92EAF /* Components */ = { isa = PBXGroup; children = ( - E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */, - E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */, + E1DD20402BE1EB8C00C0DE51 /* AddUserGridButton.swift */, + E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */, E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */, E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */, - E10B1EB32BD9803100A92EAF /* UserRow.swift */, + E10B1EB32BD9803100A92EAF /* UserListRow.swift */, ); path = Components; sourceTree = ""; @@ -6609,7 +6609,7 @@ E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */, E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, 4EB538C12CE3CF0F00EB72D5 /* ManagementSection.swift in Sources */, - E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */, + E10B1EB42BD9803100A92EAF /* UserListRow.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */, 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */, E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */, @@ -6902,9 +6902,9 @@ E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, - E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */, + E10B1EB62BD98C6600A92EAF /* AddUserListRow.swift in Sources */, E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, - E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */, + E1DD20412BE1EB8C00C0DE51 /* AddUserGridButton.swift in Sources */, 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift deleted file mode 100644 index 5ebfa214..00000000 --- a/Swiftfin/Views/SelectUserView/Components/AddUserButton.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import OrderedCollections -import SwiftUI - -extension SelectUserView { - - struct AddUserButton: View { - - @Binding - private var serverSelection: SelectUserServerSelection - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEnabled) - private var isEnabled - - private let action: (ServerState) -> Void - private let servers: OrderedSet - - private var selectedServer: ServerState? { - if case let SelectUserServerSelection.server(id: id) = serverSelection, - let server = servers.first(where: { server in server.id == id }) - { - return server - } - - return nil - } - - init( - serverSelection: Binding, - servers: OrderedSet, - action: @escaping (ServerState) -> Void - ) { - self._serverSelection = serverSelection - self.action = action - self.servers = servers - } - - @ViewBuilder - private var content: some View { - VStack(alignment: .center) { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "plus") - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - - if serverSelection == .all { - Text(L10n.hidden) - .font(.footnote) - .hidden() - } - } - } - - var body: some View { - if serverSelection == .all { - Menu { - - Text(L10n.selectServer) - - ForEach(servers) { server in - Button { - action(server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } label: { - content - } - .disabled(!isEnabled) - .foregroundStyle(.primary, .secondary) - } else { - Button { - if let selectedServer { - action(selectedServer) - } - } label: { - content - } - .buttonStyle(.plain) - .disabled(!isEnabled) - } - } - } -} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift b/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift new file mode 100644 index 00000000..847bc7ac --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserGridButton.swift @@ -0,0 +1,79 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserGridButton: View { + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var label: some View { + VStack(alignment: .center) { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .clipShape(.circle) + .aspectRatio(1, contentMode: .fill) + + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if selectedServer == nil { + // For layout, not to be localized + Text("Hidden") + .font(.footnote) + .hidden() + } + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + .buttonStyle(.plain) + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift new file mode 100644 index 00000000..186a6dbf --- /dev/null +++ b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift @@ -0,0 +1,92 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import OrderedCollections +import SwiftUI + +extension SelectUserView { + + struct AddUserListRow: View { + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEnabled) + private var isEnabled + + let selectedServer: ServerState? + let servers: OrderedSet + let action: (ServerState) -> Void + + @ViewBuilder + private var rowContent: some View { + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var rowLeading: some View { + ZStack { + Group { + if colorScheme == .light { + Color.secondarySystemFill + } else { + Color.tertiarySystemBackground + } + } + .posterShadow() + + RelativeSystemImageView(systemName: "plus") + .foregroundStyle(.secondary) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(.circle) + .frame(width: 80) + .padding(.vertical, 8) + } + + @ViewBuilder + private var label: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + rowContent + } + .isSeparatorVisible(false) + .onSelect { + if let selectedServer { + action(selectedServer) + } + } + } + + var body: some View { + ConditionalMenu( + tracking: selectedServer, + action: action + ) { + Text(L10n.selectServer) + + ForEach(servers) { server in + Button { + action(server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } label: { + label + } + } + } +} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift deleted file mode 100644 index c1826586..00000000 --- a/Swiftfin/Views/SelectUserView/Components/AddUserRow.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import OrderedCollections -import SwiftUI - -extension SelectUserView { - - struct AddUserRow: View { - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEnabled) - private var isEnabled - - @Binding - private var serverSelection: SelectUserServerSelection - - private let action: (ServerState) -> Void - private let servers: OrderedSet - - private var selectedServer: ServerState? { - if case let SelectUserServerSelection.server(id: id) = serverSelection, - let server = servers.first(where: { server in server.id == id }) - { - return server - } - - return nil - } - - init( - serverSelection: Binding, - servers: OrderedSet, - action: @escaping (ServerState) -> Void - ) { - self._serverSelection = serverSelection - self.action = action - self.servers = servers - } - - @ViewBuilder - private var rowContent: some View { - HStack { - - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - Spacer() - } - } - - @ViewBuilder - private var rowLeading: some View { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "plus") - .foregroundStyle(.secondary) - } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) - .frame(width: 80) - .padding(.vertical, 8) - } - - @ViewBuilder - private var content: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .isSeparatorVisible(false) - .onSelect { - if let selectedServer { - action(selectedServer) - } - } - } - - var body: some View { - if serverSelection == .all { - Menu { - - Text(L10n.selectServer) - - ForEach(servers) { server in - Button { - action(server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } label: { - content - } - .disabled(!isEnabled) - .foregroundStyle(.primary, .secondary) - } else { - content - .disabled(!isEnabled) - .foregroundStyle(.primary, .secondary) - } - } - } -} diff --git a/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift index 38b64834..16e1609e 100644 --- a/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift +++ b/Swiftfin/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import OrderedCollections import SwiftUI extension SelectUserView { @@ -21,25 +22,17 @@ extension SelectUserView { @Binding private var serverSelection: SelectUserServerSelection - @ObservedObject - private var viewModel: SelectUserViewModel - - private var selectedServer: ServerState? { - if case let SelectUserServerSelection.server(id: id) = serverSelection, - let server = viewModel.servers.keys.first(where: { server in server.id == id }) - { - return server - } - - return nil - } + let selectedServer: ServerState? + let servers: OrderedSet init( selection: Binding, - viewModel: SelectUserViewModel + selectedServer: ServerState?, + servers: OrderedSet ) { self._serverSelection = selection - self.viewModel = viewModel + self.selectedServer = selectedServer + self.servers = servers } var body: some View { @@ -58,12 +51,12 @@ extension SelectUserView { Picker(L10n.servers, selection: _serverSelection) { - if viewModel.servers.keys.count > 1 { + if servers.count > 1 { Label(L10n.allServers, systemImage: "person.2.fill") .tag(SelectUserServerSelection.all) } - ForEach(viewModel.servers.keys.reversed()) { server in + ForEach(servers.reversed()) { server in Button { Text(server.name) Text(server.currentURL.absoluteString) @@ -85,7 +78,7 @@ extension SelectUserView { case .all: Label(L10n.allServers, systemImage: "person.2.fill") case let .server(id): - if let server = viewModel.servers.keys.first(where: { $0.id == id }) { + if let server = servers.first(where: { $0.id == id }) { Label(server.name, systemImage: "server.rack") } } diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift similarity index 99% rename from Swiftfin/Views/SelectUserView/Components/UserRow.swift rename to Swiftfin/Views/SelectUserView/Components/UserListRow.swift index bbc5e7f8..3c9fea55 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift @@ -12,7 +12,7 @@ import SwiftUI extension SelectUserView { - struct UserRow: View { + struct UserListRow: View { @Default(.accentColor) private var accentColor diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift index 19a2b366..47ff1d17 100644 --- a/Swiftfin/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -6,12 +6,10 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // -import CollectionVGrid import Defaults import Factory import JellyfinAPI import LocalAuthentication -import OrderedCollections import SwiftUI // TODO: authentication view during device authentication @@ -23,9 +21,13 @@ import SwiftUI // TODO: between the server selection menu and delete toolbar, // figure out a way to make the grid/list and splash screen // not jump when size is changed +// TODO: fix splash screen pulsing +// - should have used successful image source binding on ImageView? struct SelectUserView: View { + typealias UserItem = (user: UserState, server: ServerState) + // MARK: - Defaults @Default(.selectUserUseSplashscreen) @@ -37,44 +39,17 @@ struct SelectUserView: View { @Default(.selectUserDisplayType) private var userListDisplayType - // MARK: - Environment Variable - - @Environment(\.colorScheme) - private var colorScheme - - // MARK: - Focus Fields - - private enum UserGridItem: Hashable { - case user(UserState, server: ServerState) - case addUser - } - // MARK: - State & Environment Objects @EnvironmentObject private var router: SelectUserCoordinator.Router - @StateObject - private var viewModel = SelectUserViewModel() - - // MARK: - Select Users Variables - - @State - private var contentSize: CGSize = .zero - @State - private var gridItems: OrderedSet = [] - @State - private var gridItemSize: CGSize = .zero @State private var isEditingUsers: Bool = false @State - private var padGridItemColumnCount: Int = 1 - @State private var pin: String = "" @State private var selectedUsers: Set = [] - @State - private var splashScreenImageSources: [ImageSource] = [] // MARK: - Dialog States @@ -88,85 +63,63 @@ struct SelectUserView: View { @State private var error: Error? = nil - private var users: [UserState] { - gridItems.compactMap { item in - switch item { - case let .user(user, _): - return user - default: - return nil - } - } - } - - // MARK: - Select Server + @StateObject + private var viewModel = SelectUserViewModel() private var selectedServer: ServerState? { - if case let SelectUserServerSelection.server(id: id) = serverSelection, - let server = viewModel.servers.keys.first(where: { server in server.id == id }) - { - return server - } - - return nil + serverSelection.server(from: viewModel.servers.keys) } - // MARK: - Make Grid Items + private var splashScreenImageSources: [ImageSource] { + switch (serverSelection, selectUserAllServersSplashscreen) { + case (.all, .all): + return viewModel + .servers + .keys + .shuffled() + .map(\.splashScreenImageSource) - private func makeGridItems(for serverSelection: SelectUserServerSelection) -> OrderedSet { + // need to evaluate server with id selection first + case let (.server(id), _), let (.all, .server(id)): + guard let server = viewModel + .servers + .keys + .first(where: { $0.id == id }) else { return [] } + + return [server.splashScreenImageSource] + } + } + + private var userItems: [UserItem] { switch serverSelection { case .all: - let items = viewModel.servers + return viewModel.servers .map { server, users in users.map { (server: server, user: $0) } } .flatMap { $0 } .sorted(using: \.user.username) .reversed() - .map { UserGridItem.user($0.user, server: $0.server) } - .appending(.addUser) - - return OrderedSet(items) + .map { UserItem(user: $0.user, server: $0.server) } case let .server(id: id): guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { - assertionFailure("server with ID not found?") - return [.addUser] + return [] } - let items = viewModel.servers[server]! + return viewModel.servers[server]! .sorted(using: \.username) - .map { UserGridItem.user($0, server: server) } - .appending(.addUser) - - return OrderedSet(items) + .map { UserItem(user: $0, server: server) } } } - // MARK: - Make Splash Screen Image Source + private func addUserSelected(server: ServerState) { + UIDevice.impact(.light) + router.route(to: \.userSignIn, server) + } - // For all server selection, .all is random - private func makeSplashScreenImageSources( - serverSelection: SelectUserServerSelection, - allServersSelection: SelectUserServerSelection - ) -> [ImageSource] { - switch (serverSelection, allServersSelection) { - case (.all, .all): - return viewModel - .servers - .keys - .shuffled() - .map { $0.splashScreenImageSource() } - - // need to evaluate server with id selection first - case let (.server(id), _), let (.all, .server(id)): - return [ - viewModel - .servers - .keys - .first { $0.id == id }? - .splashScreenImageSource() ?? .init(), - ] - } + private func delete(user: UserState) { + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true } // MARK: - Select User(s) @@ -230,14 +183,37 @@ struct SelectUserView: View { Menu(L10n.advanced, systemImage: "gearshape.fill") { Section { - if gridItems.count > 1 { - Button(L10n.editUsers, systemImage: "person.crop.circle") { - isEditingUsers.toggle() + + if userItems.isNotEmpty { + ConditionalMenu( + tracking: selectedServer, + action: addUserSelected + ) { + Section(L10n.servers) { + let servers = viewModel.servers.keys + + ForEach(servers) { server in + Button { + addUserSelected(server: server) + } label: { + Text(server.name) + Text(server.currentURL.absoluteString) + } + } + } + } label: { + Label(L10n.addUser, systemImage: "plus") } + + Toggle( + L10n.editUsers, + systemImage: "person.crop.circle", + isOn: $isEditingUsers + ) } } - if !viewModel.servers.isEmpty { + if viewModel.servers.isNotEmpty { Picker(selection: $userListDisplayType) { ForEach(LibraryDisplayType.allCases, id: \.hashValue) { Label($0.displayTitle, systemImage: $0.systemImage) @@ -259,38 +235,59 @@ struct SelectUserView: View { } } - // MARK: - iPad Grid Item Offset + @ViewBuilder + private var addUserGridButtonView: some View { + AddUserGridButton( + selectedServer: selectedServer, + servers: viewModel.servers.keys, + action: addUserSelected + ) + } - private func padGridItemOffset(index: Int) -> CGFloat { - let lastRowIndices = (gridItems.count - gridItems.count % padGridItemColumnCount ..< gridItems.count) + @ViewBuilder + private func userGridItemView(for item: UserItem) -> some View { + let user = item.user + let server = item.server - guard lastRowIndices.contains(index) else { return 0 } - - let lastRowMissing = padGridItemColumnCount - gridItems.count % padGridItemColumnCount - return CGFloat(lastRowMissing) * (gridItemSize.width + EdgeInsets.edgePadding) / 2 + UserGridButton( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + delete(user: user) + } + .environment(\.isSelected, selectedUsers.contains(user)) } // MARK: - iPad Grid Content View @ViewBuilder private var padGridContentView: some View { - let columns = [GridItem(.adaptive(minimum: 150, maximum: 300), spacing: EdgeInsets.edgePadding)] - - LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { - ForEach(Array(gridItems.enumerated().map(\.offset)), id: \.hashValue) { index in - let item = gridItems[index] - - gridItemView(for: item) - .trackingSize($gridItemSize) - .offset(x: padGridItemOffset(index: index)) + if userItems.isEmpty { + CenteredLazyVGrid( + data: [0], + id: \.self, + minimum: 150, + maximum: 300, + spacing: EdgeInsets.edgePadding + ) { _ in + addUserGridButtonView } - } - .edgePadding() - .scrollIfLargerThanContainer(padding: 100) - .onChange(of: gridItemSize) { newValue in - let columns = Int(contentSize.width / (newValue.width + EdgeInsets.edgePadding)) - - padGridItemColumnCount = columns + } else { + CenteredLazyVGrid( + data: userItems, + id: \.user.id, + minimum: 150, + maximum: 300, + spacing: EdgeInsets.edgePadding, + content: userGridItemView + ) } } @@ -298,52 +295,23 @@ struct SelectUserView: View { @ViewBuilder private var phoneGridContentView: some View { - let columns = [GridItem(.flexible(), spacing: EdgeInsets.edgePadding), GridItem(.flexible())] - - LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { - ForEach(gridItems, id: \.hashValue) { item in - gridItemView(for: item) - .if(gridItems.count % 2 == 1 && item == gridItems.last) { view in - view.trackingSize($gridItemSize) - .offset(x: (gridItemSize.width + EdgeInsets.edgePadding) / 2) - } + if userItems.isEmpty { + CenteredLazyVGrid( + data: [0], + id: \.self, + columns: 2 + ) { _ in + addUserGridButtonView } - } - .edgePadding() - .scrollIfLargerThanContainer(padding: 100) - } - - // MARK: - Grid Item View - - @ViewBuilder - private func gridItemView(for item: UserGridItem) -> some View { - switch item { - case let .user(user, server): - UserGridButton( - user: user, - server: server, - showServer: serverSelection == .all - ) { - if isEditingUsers { - selectedUsers.toggle(value: user) - } else { - select(user: user) - } - } onDelete: { - selectedUsers.insert(user) - isPresentingConfirmDeleteUsers = true - } - .environment(\.isEditing, isEditingUsers) - .environment(\.isSelected, selectedUsers.contains(user)) - case .addUser: - AddUserButton( - serverSelection: $serverSelection, - servers: viewModel.servers.keys - ) { server in - UIDevice.impact(.light) - router.route(to: \.userSignIn, server) - } - .environment(\.isEnabled, !isEditingUsers) + } else { + CenteredLazyVGrid( + data: userItems, + id: \.user.id, + columns: 2, + spacing: EdgeInsets.edgePadding, + content: userGridItemView + ) + .edgePadding() } } @@ -351,71 +319,79 @@ struct SelectUserView: View { @ViewBuilder private var listContentView: some View { - ScrollView { - LazyVStack { - ForEach(gridItems, id: \.hashValue) { item in - listItemView(for: item) + List { + let userItems = self.userItems + + if userItems.isEmpty { + AddUserListRow( + selectedServer: selectedServer, + servers: viewModel.servers.keys, + action: addUserSelected + ) + .listRowBackground(EmptyView()) + .listRowInsets(.zero) + .listRowSeparator(.hidden) + } + + ForEach(userItems, id: \.user.id) { item in + let user = item.user + let server = item.server + + UserListRow( + user: user, + server: server, + showServer: serverSelection == .all + ) { + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + select(user: user) + } + } onDelete: { + delete(user: user) + } + .environment(\.isSelected, selectedUsers.contains(user)) + .swipeActions { + if !isEditingUsers { + Button( + L10n.delete, + systemImage: "trash" + ) { + delete(user: user) + } + .tint(.red) + } } } + .listRowBackground(EmptyView()) + .listRowInsets(.zero) + .listRowSeparator(.hidden) } - } - - // MARK: - List Item View - - @ViewBuilder - private func listItemView(for item: UserGridItem) -> some View { - switch item { - case let .user(user, server): - UserRow( - user: user, - server: server, - showServer: serverSelection == .all - ) { - if isEditingUsers { - selectedUsers.toggle(value: user) - } else { - select(user: user) - } - } onDelete: { - selectedUsers.insert(user) - isPresentingConfirmDeleteUsers = true - } - .environment(\.isEditing, isEditingUsers) - .environment(\.isSelected, selectedUsers.contains(user)) - case .addUser: - AddUserRow( - serverSelection: $serverSelection, - servers: viewModel.servers.keys - ) { server in - UIDevice.impact(.light) - router.route(to: \.userSignIn, server) - } - .environment(\.isEnabled, !isEditingUsers) - } + .listStyle(.plain) } // MARK: - User View @ViewBuilder - private var userView: some View { + private var contentView: some View { VStack(spacing: 0) { ZStack { - Color.clear - .onSizeChanged { size, _ in - contentSize = size - } - switch userListDisplayType { case .grid: - if UIDevice.isPhone { - phoneGridContentView - } else { - padGridContentView + Group { + if UIDevice.isPhone { + phoneGridContentView + } else { + padGridContentView + } } + .scrollIfLargerThanContainer(padding: 100) case .list: listContentView } } + .animation(.linear(duration: 0.1), value: userListDisplayType) + .environment(\.isEditing, isEditingUsers) .frame(maxHeight: .infinity) .mask { VStack(spacing: 0) { @@ -436,7 +412,8 @@ struct SelectUserView: View { if !isEditingUsers { ServerSelectionMenu( selection: $serverSelection, - viewModel: viewModel + selectedServer: selectedServer, + servers: viewModel.servers.keys ) .edgePadding([.bottom, .horizontal]) } @@ -449,9 +426,8 @@ struct SelectUserView: View { ImageView(splashScreenImageSources) .pipeline(.Swiftfin.local) .aspectRatio(contentMode: .fill) + .transition(.opacity.animation(.linear(duration: 0.1))) .id(splashScreenImageSources) - .transition(.opacity) - .animation(.linear, value: splashScreenImageSources) Color.black .opacity(0.9) @@ -461,10 +437,10 @@ struct SelectUserView: View { } } - // MARK: - Empty View + // MARK: - Connect to Server View @ViewBuilder - private var emptyView: some View { + private var connectToServerView: some View { VStack(spacing: 10) { L10n.connectToJellyfinServerStart.text .frame(minWidth: 50, maxWidth: 240) @@ -481,11 +457,11 @@ struct SelectUserView: View { // MARK: - Body var body: some View { - WrappedView { + ZStack { if viewModel.servers.isEmpty { - emptyView + connectToServerView } else { - userView + contentView } } .ignoresSafeArea(.keyboard, edges: .bottom) @@ -501,14 +477,14 @@ struct SelectUserView: View { ToolbarItem(placement: .topBarLeading) { if isEditingUsers { - if selectedUsers.count == users.count { + if selectedUsers.count == userItems.count { Button(L10n.removeAll) { selectedUsers.removeAll() } .buttonStyle(.toolbarPill) } else { Button(L10n.selectAll) { - selectedUsers.insert(contentsOf: users) + selectedUsers.insert(contentsOf: userItems.map(\.user)) } .buttonStyle(.toolbarPill) } @@ -545,11 +521,6 @@ struct SelectUserView: View { } .onAppear { viewModel.send(.getServers) - - splashScreenImageSources = makeSplashScreenImageSources( - serverSelection: serverSelection, - allServersSelection: selectUserAllServersSplashscreen - ) } .onChange(of: isEditingUsers) { newValue in guard !newValue else { return } @@ -561,29 +532,23 @@ struct SelectUserView: View { selectedUsers.removeAll() } .onChange(of: isPresentingLocalPin) { newValue in - if newValue { - pin = "" - } else { - selectedUsers.removeAll() + guard newValue else { return } + pin = "" + } + .onChange(of: viewModel.servers.keys) { newValue in + if case let SelectUserServerSelection.server(id: id) = serverSelection, + !newValue.contains(where: { $0.id == id }) + { + if newValue.count == 1, let firstServer = newValue.first { + let newSelection = SelectUserServerSelection.server(id: firstServer.id) + serverSelection = newSelection + selectUserAllServersSplashscreen = newSelection + } else { + serverSelection = .all + selectUserAllServersSplashscreen = .all + } } } - .onChange(of: selectUserAllServersSplashscreen) { newValue in - splashScreenImageSources = makeSplashScreenImageSources( - serverSelection: serverSelection, - allServersSelection: newValue - ) - } - .onChange(of: serverSelection) { newValue in - gridItems = makeGridItems(for: newValue) - - splashScreenImageSources = makeSplashScreenImageSources( - serverSelection: newValue, - allServersSelection: selectUserAllServersSplashscreen - ) - } - .onChange(of: viewModel.servers) { _ in - gridItems = makeGridItems(for: serverSelection) - } .onReceive(viewModel.events) { event in switch event { case let .error(eventError): @@ -601,33 +566,20 @@ struct SelectUserView: View { viewModel.send(.getServers) serverSelection = .server(id: server.id) } - .onNotification(.didChangeCurrentServerURL) { server in + .onNotification(.didChangeCurrentServerURL) { _ in viewModel.send(.getServers) - serverSelection = .server(id: server.id) } - .onNotification(.didDeleteServer) { server in + .onNotification(.didDeleteServer) { _ in viewModel.send(.getServers) - - if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { - if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { - serverSelection = .server(id: first.id) - } else { - serverSelection = .all - } - } - - // change splash screen selection if necessary - selectUserAllServersSplashscreen = serverSelection } .alert( - Text(L10n.deleteUser), - isPresented: $isPresentingConfirmDeleteUsers, - presenting: selectedUsers - ) { selectedUsers in + L10n.deleteUser, + isPresented: $isPresentingConfirmDeleteUsers + ) { Button(L10n.delete, role: .destructive) { - viewModel.send(.deleteUsers(Array(selectedUsers))) + viewModel.send(.deleteUsers(selectedUsers)) } - } message: { selectedUsers in + } message: { if selectedUsers.count == 1, let first = selectedUsers.first { Text(L10n.deleteUserSingleConfirmation(first.username)) } else {