diff --git a/Shared/Extensions/Color.swift b/Shared/Extensions/Color.swift index a8940949..16bc9b31 100644 --- a/Shared/Extensions/Color.swift +++ b/Shared/Extensions/Color.swift @@ -22,9 +22,9 @@ extension Color { // TODO: Correct and add colors #if os(tvOS) // tvOS doesn't have these - static let systemFill = Color(UIColor.white) - static let secondarySystemFill = Color(UIColor.gray) - static let tertiarySystemFill = Color(UIColor.black) + static let systemFill = Color.white + static let secondarySystemFill = Color.gray + static let tertiarySystemFill = Color.black static let lightGray = Color(UIColor.lightGray) #else diff --git a/Shared/Extensions/ViewExtensions/Backport/Backport.swift b/Shared/Extensions/ViewExtensions/Backport/Backport.swift index f3b8b369..30844f15 100644 --- a/Shared/Extensions/ViewExtensions/Backport/Backport.swift +++ b/Shared/Extensions/ViewExtensions/Backport/Backport.swift @@ -109,7 +109,7 @@ extension Backport where Content: View { extension ButtonBorderShape { static let circleBackport: ButtonBorderShape = { - if #available(iOS 17, tvOS 16.4, *) { + if #available(iOS 17, *) { return ButtonBorderShape.circle } else { return ButtonBorderShape.roundedRectangle diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift index aad19c6a..1ee50028 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/AddUserButton.swift @@ -57,21 +57,22 @@ extension SelectUserView { } .clipShape(.circle) .aspectRatio(1, contentMode: .fill) + .hoverEffect(.highlight) + + Text(L10n.addUser) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(isEnabled ? .primary : .secondary) + + if serverSelection == .all { + Text(L10n.hidden) + .font(.footnote) + .hidden() + } } - .buttonStyle(.card) - .buttonBorderShape(.circleBackport) + .buttonStyle(.borderless) + .buttonBorderShape(.circle) .disabled(!isEnabled) - - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - - if serverSelection == .all { - Text(L10n.hidden) - .font(.footnote) - .hidden() - } } } } diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift new file mode 100644 index 00000000..9761c444 --- /dev/null +++ b/Swiftfin tvOS/Views/SelectUserView/Components/SelectUserBottomBar.swift @@ -0,0 +1,151 @@ +// +// 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 SwiftUI + +extension SelectUserView { + + struct SelectUserBottomBar: View { + + @Binding + private var isEditing: Bool + + @Binding + private var serverSelection: SelectUserServerSelection + + @ObservedObject + private var viewModel: SelectUserViewModel + + private let areUsersSelected: Bool + private let userCount: Int + + private let onDelete: () -> Void + private let toggleAllUsersSelected: () -> Void + + // MARK: - Advanced Menu + + @ViewBuilder + private var advancedMenu: some View { + Menu(L10n.advanced, systemImage: "gearshape.fill") { + + Button(L10n.editUsers, systemImage: "person.crop.circle") { + isEditing.toggle() + } + + // TODO: Do we want to support a grid view and list view like iOS? +// if !viewModel.servers.isEmpty { +// Picker(selection: $userListDisplayType) { +// ForEach(LibraryDisplayType.allCases, id: \.hashValue) { +// Label($0.displayTitle, systemImage: $0.systemImage) +// .tag($0) +// } +// } label: { +// Text(L10n.layout) +// Text(userListDisplayType.displayTitle) +// Image(systemName: userListDisplayType.systemImage) +// } +// .pickerStyle(.menu) +// } + + // TODO: Advanced settings on tvOS? +// Section { +// Button(L10n.advanced, systemImage: "gearshape.fill") { +// router.route(to: \.advancedSettings) +// } +// } + } + .labelStyle(.iconOnly) + } + + private var deleteUsersButton: some View { + Button { + onDelete() + } label: { + ZStack { + Color.red + + Text(L10n.delete) + .font(.body.weight(.semibold)) + .foregroundStyle(areUsersSelected ? .primary : .secondary) + + if !areUsersSelected { + Color.black + .opacity(0.5) + } + } + .frame(width: 400, height: 65) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(!areUsersSelected) + .buttonStyle(.card) + } + + init( + isEditing: Binding, + serverSelection: Binding, + areUsersSelected: Bool, + viewModel: SelectUserViewModel, + userCount: Int, + onDelete: @escaping () -> Void, + toggleAllUsersSelected: @escaping () -> Void + ) { + self._isEditing = isEditing + self._serverSelection = serverSelection + self.viewModel = viewModel + self.areUsersSelected = areUsersSelected + self.userCount = userCount + self.onDelete = onDelete + self.toggleAllUsersSelected = toggleAllUsersSelected + } + + @ViewBuilder + private var contentView: some View { + HStack(alignment: .center) { + if isEditing { + deleteUsersButton + + Button { + toggleAllUsersSelected() + } label: { + Text(areUsersSelected ? L10n.removeAll : L10n.selectAll) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + } + + Button { + isEditing = false + } label: { + L10n.cancel.text + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + } + } else { + ServerSelectionMenu( + selection: $serverSelection, + viewModel: viewModel + ) + + if userCount > 1 { + advancedMenu + } + } + } + } + + var body: some View { + // `Menu` with custom label has some weird additional + // frame/padding that differs from default label style + AlternateLayoutView(alignment: .top) { + Color.clear + .frame(height: 100) + } content: { + contentView + } + } + } +} diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift index 30c3b344..e9d4b896 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/ServerSelectionMenu.swift @@ -90,12 +90,9 @@ extension SelectUserView { } .font(.body.weight(.semibold)) .foregroundStyle(Color.primary) - .frame(height: 50) - .frame(maxWidth: 400) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(width: 400, height: 50) } .menuOrder(.fixed) - .padding() } } } diff --git a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift index af8a5e8d..f92e1a71 100644 --- a/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift +++ b/Swiftfin tvOS/Views/SelectUserView/Components/UserGridButton.swift @@ -60,62 +60,61 @@ extension SelectUserView { .aspectRatio(1, contentMode: .fill) } + @ViewBuilder + private var userImage: some View { + UserProfileImage( + userID: user.id, + source: user.profileImageSource( + client: server.client, + maxWidth: 120 + ) + ) + .aspectRatio(1, contentMode: .fill) + .overlay { + if isEditing { + Color.black + .opacity(isSelected ? 0 : 0.5) + .clipShape(.circle) + } + } + } + var body: some View { VStack { Button { action() } label: { - VStack(alignment: .center) { - ZStack { - Color.clear + userImage + .hoverEffect(.highlight) - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: server.client, - maxWidth: 120 - ) - ) - } - .aspectRatio(1, contentMode: .fill) + Text(user.username) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(labelForegroundStyle) + .lineLimit(1) + + if showServer { + Text(server.name) + .font(.footnote) + .foregroundStyle(.secondary) } } - .buttonStyle(.card) - .buttonBorderShape(.circleBackport) - // .contextMenu { - // Button(L10n.delete, role: .destructive) { - // onDelete() - // } - // } - - Text(user.username) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(labelForegroundStyle) - .lineLimit(1) - - if showServer { - Text(server.name) - .font(.footnote) - .foregroundStyle(.secondary) + .buttonStyle(.borderless) + .buttonBorderShape(.circle) + .contextMenu { + Button("Delete", role: .destructive) { + onDelete() + } } } .overlay { - if isEditing { - ZStack(alignment: .bottomTrailing) { - Color.black - .opacity(isSelected ? 0 : 0.5) - .clipShape(.circle) - - if isSelected { - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40, alignment: .bottomTrailing) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } + if isEditing && isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40, alignment: .bottomTrailing) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) } } } diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift index 9e4eda11..5aa17a6c 100644 --- a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -17,11 +17,6 @@ import SwiftUI struct SelectUserView: View { - // MARK: - Defaults - - @Default(.selectUserServerSelection) - private var serverSelection - // MARK: - User Grid Item Enum private enum UserGridItem: Hashable { @@ -29,6 +24,16 @@ struct SelectUserView: View { case addUser } + // MARK: - Defaults + + @Default(.selectUserServerSelection) + private var serverSelection + + // MARK: - Environment Variable + + @Environment(\.colorScheme) + private var colorScheme + // MARK: - State & Environment Objects @EnvironmentObject @@ -46,14 +51,31 @@ struct SelectUserView: View { @State private var gridItemSize: CGSize = .zero @State + private var isEditingUsers: Bool = false + @State private var padGridItemColumnCount: Int = 1 @State private var scrollViewOffset: CGFloat = 0 @State + private var selectedUsers: Set = [] + @State private var splashScreenImageSource: ImageSource? = nil + private var users: [UserState] { + gridItems.compactMap { item in + switch item { + case let .user(user, _): + return user + default: + return nil + } + } + } + // MARK: - Dialog States + @State + private var isPresentingConfirmDeleteUsers = false @State private var isPresentingServers: Bool = false @@ -175,17 +197,17 @@ struct SelectUserView: View { server: server, showServer: serverSelection == .all ) { -// if isEditingUsers { -// selectedUsers.toggle(value: user) -// } else { - viewModel.send(.signIn(user, pin: "")) -// } + if isEditingUsers { + selectedUsers.toggle(value: user) + } else { + viewModel.send(.signIn(user, pin: "")) + } } onDelete: { -// selectedUsers.insert(user) -// isPresentingConfirmDeleteUsers = true + selectedUsers.insert(user) + isPresentingConfirmDeleteUsers = true } -// .environment(\.isEditing, isEditingUsers) -// .environment(\.isSelected, selectedUsers.contains(user)) + .environment(\.isEditing, isEditingUsers) + .environment(\.isSelected, selectedUsers.contains(user)) case .addUser: AddUserButton( serverSelection: $serverSelection, @@ -193,6 +215,7 @@ struct SelectUserView: View { ) { server in router.route(to: \.userSignIn, server) } + .environment(\.isEnabled, !isEditingUsers) } } @@ -211,24 +234,34 @@ struct SelectUserView: View { .frame(height: 100) gridContentView + .focusSection() } .scrollIfLargerThanContainer(padding: 100) .scrollViewOffset($scrollViewOffset) } - HStack { - ServerSelectionMenu( - selection: $serverSelection, - viewModel: viewModel - ) + SelectUserBottomBar( + isEditing: $isEditingUsers, + serverSelection: $serverSelection, + areUsersSelected: selectedUsers.isNotEmpty, + viewModel: viewModel, + userCount: gridItems.count, + onDelete: { + isPresentingConfirmDeleteUsers = true + } + ) { + if selectedUsers.count == users.count { + selectedUsers.removeAll() + } else { + selectedUsers.insert(contentsOf: users) + } } + .focusSection() } .animation(.linear(duration: 0.1), value: scrollViewOffset) .background { if let splashScreenImageSource { ZStack { - Color.clear - ImageView(splashScreenImageSource) .aspectRatio(contentMode: .fill) .id(splashScreenImageSource) @@ -278,6 +311,32 @@ struct SelectUserView: View { } } + // MARK: - Functions + + private func didDelete(_ server: ServerState) { + 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 + } + + private func didAppear() { + viewModel.send(.getServers) + + splashScreenImageSource = makeSplashScreenImageSource( + serverSelection: serverSelection, + allServersSelection: .all + ) + } + // MARK: - Body var body: some View { @@ -291,22 +350,11 @@ struct SelectUserView: View { .ignoresSafeArea() .navigationBarBranding() .onAppear { - viewModel.send(.getServers) - - splashScreenImageSource = makeSplashScreenImageSource( - serverSelection: serverSelection, - allServersSelection: .all - ) - -// gridItems = OrderedSet( -// (0 ..< 20) -// .map { i in -// UserState(accessToken: "", id: "\(i)", serverID: "", username: "\(i)") -// } -// .map { u in -// UserGridItem.user(u, server: .init(urls: [], currentURL: URL(string: "/")!, name: "Test", id: "", usersIDs: [])) -// } -// ) + didAppear() + } + .onChange(of: isEditingUsers) { _, newValue in + guard !newValue else { return } + selectedUsers.removeAll() } .onChange(of: serverSelection) { _, newValue in gridItems = makeGridItems(for: newValue) @@ -343,18 +391,23 @@ struct SelectUserView: View { serverSelection = .server(id: server.id) } .onNotification(.didDeleteServer) { server in - viewModel.send(.getServers) - - if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { - if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { - serverSelection = .server(id: first.id) - } else { - serverSelection = .all - } + didDelete(server) + } + .confirmationDialog( + Text(L10n.deleteUser), + isPresented: $isPresentingConfirmDeleteUsers, + presenting: selectedUsers + ) { selectedUsers in + Button(L10n.delete, role: .destructive) { + viewModel.send(.deleteUsers(Array(selectedUsers))) + isEditingUsers = false + } + } message: { selectedUsers in + if selectedUsers.count == 1, let first = selectedUsers.first { + Text(L10n.deleteUserSingleConfirmation(first.username)) + } else { + Text(L10n.deleteUserMultipleConfirmation(selectedUsers.count)) } - - // change splash screen selection if necessary -// selectUserAllServersSplashscreen = serverSelection } .errorMessage($error) } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 1a053995..f57561bd 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -373,6 +373,7 @@ BD3957792C113EC40078CEF8 /* SubtitleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3957782C113EC40078CEF8 /* SubtitleSection.swift */; }; BD39577C2C113FAA0078CEF8 /* TimestampSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */; }; BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577D2C1140810078CEF8 /* TransitionSection.swift */; }; + BDA623532D0D0854009A157F /* SelectUserBottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; }; C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; }; @@ -1485,6 +1486,7 @@ BD3957782C113EC40078CEF8 /* SubtitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSection.swift; sourceTree = ""; }; BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampSection.swift; sourceTree = ""; }; BD39577D2C1140810078CEF8 /* TransitionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionSection.swift; sourceTree = ""; }; + BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserBottomBar.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = ""; }; C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = ""; }; C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = ""; }; @@ -4027,6 +4029,7 @@ E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, + BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, ); path = Components; sourceTree = ""; @@ -5572,6 +5575,7 @@ E1D90D772C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */, E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */, E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, + BDA623532D0D0854009A157F /* SelectUserBottomBar.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */,