From 548d35b19e0a64b2182b383391488d56b28bf039 Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Tue, 10 Dec 2024 13:37:22 -0700 Subject: [PATCH] [tvOS] Media Item Menu - Refresh / Delete Items (#1348) * Mirror tvOS to iOS * Fix router dismiss. Remove redundent viewModel.refresh from itemView * reset dev team info * View Modifier and ViewModel cleanup * Remove testing comments / events * Cleanup `.errorMessage($error)` * Cleanup all viewModel.states for item editing, add errorViews if the data fails to load, and add errorMessage on failed events. MARK sections: Var/Func always unless only Body and Var/Lets only if there are several of varying types / functions. --- .../Modifiers/ErrorMessage.swift | 35 +++ .../ViewExtensions/ViewExtensions.swift | 9 + .../DeleteItemViewModel.swift | 39 ++-- .../RefreshMetadataViewModel.swift | 85 ++++---- .../ActionButtons/ActionButton.swift | 10 + .../ActionButtons/ActionButtonHStack.swift | 97 ++++++++- .../Components/ActionButtons/ActionMenu.swift | 4 + .../ActionButtons/RefreshMetadataButton.swift | 105 +++++++++ .../Components/Sections/ItemSection.swift | 50 +++++ .../CustomizeViewsSettings.swift | 2 + Swiftfin.xcodeproj/project.pbxproj | 22 +- .../AddItemElementView.swift | 33 +-- .../Components/NameInput.swift | 9 +- .../Components/SearchResultsSection.swift | 16 +- .../Components/RefreshMetadataButton.swift | 23 +- .../Components/EditItemElementRow.swift | 7 + .../EditItemElementView.swift | 204 +++++++++++------- .../Components/Sections/EpisodeSection.swift | 6 +- .../Components/Sections/OverviewSection.swift | 6 + .../Sections/ParentialRatingsSection.swift | 15 +- .../Components/Sections/ReviewsSection.swift | 6 +- .../EditMetadataView/EditMetadataView.swift | 59 +++-- .../Views/ItemEditorView/ItemEditorView.swift | 31 ++- .../Components/Sections/ItemSection.swift | 52 +++-- 24 files changed, 680 insertions(+), 245 deletions(-) create mode 100644 Shared/Extensions/ViewExtensions/Modifiers/ErrorMessage.swift create mode 100644 Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift create mode 100644 Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ErrorMessage.swift b/Shared/Extensions/ViewExtensions/Modifiers/ErrorMessage.swift new file mode 100644 index 00000000..e151c38a --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/ErrorMessage.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct ErrorMessageModifier: ViewModifier { + + @Binding + var error: Error? + + let dismissActions: (() -> Void)? + + // MARK: - Body + + func body(content: Content) -> some View { + content + .alert( + L10n.error.text, + isPresented: .constant(error != nil), + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) { + error = nil + dismissActions?() + } + } message: { error in + Text(error.localizedDescription) + } + } +} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 583cf7c5..3341c921 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -148,6 +148,15 @@ extension View { modifier(BottomEdgeGradientModifier(bottomColor: bottomColor)) } + /// Error Message Alert + func errorMessage( + _ error: Binding, + dismissActions: (() -> Void)? = nil + ) -> some View { + modifier(ErrorMessageModifier(error: error, dismissActions: dismissActions)) + } + + /// Apply a corner radius as a ratio of a view's side func posterShadow() -> some View { shadow(radius: 4, y: 2) } diff --git a/Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift b/Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift index 9a3f6732..a8138b03 100644 --- a/Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift +++ b/Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift @@ -12,61 +12,57 @@ import JellyfinAPI class DeleteItemViewModel: ViewModel, Stateful, Eventful { - // MARK: Events + // MARK: - Events enum Event: Equatable { - case error(JellyfinAPIError) case deleted + case error(JellyfinAPIError) } - // MARK: Action + // MARK: - Action enum Action: Equatable { - case error(JellyfinAPIError) case delete } - // MARK: State + // MARK: - State enum State: Hashable { - case content - case error(JellyfinAPIError) case initial - case refreshing + case error(JellyfinAPIError) } - @Published - var item: BaseItemDto? - @Published final var state: State = .initial - private var deleteTask: AnyCancellable? + // MARK: - Published Item + + @Published + var item: BaseItemDto? // MARK: Event Variables + private var deleteTask: AnyCancellable? private var eventSubject: PassthroughSubject = .init() var events: AnyPublisher { eventSubject - .receive(on: RunLoop.main) .eraseToAnyPublisher() + // Causes issues with the Deleted Event unless this is removed + // .receive(on: RunLoop.main) } - // MARK: Init + // MARK: - Initializer init(item: BaseItemDto) { self.item = item super.init() } - // MARK: Respond + // MARK: - Respond func respond(to action: Action) -> State { switch action { - case let .error(error): - return .error(error) - case .delete: deleteTask?.cancel() @@ -75,12 +71,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful { do { try await self.deleteItem() await MainActor.run { - self.state = .content + self.state = .initial self.eventSubject.send(.deleted) } } catch { guard !Task.isCancelled else { return } - await MainActor.run { self.state = .error(JellyfinAPIError(error.localizedDescription)) self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription))) @@ -89,11 +84,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful { } .asAnyCancellable() - return .refreshing + return .initial } } - // MARK: Metadata Refresh Logic + // MARK: - Item Deletion Logic private func deleteItem() async throws { guard let item, let itemID = item.id else { diff --git a/Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift b/Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift index 7d45c45f..bbd20192 100644 --- a/Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift +++ b/Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift @@ -12,17 +12,15 @@ import JellyfinAPI class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { - // MARK: Events + // MARK: - Events enum Event: Equatable { case error(JellyfinAPIError) - case refreshTriggered } - // MARK: Action + // MARK: - Action enum Action: Equatable { - case error(JellyfinAPIError) case refreshMetadata( metadataRefreshMode: MetadataRefreshMode, imageRefreshMode: MetadataRefreshMode, @@ -31,25 +29,25 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { ) } - // MARK: State + // MARK: States enum State: Hashable { - case content - case error(JellyfinAPIError) case initial case refreshing } - // A spoof progress, since there isn't a - // single item metadata refresh task - @Published - private(set) var progress: Double = 0.0 - - @Published - private var item: BaseItemDto @Published final var state: State = .initial + // MARK: - Published Items + + @Published + private(set) var progress: Double = 0.0 + @Published + private var item: BaseItemDto + + // MARK: - Event Objects + private var itemTask: AnyCancellable? private var eventSubject = PassthroughSubject() @@ -59,21 +57,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { .eraseToAnyPublisher() } - // MARK: Init + // MARK: - Init init(item: BaseItemDto) { self.item = item super.init() } - // MARK: Respond + // MARK: - Respond func respond(to action: Action) -> State { switch action { - case let .error(error): - eventSubject.send(.error(error)) - return .error(error) - case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages): itemTask?.cancel() @@ -81,8 +75,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { guard let self else { return } do { await MainActor.run { - self.state = .content - self.eventSubject.send(.refreshTriggered) + self.state = .refreshing } try await self.refreshMetadata( @@ -93,14 +86,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { ) await MainActor.run { - self.state = .refreshing - self.eventSubject.send(.refreshTriggered) - } - - try await self.refreshItem() - - await MainActor.run { - self.state = .content + self.state = .initial } } catch { @@ -108,18 +94,17 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { let apiError = JellyfinAPIError(error.localizedDescription) await MainActor.run { - self.state = .error(apiError) self.eventSubject.send(.error(apiError)) } } } .asAnyCancellable() - return .refreshing + return state } } - // MARK: Metadata Refresh Logic + // MARK: - Metadata Refresh Logic private func refreshMetadata( metadataRefreshMode: MetadataRefreshMode, @@ -140,18 +125,37 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { parameters: parameters ) _ = try await userSession.client.send(request) + + try await self.refreshItem() } - // MARK: Refresh Item After Request Queued + // MARK: - Refresh Item After Request Queued private func refreshItem() async throws { guard let itemId = item.id else { return } + try await pollRefreshProgress() + + let request = Paths.getItem(userID: userSession.user.id, itemID: itemId) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.item = response.value + self.progress = 0.0 + + Notifications[.itemMetadataDidChange].post(self.item) + } + } + + // MARK: - Poll Progress + + // TODO: Find a way to actually check refresh progress. Not currently possible on 10.10. + private func pollRefreshProgress() async throws { let totalDuration: Double = 5.0 let interval: Double = 0.05 let steps = Int(totalDuration / interval) - // Update progress every 0.05 seconds. Ticks up "1%" at a time. + /// Update progress every 0.05 seconds. Ticks up "1%" at a time. for i in 1 ... steps { try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) @@ -160,16 +164,5 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful { self.progress = currentProgress } } - - // After waiting for 5 seconds, fetch the updated item - let request = Paths.getItem(userID: userSession.user.id, itemID: itemId) - let response = try await userSession.client.send(request) - - await MainActor.run { - self.item = response.value - self.progress = 0.0 - - Notifications[.itemMetadataDidChange].post(item) - } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift index c6f6c425..dafee461 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButton.swift @@ -13,14 +13,24 @@ extension ItemView { struct ActionButton: View { + // MARK: - Environment Objects + @Environment(\.isSelected) private var isSelected + + // MARK: - Focus State + @FocusState private var isFocused: Bool + // MARK: - Item Variables + let title: String let icon: String let selectedIcon: String + + // MARK: - Item Actions + let onSelect: () -> Void // MARK: - Body diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift index 2e6377fa..4a31af6a 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionButtonHStack.swift @@ -12,10 +12,68 @@ extension ItemView { struct ActionButtonHStack: View { + // MARK: - Observed, State, & Environment Objects + + @EnvironmentObject + private var router: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel - // TODO: Shrink to minWWith 100 (button) / 50 (menu) and 16 spacing to get 4 buttons inline + @StateObject + var deleteViewModel: DeleteItemViewModel + + // MARK: - Defaults + + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion: Bool + @StoredValue(.User.enableItemEditing) + private var enableItemEditing: Bool + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement: Bool + + // MARK: - Dialog States + + @State + private var showConfirmationDialog = false + @State + private var isPresentingEventAlert = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Can Delete Item + + private var canDelete: Bool { + if viewModel.item.type == .boxSet { + return enableCollectionManagement && viewModel.item.canDelete ?? false + } else { + return enableItemDeletion && viewModel.item.canDelete ?? false + } + } + + // MARK: - Refresh Item + + private var canRefresh: Bool { + if viewModel.item.type == .boxSet { + return enableCollectionManagement + } else { + return enableItemEditing + } + } + + // MARK: - Initializer + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel + self._deleteViewModel = StateObject(wrappedValue: .init(item: viewModel.item)) + } + + // MARK: - Body + + /// Shrink to minWidth 100 (button) / 50 (menu) and 16 spacing to get 3 buttons + menu var body: some View { HStack(alignment: .center, spacing: 24) { @@ -47,11 +105,42 @@ extension ItemView { // MARK: - Additional Menu Options - // TODO: Enable if there are more items needed - /* ActionMenu {} - .frame(width: 70)*/ + if canRefresh || canDelete { + ActionMenu { + if canRefresh { + RefreshMetadataButton(item: viewModel.item) + } + + if canDelete { + Divider() + Button(L10n.delete, systemImage: "trash", role: .destructive) { + showConfirmationDialog = true + } + } + } + .frame(width: 70) + } } .frame(height: 100) + .confirmationDialog( + L10n.deleteItemConfirmationMessage, + isPresented: $showConfirmationDialog, + titleVisibility: .visible + ) { + Button(L10n.confirm, role: .destructive) { + deleteViewModel.send(.delete) + } + Button(L10n.cancel, role: .cancel) {} + } + .onReceive(deleteViewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + case .deleted: + router.dismissCoordinator() + } + } + .errorMessage($error) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift index 8fc090df..ff66b787 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/ActionMenu.swift @@ -13,9 +13,13 @@ extension ItemView { struct ActionMenu: View { + // MARK: - Focus State + @FocusState private var isFocused: Bool + // MARK: - Menu Items + @ViewBuilder let menuItems: Content diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift new file mode 100644 index 00000000..6f82251c --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtons/RefreshMetadataButton.swift @@ -0,0 +1,105 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension ItemView { + + struct RefreshMetadataButton: View { + + // MARK: - State Object + + @StateObject + private var viewModel: RefreshMetadataViewModel + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(item: BaseItemDto) { + _viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item)) + } + + // MARK: - Body + + var body: some View { + Menu { + Group { + Button(L10n.findMissing, systemImage: "magnifyingglass") { + viewModel.send( + .refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: false + ) + ) + } + + Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") { + viewModel.send( + .refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .none, + replaceMetadata: true, + replaceImages: false + ) + ) + } + + Button(L10n.replaceImages, systemImage: "photo") { + viewModel.send( + .refreshMetadata( + metadataRefreshMode: .none, + imageRefreshMode: .fullRefresh, + replaceMetadata: false, + replaceImages: true + ) + ) + } + + Button(L10n.replaceAll, systemImage: "staroflife") { + viewModel.send( + .refreshMetadata( + metadataRefreshMode: .fullRefresh, + imageRefreshMode: .fullRefresh, + replaceMetadata: true, + replaceImages: true + ) + ) + } + } + } label: { + HStack { + Text(L10n.refreshMetadata) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + .backport + .fontWeight(.semibold) + } + } + .foregroundStyle(.primary, .secondary) + .disabled(viewModel.state == .refreshing || error != nil) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + error = eventError + } + } + .errorMessage($error) + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift new file mode 100644 index 00000000..dadfba62 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -0,0 +1,50 @@ +// +// 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 Defaults +import Factory +import SwiftUI + +extension CustomizeViewsSettings { + + struct ItemSection: View { + + @Injected(\.currentUserSession) + private var userSession + + @StoredValue(.User.enableItemEditing) + private var enableItemEditing + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement + + var body: some View { + if userSession?.user.permissions.items.canEditMetadata ?? false || + userSession?.user.permissions.items.canDelete ?? false || + userSession?.user.permissions.items.canManageCollections ?? false + { + + Section(L10n.items) { + /// Enable Refreshing Items from All Visible LIbraries + if userSession?.user.permissions.items.canEditMetadata ?? false { + Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) + } + /// Enable Deleting Items from Approved Libraries + if userSession?.user.permissions.items.canDelete ?? false { + Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) + } + /// Enable Refreshing & Deleting Collections + if userSession?.user.permissions.items.canManageCollections ?? false { + Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index df65220e..ec4f4811 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -105,6 +105,8 @@ struct CustomizeViewsSettings: View { } } + ItemSection() + HomeSection() } .withDescriptionTopPadding() diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index faf6f718..72d9df83 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -153,6 +153,8 @@ 4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */; }; 4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */; }; 4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */; }; + 4E97D1832D064748004B89AD /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E97D1822D064748004B89AD /* ItemSection.swift */; }; + 4E97D1852D064B43004B89AD /* RefreshMetadataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */; }; 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; }; 4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */; }; 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; @@ -195,6 +197,8 @@ 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; + 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; + 4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; @@ -1265,6 +1269,8 @@ 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = ""; }; 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = ""; }; 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerTaskView.swift; sourceTree = ""; }; + 4E97D1822D064748004B89AD /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; + 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataButton.swift; sourceTree = ""; }; 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; @@ -1301,6 +1307,7 @@ 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = ""; }; 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = ""; }; 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; + 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; 4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; 4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; @@ -2255,6 +2262,7 @@ 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */, E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */, + 4E97D1842D064B43004B89AD /* RefreshMetadataButton.swift */, ); path = ActionButtons; sourceTree = ""; @@ -2392,6 +2400,7 @@ isa = PBXGroup; children = ( 4E699BBF2CB34775007CBD5D /* HomeSection.swift */, + 4E97D1822D064748004B89AD /* ItemSection.swift */, ); path = Sections; sourceTree = ""; @@ -3857,6 +3866,7 @@ E18E0202288749200022598C /* AttributeStyleModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, + 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */, E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */, E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */, E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, @@ -4974,6 +4984,7 @@ 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */, 4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, + 4E97D1832D064748004B89AD /* ItemSection.swift in Sources */, E145EB232BDCCA43003BF6F3 /* BulletedList.swift in Sources */, E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E1E1643E28BB074000323B0A /* SelectorView.swift in Sources */, @@ -5248,6 +5259,7 @@ E18E02232887492B0022598C /* ImageView.swift in Sources */, E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */, E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */, + 4E97D1852D064B43004B89AD /* RefreshMetadataButton.swift in Sources */, E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */, E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */, @@ -5298,6 +5310,7 @@ E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, 4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */, + 4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */, E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, 4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */, @@ -5615,6 +5628,7 @@ E168BD10289A4162001A6922 /* HomeView.swift in Sources */, 4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */, 4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */, + 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */, 4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */, @@ -6270,7 +6284,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -6286,7 +6300,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -6310,7 +6324,7 @@ CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -6326,7 +6340,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift index 825dcfde..468fc05f 100644 --- a/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/AddItemElementView.swift @@ -13,15 +13,21 @@ import SwiftUI struct AddItemElementView: View { + // MARK: - Defaults + @Default(.accentColor) private var accentColor + // MARK: - Environment & Observed Objects + @EnvironmentObject private var router: BasicNavigationViewCoordinator.Router @ObservedObject var viewModel: ItemEditorViewModel + // MARK: - Elements Variables + let type: ItemArrayElements @State @@ -33,11 +39,13 @@ struct AddItemElementView: View { @State private var personRole: String = "" + // MARK: - Trie Data Loaded + @State private var loaded: Bool = false - @State - private var isPresentingError: Bool = false + // MARK: - Error State + @State private var error: Error? @@ -47,6 +55,8 @@ struct AddItemElementView: View { name.isNotEmpty } + // MARK: - Name Already Exists + private var itemAlreadyExists: Bool { viewModel.trie.contains(key: name.localizedLowercase) } @@ -56,12 +66,10 @@ struct AddItemElementView: View { var body: some View { ZStack { switch viewModel.state { + case .initial, .content, .updating: + contentView case let .error(error): ErrorView(error: error) - case .updating: - DelayedProgressView() - case .initial, .content: - contentView } } .navigationTitle(type.displayTitle) @@ -104,16 +112,9 @@ struct AddItemElementView: View { case let .error(eventError): UIDevice.feedback(.error) error = eventError - isPresentingError = true } } - .alert( - L10n.error, - isPresented: $isPresentingError, - presenting: error - ) { error in - Text(error.localizedDescription) - } + .errorMessage($error) } // MARK: - Content View @@ -122,15 +123,15 @@ struct AddItemElementView: View { List { NameInput( name: $name, - type: type, personKind: $personKind, personRole: $personRole, + type: type, itemAlreadyExists: itemAlreadyExists ) SearchResultsSection( - id: $id, name: $name, + id: $id, type: type, population: viewModel.matches, isSearching: viewModel.backgroundStates.contains(.searching) diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift index 7c6a1586..4f9bc97c 100644 --- a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/NameInput.swift @@ -13,15 +13,16 @@ extension AddItemElementView { struct NameInput: View { + // MARK: - Element Variables + @Binding var name: String - var type: ItemArrayElements - @Binding var personKind: PersonKind @Binding var personRole: String + let type: ItemArrayElements let itemAlreadyExists: Bool // MARK: - Body @@ -34,7 +35,7 @@ extension AddItemElementView { } } - // MARK: - Name Input Field + // MARK: - Name View private var nameView: some View { Section { @@ -67,7 +68,7 @@ extension AddItemElementView { } } - // MARK: - Person Input Fields + // MARK: - Person View var personView: some View { Section { diff --git a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift index 53f950de..099f7a4a 100644 --- a/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift +++ b/Swiftfin/Views/ItemEditorView/AddItemElementView/Components/SearchResultsSection.swift @@ -13,13 +13,19 @@ extension AddItemElementView { struct SearchResultsSection: View { - @Binding - var id: String? + // MARK: - Element Variables + @Binding var name: String + @Binding + var id: String? + + // MARK: - Element Search Variables let type: ItemArrayElements let population: [Element] + + // TODO: Why doesn't environment(\.isSearching) work? let isSearching: Bool // MARK: - Body @@ -50,7 +56,7 @@ extension AddItemElementView { } } - // MARK: - Empty Matches Results + // MARK: - No Results View private var noResultsView: some View { Text(L10n.none) @@ -58,7 +64,7 @@ extension AddItemElementView { .frame(maxWidth: .infinity, alignment: .center) } - // MARK: - Formatted Matches Results + // MARK: - Results View private var resultsView: some View { ForEach(population, id: \.self) { result in @@ -75,7 +81,7 @@ extension AddItemElementView { } } - // MARK: - Element Matches Button Label by Type + // MARK: - Label View @ViewBuilder private func labelView(_ match: Element) -> some View { diff --git a/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift index b388f2bc..3f3931e8 100644 --- a/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift +++ b/Swiftfin/Views/ItemEditorView/Components/RefreshMetadataButton.swift @@ -13,6 +13,8 @@ extension ItemEditorView { struct RefreshMetadataButton: View { + // MARK: - Environment & State Objects + // Bug in SwiftUI where Menu item icons will be black in dark mode // when a HierarchicalShapeStyle is applied to the Buttons @Environment(\.colorScheme) @@ -21,10 +23,10 @@ extension ItemEditorView { @StateObject private var viewModel: RefreshMetadataViewModel + // MARK: - Error State + @State - private var isPresentingEventAlert = false - @State - private var error: JellyfinAPIError? + private var error: Error? // MARK: - Initializer @@ -103,25 +105,14 @@ extension ItemEditorView { } } .foregroundStyle(.primary, .secondary) - .disabled(viewModel.state == .refreshing || isPresentingEventAlert) + .disabled(viewModel.state == .refreshing || error != nil) .onReceive(viewModel.events) { event in switch event { case let .error(eventError): error = eventError - isPresentingEventAlert = true - case .refreshTriggered: - UIDevice.impact(.light) } } - .alert( - L10n.error, - isPresented: $isPresentingEventAlert, - presenting: error - ) { _ in - - } message: { error in - Text(error.localizedDescription) - } + .errorMessage($error) } } } diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift index 689413fc..730eb9d6 100644 --- a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift @@ -14,13 +14,20 @@ extension EditItemElementView { struct EditItemElementRow: View { + // MARK: - Enviroment Variables + @Environment(\.isEditing) var isEditing @Environment(\.isSelected) var isSelected + // MARK: - Metadata Variables + let item: Element let type: ItemArrayElements + + // MARK: - Row Actions + let onSelect: () -> Void let onDelete: () -> Void diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift index 5e3e6e50..cef53b59 100644 --- a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift @@ -13,25 +13,38 @@ import SwiftUI struct EditItemElementView: View { + // MARK: - Defaults + @Default(.accentColor) private var accentColor + // MARK: - Observed & Environment Objects + @EnvironmentObject private var router: ItemEditorCoordinator.Router @ObservedObject var viewModel: ItemEditorViewModel + // MARK: - Elements + @State private var elements: [Element] + // MARK: - Type & Route + private let type: ItemArrayElements private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void + // MARK: - Dialog States + @State private var isPresentingDeleteConfirmation = false @State private var isPresentingDeleteSelectionConfirmation = false + + // MARK: - Editing States + @State private var selectedElements: Set = [] @State @@ -39,6 +52,11 @@ struct EditItemElementView: View { @State private var isReordering: Bool = false + // MARK: - Error State + + @State + private var error: Error? + // MARK: - Initializer init( @@ -55,95 +73,111 @@ struct EditItemElementView: View { // MARK: - Body var body: some View { - contentView - .navigationBarTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing || isReordering) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing || isReordering { - Button(L10n.cancel) { - if isEditing { - isEditing.toggle() - } - if isReordering { - elements = type.getElement(for: viewModel.item) - isReordering.toggle() - } - UIDevice.impact(.light) - selectedElements.removeAll() - } - .buttonStyle(.toolbarPill) - .foregroundStyle(accentColor) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedElements.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - if isReordering { - Button(L10n.save) { - viewModel.send(.reorder(elements)) - isReordering = false - } - .buttonStyle(.toolbarPill) - .disabled(type.getElement(for: viewModel.item) == elements) - .frame(maxWidth: .infinity, alignment: .trailing) - } + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + errorView(with: error) + } + } + .navigationBarTitle(type.displayTitle) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing || isReordering) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView } } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refreshing), - isHidden: isEditing || isReordering - ) { - Button(L10n.add, systemImage: "plus") { - route(router, viewModel) + ToolbarItem(placement: .topBarTrailing) { + if isEditing || isReordering { + Button(L10n.cancel) { + if isEditing { + isEditing.toggle() + } + if isReordering { + elements = type.getElement(for: viewModel.item) + isReordering.toggle() + } + UIDevice.impact(.light) + selectedElements.removeAll() + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedElements.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + if isReordering { + Button(L10n.save) { + viewModel.send(.reorder(elements)) + isReordering = false + } + .buttonStyle(.toolbarPill) + .disabled(type.getElement(for: viewModel.item) == elements) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || isReordering + ) { + Button(L10n.add, systemImage: "plus") { + route(router, viewModel) + } + + if elements.isNotEmpty == true { + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true } - if elements.isNotEmpty == true { - Button(L10n.edit, systemImage: "checkmark.circle") { - isEditing = true - } - - Button(L10n.reorder, systemImage: "arrow.up.arrow.down") { - isReordering = true - } + Button(L10n.reorder, systemImage: "arrow.up.arrow.down") { + isReordering = true } } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedConfirmationActions - } message: { - Text(L10n.deleteSelectedConfirmation) - } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteConfirmationActions - } message: { - Text(L10n.deleteItemConfirmation) - } - .onNotification(.itemMetadataDidChange) { _ in - self.elements = type.getElement(for: self.viewModel.item) + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + default: + break } + } + .errorMessage($error) + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedConfirmationActions + } message: { + Text(L10n.deleteSelectedConfirmation) + } + .confirmationDialog( + L10n.delete, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteConfirmationActions + } message: { + Text(L10n.deleteItemConfirmation) + } + .onNotification(.itemMetadataDidChange) { _ in + self.elements = type.getElement(for: self.viewModel.item) + } } - // MARK: - Navigation Bar Select/Remove All Content + // MARK: - Select/Remove All Button @ViewBuilder private var navigationBarSelectView: some View { @@ -156,6 +190,16 @@ struct EditItemElementView: View { .foregroundStyle(accentColor) } + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.load) + } + } + // MARK: - Content View private var contentView: some View { diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift index 7f39e20c..e42c7b23 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/EpisodeSection.swift @@ -17,10 +17,12 @@ extension EditMetadataView { @Binding var item: BaseItemDto + // MARK: - Body + var body: some View { Section(L10n.season) { - // MARK: Season Number + // MARK: - Season Number ChevronAlertButton( L10n.season, @@ -35,7 +37,7 @@ extension EditMetadataView { .keyboardType(.numberPad) } - // MARK: Episode Number + // MARK: - Episode Number ChevronAlertButton( L10n.episode, diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift index dc04c5ee..97998bac 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/OverviewSection.swift @@ -14,11 +14,15 @@ extension EditMetadataView { struct OverviewSection: View { + // MARK: - Metadata Variables + @Binding var item: BaseItemDto let itemType: BaseItemKind + // MARK: - Show Tagline + private var showTaglines: Bool { [ BaseItemKind.movie, @@ -29,6 +33,8 @@ extension EditMetadataView { ].contains(itemType) } + // MARK: - Body + var body: some View { if showTaglines { // There doesn't seem to be a usage anywhere of more than 1 tagline? diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift index c1eb5452..9b4be848 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ParentialRatingsSection.swift @@ -10,17 +10,22 @@ import Combine import JellyfinAPI import SwiftUI -// TODO: Reimagine this whole thing to be much leaner. extension EditMetadataView { struct ParentalRatingSection: View { - @Binding - var item: BaseItemDto + // MARK: - Observed Object @ObservedObject private var viewModel = ParentalRatingsViewModel() + // MARK: - Item + + @Binding + var item: BaseItemDto + + // MARK: - Ratings States + @State private var officialRatings: [ParentalRating] = [] @State @@ -31,7 +36,7 @@ extension EditMetadataView { var body: some View { Section(L10n.parentalRating) { - // MARK: Official Rating Picker + // MARK: - Official Rating Picker Picker( L10n.officialRating, @@ -53,7 +58,7 @@ extension EditMetadataView { updateOfficialRatings() } - // MARK: Custom Rating Picker + // MARK: - Custom Rating Picker Picker( L10n.customRating, diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift index db2319b9..0cb29855 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/ReviewsSection.swift @@ -17,10 +17,12 @@ extension EditMetadataView { @Binding var item: BaseItemDto + // MARK: - Body + var body: some View { Section(L10n.reviews) { - // MARK: Critics Rating + // MARK: - Critics Rating ChevronAlertButton( L10n.critics, @@ -40,7 +42,7 @@ extension EditMetadataView { } } - // MARK: Community Rating + // MARK: - Community Rating ChevronAlertButton( L10n.community, diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift index 02e74798..39832ccb 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/EditMetadataView.swift @@ -12,12 +12,16 @@ import SwiftUI struct EditMetadataView: View { + // MARK: - Observed & Environment Objects + @EnvironmentObject private var router: BasicNavigationViewCoordinator.Router @ObservedObject private var viewModel: ItemEditorViewModel + // MARK: - Metadata Variables + @Binding var item: BaseItemDto @@ -26,6 +30,11 @@ struct EditMetadataView: View { private let itemType: BaseItemKind + // MARK: - Error State + + @State + private var error: Error? + // MARK: - Initializer init(viewModel: ItemEditorViewModel) { @@ -39,21 +48,47 @@ struct EditMetadataView: View { @ViewBuilder var body: some View { - contentView - .navigationBarTitle(L10n.metadata) - .navigationBarTitleDisplayMode(.inline) - .topBarTrailing { - Button(L10n.save) { - item = tempItem - viewModel.send(.update(tempItem)) - router.dismissCoordinator() - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.item == tempItem) + ZStack { + switch viewModel.state { + case .initial, .content, .updating: + contentView + case let .error(error): + errorView(with: error) } - .navigationBarCloseButton { + } + .navigationBarTitle(L10n.metadata) + .navigationBarTitleDisplayMode(.inline) + .topBarTrailing { + Button(L10n.save) { + item = tempItem + viewModel.send(.update(tempItem)) router.dismissCoordinator() } + .buttonStyle(.toolbarPill) + .disabled(viewModel.item == tempItem) + } + .navigationBarCloseButton { + router.dismissCoordinator() + } + .onReceive(viewModel.events) { events in + switch events { + case let .error(eventError): + error = eventError + default: + break + } + } + .errorMessage($error) + } + + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.load) + } } // MARK: - Content View diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift index 7a392004..f926dd57 100644 --- a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -24,12 +24,19 @@ struct ItemEditorView: View { // MARK: - Body var body: some View { - contentView - .navigationBarTitle(L10n.metadata) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() + ZStack { + switch viewModel.state { + case .initial, .content, .refreshing: + contentView + case let .error(error): + errorView(with: error) } + } + .navigationBarTitle(L10n.metadata) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } } // MARK: - Content View @@ -47,6 +54,18 @@ struct ItemEditorView: View { } } + // MARK: - ErrorView + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + // MARK: - Refresh Menu Button + @ViewBuilder private var refreshButtonView: some View { Section { @@ -74,6 +93,8 @@ struct ItemEditorView: View { } } + // MARK: - Editable Routing Buttons + @ViewBuilder private var editView: some View { Section(L10n.edit) { diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift index 11180c0b..0e0f730f 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift @@ -25,31 +25,39 @@ extension CustomizeViewsSettings { private var enableCollectionManagement var body: some View { - Section(L10n.items) { - /// Enable Editing Items from All Visible LIbraries - if userSession?.user.permissions.items.canEditMetadata ?? false { - Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) - } - /// Enable Downloading All Items - /* if userSession?.user.permissions.items.canDownload ?? false { + if userSession?.user.permissions.items.canEditMetadata ?? false + || userSession?.user.permissions.items.canDelete ?? false + // || userSession?.user.permissions.items.canDownload ?? false + || userSession?.user.permissions.items.canManageCollections ?? false + // || userSession?.user.permissions.items.canManageLyrics ?? false + // || userSession?.user.permissions.items.canManageSubtitles + { + Section(L10n.items) { + /// Enable Editing Items from All Visible LIbraries + if userSession?.user.permissions.items.canEditMetadata ?? false { + Toggle(L10n.allowItemEditing, isOn: $enableItemEditing) + } + /// Enable Deleting Items from Approved Libraries + if userSession?.user.permissions.items.canDelete ?? false { + Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) + } + /// Enable Downloading All Items + /* if userSession?.user.permissions.items.canDownload ?? false { Toggle(L10n.allowItemDownloading, isOn: $enableItemDownloads) - } */ - /// Enable Deleting or Editing Collections - if userSession?.user.permissions.items.canManageCollections ?? false { - Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) - } - /// Manage Item Lyrics - /* if userSession?.user.permissions.items.canManageLyrics ?? false { + } */ + /// Enable Deleting or Editing Collections + if userSession?.user.permissions.items.canManageCollections ?? false { + Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement) + } + /// Manage Item Lyrics + /* if userSession?.user.permissions.items.canManageLyrics ?? false { Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement) - } */ - /// Manage Item Subtitles - /* if userSession?.user.items.canManageSubtitles ?? false { + } */ + /// Manage Item Subtitles + /* if userSession?.user.items.canManageSubtitles ?? false { Toggle(L10n.allowSubtitleManagement, isOn: $enableSubtitleManagement) - } */ - } - /// Enable Deleting Items from Approved Libraries - if userSession?.user.permissions.items.canDelete ?? false { - Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion) + } */ + } } } }