parent
c0b875ed2a
commit
0845545417
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, *) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 */,
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue