651 lines
21 KiB
Swift
651 lines
21 KiB
Swift
//
|
|
// 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) 2024 Jellyfin & Jellyfin Contributors
|
|
//
|
|
|
|
import CollectionVGrid
|
|
import Defaults
|
|
import Factory
|
|
import JellyfinAPI
|
|
import LocalAuthentication
|
|
import OrderedCollections
|
|
import SwiftUI
|
|
|
|
// TODO: authentication view during device authentication
|
|
// - could use provided UI, but is iOS 16+
|
|
// - could just ignore for iOS 15, or basic view
|
|
// TODO: user ordering
|
|
// - name
|
|
// - last signed in date
|
|
// TODO: for random splash screen, instead have a random sorted array
|
|
// for failure cases
|
|
|
|
struct SelectUserView: View {
|
|
|
|
private enum UserGridItem: Hashable {
|
|
case user(UserState, server: ServerState)
|
|
case addUser
|
|
}
|
|
|
|
@Default(.selectUserUseSplashscreen)
|
|
private var selectUserUseSplashscreen
|
|
@Default(.selectUserAllServersSplashscreen)
|
|
private var selectUserAllServersSplashscreen
|
|
@Default(.selectUserServerSelection)
|
|
private var serverSelection
|
|
@Default(.selectUserDisplayType)
|
|
private var userListDisplayType
|
|
|
|
@Environment(\.colorScheme)
|
|
private var colorScheme
|
|
|
|
@EnvironmentObject
|
|
private var router: SelectUserCoordinator.Router
|
|
|
|
@State
|
|
private var contentSafeAreaInsets: EdgeInsets = .zero
|
|
@State
|
|
private var contentSize: CGSize = .zero
|
|
@State
|
|
private var error: Error? = nil
|
|
@State
|
|
private var gridItems: OrderedSet<UserGridItem> = []
|
|
@State
|
|
private var gridItemSize: CGSize = .zero
|
|
@State
|
|
private var isEditingUsers: Bool = false
|
|
@State
|
|
private var isPresentingConfirmDeleteUsers = false
|
|
@State
|
|
private var isPresentingError: Bool = false
|
|
@State
|
|
private var isPresentingLocalPin: Bool = false
|
|
@State
|
|
private var padGridItemColumnCount: Int = 1
|
|
@State
|
|
private var pin: String = ""
|
|
@State
|
|
private var selectedUsers: Set<UserState> = []
|
|
@State
|
|
private var splashScreenImageSource: ImageSource? = nil
|
|
|
|
@StateObject
|
|
private var viewModel = SelectUserViewModel()
|
|
|
|
private var selectedServer: ServerState? {
|
|
if case let SelectUserServerSelection.server(id: id) = serverSelection,
|
|
let server = viewModel.servers.keys.first(where: { server in server.id == id })
|
|
{
|
|
return server
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func makeGridItems(for serverSelection: SelectUserServerSelection) -> OrderedSet<UserGridItem> {
|
|
switch serverSelection {
|
|
case .all:
|
|
let items = viewModel.servers
|
|
.map { server, users in
|
|
users.map { (server: server, user: $0) }
|
|
}
|
|
.flatMap { $0 }
|
|
.sorted(using: \.user.username)
|
|
.reversed()
|
|
.map { UserGridItem.user($0.user, server: $0.server) }
|
|
.appending(.addUser)
|
|
|
|
return OrderedSet(items)
|
|
case let .server(id: id):
|
|
guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else {
|
|
assertionFailure("server with ID not found?")
|
|
return [.addUser]
|
|
}
|
|
|
|
let items = viewModel.servers[server]!
|
|
.sorted(using: \.username)
|
|
.map { UserGridItem.user($0, server: server) }
|
|
.appending(.addUser)
|
|
|
|
return OrderedSet(items)
|
|
}
|
|
}
|
|
|
|
// For all server selection, .all is random
|
|
private func makeSplashScreenImageSource(
|
|
serverSelection: SelectUserServerSelection,
|
|
allServersSelection: SelectUserServerSelection
|
|
) -> ImageSource? {
|
|
switch (serverSelection, allServersSelection) {
|
|
case (.all, .all):
|
|
return viewModel
|
|
.servers
|
|
.keys
|
|
.randomElement()?
|
|
.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()
|
|
}
|
|
}
|
|
|
|
private func select(user: UserState, needsPin: Bool = true) {
|
|
Task { @MainActor in
|
|
selectedUsers.insert(user)
|
|
|
|
switch user.signInPolicy {
|
|
case .requireDeviceAuthentication:
|
|
try await performDeviceAuthentication(reason: "User \(user.username) requires device authentication")
|
|
case .requirePin:
|
|
if needsPin {
|
|
isPresentingLocalPin = true
|
|
return
|
|
}
|
|
case .none: ()
|
|
}
|
|
|
|
viewModel.send(.signIn(user, pin: pin))
|
|
}
|
|
}
|
|
|
|
// error logging/presentation is handled within here, just
|
|
// use try+thrown error in local Task for early return
|
|
private func performDeviceAuthentication(reason: String) async throws {
|
|
let context = LAContext()
|
|
var policyError: NSError?
|
|
|
|
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else {
|
|
viewModel.logger.critical("\(policyError!.localizedDescription)")
|
|
|
|
await MainActor.run {
|
|
self
|
|
.error =
|
|
JellyfinAPIError(
|
|
"Unable to perform device authentication. You may need to enable Face ID in the Settings app for Swiftfin."
|
|
)
|
|
self.isPresentingError = true
|
|
}
|
|
|
|
throw JellyfinAPIError("Device auth failed")
|
|
}
|
|
|
|
do {
|
|
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason)
|
|
} catch {
|
|
viewModel.logger.critical("\(error.localizedDescription)")
|
|
|
|
await MainActor.run {
|
|
self.error = JellyfinAPIError("Unable to perform device authentication")
|
|
self.isPresentingError = true
|
|
}
|
|
|
|
throw JellyfinAPIError("Device auth failed")
|
|
}
|
|
}
|
|
|
|
// MARK: advancedMenu
|
|
|
|
@ViewBuilder
|
|
private var advancedMenu: some View {
|
|
Menu(L10n.advanced, systemImage: "gearshape.fill") {
|
|
|
|
Section {
|
|
if gridItems.count > 1 {
|
|
Button("Edit Users", systemImage: "person.crop.circle") {
|
|
isEditingUsers.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
if !viewModel.servers.isEmpty {
|
|
Picker(selection: $userListDisplayType) {
|
|
ForEach(LibraryDisplayType.allCases, id: \.hashValue) {
|
|
Label($0.displayTitle, systemImage: $0.systemImage)
|
|
.tag($0)
|
|
}
|
|
} label: {
|
|
Text("Layout")
|
|
Text(userListDisplayType.displayTitle)
|
|
Image(systemName: userListDisplayType.systemImage)
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
|
|
Section {
|
|
Button(L10n.advanced, systemImage: "gearshape.fill") {
|
|
router.route(to: \.advancedSettings)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: grid
|
|
|
|
private func padGridItemOffset(index: Int) -> CGFloat {
|
|
let lastRowIndices = (gridItems.count - gridItems.count % padGridItemColumnCount ..< gridItems.count)
|
|
|
|
guard lastRowIndices.contains(index) else { return 0 }
|
|
|
|
let lastRowMissing = padGridItemColumnCount - gridItems.count % padGridItemColumnCount
|
|
return CGFloat(lastRowMissing) * (gridItemSize.width + EdgeInsets.edgePadding) / 2
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var padGridContentView: some View {
|
|
let columns = [GridItem(.adaptive(minimum: 150, maximum: 300), spacing: EdgeInsets.edgePadding)]
|
|
|
|
LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) {
|
|
ForEach(Array(gridItems.enumerated().map(\.offset)), id: \.hashValue) { index in
|
|
let item = gridItems[index]
|
|
|
|
gridItemView(for: item)
|
|
.trackingSize($gridItemSize)
|
|
.offset(x: padGridItemOffset(index: index))
|
|
}
|
|
}
|
|
.edgePadding()
|
|
.scroll(ifLargerThan: contentSize.height - 100)
|
|
.onChange(of: gridItemSize) { newValue in
|
|
let columns = Int(contentSize.width / (newValue.width + EdgeInsets.edgePadding))
|
|
|
|
padGridItemColumnCount = columns
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var phoneGridContentView: some View {
|
|
let columns = [GridItem(.flexible(), spacing: EdgeInsets.edgePadding), GridItem(.flexible())]
|
|
|
|
LazyVGrid(columns: columns, spacing: EdgeInsets.edgePadding) {
|
|
ForEach(gridItems, id: \.hashValue) { item in
|
|
gridItemView(for: item)
|
|
.if(gridItems.count % 2 == 1 && item == gridItems.last) { view in
|
|
view.trackingSize($gridItemSize)
|
|
.offset(x: (gridItemSize.width + EdgeInsets.edgePadding) / 2)
|
|
}
|
|
}
|
|
}
|
|
.edgePadding()
|
|
.scroll(ifLargerThan: contentSize.height - 100)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func gridItemView(for item: UserGridItem) -> some View {
|
|
switch item {
|
|
case let .user(user, server):
|
|
UserGridButton(
|
|
user: user,
|
|
server: server,
|
|
showServer: serverSelection == .all
|
|
) {
|
|
if isEditingUsers {
|
|
selectedUsers.toggle(value: user)
|
|
} else {
|
|
select(user: user)
|
|
}
|
|
} onDelete: {
|
|
selectedUsers.insert(user)
|
|
isPresentingConfirmDeleteUsers = true
|
|
}
|
|
.environment(\.isEditing, isEditingUsers)
|
|
.environment(\.isSelected, selectedUsers.contains(user))
|
|
case .addUser:
|
|
AddUserButton(
|
|
serverSelection: $serverSelection,
|
|
servers: viewModel.servers.keys
|
|
) { server in
|
|
UIDevice.impact(.light)
|
|
router.route(to: \.userSignIn, server)
|
|
}
|
|
.environment(\.isEnabled, !isEditingUsers)
|
|
}
|
|
}
|
|
|
|
// MARK: list
|
|
|
|
@ViewBuilder
|
|
private var listContentView: some View {
|
|
ScrollView {
|
|
LazyVStack {
|
|
ForEach(gridItems, id: \.hashValue) { item in
|
|
listItemView(for: item)
|
|
}
|
|
}
|
|
.edgePadding()
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
|
|
private var deleteUsersButton: some View {
|
|
Button {
|
|
isPresentingConfirmDeleteUsers = true
|
|
} label: {
|
|
ZStack {
|
|
Color.red
|
|
|
|
Text("Delete")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(selectedUsers.isNotEmpty ? .primary : .secondary)
|
|
|
|
if selectedUsers.isEmpty {
|
|
Color.black
|
|
.opacity(0.5)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.frame(height: 50)
|
|
.frame(maxWidth: 400)
|
|
}
|
|
.disabled(selectedUsers.isEmpty)
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: userView
|
|
|
|
@ViewBuilder
|
|
private var userView: some View {
|
|
VStack(spacing: 0) {
|
|
ZStack {
|
|
Color.clear
|
|
.onSizeChanged { size, safeAreaInsets in
|
|
contentSize = size
|
|
contentSafeAreaInsets = safeAreaInsets
|
|
}
|
|
|
|
switch userListDisplayType {
|
|
case .grid:
|
|
if UIDevice.isPhone {
|
|
phoneGridContentView
|
|
} else {
|
|
padGridContentView
|
|
}
|
|
case .list:
|
|
listContentView
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.mask {
|
|
VStack(spacing: 0) {
|
|
Color.white
|
|
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .white, location: 0),
|
|
.init(color: .clear, location: 1),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 30)
|
|
}
|
|
}
|
|
|
|
if !isEditingUsers {
|
|
ServerSelectionMenu(
|
|
selection: $serverSelection,
|
|
viewModel: viewModel
|
|
)
|
|
.edgePadding([.bottom, .horizontal])
|
|
}
|
|
|
|
if isEditingUsers {
|
|
deleteUsersButton
|
|
.edgePadding([.bottom, .horizontal])
|
|
}
|
|
}
|
|
.background {
|
|
if selectUserUseSplashscreen, let splashScreenImageSource {
|
|
ZStack {
|
|
Color.clear
|
|
|
|
ImageView(splashScreenImageSource)
|
|
.pipeline(.Swiftfin.branding)
|
|
.aspectRatio(contentMode: .fill)
|
|
.id(splashScreenImageSource)
|
|
.transition(.opacity)
|
|
.animation(.linear, value: splashScreenImageSource)
|
|
|
|
Color.black
|
|
.opacity(0.9)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: emptyView
|
|
|
|
private var emptyView: some View {
|
|
VStack(spacing: 10) {
|
|
L10n.connectToJellyfinServerStart.text
|
|
.frame(minWidth: 50, maxWidth: 240)
|
|
.multilineTextAlignment(.center)
|
|
|
|
PrimaryButton(title: L10n.connect)
|
|
.onSelect {
|
|
router.route(to: \.connectToServer)
|
|
}
|
|
.frame(maxWidth: 300)
|
|
}
|
|
}
|
|
|
|
// MARK: body
|
|
|
|
var body: some View {
|
|
WrappedView {
|
|
if viewModel.servers.isEmpty {
|
|
emptyView
|
|
} else {
|
|
userView
|
|
}
|
|
}
|
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
.navigationTitle("Users")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
Image(uiImage: .jellyfinBlobBlue)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 30)
|
|
}
|
|
}
|
|
.topBarTrailing {
|
|
if isEditingUsers {
|
|
Button {
|
|
isEditingUsers = false
|
|
} label: {
|
|
L10n.cancel.text
|
|
.font(.headline)
|
|
.padding(.vertical, 5)
|
|
.padding(.horizontal, 10)
|
|
.background {
|
|
if colorScheme == .light {
|
|
Color.secondarySystemFill
|
|
} else {
|
|
Color.tertiarySystemBackground
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
advancedMenu
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.send(.getServers)
|
|
|
|
splashScreenImageSource = makeSplashScreenImageSource(
|
|
serverSelection: serverSelection,
|
|
allServersSelection: selectUserAllServersSplashscreen
|
|
)
|
|
}
|
|
.onChange(of: isEditingUsers) { newValue in
|
|
guard !newValue else { return }
|
|
selectedUsers.removeAll()
|
|
}
|
|
.onChange(of: isPresentingConfirmDeleteUsers) { newValue in
|
|
guard !newValue else { return }
|
|
isEditingUsers = false
|
|
selectedUsers.removeAll()
|
|
}
|
|
.onChange(of: isPresentingLocalPin) { newValue in
|
|
if newValue {
|
|
pin = ""
|
|
} else {
|
|
selectedUsers.removeAll()
|
|
}
|
|
}
|
|
.onChange(of: selectUserAllServersSplashscreen) { newValue in
|
|
splashScreenImageSource = makeSplashScreenImageSource(
|
|
serverSelection: serverSelection,
|
|
allServersSelection: newValue
|
|
)
|
|
}
|
|
.onChange(of: serverSelection) { newValue in
|
|
gridItems = makeGridItems(for: newValue)
|
|
|
|
splashScreenImageSource = makeSplashScreenImageSource(
|
|
serverSelection: newValue,
|
|
allServersSelection: selectUserAllServersSplashscreen
|
|
)
|
|
}
|
|
.onChange(of: viewModel.servers) { _ in
|
|
gridItems = makeGridItems(for: serverSelection)
|
|
}
|
|
.onReceive(viewModel.events) { event in
|
|
switch event {
|
|
case let .error(eventError):
|
|
UIDevice.feedback(.error)
|
|
|
|
self.error = eventError
|
|
self.isPresentingError = true
|
|
case let .signedIn(user):
|
|
UIDevice.feedback(.success)
|
|
|
|
Defaults[.lastSignedInUserID] = user.id
|
|
UserSession.current.reset()
|
|
Notifications[.didSignIn].post()
|
|
}
|
|
}
|
|
.onNotification(.didConnectToServer) { notification in
|
|
if let server = notification.object as? ServerState {
|
|
viewModel.send(.getServers)
|
|
serverSelection = .server(id: server.id)
|
|
}
|
|
}
|
|
.onNotification(.didChangeCurrentServerURL) { notification in
|
|
if let server = notification.object as? ServerState {
|
|
viewModel.send(.getServers)
|
|
serverSelection = .server(id: server.id)
|
|
}
|
|
}
|
|
.onNotification(.didDeleteServer) { notification in
|
|
viewModel.send(.getServers)
|
|
|
|
if let server = notification.object as? ServerState {
|
|
if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id {
|
|
if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first {
|
|
serverSelection = .server(id: first.id)
|
|
} else {
|
|
serverSelection = .all
|
|
}
|
|
}
|
|
|
|
// change splash screen selection if necessary
|
|
selectUserAllServersSplashscreen = serverSelection
|
|
}
|
|
}
|
|
.alert(
|
|
Text("Delete User"),
|
|
isPresented: $isPresentingConfirmDeleteUsers,
|
|
presenting: selectedUsers
|
|
) { selectedUsers in
|
|
Button("Delete", role: .destructive) {
|
|
viewModel.send(.deleteUsers(Array(selectedUsers)))
|
|
}
|
|
} message: { selectedUsers in
|
|
if selectedUsers.count == 1, let first = selectedUsers.first {
|
|
Text("Are you sure you want to delete \(first.username)?")
|
|
} else {
|
|
Text("Are you sure you want to delete \(selectedUsers.count) users?")
|
|
}
|
|
}
|
|
.alert(
|
|
L10n.error.text,
|
|
isPresented: $isPresentingError,
|
|
presenting: error
|
|
) { _ in
|
|
Button(L10n.dismiss, role: .destructive)
|
|
} message: { error in
|
|
Text(error.localizedDescription)
|
|
}
|
|
.alert("Sign in", isPresented: $isPresentingLocalPin) {
|
|
|
|
TextField("Pin", text: $pin)
|
|
.keyboardType(.numberPad)
|
|
|
|
// bug in SwiftUI: having .disabled will dismiss
|
|
// alert but not call the closure (for length)
|
|
Button("Sign In") {
|
|
guard let user = selectedUsers.first else {
|
|
assertionFailure("User not selected")
|
|
return
|
|
}
|
|
|
|
select(user: user, needsPin: false)
|
|
}
|
|
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
if let user = selectedUsers.first, user.pinHint.isNotEmpty {
|
|
Text(user.pinHint)
|
|
} else {
|
|
let username = selectedUsers.first?.username ?? .emptyDash
|
|
|
|
Text("Enter pin for \(username)")
|
|
}
|
|
}
|
|
}
|
|
}
|