Clean Up `SelectUserView` (#1482)

* cleanup

* fix adaptive layout
This commit is contained in:
Ethan Pippin 2025-04-06 21:58:47 -04:00 committed by GitHub
parent c0b875ed2a
commit 0845545417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 626 additions and 600 deletions

View File

@ -11,40 +11,11 @@ import SwiftUI
/// A LazyVGrid that centers its elements, most notably on the last row. /// A LazyVGrid that centers its elements, most notably on the last row.
struct CenteredLazyVGrid<Data: RandomAccessCollection, ID: Hashable, Content: View>: View { struct CenteredLazyVGrid<Data: RandomAccessCollection, ID: Hashable, Content: View>: View {
@State private let innerContent: () -> any View
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<Data.Element, ID>
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
}
var body: some View { var body: some View {
LazyVGrid(columns: columns, spacing: spacing) { innerContent()
ForEach(Array(data.enumerated()), id: \.offset) { offset, element in .eraseToAnyView()
content(element)
.trackingSize($elementSize)
.offset(x: elementXOffset(for: offset))
}
}
} }
} }
@ -57,13 +28,35 @@ extension CenteredLazyVGrid {
spacing: CGFloat = 0, spacing: CGFloat = 0,
@ViewBuilder content: @escaping (Data.Element) -> Content @ViewBuilder content: @escaping (Data.Element) -> Content
) { ) {
self.columnCount = columns self.innerContent = {
self.content = content FixedColumnContentView(
self.data = data columnCount: columns,
self.id = id content: content,
self.spacing = spacing data: data,
id: id,
spacing: spacing
)
}
}
self.columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: columns) init(
data: Data,
id: KeyPath<Data.Element, ID>,
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 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<Data.Element, ID>
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<Data.Element, ID>
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))
}
}
}
}
} }

View File

@ -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 @ViewBuilder
func scrollDisabled(_ disabled: Bool) -> some View { func scrollDisabled(_ disabled: Bool) -> some View {
if #available(iOS 16, tvOS 16, *) { if #available(iOS 16, tvOS 16, *) {

View File

@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
// TODO: both axes // TODO: both axes
// TODO: add scrollClipDisabled() to iOS when iOS 15 dropped
struct ScrollIfLargerThanContainerModifier: ViewModifier { struct ScrollIfLargerThanContainerModifier: ViewModifier {
@ -29,11 +28,10 @@ struct ScrollIfLargerThanContainerModifier: ViewModifier {
content content
.trackingSize($contentSize) .trackingSize($contentSize)
} }
#if os(tvOS)
.scrollClipDisabled()
#endif
.frame(maxHeight: contentSize.height >= layoutSize.height ? .infinity : contentSize.height) .frame(maxHeight: contentSize.height >= layoutSize.height ? .infinity : contentSize.height)
.backport .backport
.scrollClipDisabled()
.backport
.scrollDisabled(contentSize.height < layoutSize.height) .scrollDisabled(contentSize.height < layoutSize.height)
.backport .backport
.scrollIndicators(.never) .scrollIndicators(.never)

View File

@ -74,7 +74,7 @@ extension ServerState {
return response.value return response.value
} }
func splashScreenImageSource() -> ImageSource { var splashScreenImageSource: ImageSource {
let request = Paths.getSplashscreen() let request = Paths.getSplashscreen()
return ImageSource(url: client.fullURL(with: request)) return ImageSource(url: client.fullURL(with: request))
} }

View File

@ -8,7 +8,6 @@
import Combine import Combine
import CoreStore import CoreStore
import Factory
import Foundation import Foundation
import JellyfinAPI import JellyfinAPI
import KeychainSwift import KeychainSwift
@ -26,7 +25,7 @@ final class SelectUserViewModel: ViewModel, Eventful, Stateful {
// MARK: Action // MARK: Action
enum Action: Equatable { enum Action: Equatable {
case deleteUsers([UserState]) case deleteUsers(Set<UserState>)
case getServers case getServers
case signIn(UserState, pin: String) case signIn(UserState, pin: String)
} }
@ -38,7 +37,7 @@ final class SelectUserViewModel: ViewModel, Eventful, Stateful {
} }
@Published @Published
var servers: OrderedDictionary<ServerState, [UserState]> = [:] private(set) var servers: OrderedDictionary<ServerState, [UserState]> = [:]
@Published @Published
var state: State = .content var state: State = .content

View File

@ -6,20 +6,15 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import CollectionVGrid
import Defaults import Defaults
import Factory import Factory
import JellyfinAPI import JellyfinAPI
import OrderedCollections import OrderedCollections
import SwiftUI import SwiftUI
// TODO: user deletion
struct SelectUserView: View { struct SelectUserView: View {
// MARK: - User Grid Item Enum typealias UserItem = (user: UserState, server: ServerState)
typealias UserGridItem = (user: UserState, server: ServerState)
// MARK: - Defaults // MARK: - Defaults
@ -72,20 +67,20 @@ struct SelectUserView: View {
.servers .servers
.keys .keys
.shuffled() .shuffled()
.map { $0.splashScreenImageSource() } .map(\.splashScreenImageSource)
// need to evaluate server with id selection first // need to evaluate server with id selection first
case let (.server(id), _), let (.all, .server(id)): case let (.server(id), _), let (.all, .server(id)):
guard let imageSource = viewModel guard let server = viewModel
.servers .servers
.keys .keys
.first(where: { $0.id == id }) else { return [] } .first(where: { $0.id == id }) else { return [] }
return [imageSource.splashScreenImageSource()] return [server.splashScreenImageSource]
} }
} }
private var userGridItems: [UserGridItem] { private var userItems: [UserItem] {
switch serverSelection { switch serverSelection {
case .all: case .all:
return viewModel.servers return viewModel.servers
@ -95,19 +90,27 @@ struct SelectUserView: View {
.flatMap { $0 } .flatMap { $0 }
.sorted(using: \.user.username) .sorted(using: \.user.username)
.reversed() .reversed()
.map { UserGridItem(user: $0.user, server: $0.server) } .map { UserItem(user: $0.user, server: $0.server) }
case let .server(id: id): case let .server(id: id):
guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else {
assertionFailure("server with ID not found?")
return [] return []
} }
return viewModel.servers[server]! return viewModel.servers[server]!
.sorted(using: \.username) .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) // MARK: - Select User(s)
private func select(user: UserState, needsPin: Bool = true) { private func select(user: UserState, needsPin: Bool = true) {
@ -133,7 +136,7 @@ struct SelectUserView: View {
@ViewBuilder @ViewBuilder
private var userGrid: some View { private var userGrid: some View {
CenteredLazyVGrid( CenteredLazyVGrid(
data: userGridItems, data: userItems,
id: \.user.id, id: \.user.id,
columns: 5, columns: 5,
spacing: EdgeInsets.edgePadding spacing: EdgeInsets.edgePadding
@ -155,6 +158,7 @@ struct SelectUserView: View {
selectedUsers.insert(user) selectedUsers.insert(user)
isPresentingConfirmDeleteUsers = true isPresentingConfirmDeleteUsers = true
} }
.environment(\.isSelected, selectedUsers.contains(user))
} }
} }
@ -188,7 +192,7 @@ struct SelectUserView: View {
.frame(height: 100) .frame(height: 100)
Group { Group {
if userGridItems.isEmpty { if userItems.isEmpty {
addUserButtonGrid addUserButtonGrid
} else { } else {
userGrid userGrid
@ -199,6 +203,7 @@ struct SelectUserView: View {
.scrollIfLargerThanContainer(padding: 100) .scrollIfLargerThanContainer(padding: 100)
.scrollViewOffset($scrollViewOffset) .scrollViewOffset($scrollViewOffset)
} }
.environment(\.isEditing, isEditingUsers)
SelectUserBottomBar( SelectUserBottomBar(
isEditing: $isEditingUsers, isEditing: $isEditingUsers,
@ -206,14 +211,14 @@ struct SelectUserView: View {
selectedServer: selectedServer, selectedServer: selectedServer,
servers: viewModel.servers.keys, servers: viewModel.servers.keys,
areUsersSelected: selectedUsers.isNotEmpty, areUsersSelected: selectedUsers.isNotEmpty,
hasUsers: userGridItems.isNotEmpty hasUsers: userItems.isNotEmpty
) { ) {
isPresentingConfirmDeleteUsers = true isPresentingConfirmDeleteUsers = true
} toggleAllUsersSelected: { } toggleAllUsersSelected: {
if selectedUsers.isNotEmpty { if selectedUsers.isNotEmpty {
selectedUsers.removeAll() selectedUsers.removeAll()
} else { } else {
selectedUsers.insert(contentsOf: userGridItems.map(\.user)) selectedUsers.insert(contentsOf: userItems.map(\.user))
} }
} }
.focusSection() .focusSection()
@ -237,10 +242,10 @@ struct SelectUserView: View {
} }
} }
// MARK: - Empty View // MARK: - Connect to Server View
@ViewBuilder @ViewBuilder
private var emptyView: some View { private var connectToServerView: some View {
VStack(spacing: 50) { VStack(spacing: 50) {
L10n.connectToJellyfinServerStart.text L10n.connectToJellyfinServerStart.text
.font(.body) .font(.body)
@ -279,7 +284,7 @@ struct SelectUserView: View {
var body: some View { var body: some View {
ZStack { ZStack {
if viewModel.servers.isEmpty { if viewModel.servers.isEmpty {
emptyView connectToServerView
} else { } else {
contentView contentView
} }
@ -300,6 +305,22 @@ struct SelectUserView: View {
selectedUsers.removeAll() 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 .onReceive(viewModel.events) { event in
switch event { switch event {
case let .error(eventError): case let .error(eventError):
@ -323,14 +344,12 @@ struct SelectUserView: View {
} }
.confirmationDialog( .confirmationDialog(
Text(L10n.deleteUser), Text(L10n.deleteUser),
isPresented: $isPresentingConfirmDeleteUsers, isPresented: $isPresentingConfirmDeleteUsers
presenting: selectedUsers ) {
) { selectedUsers in
Button(L10n.delete, role: .destructive) { Button(L10n.delete, role: .destructive) {
viewModel.send(.deleteUsers(Array(selectedUsers))) viewModel.send(.deleteUsers(selectedUsers))
isEditingUsers = false
} }
} message: { selectedUsers in } message: {
if selectedUsers.count == 1, let first = selectedUsers.first { if selectedUsers.count == 1, let first = selectedUsers.first {
Text(L10n.deleteUserSingleConfirmation(first.username)) Text(L10n.deleteUserSingleConfirmation(first.username))
} else { } else {

View File

@ -186,7 +186,7 @@ struct UserSignInView: View {
isLoading: viewModel.state == .signingIn, isLoading: viewModel.state == .signingIn,
leadingTitle: L10n.signInToServer(viewModel.server.name), leadingTitle: L10n.signInToServer(viewModel.server.name),
trailingTitle: L10n.publicUsers, trailingTitle: L10n.publicUsers,
backgroundImageSource: viewModel.server.splashScreenImageSource() backgroundImageSource: viewModel.server.splashScreenImageSource
) { ) {
signInSection signInSection
} trailingContentView: { } trailingContentView: {

View File

@ -545,8 +545,8 @@
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; };
E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */; }; E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */; };
E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; };
E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB32BD9803100A92EAF /* UserRow.swift */; }; E10B1EB42BD9803100A92EAF /* UserListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB32BD9803100A92EAF /* UserListRow.swift */; };
E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */; }; E10B1EB62BD98C6600A92EAF /* AddUserListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */; };
E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; }; E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */; };
E10B1EBF2BD9AD5C00A92EAF /* 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 */; }; 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 */; }; E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9843296DECB600982F06 /* ProgressIndicator.swift */; };
E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; }; E1DC9847296DEFF500982F06 /* FavoriteIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */; };
E1DC9848296DEFF500982F06 /* 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 */; }; E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; };
E1DD55382B6EE533007501C0 /* 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 */; }; 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 = "<group>"; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = "<group>"; };
E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = "<group>"; }; E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = "<group>"; };
E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; }; E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
E10B1EB32BD9803100A92EAF /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; }; E10B1EB32BD9803100A92EAF /* UserListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListRow.swift; sourceTree = "<group>"; };
E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserRow.swift; sourceTree = "<group>"; }; E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUserListRow.swift; sourceTree = "<group>"; };
E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1ServerModel.swift; sourceTree = "<group>"; }; E10B1EBD2BD9AD5C00A92EAF /* V1ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1ServerModel.swift; sourceTree = "<group>"; };
E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1UserModel.swift; sourceTree = "<group>"; }; E10B1EC02BD9AD6100A92EAF /* V1UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1UserModel.swift; sourceTree = "<group>"; };
E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ServerModel.swift; sourceTree = "<group>"; }; E10B1EC62BD9AF6100A92EAF /* V2ServerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ServerModel.swift; sourceTree = "<group>"; };
@ -2088,7 +2088,7 @@
E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = "<group>"; }; E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedIndicator.swift; sourceTree = "<group>"; };
E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; }; E1DC9843296DECB600982F06 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; };
E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = "<group>"; }; E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = "<group>"; };
E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = "<group>"; }; E1DD20402BE1EB8C00C0DE51 /* AddUserGridButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserGridButton.swift; sourceTree = "<group>"; };
E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; }; E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
E1DD95CB2D07876400335494 /* SeparatorVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorVStack.swift; sourceTree = "<group>"; }; E1DD95CB2D07876400335494 /* SeparatorVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorVStack.swift; sourceTree = "<group>"; };
E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
@ -4285,11 +4285,11 @@
E10B1EB02BD9769C00A92EAF /* Components */ = { E10B1EB02BD9769C00A92EAF /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */, E1DD20402BE1EB8C00C0DE51 /* AddUserGridButton.swift */,
E10B1EB52BD98C6600A92EAF /* AddUserRow.swift */, E10B1EB52BD98C6600A92EAF /* AddUserListRow.swift */,
E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */, E145EB412BE0A6EE003BF6F3 /* ServerSelectionMenu.swift */,
E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */, E1BE1CE92BDB5AFE008176A9 /* UserGridButton.swift */,
E10B1EB32BD9803100A92EAF /* UserRow.swift */, E10B1EB32BD9803100A92EAF /* UserListRow.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -6609,7 +6609,7 @@
E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */, E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */,
E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */,
4EB538C12CE3CF0F00EB72D5 /* ManagementSection.swift in Sources */, 4EB538C12CE3CF0F00EB72D5 /* ManagementSection.swift in Sources */,
E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */, E10B1EB42BD9803100A92EAF /* UserListRow.swift in Sources */,
E1E6C45029B104840064123F /* Button.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */,
4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */, 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */,
E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */, E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */,
@ -6902,9 +6902,9 @@
E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */,
4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */, 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */,
E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */,
E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */, E10B1EB62BD98C6600A92EAF /* AddUserListRow.swift in Sources */,
E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */, E1DD20412BE1EB8C00C0DE51 /* AddUserGridButton.swift in Sources */,
4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */, 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */,
E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */,
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */,

View File

@ -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<ServerState>
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<SelectUserServerSelection>,
servers: OrderedSet<ServerState>,
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)
}
}
}
}

View File

@ -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<ServerState>
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)
}
}
}

View File

@ -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<ServerState>
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
}
}
}
}

View File

@ -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<ServerState>
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<SelectUserServerSelection>,
servers: OrderedSet<ServerState>,
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)
}
}
}
}

View File

@ -6,6 +6,7 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import OrderedCollections
import SwiftUI import SwiftUI
extension SelectUserView { extension SelectUserView {
@ -21,25 +22,17 @@ extension SelectUserView {
@Binding @Binding
private var serverSelection: SelectUserServerSelection private var serverSelection: SelectUserServerSelection
@ObservedObject let selectedServer: ServerState?
private var viewModel: SelectUserViewModel let servers: OrderedSet<ServerState>
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
}
init( init(
selection: Binding<SelectUserServerSelection>, selection: Binding<SelectUserServerSelection>,
viewModel: SelectUserViewModel selectedServer: ServerState?,
servers: OrderedSet<ServerState>
) { ) {
self._serverSelection = selection self._serverSelection = selection
self.viewModel = viewModel self.selectedServer = selectedServer
self.servers = servers
} }
var body: some View { var body: some View {
@ -58,12 +51,12 @@ extension SelectUserView {
Picker(L10n.servers, selection: _serverSelection) { Picker(L10n.servers, selection: _serverSelection) {
if viewModel.servers.keys.count > 1 { if servers.count > 1 {
Label(L10n.allServers, systemImage: "person.2.fill") Label(L10n.allServers, systemImage: "person.2.fill")
.tag(SelectUserServerSelection.all) .tag(SelectUserServerSelection.all)
} }
ForEach(viewModel.servers.keys.reversed()) { server in ForEach(servers.reversed()) { server in
Button { Button {
Text(server.name) Text(server.name)
Text(server.currentURL.absoluteString) Text(server.currentURL.absoluteString)
@ -85,7 +78,7 @@ extension SelectUserView {
case .all: case .all:
Label(L10n.allServers, systemImage: "person.2.fill") Label(L10n.allServers, systemImage: "person.2.fill")
case let .server(id): 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") Label(server.name, systemImage: "server.rack")
} }
} }

View File

@ -12,7 +12,7 @@ import SwiftUI
extension SelectUserView { extension SelectUserView {
struct UserRow: View { struct UserListRow: View {
@Default(.accentColor) @Default(.accentColor)
private var accentColor private var accentColor

View File

@ -6,12 +6,10 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import CollectionVGrid
import Defaults import Defaults
import Factory import Factory
import JellyfinAPI import JellyfinAPI
import LocalAuthentication import LocalAuthentication
import OrderedCollections
import SwiftUI import SwiftUI
// TODO: authentication view during device authentication // TODO: authentication view during device authentication
@ -23,9 +21,13 @@ import SwiftUI
// TODO: between the server selection menu and delete toolbar, // TODO: between the server selection menu and delete toolbar,
// figure out a way to make the grid/list and splash screen // figure out a way to make the grid/list and splash screen
// not jump when size is changed // not jump when size is changed
// TODO: fix splash screen pulsing
// - should have used successful image source binding on ImageView?
struct SelectUserView: View { struct SelectUserView: View {
typealias UserItem = (user: UserState, server: ServerState)
// MARK: - Defaults // MARK: - Defaults
@Default(.selectUserUseSplashscreen) @Default(.selectUserUseSplashscreen)
@ -37,44 +39,17 @@ struct SelectUserView: View {
@Default(.selectUserDisplayType) @Default(.selectUserDisplayType)
private var userListDisplayType 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 // MARK: - State & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: SelectUserCoordinator.Router 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<UserGridItem> = []
@State
private var gridItemSize: CGSize = .zero
@State @State
private var isEditingUsers: Bool = false private var isEditingUsers: Bool = false
@State @State
private var padGridItemColumnCount: Int = 1
@State
private var pin: String = "" private var pin: String = ""
@State @State
private var selectedUsers: Set<UserState> = [] private var selectedUsers: Set<UserState> = []
@State
private var splashScreenImageSources: [ImageSource] = []
// MARK: - Dialog States // MARK: - Dialog States
@ -88,85 +63,63 @@ struct SelectUserView: View {
@State @State
private var error: Error? = nil private var error: Error? = nil
private var users: [UserState] { @StateObject
gridItems.compactMap { item in private var viewModel = SelectUserViewModel()
switch item {
case let .user(user, _):
return user
default:
return nil
}
}
}
// MARK: - Select Server
private var selectedServer: ServerState? { private var selectedServer: ServerState? {
if case let SelectUserServerSelection.server(id: id) = serverSelection, serverSelection.server(from: viewModel.servers.keys)
let server = viewModel.servers.keys.first(where: { server in server.id == id })
{
return server
}
return nil
} }
// 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<UserGridItem> { // 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 { switch serverSelection {
case .all: case .all:
let items = viewModel.servers return viewModel.servers
.map { server, users in .map { server, users in
users.map { (server: server, user: $0) } users.map { (server: server, user: $0) }
} }
.flatMap { $0 } .flatMap { $0 }
.sorted(using: \.user.username) .sorted(using: \.user.username)
.reversed() .reversed()
.map { UserGridItem.user($0.user, server: $0.server) } .map { UserItem(user: $0.user, server: $0.server) }
.appending(.addUser)
return OrderedSet(items)
case let .server(id: id): case let .server(id: id):
guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else {
assertionFailure("server with ID not found?") return []
return [.addUser]
} }
let items = viewModel.servers[server]! return viewModel.servers[server]!
.sorted(using: \.username) .sorted(using: \.username)
.map { UserGridItem.user($0, server: server) } .map { UserItem(user: $0, server: server) }
.appending(.addUser)
return OrderedSet(items)
} }
} }
// 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 delete(user: UserState) {
private func makeSplashScreenImageSources( selectedUsers.insert(user)
serverSelection: SelectUserServerSelection, isPresentingConfirmDeleteUsers = true
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(),
]
}
} }
// MARK: - Select User(s) // MARK: - Select User(s)
@ -230,14 +183,37 @@ struct SelectUserView: View {
Menu(L10n.advanced, systemImage: "gearshape.fill") { Menu(L10n.advanced, systemImage: "gearshape.fill") {
Section { Section {
if gridItems.count > 1 {
Button(L10n.editUsers, systemImage: "person.crop.circle") { if userItems.isNotEmpty {
isEditingUsers.toggle() 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) { Picker(selection: $userListDisplayType) {
ForEach(LibraryDisplayType.allCases, id: \.hashValue) { ForEach(LibraryDisplayType.allCases, id: \.hashValue) {
Label($0.displayTitle, systemImage: $0.systemImage) 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 { @ViewBuilder
let lastRowIndices = (gridItems.count - gridItems.count % padGridItemColumnCount ..< gridItems.count) private func userGridItemView(for item: UserItem) -> some View {
let user = item.user
let server = item.server
guard lastRowIndices.contains(index) else { return 0 } UserGridButton(
user: user,
let lastRowMissing = padGridItemColumnCount - gridItems.count % padGridItemColumnCount server: server,
return CGFloat(lastRowMissing) * (gridItemSize.width + EdgeInsets.edgePadding) / 2 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 // MARK: - iPad Grid Content View
@ViewBuilder @ViewBuilder
private var padGridContentView: some View { private var padGridContentView: some View {
let columns = [GridItem(.adaptive(minimum: 150, maximum: 300), spacing: EdgeInsets.edgePadding)] if userItems.isEmpty {
CenteredLazyVGrid(
LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { data: [0],
ForEach(Array(gridItems.enumerated().map(\.offset)), id: \.hashValue) { index in id: \.self,
let item = gridItems[index] minimum: 150,
maximum: 300,
gridItemView(for: item) spacing: EdgeInsets.edgePadding
.trackingSize($gridItemSize) ) { _ in
.offset(x: padGridItemOffset(index: index)) addUserGridButtonView
} }
} } else {
.edgePadding() CenteredLazyVGrid(
.scrollIfLargerThanContainer(padding: 100) data: userItems,
.onChange(of: gridItemSize) { newValue in id: \.user.id,
let columns = Int(contentSize.width / (newValue.width + EdgeInsets.edgePadding)) minimum: 150,
maximum: 300,
padGridItemColumnCount = columns spacing: EdgeInsets.edgePadding,
content: userGridItemView
)
} }
} }
@ -298,52 +295,23 @@ struct SelectUserView: View {
@ViewBuilder @ViewBuilder
private var phoneGridContentView: some View { private var phoneGridContentView: some View {
let columns = [GridItem(.flexible(), spacing: EdgeInsets.edgePadding), GridItem(.flexible())] if userItems.isEmpty {
CenteredLazyVGrid(
LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) { data: [0],
ForEach(gridItems, id: \.hashValue) { item in id: \.self,
gridItemView(for: item) columns: 2
.if(gridItems.count % 2 == 1 && item == gridItems.last) { view in ) { _ in
view.trackingSize($gridItemSize) addUserGridButtonView
.offset(x: (gridItemSize.width + EdgeInsets.edgePadding) / 2)
}
} }
} } else {
.edgePadding() CenteredLazyVGrid(
.scrollIfLargerThanContainer(padding: 100) data: userItems,
} id: \.user.id,
columns: 2,
// MARK: - Grid Item View spacing: EdgeInsets.edgePadding,
content: userGridItemView
@ViewBuilder )
private func gridItemView(for item: UserGridItem) -> some View { .edgePadding()
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)
} }
} }
@ -351,71 +319,79 @@ struct SelectUserView: View {
@ViewBuilder @ViewBuilder
private var listContentView: some View { private var listContentView: some View {
ScrollView { List {
LazyVStack { let userItems = self.userItems
ForEach(gridItems, id: \.hashValue) { item in
listItemView(for: item) 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)
} }
} .listStyle(.plain)
// 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)
}
} }
// MARK: - User View // MARK: - User View
@ViewBuilder @ViewBuilder
private var userView: some View { private var contentView: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack { ZStack {
Color.clear
.onSizeChanged { size, _ in
contentSize = size
}
switch userListDisplayType { switch userListDisplayType {
case .grid: case .grid:
if UIDevice.isPhone { Group {
phoneGridContentView if UIDevice.isPhone {
} else { phoneGridContentView
padGridContentView } else {
padGridContentView
}
} }
.scrollIfLargerThanContainer(padding: 100)
case .list: case .list:
listContentView listContentView
} }
} }
.animation(.linear(duration: 0.1), value: userListDisplayType)
.environment(\.isEditing, isEditingUsers)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.mask { .mask {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -436,7 +412,8 @@ struct SelectUserView: View {
if !isEditingUsers { if !isEditingUsers {
ServerSelectionMenu( ServerSelectionMenu(
selection: $serverSelection, selection: $serverSelection,
viewModel: viewModel selectedServer: selectedServer,
servers: viewModel.servers.keys
) )
.edgePadding([.bottom, .horizontal]) .edgePadding([.bottom, .horizontal])
} }
@ -449,9 +426,8 @@ struct SelectUserView: View {
ImageView(splashScreenImageSources) ImageView(splashScreenImageSources)
.pipeline(.Swiftfin.local) .pipeline(.Swiftfin.local)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.transition(.opacity.animation(.linear(duration: 0.1)))
.id(splashScreenImageSources) .id(splashScreenImageSources)
.transition(.opacity)
.animation(.linear, value: splashScreenImageSources)
Color.black Color.black
.opacity(0.9) .opacity(0.9)
@ -461,10 +437,10 @@ struct SelectUserView: View {
} }
} }
// MARK: - Empty View // MARK: - Connect to Server View
@ViewBuilder @ViewBuilder
private var emptyView: some View { private var connectToServerView: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
L10n.connectToJellyfinServerStart.text L10n.connectToJellyfinServerStart.text
.frame(minWidth: 50, maxWidth: 240) .frame(minWidth: 50, maxWidth: 240)
@ -481,11 +457,11 @@ struct SelectUserView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
WrappedView { ZStack {
if viewModel.servers.isEmpty { if viewModel.servers.isEmpty {
emptyView connectToServerView
} else { } else {
userView contentView
} }
} }
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
@ -501,14 +477,14 @@ struct SelectUserView: View {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
if isEditingUsers { if isEditingUsers {
if selectedUsers.count == users.count { if selectedUsers.count == userItems.count {
Button(L10n.removeAll) { Button(L10n.removeAll) {
selectedUsers.removeAll() selectedUsers.removeAll()
} }
.buttonStyle(.toolbarPill) .buttonStyle(.toolbarPill)
} else { } else {
Button(L10n.selectAll) { Button(L10n.selectAll) {
selectedUsers.insert(contentsOf: users) selectedUsers.insert(contentsOf: userItems.map(\.user))
} }
.buttonStyle(.toolbarPill) .buttonStyle(.toolbarPill)
} }
@ -545,11 +521,6 @@ struct SelectUserView: View {
} }
.onAppear { .onAppear {
viewModel.send(.getServers) viewModel.send(.getServers)
splashScreenImageSources = makeSplashScreenImageSources(
serverSelection: serverSelection,
allServersSelection: selectUserAllServersSplashscreen
)
} }
.onChange(of: isEditingUsers) { newValue in .onChange(of: isEditingUsers) { newValue in
guard !newValue else { return } guard !newValue else { return }
@ -561,29 +532,23 @@ struct SelectUserView: View {
selectedUsers.removeAll() selectedUsers.removeAll()
} }
.onChange(of: isPresentingLocalPin) { newValue in .onChange(of: isPresentingLocalPin) { newValue in
if newValue { guard newValue else { return }
pin = "" pin = ""
} else { }
selectedUsers.removeAll() .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 .onReceive(viewModel.events) { event in
switch event { switch event {
case let .error(eventError): case let .error(eventError):
@ -601,33 +566,20 @@ struct SelectUserView: View {
viewModel.send(.getServers) viewModel.send(.getServers)
serverSelection = .server(id: server.id) serverSelection = .server(id: server.id)
} }
.onNotification(.didChangeCurrentServerURL) { server in .onNotification(.didChangeCurrentServerURL) { _ in
viewModel.send(.getServers) viewModel.send(.getServers)
serverSelection = .server(id: server.id)
} }
.onNotification(.didDeleteServer) { server in .onNotification(.didDeleteServer) { _ in
viewModel.send(.getServers) 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( .alert(
Text(L10n.deleteUser), L10n.deleteUser,
isPresented: $isPresentingConfirmDeleteUsers, isPresented: $isPresentingConfirmDeleteUsers
presenting: selectedUsers ) {
) { selectedUsers in
Button(L10n.delete, role: .destructive) { 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 { if selectedUsers.count == 1, let first = selectedUsers.first {
Text(L10n.deleteUserSingleConfirmation(first.username)) Text(L10n.deleteUserSingleConfirmation(first.username))
} else { } else {