From bc6c5236f7eba2cc438968698bdecc7498a11dd7 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Oct 2024 15:58:22 -0600 Subject: [PATCH 01/10] Rebase on Main. Starting the conversions from Ints to TimeIntervals & Ticks. --- Shared/Coordinators/SettingsCoordinator.swift | 7 + Shared/Extensions/TaskTriggerType.swift | 52 ++++ Shared/Objects/TaskTriggerInterval.swift | 60 ++++ Shared/Strings/Strings.swift | 72 +++++ Shared/ViewModels/ServerTaskObserver.swift | 65 ++++- Swiftfin.xcodeproj/project.pbxproj | 38 ++- .../EditScheduledTaskView.swift | 95 ------- .../Components/AddTaskTriggerView.swift | 257 ++++++++++++++++++ .../Components/TriggerButton.swift | 107 ++++++++ .../EditScheduledTaskView.swift | 86 ++++++ Translations/en.lproj/Localizable.strings | Bin 44838 -> 49496 bytes 11 files changed, 742 insertions(+), 97 deletions(-) create mode 100644 Shared/Extensions/TaskTriggerType.swift create mode 100644 Shared/Objects/TaskTriggerInterval.swift delete mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index c5d36cf46..11b44d39d 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -63,6 +63,8 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var editScheduledTask = makeEditScheduledTask @Route(.push) + var addScheduledTaskTrigger = makeAddScheduledTaskTrigger + @Route(.push) var serverLogs = makeServerLogs @Route(.modal) @@ -193,6 +195,11 @@ final class SettingsCoordinator: NavigationCoordinatable { EditScheduledTaskView(observer: observer) } + @ViewBuilder + func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> some View { + AddTaskTriggerView(observer: observer) + } + @ViewBuilder func makeServerLogs() -> some View { ServerLogsView() diff --git a/Shared/Extensions/TaskTriggerType.swift b/Shared/Extensions/TaskTriggerType.swift new file mode 100644 index 000000000..109734ed0 --- /dev/null +++ b/Shared/Extensions/TaskTriggerType.swift @@ -0,0 +1,52 @@ +// +// 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 Foundation + +public enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemImageable { + case daily = "DailyTrigger" + case weekly = "WeeklyTrigger" + case interval = "IntervalTrigger" + case startup = "StartupTrigger" + case manual = "ManualTrigger" + case unknown = "" + + public var displayTitle: String { + switch self { + case .daily: + return L10n.daily + case .weekly: + return L10n.weekly + case .interval: + return L10n.interval + case .startup: + return L10n.onApplicationStartup + case .manual: + return L10n.manual + case .unknown: + return L10n.unknown + } + } + + var systemImage: String { + switch self { + case .daily: + return "clock" + case .weekly: + return "calendar" + case .interval: + return "timer" + case .startup: + return "power" + case .manual: + return "hand.tap" + case .unknown: + return "questionmark" + } + } +} diff --git a/Shared/Objects/TaskTriggerInterval.swift b/Shared/Objects/TaskTriggerInterval.swift new file mode 100644 index 000000000..3f47089ce --- /dev/null +++ b/Shared/Objects/TaskTriggerInterval.swift @@ -0,0 +1,60 @@ +// +// 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 Foundation + +enum TaskTriggerInterval: TimeInterval, CaseIterable, Identifiable { + case fifteenMinutes = 9_000_000_000 + case thirtyMinutes = 18_000_000_000 + case fortyFiveMinutes = 27_000_000_000 + case oneHour = 36_000_000_000 + case twoHours = 72_000_000_000 + case threeHours = 108_000_000_000 + case fourHours = 144_000_000_000 + case sixHours = 216_000_000_000 + case eightHours = 288_000_000_000 + case twelveHours = 432_000_000_000 + case twentyFourHours = 864_000_000_000 + + /// Use the number of ticks as the Id + var id: TimeInterval { + self.rawValue + } + + /// Number of seconds for the interval (1 tick = 0.1 microseconds) + var seconds: Int { + Int(rawValue / 10_000_000) + } + + var displayTitle: String { + switch self { + case .fifteenMinutes: + return L10n.intervalMinutes(15) + case .thirtyMinutes: + return L10n.intervalMinutes(30) + case .fortyFiveMinutes: + return L10n.intervalMinutes(45) + case .oneHour: + return L10n.intervalHours(1) + case .twoHours: + return L10n.intervalHours(2) + case .threeHours: + return L10n.intervalHours(3) + case .fourHours: + return L10n.intervalHours(4) + case .sixHours: + return L10n.intervalHours(6) + case .eightHours: + return L10n.intervalHours(8) + case .twelveHours: + return L10n.intervalHours(12) + case .twentyFourHours: + return L10n.intervalHours(24) + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 4433c2d79..497b02db2 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -20,8 +20,14 @@ internal enum L10n { internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") /// Active Devices internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") + /// Add + internal static let add = L10n.tr("Localizable", "add", fallback: "Add") /// Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") + /// Add Task Trigger + internal static let addTaskTrigger = L10n.tr("Localizable", "addTaskTrigger", fallback: "Add Task Trigger") + /// Add trigger + internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Administration @@ -146,6 +152,8 @@ internal enum L10n { internal static let category = L10n.tr("Localizable", "category", fallback: "Category") /// Change Server internal static let changeServer = L10n.tr("Localizable", "changeServer", fallback: "Change Server") + /// Changes not saved + internal static let changesNotSaved = L10n.tr("Localizable", "changesNotSaved", fallback: "Changes not saved") /// Channels internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") /// Chapters @@ -222,20 +230,30 @@ internal enum L10n { internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize") /// Custom Profile internal static let customProfile = L10n.tr("Localizable", "customProfile", fallback: "Custom Profile") + /// Daily + internal static let daily = L10n.tr("Localizable", "daily", fallback: "Daily") /// Dark internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// Dashboard internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") /// Perform administrative tasks for your Jellyfin server. internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") + /// Day of Week + internal static let dayOfWeek = L10n.tr("Localizable", "dayOfWeek", fallback: "Day of Week") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Delete internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") /// Delete Server internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") + /// Delete Trigger + internal static let deleteTrigger = L10n.tr("Localizable", "deleteTrigger", fallback: "Delete Trigger") + /// Are you sure you want to delete this trigger? This action cannot be undone. + internal static let deleteTriggerConfirmationMessage = L10n.tr("Localizable", "deleteTriggerConfirmationMessage", fallback: "Are you sure you want to delete this trigger? This action cannot be undone.") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") + /// Details + internal static let details = L10n.tr("Localizable", "details", fallback: "Details") /// Device internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Device Profile @@ -252,6 +270,8 @@ internal enum L10n { internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream") /// Disabled internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled") + /// Discard Changes + internal static let discardChanges = L10n.tr("Localizable", "discardChanges", fallback: "Discard Changes") /// Discovered Servers internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers", fallback: "Discovered Servers") /// Dismiss @@ -268,6 +288,8 @@ internal enum L10n { internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") /// Edit Server internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server") + /// Edit Task Trigger + internal static let editTaskTrigger = L10n.tr("Localizable", "editTaskTrigger", fallback: "Edit Task Trigger") /// Empty Next Up internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") /// Enabled @@ -282,6 +304,12 @@ internal enum L10n { internal static let episodes = L10n.tr("Localizable", "episodes", fallback: "Episodes") /// Error internal static let error = L10n.tr("Localizable", "error", fallback: "Error") + /// Every + internal static let every = L10n.tr("Localizable", "every", fallback: "Every") + /// Every %1$@ + internal static func everyInterval(_ p1: Any) -> String { + return L10n.tr("Localizable", "everyInterval", String(describing: p1), fallback: "Every %1$@") + } /// Existing Server internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: "Existing Server") /// Existing User @@ -314,16 +342,36 @@ internal enum L10n { internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback") /// Home internal static let home = L10n.tr("Localizable", "home", fallback: "Home") + /// Hours + internal static let hours = L10n.tr("Localizable", "hours", fallback: "Hours") /// Indicators internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Information internal static let information = L10n.tr("Localizable", "information", fallback: "Information") /// Interlaced video is not supported internal static let interlacedVideoNotSupported = L10n.tr("Localizable", "interlacedVideoNotSupported", fallback: "Interlaced video is not supported") + /// Interval + internal static let interval = L10n.tr("Localizable", "interval", fallback: "Interval") + /// %d hour + internal static func intervalHour(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalHour", p1, fallback: "%d hour") + } + /// %d hours + internal static func intervalHours(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalHours", p1, fallback: "%d hours") + } + /// %d minutes + internal static func intervalMinutes(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalMinutes", p1, fallback: "%d minutes") + } /// Inverted Dark internal static let invertedDark = L10n.tr("Localizable", "invertedDark", fallback: "Inverted Dark") /// Inverted Light internal static let invertedLight = L10n.tr("Localizable", "invertedLight", fallback: "Inverted Light") + /// %1$@ at %2$@ + internal static func itemAtItem(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "itemAtItem", String(describing: p1), String(describing: p2), fallback: "%1$@ at %2$@") + } /// %1$@ / %2$@ internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@") @@ -390,6 +438,8 @@ internal enum L10n { } /// Logs internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") + /// Manual + internal static let manual = L10n.tr("Localizable", "manual", fallback: "Manual") /// Maximum Bitrate internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "Maximum Bitrate") /// This setting may result in media failing to start playback @@ -468,6 +518,8 @@ internal enum L10n { internal static let offset = L10n.tr("Localizable", "offset", fallback: "Offset") /// Ok internal static let ok = L10n.tr("Localizable", "ok", fallback: "Ok") + /// On application startup + internal static let onApplicationStartup = L10n.tr("Localizable", "onApplicationStartup", fallback: "On application startup") /// 1 user internal static let oneUser = L10n.tr("Localizable", "oneUser", fallback: "1 user") /// Online @@ -636,6 +688,8 @@ internal enum L10n { internal static let running = L10n.tr("Localizable", "running", fallback: "Running...") /// Runtime internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime") + /// Save + internal static let save = L10n.tr("Localizable", "save", fallback: "Save") /// Scan All Libraries internal static let scanAllLibraries = L10n.tr("Localizable", "scanAllLibraries", fallback: "Scan All Libraries") /// Scheduled Tasks @@ -786,6 +840,8 @@ internal enum L10n { internal static let system = L10n.tr("Localizable", "system", fallback: "System") /// System Control Gestures Enabled internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: "System Control Gestures Enabled") + /// System Event + internal static let systemEvent = L10n.tr("Localizable", "systemEvent", fallback: "System Event") /// Tags internal static let tags = L10n.tr("Localizable", "tags", fallback: "Tags") /// Task @@ -804,6 +860,14 @@ internal enum L10n { internal static let tasksDescription = L10n.tr("Localizable", "tasksDescription", fallback: "Tasks are operations that are scheduled to run periodically or can be triggered manually.") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") + /// Time + internal static let time = L10n.tr("Localizable", "time", fallback: "Time") + /// Time Limit (Hours) + internal static let timeLimit = L10n.tr("Localizable", "timeLimit", fallback: "Time Limit (Hours)") + /// Time limit: %1$@ + internal static func timeLimitLabelWithHours(_ p1: Any) -> String { + return L10n.tr("Localizable", "timeLimitLabelWithHours", String(describing: p1), fallback: "Time limit: %1$@") + } /// Timestamp internal static let timestamp = L10n.tr("Localizable", "timestamp", fallback: "Timestamp") /// Timestamp Type @@ -818,6 +882,10 @@ internal enum L10n { internal static let transcodeReasons = L10n.tr("Localizable", "transcodeReasons", fallback: "Transcode Reason(s)") /// Transition internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") + /// Triggers + internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers") + /// Trigger Type + internal static let triggerType = L10n.tr("Localizable", "triggerType", fallback: "Trigger Type") /// Try again internal static let tryAgain = L10n.tr("Localizable", "tryAgain", fallback: "Try again") /// TV Shows @@ -842,6 +910,8 @@ internal enum L10n { internal static let unknownVideoStreamInfo = L10n.tr("Localizable", "unknownVideoStreamInfo", fallback: "The video stream information is unknown") /// Unplayed internal static let unplayed = L10n.tr("Localizable", "unplayed", fallback: "Unplayed") + /// You have unsaved changes. Are you sure you want to discard them? + internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?") /// URL internal static let url = L10n.tr("Localizable", "url", fallback: "URL") /// Use as Transcoding Profile @@ -882,6 +952,8 @@ internal enum L10n { internal static let videoRangeTypeNotSupported = L10n.tr("Localizable", "videoRangeTypeNotSupported", fallback: "The video range type is not supported") /// The video resolution is not supported internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") + /// Weekly + internal static let weekly = L10n.tr("Localizable", "weekly", fallback: "Weekly") /// Who's watching? internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?") /// WIP diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift index efc0ad59e..c49c1b268 100644 --- a/Shared/ViewModels/ServerTaskObserver.swift +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -11,7 +11,6 @@ import Foundation import JellyfinAPI // TODO: refactor with socket implementation -// TODO: edit triggers final class ServerTaskObserver: ViewModel, Stateful, Identifiable { @@ -19,12 +18,15 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { case start case stop case stopObserving + case addTrigger(TaskTriggerInfo) + case removeTrigger(TaskTriggerInfo) } enum State: Hashable { case error(JellyfinAPIError) case initial case running + case updated } @Published @@ -88,6 +90,44 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { progressCancellable?.cancel() cancelCancellable?.cancel() + return .initial + case let .addTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + do { + try await addTrigger(newTrigger: trigger) + await MainActor.run { + self.state = .updated + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .initial + case let .removeTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + do { + try await removeTrigger(deleteTrigger: trigger) + await MainActor.run { + self.state = .updated + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + return .initial } } @@ -124,4 +164,27 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { let request = Paths.stopTask(taskID: id) try await userSession.client.send(request) } + + private func addTrigger(newTrigger: TaskTriggerInfo) async throws { + var updatedTriggers = task.triggers ?? [] + updatedTriggers.append(newTrigger) + try await updateTriggers(updatedTriggers) + } + + private func removeTrigger(deleteTrigger: TaskTriggerInfo) async throws { + var updatedTriggers = task.triggers ?? [] + updatedTriggers.removeAll { $0 == deleteTrigger } + try await updateTriggers(updatedTriggers) + } + + private func updateTriggers(_ updatedTriggers: [TaskTriggerInfo]) async throws { + guard let id = task.id else { return } + let updateRequest = Paths.updateTask(taskID: id, updatedTriggers) + try await userSession.client.send(updateRequest) + + await MainActor.run { + task.triggers = updatedTriggers + self.task = task + } + } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 12401b08b..96568c9c8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -52,6 +52,12 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */; }; + 4EA556B22CB48BB600F71E7A /* TriggerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */; }; + 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; + 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; + 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; + 4EA556B82CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */; }; @@ -1046,6 +1052,10 @@ 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTaskTriggerView.swift; sourceTree = ""; }; + 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerButton.swift; sourceTree = ""; }; + 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; + 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerInterval.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskButton.swift; sourceTree = ""; }; @@ -1860,7 +1870,7 @@ children = ( 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, - E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, + 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, @@ -1945,6 +1955,24 @@ path = Components; sourceTree = ""; }; + 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */ = { + isa = PBXGroup; + children = ( + 4EA556AE2CB48B6600F71E7A /* Components */, + E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, + ); + path = EditScheduledTaskView; + sourceTree = ""; + }; + 4EA556AE2CB48B6600F71E7A /* Components */ = { + isa = PBXGroup; + children = ( + 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, + 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { isa = PBXGroup; children = ( @@ -2153,6 +2181,7 @@ E1EF4C402911B783008CC695 /* StreamType.swift */, E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */, E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */, + 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */, E1A1528428FD191A00600579 /* TextPair.swift */, E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, @@ -2466,6 +2495,7 @@ E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, E1DD55362B6EE533007501C0 /* Task.swift */, + 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */, E1F5CF072CB0A04500607465 /* Text.swift */, E1A2C153279A7D5A005EC829 /* UIApplication.swift */, E1401CB029386C9200E8B599 /* UIColor.swift */, @@ -4329,6 +4359,7 @@ 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */, E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, + 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, @@ -4458,6 +4489,7 @@ 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, E1575EA2293E7B1E001665B1 /* Color.swift in Sources */, + 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */, E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */, E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */, @@ -4805,6 +4837,8 @@ E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, + 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */, + 4EA556B22CB48BB600F71E7A /* TriggerButton.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, @@ -4932,6 +4966,7 @@ E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, E1D842912933F87500D1041A /* ItemFields.swift in Sources */, E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, + 4EA556B82CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */, E113132F28BDB66A00930F75 /* NavigationBarDrawerModifier.swift in Sources */, E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, @@ -5050,6 +5085,7 @@ E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, + 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift deleted file mode 100644 index 968ecb41b..000000000 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift +++ /dev/null @@ -1,95 +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) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: last run details -// - result, show error if available -// TODO: observe running status -// - stop -// - run -// - progress -// TODO: triggers - -struct EditScheduledTaskView: View { - - @CurrentDate - private var currentDate: Date - - @ObservedObject - var observer: ServerTaskObserver - - var body: some View { - List { - - ListTitleSection( - observer.task.name ?? L10n.unknown, - description: observer.task.description - ) - - if let category = observer.task.category { - TextPairView( - leading: L10n.category, - trailing: category - ) - } - - if let lastEndTime = observer.task.lastExecutionResult?.endTimeUtc { - TextPairView( - L10n.lastRun, - value: Text("\(lastEndTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))") - ) - .id(currentDate) - .monospacedDigit() - - if let lastStartTime = observer.task.lastExecutionResult?.startTimeUtc { - TextPairView( - L10n.runtime, - value: Text( - "\(lastStartTime ..< lastEndTime, format: .components(style: .narrow))" - ) - ) - } - } - } - .navigationTitle(L10n.task) - } -} - -// TODO: remove after view done -#Preview { - NavigationView { - EditScheduledTaskView( - observer: .init( - task: TaskInfo( - category: "test", - currentProgressPercentage: nil, - description: "A test task", - id: "123", - isHidden: false, - key: "123", - lastExecutionResult: TaskResult( - endTimeUtc: Date(timeIntervalSinceNow: -10), - errorMessage: nil, - id: nil, - key: nil, - longErrorMessage: nil, - name: nil, - startTimeUtc: Date(timeIntervalSinceNow: -30), - status: .completed - ), - name: "Test", - state: .running, - triggers: nil - ) - ) - ) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift new file mode 100644 index 000000000..82115d649 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift @@ -0,0 +1,257 @@ +// +// 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 + +struct AddTaskTriggerView: View { + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var taskTriggerInfo: TaskTriggerInfo + private let emptyTaskTriggerInfo: TaskTriggerInfo + + private let createTrigger: Bool + private let source: Binding? + + @State + private var isPresentingNotSaved = false + @State + private var isPresentingTimeLimit = false + + @Environment(\.dismiss) + private var dismiss + + private let allowedTriggerTypes: [TaskTriggerType] = [ + .daily, + .interval, + .weekly, + .startup, + ] + + private var hasUnsavedChanges: Bool { + taskTriggerInfo != emptyTaskTriggerInfo + } + + private var isValid: Bool { + guard let type = taskTriggerInfo.type else { + return false + } + switch type { + case TaskTriggerType.daily.rawValue: + return taskTriggerInfo.timeOfDayTicks != nil + case TaskTriggerType.weekly.rawValue: + return taskTriggerInfo.timeOfDayTicks != nil && taskTriggerInfo.dayOfWeek != nil + case TaskTriggerType.interval.rawValue: + return taskTriggerInfo.intervalTicks != nil + case TaskTriggerType.startup.rawValue: + return true + default: + return false + } + } + + init(trigger: Binding? = nil, observer: ServerTaskObserver) { + self.observer = observer + self.createTrigger = trigger == nil + + if let trigger = trigger { + let triggerValue = trigger.wrappedValue + _taskTriggerInfo = State(initialValue: triggerValue) + self.emptyTaskTriggerInfo = triggerValue + self.source = trigger + } else { + let newTrigger = TaskTriggerInfo( + dayOfWeek: nil, + intervalTicks: nil, + maxRuntimeTicks: nil, + timeOfDayTicks: nil, + type: TaskTriggerType.startup.rawValue + ) + _taskTriggerInfo = State(initialValue: newTrigger) + self.emptyTaskTriggerInfo = newTrigger + self.source = nil + } + } + + @ViewBuilder + private var triggerTypePicker: some View { + Picker( + L10n.triggerType, + selection: Binding( + get: { taskTriggerInfo.type }, + set: { newValue in + taskTriggerInfo.type = newValue + taskTriggerInfo.intervalTicks = nil + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + guard newValue != nil else { return } + } + ) + ) { + ForEach(allowedTriggerTypes, id: \.rawValue) { type in + Text(type.displayTitle).tag(type.rawValue as String?) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + } + + @ViewBuilder + private var dayOfWeekPicker: some View { + if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + Picker( + L10n.dayOfWeek, + selection: Binding( + get: { taskTriggerInfo.dayOfWeek }, + set: { taskTriggerInfo.dayOfWeek = $0 } + ) + ) { + ForEach(DayOfWeek.allCases, id: \.self) { day in + Text(day.rawValue).tag(day as DayOfWeek?) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + } + } + + @ViewBuilder + private var timePicker: some View { + if let type = taskTriggerInfo.type, + type == TaskTriggerType.daily.rawValue || type == TaskTriggerType.weekly.rawValue + { + DatePicker( + L10n.time, + selection: Binding( + get: { + if let ticks = taskTriggerInfo.timeOfDayTicks { + let timeInterval = TimeInterval(ticks) / 10_000_000 + return Date(timeIntervalSince1970: timeInterval) + } else { + return Date() + } + }, + set: { date in + let timeInterval = date.timeIntervalSince1970 + taskTriggerInfo.timeOfDayTicks = Int(timeInterval * 10_000_000) + } + ), + displayedComponents: .hourAndMinute + ) + .onAppear { + if taskTriggerInfo.timeOfDayTicks == nil { + let defaultTime = Date() + taskTriggerInfo.timeOfDayTicks = Int(defaultTime.timeIntervalSince1970 * 10_000_000) + } + } + } + } + + @ViewBuilder + private var intervalPicker: some View { + if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { + Picker( + L10n.every, + selection: Binding( + get: { taskTriggerInfo.intervalTicks }, + set: { newValue in + taskTriggerInfo.intervalTicks = newValue + } + ) + ) { + ForEach(TaskTriggerInterval.allCases) { interval in + Text(interval.displayTitle).tag(Int(interval.rawValue)) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + } + } + + private var timeLimitField: some View { + Section { + ChevronButton( + L10n.timeLimit, + subtitle: { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks, maxRuntimeTicks > 0 { + let timeInterval = TimeInterval(maxRuntimeTicks) / 10_000_000 + return Text(timeInterval, format: .hourMinute) + } else { + return Text(L10n.disabled) + } + }() + ) + .onSelect { + isPresentingTimeLimit = true + } + .alert(L10n.timeLimit, isPresented: $isPresentingTimeLimit) { + TextField( + L10n.timeLimit, + value: Binding( + get: { + taskTriggerInfo.maxRuntimeTicks.map { Int($0 / (10_000_000 * 3600)) } ?? 0 + }, + set: { newValue in + taskTriggerInfo.maxRuntimeTicks = Int(TimeInterval(newValue) * 10_000_000 * 3600) + } + ), + format: .number // Use a number format for hours + ) + .keyboardType(.numberPad) + + } message: { + Text("Description") + } + } + } + + var body: some View { + Form { + triggerTypePicker + if taskTriggerInfo.type != nil { + dayOfWeekPicker + timePicker + intervalPicker + timeLimitField + } + } + .interactiveDismissDisabled(hasUnsavedChanges) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .navigationBarCloseButton { + if hasUnsavedChanges { + isPresentingNotSaved = true + } else { + dismiss() + } + } + .navigationTitle(createTrigger ? L10n.addTaskTrigger : L10n.editTaskTrigger) + .topBarTrailing { + Button(L10n.save) { + UIDevice.impact(.light) + observer.send(.addTrigger(taskTriggerInfo)) + dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(!isValid) + } + .alert(L10n.changesNotSaved, isPresented: $isPresentingNotSaved) { + Button(L10n.discardChanges, role: .destructive) { + dismiss() + } + Button(L10n.cancel, role: .cancel) { + isPresentingNotSaved = false + } + } message: { + Text(L10n.unsavedChangesMessage) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift new file mode 100644 index 000000000..c7e576409 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift @@ -0,0 +1,107 @@ +// +// 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 Stinsen +import SwiftUI + +extension EditScheduledTaskView { + + struct TriggerButton: View { + + var taskTriggerInfo: TaskTriggerInfo + var taskTriggerType: TaskTriggerType + let onSelect: () -> Void + + @State + private var isPresentingConfirmation = false + + var body: some View { + Button(action: { + isPresentingConfirmation = true + }) { + HStack { + iconView + .padding(.horizontal, 4) + labelView + Spacer() + Image(systemName: "trash.fill") + .foregroundStyle(.red) + } + } + .buttonStyle(PlainButtonStyle()) + .confirmationDialog(L10n.deleteTrigger, isPresented: $isPresentingConfirmation, actions: { + Button(L10n.cancel, role: .cancel) {} + Button(L10n.delete, role: .destructive) { + onSelect() + } + }, message: { + Text(L10n.deleteTriggerConfirmationMessage) + }) + } + + // MARK: - Label View + + private var labelView: some View { + VStack(alignment: .leading) { + switch taskTriggerType { + case .startup: + Text(taskTriggerType.displayTitle) + .fontWeight(.semibold) + + case .daily: + if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { + Text(L10n.itemAtItem(taskTriggerType.displayTitle, ticksToSeconds(timeOfDayTicks).formatted(.hourMinute))) + .fontWeight(.semibold) + } + + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + Text(L10n.everyInterval(ticksToSeconds(intervalTicks).formatted(.hourMinute))) + .fontWeight(.semibold) + } + + case .weekly: + if let dayOfWeek = taskTriggerInfo.dayOfWeek, let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { + Text(L10n.itemAtItem(dayOfWeek.rawValue.capitalized, ticksToSeconds(timeOfDayTicks).formatted(.hourMinute))) + .fontWeight(.semibold) + } + + default: + Text(L10n.unknown) + .fontWeight(.semibold) + } + + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + Text(L10n.timeLimitLabelWithHours(ticksToSeconds(maxRuntimeTicks).formatted(.hourMinute))) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Icon View + + private var iconView: some View { + ZStack { + Circle() + .fill(Color.accentColor) + .frame(width: 40, height: 40) + + Image(systemName: taskTriggerType.systemImage) + .resizable() + .foregroundStyle(.primary) + .frame(width: 25, height: 25) + } + } + + private func ticksToSeconds(_ time: Int) -> TimeInterval { + TimeInterval(time / 10_000_000) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift new file mode 100644 index 000000000..d1383ae48 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -0,0 +1,86 @@ +// +// 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 + +// TODO: last run details +// - result, show error if available +// TODO: observe running status +// - stop +// - run +// - progress + +struct EditScheduledTaskView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var observer: ServerTaskObserver + + var body: some View { + List { + ListTitleSection(observer.task.name ?? L10n.unknown, description: observer.task.description) + + detailsSection + triggersSection + } + .navigationTitle(L10n.task) + .topBarTrailing { + if let triggers = observer.task.triggers, + triggers.isNotEmpty + { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: \.addScheduledTaskTrigger, observer) + } + .buttonStyle(.toolbarPill) + } + } + } + + private var detailsSection: some View { + Section(L10n.details) { + if let category = observer.task.category { + TextPairView(leading: L10n.category, trailing: category) + } + + if let lastEndTime = observer.task.lastExecutionResult?.endTimeUtc { + TextPairView(L10n.lastRun, value: Text("\(lastEndTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))")) + .monospacedDigit() + } + } + } + + @ViewBuilder + private var triggersSection: some View { + Section(L10n.triggers) { + if let triggers = observer.task.triggers, + triggers.isNotEmpty + { + ForEach(triggers, id: \.self) { trigger in + if let triggerType = trigger.type, + let taskTriggerType = TaskTriggerType(rawValue: triggerType) + { + TriggerButton( + taskTriggerInfo: trigger, + taskTriggerType: taskTriggerType + ) { + observer.send(.removeTrigger(trigger)) + } + } + } + } else { + Button(L10n.addTrigger) { + router.route(to: \.addScheduledTaskTrigger, observer) + } + } + } + } +} diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 4d5157e5e30eb6a80cd47a3c50afd3dafcfbd937..2e422c81a6384a9b4039c2c7fafa631f49589ba1 100644 GIT binary patch delta 3439 zcma)9-%ngc6dtTpz;04pt{X}!3k0E>h**8V#*meoNCINQ8lwbZ-MzrZ{XusZDKTcz z%BvdBlQzbZR3LrP|HSy_lS%&q{{_EqX6MeGTMC50-kmvf&UeoF&Nr8%f1mm1+e?4G z{D<6KxzcW_f-0+uYO1W-P<7S7XF=8Qx2#$!R8>6f>gO#yRkcNkrxNz!XJ1iMs!xq3 z)<qmDMJGLOc zdtNOkteyX)A3w^+h!CnT;Qt)#BfBw6=RI~_>c_!I1DJP^@0oRI*MT+%t8K(Bt0MAi z;ghS&cq$-gT$6ueFaMK^r0rpL_vxwpvUS?HAroYZ~Yg@T9pXrS>Kz^7H z-h$d_wsD#{Np+|Uy-|6nac0UAUS;uWW`&xtre}F}T}?zxly@0ushnB$xh`@`UjuC9$Tva8?73QXtckk41GLS~X*Q&YMo{W` zky9ur4{Qk=XY23Ve1KRjTsbC6DJBzh312mBNxDV0GEqv&{>o*%u9B}arUFv^@-ABrjS)cW;QL`st3O#jm-!36c1O}jvxanG}w)9t20G@wpV4sw5H zNG9*SUT_{scHRu^SPVPysadS#qdlKUBu@=LBrV51M Date: Mon, 7 Oct 2024 17:56:39 -0600 Subject: [PATCH 02/10] New, improved, over-engineered, and now with 100% fewer viewModel updates after changes! --- Shared/ViewModels/ServerTaskObserver.swift | 10 +- Swiftfin.xcodeproj/project.pbxproj | 28 +++ .../Components/AddTaskTriggerView.swift | 187 +++--------------- .../Sections/DayOfWeekSection.swift | 47 +++++ .../Components/Sections/IntervalSection.swift | 49 +++++ .../Sections/TimeLimitSection.swift | 78 ++++++++ .../Components/Sections/TimeSection.swift | 64 ++++++ .../Sections/TriggerTypeSection.swift | 81 ++++++++ .../Components/TriggerButton.swift | 52 +++-- 9 files changed, 417 insertions(+), 179 deletions(-) create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift index c49c1b268..c52ceba89 100644 --- a/Shared/ViewModels/ServerTaskObserver.swift +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -109,7 +109,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } .asAnyCancellable() - return .initial + return .updated case let .removeTrigger(trigger): progressCancellable?.cancel() cancelCancellable?.cancel() @@ -128,7 +128,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } .asAnyCancellable() - return .initial + return .updated } } @@ -182,9 +182,11 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { let updateRequest = Paths.updateTask(taskID: id, updatedTriggers) try await userSession.client.send(updateRequest) + let getTaskRequest = Paths.getTask(taskID: id) + let response = try await userSession.client.send(getTaskRequest) + await MainActor.run { - task.triggers = updatedTriggers - self.task = task + self.task = response.value } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 96568c9c8..307063778 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -58,6 +58,11 @@ 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; 4EA556B82CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; + 4EA556BB2CB4A1F500F71E7A /* TriggerTypeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */; }; + 4EA556BD2CB4A21500F71E7A /* DayOfWeekSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */; }; + 4EA556BF2CB4A22C00F71E7A /* TimeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */; }; + 4EA556C12CB4A24B00F71E7A /* IntervalSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */; }; + 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */; }; @@ -1056,6 +1061,11 @@ 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerButton.swift; sourceTree = ""; }; 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerInterval.swift; sourceTree = ""; }; + 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTypeSection.swift; sourceTree = ""; }; + 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekSection.swift; sourceTree = ""; }; + 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSection.swift; sourceTree = ""; }; + 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalSection.swift; sourceTree = ""; }; + 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLimitSection.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskButton.swift; sourceTree = ""; }; @@ -1968,11 +1978,24 @@ isa = PBXGroup; children = ( 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, + 4EA556B92CB4A0BE00F71E7A /* Sections */, 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */, ); path = Components; sourceTree = ""; }; + 4EA556B92CB4A0BE00F71E7A /* Sections */ = { + isa = PBXGroup; + children = ( + 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */, + 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */, + 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */, + 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */, + 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { isa = PBXGroup; children = ( @@ -4682,6 +4705,7 @@ E1C812C5277A90B200918266 /* URLComponents.swift in Sources */, E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */, E17DC74A2BE740D900B42379 /* StoredValues+Server.swift in Sources */, + 4EA556BB2CB4A1F500F71E7A /* TriggerTypeSection.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, @@ -4755,6 +4779,7 @@ C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, + 4EA556C12CB4A24B00F71E7A /* IntervalSection.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, @@ -4838,6 +4863,7 @@ 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */, + 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */, 4EA556B22CB48BB600F71E7A /* TriggerButton.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, @@ -4914,6 +4940,7 @@ C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, + 4EA556BD2CB4A21500F71E7A /* DayOfWeekSection.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, @@ -5070,6 +5097,7 @@ E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E1F5CF092CB0A04500607465 /* Text.swift in Sources */, + 4EA556BF2CB4A22C00F71E7A /* TimeSection.swift in Sources */, 4E182C9F2C94A1E000FBEFD5 /* ScheduledTaskButton.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift index 82115d649..ba54649c0 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift @@ -18,17 +18,8 @@ struct AddTaskTriggerView: View { private var taskTriggerInfo: TaskTriggerInfo private let emptyTaskTriggerInfo: TaskTriggerInfo - private let createTrigger: Bool - private let source: Binding? - - @State - private var isPresentingNotSaved = false @State - private var isPresentingTimeLimit = false - - @Environment(\.dismiss) - private var dismiss - + private var createTrigger: Bool private let allowedTriggerTypes: [TaskTriggerType] = [ .daily, .interval, @@ -36,6 +27,12 @@ struct AddTaskTriggerView: View { .startup, ] + @Environment(\.dismiss) + private var dismiss + + @State + private var isPresentingNotSaved = false + private var hasUnsavedChanges: Bool { taskTriggerInfo != emptyTaskTriggerInfo } @@ -58,15 +55,13 @@ struct AddTaskTriggerView: View { } } - init(trigger: Binding? = nil, observer: ServerTaskObserver) { + init(observer: ServerTaskObserver, createTrigger: Bool = true, taskTriggerInfo: TaskTriggerInfo? = nil) { self.observer = observer - self.createTrigger = trigger == nil - if let trigger = trigger { - let triggerValue = trigger.wrappedValue - _taskTriggerInfo = State(initialValue: triggerValue) - self.emptyTaskTriggerInfo = triggerValue - self.source = trigger + if let taskTriggerInfo = taskTriggerInfo { + _taskTriggerInfo = State(initialValue: taskTriggerInfo) + self.emptyTaskTriggerInfo = taskTriggerInfo + self.createTrigger = false } else { let newTrigger = TaskTriggerInfo( dayOfWeek: nil, @@ -77,151 +72,17 @@ struct AddTaskTriggerView: View { ) _taskTriggerInfo = State(initialValue: newTrigger) self.emptyTaskTriggerInfo = newTrigger - self.source = nil - } - } - - @ViewBuilder - private var triggerTypePicker: some View { - Picker( - L10n.triggerType, - selection: Binding( - get: { taskTriggerInfo.type }, - set: { newValue in - taskTriggerInfo.type = newValue - taskTriggerInfo.intervalTicks = nil - taskTriggerInfo.timeOfDayTicks = nil - taskTriggerInfo.dayOfWeek = nil - guard newValue != nil else { return } - } - ) - ) { - ForEach(allowedTriggerTypes, id: \.rawValue) { type in - Text(type.displayTitle).tag(type.rawValue as String?) - } - } - .pickerStyle(.menu) - .foregroundStyle(.primary) - } - - @ViewBuilder - private var dayOfWeekPicker: some View { - if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { - Picker( - L10n.dayOfWeek, - selection: Binding( - get: { taskTriggerInfo.dayOfWeek }, - set: { taskTriggerInfo.dayOfWeek = $0 } - ) - ) { - ForEach(DayOfWeek.allCases, id: \.self) { day in - Text(day.rawValue).tag(day as DayOfWeek?) - } - } - .pickerStyle(.menu) - .foregroundStyle(.primary) - } - } - - @ViewBuilder - private var timePicker: some View { - if let type = taskTriggerInfo.type, - type == TaskTriggerType.daily.rawValue || type == TaskTriggerType.weekly.rawValue - { - DatePicker( - L10n.time, - selection: Binding( - get: { - if let ticks = taskTriggerInfo.timeOfDayTicks { - let timeInterval = TimeInterval(ticks) / 10_000_000 - return Date(timeIntervalSince1970: timeInterval) - } else { - return Date() - } - }, - set: { date in - let timeInterval = date.timeIntervalSince1970 - taskTriggerInfo.timeOfDayTicks = Int(timeInterval * 10_000_000) - } - ), - displayedComponents: .hourAndMinute - ) - .onAppear { - if taskTriggerInfo.timeOfDayTicks == nil { - let defaultTime = Date() - taskTriggerInfo.timeOfDayTicks = Int(defaultTime.timeIntervalSince1970 * 10_000_000) - } - } - } - } - - @ViewBuilder - private var intervalPicker: some View { - if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { - Picker( - L10n.every, - selection: Binding( - get: { taskTriggerInfo.intervalTicks }, - set: { newValue in - taskTriggerInfo.intervalTicks = newValue - } - ) - ) { - ForEach(TaskTriggerInterval.allCases) { interval in - Text(interval.displayTitle).tag(Int(interval.rawValue)) - } - } - .pickerStyle(.menu) - .foregroundStyle(.primary) - } - } - - private var timeLimitField: some View { - Section { - ChevronButton( - L10n.timeLimit, - subtitle: { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks, maxRuntimeTicks > 0 { - let timeInterval = TimeInterval(maxRuntimeTicks) / 10_000_000 - return Text(timeInterval, format: .hourMinute) - } else { - return Text(L10n.disabled) - } - }() - ) - .onSelect { - isPresentingTimeLimit = true - } - .alert(L10n.timeLimit, isPresented: $isPresentingTimeLimit) { - TextField( - L10n.timeLimit, - value: Binding( - get: { - taskTriggerInfo.maxRuntimeTicks.map { Int($0 / (10_000_000 * 3600)) } ?? 0 - }, - set: { newValue in - taskTriggerInfo.maxRuntimeTicks = Int(TimeInterval(newValue) * 10_000_000 * 3600) - } - ), - format: .number // Use a number format for hours - ) - .keyboardType(.numberPad) - - } message: { - Text("Description") - } + self.createTrigger = createTrigger } } var body: some View { Form { - triggerTypePicker - if taskTriggerInfo.type != nil { - dayOfWeekPicker - timePicker - intervalPicker - timeLimitField - } + TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo, allowedTriggerTypes: allowedTriggerTypes) + DayOfWeekSection(taskTriggerInfo: $taskTriggerInfo) + TimeSection(taskTriggerInfo: $taskTriggerInfo) + IntervalSection(taskTriggerInfo: $taskTriggerInfo) + TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) } .interactiveDismissDisabled(hasUnsavedChanges) .navigationBarTitleDisplayMode(.inline) @@ -233,25 +94,25 @@ struct AddTaskTriggerView: View { dismiss() } } - .navigationTitle(createTrigger ? L10n.addTaskTrigger : L10n.editTaskTrigger) + .navigationTitle(L10n.customProfile) .topBarTrailing { Button(L10n.save) { + if createTrigger { + observer.send(.addTrigger(taskTriggerInfo)) + } UIDevice.impact(.light) - observer.send(.addTrigger(taskTriggerInfo)) dismiss() } .buttonStyle(.toolbarPill) .disabled(!isValid) } - .alert(L10n.changesNotSaved, isPresented: $isPresentingNotSaved) { - Button(L10n.discardChanges, role: .destructive) { + .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { dismiss() } Button(L10n.cancel, role: .cancel) { isPresentingNotSaved = false } - } message: { - Text(L10n.unsavedChangesMessage) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift new file mode 100644 index 000000000..971545dd9 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift @@ -0,0 +1,47 @@ +// +// 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 AddTaskTriggerView { + + struct DayOfWeekSection: View { + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + Picker( + L10n.dayOfWeek, + selection: Binding( + get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek() }, + set: { newValue in + taskTriggerInfo.dayOfWeek = newValue + } + ) + ) { + ForEach(DayOfWeek.allCases, id: \.self) { day in + Text(day.rawValue.capitalized).tag(day) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + .onAppear { + if taskTriggerInfo.dayOfWeek == nil { + taskTriggerInfo.dayOfWeek = defaultDayOfWeek() + } + } + } + } + + private func defaultDayOfWeek() -> DayOfWeek { + .sunday + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift new file mode 100644 index 000000000..7b11aa76b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift @@ -0,0 +1,49 @@ +// +// 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 AddTaskTriggerView { + + struct IntervalSection: View { + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { + Picker( + L10n.every, + selection: Binding( + get: { + taskTriggerInfo.intervalTicks ?? defaultIntervalTicks() + }, + set: { newValue in + taskTriggerInfo.intervalTicks = newValue + } + ) + ) { + ForEach(TaskTriggerInterval.allCases) { interval in + Text(interval.displayTitle).tag(Int(interval.rawValue)) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + .onAppear { + if taskTriggerInfo.intervalTicks == nil { + taskTriggerInfo.intervalTicks = defaultIntervalTicks() + } + } + } + } + + private func defaultIntervalTicks() -> Int { + Int(3600 * 10_000_000) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift new file mode 100644 index 000000000..8dcf39bb8 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift @@ -0,0 +1,78 @@ +// +// 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 AddTaskTriggerView { + + struct TimeLimitSection: View { + @Binding + var taskTriggerInfo: TaskTriggerInfo + @State + private var isPresentingTimeLimitAlert = false + @State + private var inputValue: Int = 0 + + var body: some View { + Section { + ChevronButton( + L10n.timeLimit, + subtitle: timeLimitSubtitle + ) + .onSelect { + isPresentingTimeLimitAlert = true + loadInitialValue() + } + .alert(L10n.timeLimit, isPresented: $isPresentingTimeLimitAlert) { + TextField( + L10n.timeLimit, + value: $inputValue, + format: .number + ) + .keyboardType(.numberPad) + + Button(L10n.save) { + saveTimeLimit() + } + Button(L10n.cancel, role: .cancel) { + isPresentingTimeLimitAlert = false + } + } message: { + Text(L10n.timeLimit) + } + } + } + + private var timeLimitSubtitle: Text { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks, maxRuntimeTicks > 0 { + let timeInterval = TimeInterval(maxRuntimeTicks) / 10_000_000 + return Text(timeInterval.formatted(.hourMinute)) + } else { + return Text(L10n.disabled) + } + } + + private func loadInitialValue() { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + inputValue = maxRuntimeTicks / (10_000_000 * 3600) + } else { + inputValue = 0 + } + } + + private func saveTimeLimit() { + if inputValue > 0 { + taskTriggerInfo.maxRuntimeTicks = inputValue * 10_000_000 * 3600 + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + isPresentingTimeLimitAlert = false + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift new file mode 100644 index 000000000..c549876bd --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift @@ -0,0 +1,64 @@ +// +// 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 AddTaskTriggerView { + + struct TimeSection: View { + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.daily.rawValue || taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + DatePicker( + L10n.time, + selection: Binding( + get: { + if let ticks = taskTriggerInfo.timeOfDayTicks { + return dateFromTimeOfDayTicks(ticks) + } else { + return dateFromTimeOfDayTicks(defaultTimeOfDayTicks()) + } + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = timeOfDayTicksFromDate(date) + } + ), + displayedComponents: .hourAndMinute + ) + .onAppear { + if taskTriggerInfo.timeOfDayTicks == nil { + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() + } + } + } + } + + private func dateFromTimeOfDayTicks(_ ticks: Int) -> Date { + let totalSeconds = TimeInterval(ticks) / 10_000_000 + let hours = Int(totalSeconds) / 3600 + let minutes = (Int(totalSeconds) % 3600) / 60 + var components = DateComponents() + components.hour = hours + components.minute = minutes + return Calendar.current.date(from: components) ?? Date() + } + + private func timeOfDayTicksFromDate(_ date: Date) -> Int { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + let totalSeconds = TimeInterval((components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60) + return Int(totalSeconds * 10_000_000) + } + + private func defaultTimeOfDayTicks() -> Int { + 0 + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift new file mode 100644 index 000000000..b3cafa1c9 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift @@ -0,0 +1,81 @@ +// +// 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 AddTaskTriggerView { + + struct TriggerTypeSection: View { + @Binding + var taskTriggerInfo: TaskTriggerInfo + let allowedTriggerTypes: [TaskTriggerType] + + var body: some View { + Picker( + L10n.triggerType, + selection: Binding( + get: { taskTriggerInfo.type }, + set: { newValue in + if taskTriggerInfo.type != newValue { + resetValuesForNewType(newType: newValue) + } + } + ) + ) { + ForEach(allowedTriggerTypes, id: \.rawValue) { type in + Text(type.displayTitle).tag(type.rawValue as String?) + } + } + .pickerStyle(.menu) + .foregroundStyle(.primary) + } + + private func resetValuesForNewType(newType: String?) { + taskTriggerInfo.type = newType + let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks + + switch newType { + case TaskTriggerType.daily.rawValue: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + case TaskTriggerType.weekly.rawValue: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() + taskTriggerInfo.dayOfWeek = defaultDayOfWeek() + taskTriggerInfo.intervalTicks = nil + case TaskTriggerType.interval.rawValue: + taskTriggerInfo.intervalTicks = defaultIntervalTicks() + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + case TaskTriggerType.startup.rawValue: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + default: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + } + + taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks + } + + private func defaultTimeOfDayTicks() -> Int { + 0 + } + + private func defaultDayOfWeek() -> DayOfWeek { + .sunday + } + + private func defaultIntervalTicks() -> Int { + Int(3600 * 10_000_000) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift index c7e576409..b4928e36b 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift @@ -21,6 +21,8 @@ extension EditScheduledTaskView { @State private var isPresentingConfirmation = false + // MARK: - Body + var body: some View { Button(action: { isPresentingConfirmation = true @@ -56,20 +58,30 @@ extension EditScheduledTaskView { case .daily: if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { - Text(L10n.itemAtItem(taskTriggerType.displayTitle, ticksToSeconds(timeOfDayTicks).formatted(.hourMinute))) - .fontWeight(.semibold) + Text(L10n.itemAtItem( + taskTriggerType.displayTitle, + timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + )) + .fontWeight(.semibold) } case .interval: if let intervalTicks = taskTriggerInfo.intervalTicks { - Text(L10n.everyInterval(ticksToSeconds(intervalTicks).formatted(.hourMinute))) - .fontWeight(.semibold) + Text(L10n.everyInterval( + timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) + )) + .fontWeight(.semibold) } case .weekly: - if let dayOfWeek = taskTriggerInfo.dayOfWeek, let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { - Text(L10n.itemAtItem(dayOfWeek.rawValue.capitalized, ticksToSeconds(timeOfDayTicks).formatted(.hourMinute))) - .fontWeight(.semibold) + if let dayOfWeek = taskTriggerInfo.dayOfWeek, + let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks + { + Text(L10n.itemAtItem( + dayOfWeek.rawValue.capitalized, + timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + )) + .fontWeight(.semibold) } default: @@ -78,9 +90,13 @@ extension EditScheduledTaskView { } if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - Text(L10n.timeLimitLabelWithHours(ticksToSeconds(maxRuntimeTicks).formatted(.hourMinute))) - .font(.subheadline) - .foregroundStyle(.secondary) + Text( + L10n.timeLimitLabelWithHours( + timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + ) + ) + .font(.subheadline) + .foregroundStyle(.secondary) } } } @@ -100,8 +116,20 @@ extension EditScheduledTaskView { } } - private func ticksToSeconds(_ time: Int) -> TimeInterval { - TimeInterval(time / 10_000_000) + // MARK: - Convert Ticks to TimeInterval and Time from Ticks + + private func timeIntervalFromTicks(_ ticks: Int) -> TimeInterval { + TimeInterval(ticks) / 10_000_000 + } + + private func timeFromTicks(_ ticks: Int) -> Date { + let totalSeconds = timeIntervalFromTicks(ticks) + let hours = Int(totalSeconds) / 3600 + let minutes = (Int(totalSeconds) % 3600) / 60 + var components = DateComponents() + components.hour = hours + components.minute = minutes + return Calendar.current.date(from: components) ?? Date() } } } From 791a8afe4c071779ef296f6415a086e112ccf051 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 8 Oct 2024 10:04:30 -0600 Subject: [PATCH 03/10] Cleaned up and now the observer successfully recognizes changes to triggers. --- Shared/Strings/Strings.swift | 4 - Shared/ViewModels/ServerTaskObserver.swift | 15 ++-- .../Components/AddTaskTriggerView.swift | 6 +- .../Sections/DayOfWeekSection.swift | 19 ++--- .../Components/Sections/IntervalSection.swift | 21 ++--- .../Sections/TimeLimitSection.swift | 33 ++++---- .../Components/Sections/TimeSection.swift | 17 +--- .../Sections/TriggerTypeSection.swift | 48 +++++------ .../Components/TriggerButton.swift | 77 +++++++++--------- .../EditScheduledTaskView.swift | 2 +- Translations/en.lproj/Localizable.strings | Bin 49496 -> 49172 bytes 11 files changed, 96 insertions(+), 146 deletions(-) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 497b02db2..06acea9bc 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -26,8 +26,6 @@ internal enum L10n { internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") /// Add Task Trigger internal static let addTaskTrigger = L10n.tr("Localizable", "addTaskTrigger", fallback: "Add Task Trigger") - /// Add trigger - internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Administration @@ -288,8 +286,6 @@ internal enum L10n { internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") /// Edit Server internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server") - /// Edit Task Trigger - internal static let editTaskTrigger = L10n.tr("Localizable", "editTaskTrigger", fallback: "Edit Task Trigger") /// Empty Next Up internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") /// Enabled diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift index c52ceba89..f7f23b602 100644 --- a/Shared/ViewModels/ServerTaskObserver.swift +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -25,8 +25,8 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { enum State: Hashable { case error(JellyfinAPIError) case initial + case updating case running - case updated } @Published @@ -99,7 +99,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { do { try await addTrigger(newTrigger: trigger) await MainActor.run { - self.state = .updated + self.state = .updating } } catch { await MainActor.run { @@ -109,7 +109,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } .asAnyCancellable() - return .updated + return .running case let .removeTrigger(trigger): progressCancellable?.cancel() cancelCancellable?.cancel() @@ -118,7 +118,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { do { try await removeTrigger(deleteTrigger: trigger) await MainActor.run { - self.state = .updated + self.state = .updating } } catch { await MainActor.run { @@ -128,7 +128,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } .asAnyCancellable() - return .updated + return .running } } @@ -182,11 +182,8 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { let updateRequest = Paths.updateTask(taskID: id, updatedTriggers) try await userSession.client.send(updateRequest) - let getTaskRequest = Paths.getTask(taskID: id) - let response = try await userSession.client.send(getTaskRequest) - await MainActor.run { - self.task = response.value + self.task.triggers = updatedTriggers } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift index ba54649c0..2188c9c02 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift @@ -94,12 +94,10 @@ struct AddTaskTriggerView: View { dismiss() } } - .navigationTitle(L10n.customProfile) + .navigationTitle(L10n.addTaskTrigger) .topBarTrailing { Button(L10n.save) { - if createTrigger { - observer.send(.addTrigger(taskTriggerInfo)) - } + observer.send(.addTrigger(taskTriggerInfo)) UIDevice.impact(.light) dismiss() } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift index 971545dd9..5bb053e71 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift @@ -15,15 +15,15 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo + private let defaultDayOfWeek: DayOfWeek = .sunday + var body: some View { if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { Picker( L10n.dayOfWeek, - selection: Binding( - get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek() }, - set: { newValue in - taskTriggerInfo.dayOfWeek = newValue - } + selection: Binding( + get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek }, + set: { taskTriggerInfo.dayOfWeek = $0 } ) ) { ForEach(DayOfWeek.allCases, id: \.self) { day in @@ -32,16 +32,7 @@ extension AddTaskTriggerView { } .pickerStyle(.menu) .foregroundStyle(.primary) - .onAppear { - if taskTriggerInfo.dayOfWeek == nil { - taskTriggerInfo.dayOfWeek = defaultDayOfWeek() - } - } } } - - private func defaultDayOfWeek() -> DayOfWeek { - .sunday - } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift index 7b11aa76b..c66c58bbf 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift @@ -15,17 +15,15 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo + private let defaultIntervalTicks = 36_000_000_000 + var body: some View { if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { Picker( L10n.every, - selection: Binding( - get: { - taskTriggerInfo.intervalTicks ?? defaultIntervalTicks() - }, - set: { newValue in - taskTriggerInfo.intervalTicks = newValue - } + selection: Binding( + get: { taskTriggerInfo.intervalTicks ?? defaultIntervalTicks }, + set: { taskTriggerInfo.intervalTicks = $0 } ) ) { ForEach(TaskTriggerInterval.allCases) { interval in @@ -34,16 +32,7 @@ extension AddTaskTriggerView { } .pickerStyle(.menu) .foregroundStyle(.primary) - .onAppear { - if taskTriggerInfo.intervalTicks == nil { - taskTriggerInfo.intervalTicks = defaultIntervalTicks() - } - } } } - - private func defaultIntervalTicks() -> Int { - Int(3600 * 10_000_000) - } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift index 8dcf39bb8..c265025dc 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift @@ -27,7 +27,7 @@ extension AddTaskTriggerView { ) .onSelect { isPresentingTimeLimitAlert = true - loadInitialValue() + inputValue = hoursFromTicks(taskTriggerInfo.maxRuntimeTicks) } .alert(L10n.timeLimit, isPresented: $isPresentingTimeLimitAlert) { TextField( @@ -38,41 +38,36 @@ extension AddTaskTriggerView { .keyboardType(.numberPad) Button(L10n.save) { - saveTimeLimit() + taskTriggerInfo.maxRuntimeTicks = ticksFromHours(inputValue) + isPresentingTimeLimitAlert = false } Button(L10n.cancel, role: .cancel) { isPresentingTimeLimitAlert = false } - } message: { - Text(L10n.timeLimit) } } } private var timeLimitSubtitle: Text { if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks, maxRuntimeTicks > 0 { - let timeInterval = TimeInterval(maxRuntimeTicks) / 10_000_000 - return Text(timeInterval.formatted(.hourMinute)) + return Text(timeFromTicks(maxRuntimeTicks)) } else { return Text(L10n.disabled) } } - private func loadInitialValue() { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - inputValue = maxRuntimeTicks / (10_000_000 * 3600) - } else { - inputValue = 0 - } + private func hoursFromTicks(_ ticks: Int?) -> Int { + guard let ticks = ticks else { return 0 } + return ticks / 36_000_000_000 } - private func saveTimeLimit() { - if inputValue > 0 { - taskTriggerInfo.maxRuntimeTicks = inputValue * 10_000_000 * 3600 - } else { - taskTriggerInfo.maxRuntimeTicks = nil - } - isPresentingTimeLimitAlert = false + private func ticksFromHours(_ hours: Int) -> Int? { + hours > 0 ? hours * 36_000_000_000 : nil + } + + private func timeFromTicks(_ ticks: Int) -> String { + let timeInterval = TimeInterval(ticks) / 10_000_000 + return timeInterval.formatted(.hourMinute) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift index c549876bd..c142584c2 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift @@ -15,17 +15,15 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo + private let defaultTimeOfDayTicks = 0 + var body: some View { if taskTriggerInfo.type == TaskTriggerType.daily.rawValue || taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { DatePicker( L10n.time, selection: Binding( get: { - if let ticks = taskTriggerInfo.timeOfDayTicks { - return dateFromTimeOfDayTicks(ticks) - } else { - return dateFromTimeOfDayTicks(defaultTimeOfDayTicks()) - } + dateFromTimeOfDayTicks(taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks) }, set: { date in taskTriggerInfo.timeOfDayTicks = timeOfDayTicksFromDate(date) @@ -33,11 +31,6 @@ extension AddTaskTriggerView { ), displayedComponents: .hourAndMinute ) - .onAppear { - if taskTriggerInfo.timeOfDayTicks == nil { - taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() - } - } } } @@ -56,9 +49,5 @@ extension AddTaskTriggerView { let totalSeconds = TimeInterval((components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60) return Int(totalSeconds * 10_000_000) } - - private func defaultTimeOfDayTicks() -> Int { - 0 - } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift index b3cafa1c9..f4e6e1726 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift @@ -16,44 +16,50 @@ extension AddTaskTriggerView { var taskTriggerInfo: TaskTriggerInfo let allowedTriggerTypes: [TaskTriggerType] + private let defaultTimeOfDayTicks = 0 + private let defaultDayOfWeek: DayOfWeek = .sunday + private let defaultIntervalTicks = 36_000_000_000 + var body: some View { Picker( L10n.triggerType, - selection: Binding( - get: { taskTriggerInfo.type }, + selection: Binding( + get: { + TaskTriggerType(rawValue: taskTriggerInfo.type ?? "") + }, set: { newValue in - if taskTriggerInfo.type != newValue { + if taskTriggerInfo.type != newValue?.rawValue { resetValuesForNewType(newType: newValue) } } ) ) { - ForEach(allowedTriggerTypes, id: \.rawValue) { type in - Text(type.displayTitle).tag(type.rawValue as String?) + ForEach(allowedTriggerTypes, id: \.self) { type in + Text(type.displayTitle).tag(type as TaskTriggerType?) } } .pickerStyle(.menu) .foregroundStyle(.primary) } - private func resetValuesForNewType(newType: String?) { - taskTriggerInfo.type = newType + private func resetValuesForNewType(newType: TaskTriggerType?) { + taskTriggerInfo.type = newType?.rawValue let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks switch newType { - case TaskTriggerType.daily.rawValue: - taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() + case .daily: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks taskTriggerInfo.dayOfWeek = nil taskTriggerInfo.intervalTicks = nil - case TaskTriggerType.weekly.rawValue: - taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks() - taskTriggerInfo.dayOfWeek = defaultDayOfWeek() + case .weekly: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = defaultDayOfWeek taskTriggerInfo.intervalTicks = nil - case TaskTriggerType.interval.rawValue: - taskTriggerInfo.intervalTicks = defaultIntervalTicks() + case .interval: + taskTriggerInfo.intervalTicks = defaultIntervalTicks taskTriggerInfo.timeOfDayTicks = nil taskTriggerInfo.dayOfWeek = nil - case TaskTriggerType.startup.rawValue: + case .startup: taskTriggerInfo.timeOfDayTicks = nil taskTriggerInfo.dayOfWeek = nil taskTriggerInfo.intervalTicks = nil @@ -65,17 +71,5 @@ extension AddTaskTriggerView { taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks } - - private func defaultTimeOfDayTicks() -> Int { - 0 - } - - private func defaultDayOfWeek() -> DayOfWeek { - .sunday - } - - private func defaultIntervalTicks() -> Int { - Int(3600 * 10_000_000) - } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift index b4928e36b..033dfae29 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift @@ -51,43 +51,8 @@ extension EditScheduledTaskView { private var labelView: some View { VStack(alignment: .leading) { - switch taskTriggerType { - case .startup: - Text(taskTriggerType.displayTitle) - .fontWeight(.semibold) - - case .daily: - if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { - Text(L10n.itemAtItem( - taskTriggerType.displayTitle, - timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) - )) - .fontWeight(.semibold) - } - - case .interval: - if let intervalTicks = taskTriggerInfo.intervalTicks { - Text(L10n.everyInterval( - timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) - )) - .fontWeight(.semibold) - } - - case .weekly: - if let dayOfWeek = taskTriggerInfo.dayOfWeek, - let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks - { - Text(L10n.itemAtItem( - dayOfWeek.rawValue.capitalized, - timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) - )) - .fontWeight(.semibold) - } - - default: - Text(L10n.unknown) - .fontWeight(.semibold) - } + Text(triggerDisplayText) + .fontWeight(.semibold) if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { Text( @@ -101,6 +66,40 @@ extension EditScheduledTaskView { } } + // MARK: - Trigger Display Text + + private var triggerDisplayText: String { + switch taskTriggerType { + case .startup: + return taskTriggerType.displayTitle + case .daily: + if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { + return L10n.itemAtItem( + taskTriggerType.displayTitle, + timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + ) + } + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + return L10n.everyInterval( + timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) + ) + } + case .weekly: + if let dayOfWeek = taskTriggerInfo.dayOfWeek, + let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks + { + return L10n.itemAtItem( + dayOfWeek.rawValue.capitalized, + timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + ) + } + default: + return L10n.unknown + } + return L10n.unknown + } + // MARK: - Icon View private var iconView: some View { @@ -116,12 +115,14 @@ extension EditScheduledTaskView { } } - // MARK: - Convert Ticks to TimeInterval and Time from Ticks + // MARK: - Convert Ticks to TimeInterval private func timeIntervalFromTicks(_ ticks: Int) -> TimeInterval { TimeInterval(ticks) / 10_000_000 } + // MARK: - Convert Ticks to Time + private func timeFromTicks(_ ticks: Int) -> Date { let totalSeconds = timeIntervalFromTicks(ticks) let hours = Int(totalSeconds) / 3600 diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift index d1383ae48..3e75f152c 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -77,7 +77,7 @@ struct EditScheduledTaskView: View { } } } else { - Button(L10n.addTrigger) { + Button(L10n.addTaskTrigger) { router.route(to: \.addScheduledTaskTrigger, observer) } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 2e422c81a6384a9b4039c2c7fafa631f49589ba1..42be4e42b7e66af287191d764776fca5c35fa3a1 100644 GIT binary patch delta 14 Wcmcc7#5|>edBdw6n-}cVVg~>=&Ibel delta 124 zcmbQzz Date: Tue, 8 Oct 2024 14:10:05 -0600 Subject: [PATCH 04/10] edit trigger deletion, don't consider unused trigger types --- Shared/Coordinators/SettingsCoordinator.swift | 9 +- Shared/Extensions/TaskTriggerType.swift | 17 +--- Swiftfin.xcodeproj/project.pbxproj | 16 ++-- .../Components/AddTaskTriggerView.swift | 39 ++------ .../Sections/DayOfWeekSection.swift | 4 +- .../Components/Sections/IntervalSection.swift | 1 + .../Sections/TimeLimitSection.swift | 2 + .../Components/Sections/TimeSection.swift | 1 + .../Sections/TriggerTypeSection.swift | 7 +- .../{TriggerButton.swift => TriggerRow.swift} | 96 +++++++------------ .../EditScheduledTaskView.swift | 78 +++++++++------ 11 files changed, 119 insertions(+), 151 deletions(-) rename Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/{TriggerButton.swift => TriggerRow.swift} (57%) diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 11b44d39d..5491243e6 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -62,7 +62,7 @@ final class SettingsCoordinator: NavigationCoordinatable { var tasks = makeTasks @Route(.push) var editScheduledTask = makeEditScheduledTask - @Route(.push) + @Route(.modal) var addScheduledTaskTrigger = makeAddScheduledTaskTrigger @Route(.push) var serverLogs = makeServerLogs @@ -195,9 +195,10 @@ final class SettingsCoordinator: NavigationCoordinatable { EditScheduledTaskView(observer: observer) } - @ViewBuilder - func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> some View { - AddTaskTriggerView(observer: observer) + func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddTaskTriggerView(observer: observer) + } } @ViewBuilder diff --git a/Shared/Extensions/TaskTriggerType.swift b/Shared/Extensions/TaskTriggerType.swift index 109734ed0..add2d13cf 100644 --- a/Shared/Extensions/TaskTriggerType.swift +++ b/Shared/Extensions/TaskTriggerType.swift @@ -8,15 +8,16 @@ import Foundation -public enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemImageable { +// TODO: move to SDK as patch file + +enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemImageable { + case daily = "DailyTrigger" case weekly = "WeeklyTrigger" case interval = "IntervalTrigger" case startup = "StartupTrigger" - case manual = "ManualTrigger" - case unknown = "" - public var displayTitle: String { + var displayTitle: String { switch self { case .daily: return L10n.daily @@ -26,10 +27,6 @@ public enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemI return L10n.interval case .startup: return L10n.onApplicationStartup - case .manual: - return L10n.manual - case .unknown: - return L10n.unknown } } @@ -43,10 +40,6 @@ public enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemI return "timer" case .startup: return "power" - case .manual: - return "hand.tap" - case .unknown: - return "questionmark" } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 307063778..604f10f01 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -53,7 +53,7 @@ 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */; }; - 4EA556B22CB48BB600F71E7A /* TriggerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */; }; + 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */; }; 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; @@ -1058,7 +1058,7 @@ 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTaskTriggerView.swift; sourceTree = ""; }; - 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerButton.swift; sourceTree = ""; }; + 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = ""; }; 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerInterval.swift; sourceTree = ""; }; 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTypeSection.swift; sourceTree = ""; }; @@ -1979,7 +1979,7 @@ children = ( 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, 4EA556B92CB4A0BE00F71E7A /* Sections */, - 4EA556B02CB48BB600F71E7A /* TriggerButton.swift */, + 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */, ); path = Components; sourceTree = ""; @@ -4864,7 +4864,7 @@ E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */, 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */, - 4EA556B22CB48BB600F71E7A /* TriggerButton.swift in Sources */, + 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, @@ -5492,7 +5492,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -5508,7 +5508,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -5532,7 +5532,7 @@ CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -5548,7 +5548,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift index 2188c9c02..e369ceab2 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift @@ -16,16 +16,11 @@ struct AddTaskTriggerView: View { @State private var taskTriggerInfo: TaskTriggerInfo + private let emptyTaskTriggerInfo: TaskTriggerInfo @State private var createTrigger: Bool - private let allowedTriggerTypes: [TaskTriggerType] = [ - .daily, - .interval, - .weekly, - .startup, - ] @Environment(\.dismiss) private var dismiss @@ -37,24 +32,6 @@ struct AddTaskTriggerView: View { taskTriggerInfo != emptyTaskTriggerInfo } - private var isValid: Bool { - guard let type = taskTriggerInfo.type else { - return false - } - switch type { - case TaskTriggerType.daily.rawValue: - return taskTriggerInfo.timeOfDayTicks != nil - case TaskTriggerType.weekly.rawValue: - return taskTriggerInfo.timeOfDayTicks != nil && taskTriggerInfo.dayOfWeek != nil - case TaskTriggerType.interval.rawValue: - return taskTriggerInfo.intervalTicks != nil - case TaskTriggerType.startup.rawValue: - return true - default: - return false - } - } - init(observer: ServerTaskObserver, createTrigger: Bool = true, taskTriggerInfo: TaskTriggerInfo? = nil) { self.observer = observer @@ -78,15 +55,18 @@ struct AddTaskTriggerView: View { var body: some View { Form { - TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo, allowedTriggerTypes: allowedTriggerTypes) + TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo, allowedTriggerTypes: TaskTriggerType.allCases) + DayOfWeekSection(taskTriggerInfo: $taskTriggerInfo) + TimeSection(taskTriggerInfo: $taskTriggerInfo) + IntervalSection(taskTriggerInfo: $taskTriggerInfo) + TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) } - .interactiveDismissDisabled(hasUnsavedChanges) + .interactiveDismissDisabled(true) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) .navigationBarCloseButton { if hasUnsavedChanges { isPresentingNotSaved = true @@ -97,12 +77,13 @@ struct AddTaskTriggerView: View { .navigationTitle(L10n.addTaskTrigger) .topBarTrailing { Button(L10n.save) { - observer.send(.addTrigger(taskTriggerInfo)) + UIDevice.impact(.light) + + observer.send(.addTrigger(taskTriggerInfo)) dismiss() } .buttonStyle(.toolbarPill) - .disabled(!isValid) } .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { Button(L10n.close, role: .destructive) { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift index 5bb053e71..cd6b7bae0 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift @@ -12,6 +12,7 @@ import SwiftUI extension AddTaskTriggerView { struct DayOfWeekSection: View { + @Binding var taskTriggerInfo: TaskTriggerInfo @@ -26,12 +27,11 @@ extension AddTaskTriggerView { set: { taskTriggerInfo.dayOfWeek = $0 } ) ) { + // TODO: don't use rawValue ForEach(DayOfWeek.allCases, id: \.self) { day in Text(day.rawValue.capitalized).tag(day) } } - .pickerStyle(.menu) - .foregroundStyle(.primary) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift index c66c58bbf..90ec6c5a3 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift @@ -12,6 +12,7 @@ import SwiftUI extension AddTaskTriggerView { struct IntervalSection: View { + @Binding var taskTriggerInfo: TaskTriggerInfo diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift index c265025dc..d111c922e 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift @@ -12,8 +12,10 @@ import SwiftUI extension AddTaskTriggerView { struct TimeLimitSection: View { + @Binding var taskTriggerInfo: TaskTriggerInfo + @State private var isPresentingTimeLimitAlert = false @State diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift index c142584c2..48a8529ab 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift @@ -12,6 +12,7 @@ import SwiftUI extension AddTaskTriggerView { struct TimeSection: View { + @Binding var taskTriggerInfo: TaskTriggerInfo diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift index f4e6e1726..e02aa0c47 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift @@ -12,8 +12,10 @@ import SwiftUI extension AddTaskTriggerView { struct TriggerTypeSection: View { + @Binding var taskTriggerInfo: TaskTriggerInfo + let allowedTriggerTypes: [TaskTriggerType] private let defaultTimeOfDayTicks = 0 @@ -35,11 +37,10 @@ extension AddTaskTriggerView { ) ) { ForEach(allowedTriggerTypes, id: \.self) { type in - Text(type.displayTitle).tag(type as TaskTriggerType?) + Text(type.displayTitle) + .tag(type as TaskTriggerType?) } } - .pickerStyle(.menu) - .foregroundStyle(.primary) } private func resetValuesForNewType(newType: TaskTriggerType?) { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift similarity index 57% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift index 033dfae29..d22c2cd97 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerButton.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift @@ -12,66 +12,48 @@ import SwiftUI extension EditScheduledTaskView { - struct TriggerButton: View { + struct TriggerRow: View { - var taskTriggerInfo: TaskTriggerInfo - var taskTriggerType: TaskTriggerType - let onSelect: () -> Void + let taskTriggerInfo: TaskTriggerInfo - @State - private var isPresentingConfirmation = false - - // MARK: - Body - - var body: some View { - Button(action: { - isPresentingConfirmation = true - }) { - HStack { - iconView - .padding(.horizontal, 4) - labelView - Spacer() - Image(systemName: "trash.fill") - .foregroundStyle(.red) - } + // TODO: remove after `TaskTriggerType` is provided by SDK + private var taskTriggerType: TaskTriggerType { + if let type = taskTriggerInfo.type { + return TaskTriggerType(rawValue: type)! + } else { + return .startup } - .buttonStyle(PlainButtonStyle()) - .confirmationDialog(L10n.deleteTrigger, isPresented: $isPresentingConfirmation, actions: { - Button(L10n.cancel, role: .cancel) {} - Button(L10n.delete, role: .destructive) { - onSelect() - } - }, message: { - Text(L10n.deleteTriggerConfirmationMessage) - }) } - // MARK: - Label View + // MARK: - Body - private var labelView: some View { + var body: some View { VStack(alignment: .leading) { + Text(triggerDisplayText) .fontWeight(.semibold) - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - Text( - L10n.timeLimitLabelWithHours( - timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + Group { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + Text( + L10n.timeLimitLabelWithHours( + timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + ) ) - ) - .font(.subheadline) - .foregroundStyle(.secondary) + } else { + Text("No runtime limit") + } } + .font(.subheadline) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Trigger Display Text private var triggerDisplayText: String { switch taskTriggerType { - case .startup: - return taskTriggerType.displayTitle case .daily: if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { return L10n.itemAtItem( @@ -79,12 +61,6 @@ extension EditScheduledTaskView { timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) ) } - case .interval: - if let intervalTicks = taskTriggerInfo.intervalTicks { - return L10n.everyInterval( - timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) - ) - } case .weekly: if let dayOfWeek = taskTriggerInfo.dayOfWeek, let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks @@ -94,25 +70,17 @@ extension EditScheduledTaskView { timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) ) } - default: - return L10n.unknown + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + return L10n.everyInterval( + timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) + ) + } + case .startup: + return taskTriggerType.displayTitle } - return L10n.unknown - } - // MARK: - Icon View - - private var iconView: some View { - ZStack { - Circle() - .fill(Color.accentColor) - .frame(width: 40, height: 40) - - Image(systemName: taskTriggerType.systemImage) - .resizable() - .foregroundStyle(.primary) - .frame(width: 25, height: 25) - } + return L10n.unknown } // MARK: - Convert Ticks to TimeInterval diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift index 3e75f152c..d04017821 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -24,26 +24,10 @@ struct EditScheduledTaskView: View { @ObservedObject var observer: ServerTaskObserver - var body: some View { - List { - ListTitleSection(observer.task.name ?? L10n.unknown, description: observer.task.description) - - detailsSection - triggersSection - } - .navigationTitle(L10n.task) - .topBarTrailing { - if let triggers = observer.task.triggers, - triggers.isNotEmpty - { - Button(L10n.add) { - UIDevice.impact(.light) - router.route(to: \.addScheduledTaskTrigger, observer) - } - .buttonStyle(.toolbarPill) - } - } - } + @State + private var isPresentingDeleteConfirmation = false + @State + private var selectedTrigger: TaskTriggerInfo? private var detailsSection: some View { Section(L10n.details) { @@ -65,16 +49,14 @@ struct EditScheduledTaskView: View { triggers.isNotEmpty { ForEach(triggers, id: \.self) { trigger in - if let triggerType = trigger.type, - let taskTriggerType = TaskTriggerType(rawValue: triggerType) - { - TriggerButton( - taskTriggerInfo: trigger, - taskTriggerType: taskTriggerType - ) { - observer.send(.removeTrigger(trigger)) + TriggerRow(taskTriggerInfo: trigger) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(L10n.delete) { + selectedTrigger = trigger + isPresentingDeleteConfirmation = true + } + .tint(.red) } - } } } else { Button(L10n.addTaskTrigger) { @@ -83,4 +65,42 @@ struct EditScheduledTaskView: View { } } } + + var body: some View { + List { + ListTitleSection( + observer.task.name ?? L10n.unknown, + description: observer.task.description + ) + + detailsSection + + triggersSection + } + .navigationTitle(L10n.task) + .topBarTrailing { + if let triggers = observer.task.triggers, + triggers.isNotEmpty + { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: \.addScheduledTaskTrigger, observer) + } + .buttonStyle(.toolbarPill) + } + } + .confirmationDialog( + L10n.deleteTrigger, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + // TODO: delete selected trigger + } + } message: { + Text(L10n.deleteTriggerConfirmationMessage) + } + } } From 5f3263e1e78e9ddae43626396e17b9e0677e2d11 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 9 Oct 2024 00:32:31 -0600 Subject: [PATCH 05/10] Creation of displayTitles for localized text for DayOfWeek & TaskInfo. Remove leftover EditTaskTriggerView logic. Add Results/Errors to the EditScheduledTaskView. Track Progress on EditScheduledTaskView. --- Shared/Extensions/JellyfinAPI/DayOfWeek.swift | 32 ++++++ Shared/Extensions/JellyfinAPI/TaskInfo.swift | 24 +++++ Shared/Strings/Strings.swift | 28 ++++++ Swiftfin.xcodeproj/project.pbxproj | 18 +++- .../Components/AddTaskTriggerView.swift | 31 +++--- .../Sections/DayOfWeekSection.swift | 3 +- .../Components/Sections/IntervalSection.swift | 3 +- .../Components/TriggerRow.swift | 37 ++++--- .../EditScheduledTaskView.swift | 93 +++++++++++++++--- Translations/en.lproj/Localizable.strings | Bin 49172 -> 51166 bytes 10 files changed, 213 insertions(+), 56 deletions(-) create mode 100644 Shared/Extensions/JellyfinAPI/DayOfWeek.swift create mode 100644 Shared/Extensions/JellyfinAPI/TaskInfo.swift diff --git a/Shared/Extensions/JellyfinAPI/DayOfWeek.swift b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift new file mode 100644 index 000000000..c7ac6eab9 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift @@ -0,0 +1,32 @@ +// +// 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 Foundation +import JellyfinAPI + +extension DayOfWeek { + + var displayTitle: String { + switch self { + case .sunday: + return L10n.dayOfWeekSunday + case .monday: + return L10n.dayOfWeekMonday + case .tuesday: + return L10n.dayOfWeekTuesday + case .wednesday: + return L10n.dayOfWeekWednesday + case .thursday: + return L10n.dayOfWeekThursday + case .friday: + return L10n.dayOfWeekFriday + case .saturday: + return L10n.dayOfWeekSaturday + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/TaskInfo.swift b/Shared/Extensions/JellyfinAPI/TaskInfo.swift new file mode 100644 index 000000000..ba0f48b5c --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TaskInfo.swift @@ -0,0 +1,24 @@ +// +// 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 Foundation +import JellyfinAPI + +extension TaskState { + + var displayTitle: String { + switch self { + case .idle: + return L10n.taskStateIdle + case .cancelling: + return L10n.taskStateCancelling + case .running: + return L10n.taskStateRunning + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 06acea9bc..01795e978 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -238,6 +238,20 @@ internal enum L10n { internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") /// Day of Week internal static let dayOfWeek = L10n.tr("Localizable", "dayOfWeek", fallback: "Day of Week") + /// Friday + internal static let dayOfWeekFriday = L10n.tr("Localizable", "dayOfWeekFriday", fallback: "Friday") + /// Monday + internal static let dayOfWeekMonday = L10n.tr("Localizable", "dayOfWeekMonday", fallback: "Monday") + /// Saturday + internal static let dayOfWeekSaturday = L10n.tr("Localizable", "dayOfWeekSaturday", fallback: "Saturday") + /// Sunday + internal static let dayOfWeekSunday = L10n.tr("Localizable", "dayOfWeekSunday", fallback: "Sunday") + /// Thursday + internal static let dayOfWeekThursday = L10n.tr("Localizable", "dayOfWeekThursday", fallback: "Thursday") + /// Tuesday + internal static let dayOfWeekTuesday = L10n.tr("Localizable", "dayOfWeekTuesday", fallback: "Tuesday") + /// Wednesday + internal static let dayOfWeekWednesday = L10n.tr("Localizable", "dayOfWeekWednesday", fallback: "Wednesday") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Delete @@ -300,12 +314,16 @@ internal enum L10n { internal static let episodes = L10n.tr("Localizable", "episodes", fallback: "Episodes") /// Error internal static let error = L10n.tr("Localizable", "error", fallback: "Error") + /// Error Details + internal static let errorDetails = L10n.tr("Localizable", "errorDetails", fallback: "Error Details") /// Every internal static let every = L10n.tr("Localizable", "every", fallback: "Every") /// Every %1$@ internal static func everyInterval(_ p1: Any) -> String { return L10n.tr("Localizable", "everyInterval", String(describing: p1), fallback: "Every %1$@") } + /// Executed + internal static let executed = L10n.tr("Localizable", "executed", fallback: "Executed") /// Existing Server internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: "Existing Server") /// Existing User @@ -500,6 +518,8 @@ internal enum L10n { internal static let noResults = L10n.tr("Localizable", "noResults", fallback: "No results.") /// Normal internal static let normal = L10n.tr("Localizable", "normal", fallback: "Normal") + /// No runtime limit + internal static let noRuntimeLimit = L10n.tr("Localizable", "noRuntimeLimit", fallback: "No runtime limit") /// No session internal static let noSession = L10n.tr("Localizable", "noSession", fallback: "No session") /// N/A @@ -804,6 +824,8 @@ internal enum L10n { internal static let specialFeatures = L10n.tr("Localizable", "specialFeatures", fallback: "Special Features") /// Sports internal static let sports = L10n.tr("Localizable", "sports", fallback: "Sports") + /// Status + internal static let status = L10n.tr("Localizable", "status", fallback: "Status") /// Stop internal static let stop = L10n.tr("Localizable", "stop", fallback: "Stop") /// Streams @@ -854,6 +876,12 @@ internal enum L10n { internal static let tasks = L10n.tr("Localizable", "tasks", fallback: "Tasks") /// Tasks are operations that are scheduled to run periodically or can be triggered manually. internal static let tasksDescription = L10n.tr("Localizable", "tasksDescription", fallback: "Tasks are operations that are scheduled to run periodically or can be triggered manually.") + /// Cancelling + internal static let taskStateCancelling = L10n.tr("Localizable", "taskStateCancelling", fallback: "Cancelling") + /// Idle + internal static let taskStateIdle = L10n.tr("Localizable", "taskStateIdle", fallback: "Idle") + /// Running + internal static let taskStateRunning = L10n.tr("Localizable", "taskStateRunning", fallback: "Running") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") /// Time diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 604f10f01..2ffa483b6 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -34,6 +34,10 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; }; 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; + 4E59E92A2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; + 4E59E92B2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; + 4E59E92F2CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; + 4E59E9302CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; @@ -1043,6 +1047,8 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; + 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskInfo.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; @@ -3591,10 +3597,9 @@ E1D37F5B2B9CF02600343D2B /* BaseItemDto */, E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, E1002B632793CEE700E47059 /* ChapterInfo.swift */, - E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, - 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, + 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, @@ -3604,10 +3609,13 @@ E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, - E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, + E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */, + 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, + 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */, 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */, E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, @@ -4506,6 +4514,7 @@ E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, + 4E59E92B2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */, E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, @@ -4617,6 +4626,7 @@ C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, + 4E59E92F2CB64CD100FA28E1 /* TaskInfo.swift in Sources */, 4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */, E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, @@ -4767,6 +4777,7 @@ 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, + 4E59E9302CB64CD100FA28E1 /* TaskInfo.swift in Sources */, C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */, @@ -5066,6 +5077,7 @@ E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, + 4E59E92A2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift index e369ceab2..885d2efcf 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift @@ -19,9 +19,6 @@ struct AddTaskTriggerView: View { private let emptyTaskTriggerInfo: TaskTriggerInfo - @State - private var createTrigger: Bool - @Environment(\.dismiss) private var dismiss @@ -32,25 +29,19 @@ struct AddTaskTriggerView: View { taskTriggerInfo != emptyTaskTriggerInfo } - init(observer: ServerTaskObserver, createTrigger: Bool = true, taskTriggerInfo: TaskTriggerInfo? = nil) { + init(observer: ServerTaskObserver) { self.observer = observer - if let taskTriggerInfo = taskTriggerInfo { - _taskTriggerInfo = State(initialValue: taskTriggerInfo) - self.emptyTaskTriggerInfo = taskTriggerInfo - self.createTrigger = false - } else { - let newTrigger = TaskTriggerInfo( - dayOfWeek: nil, - intervalTicks: nil, - maxRuntimeTicks: nil, - timeOfDayTicks: nil, - type: TaskTriggerType.startup.rawValue - ) - _taskTriggerInfo = State(initialValue: newTrigger) - self.emptyTaskTriggerInfo = newTrigger - self.createTrigger = createTrigger - } + let newTrigger = TaskTriggerInfo( + dayOfWeek: nil, + intervalTicks: nil, + maxRuntimeTicks: nil, + timeOfDayTicks: nil, + type: TaskTriggerType.startup.rawValue + ) + + _taskTriggerInfo = State(initialValue: newTrigger) + self.emptyTaskTriggerInfo = newTrigger } var body: some View { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift index cd6b7bae0..fbd143d39 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift @@ -27,9 +27,8 @@ extension AddTaskTriggerView { set: { taskTriggerInfo.dayOfWeek = $0 } ) ) { - // TODO: don't use rawValue ForEach(DayOfWeek.allCases, id: \.self) { day in - Text(day.rawValue.capitalized).tag(day) + Text(day.displayTitle).tag(day) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift index 90ec6c5a3..f8802bda1 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift @@ -18,6 +18,7 @@ extension AddTaskTriggerView { private let defaultIntervalTicks = 36_000_000_000 + // TODO: Make Normal Numbers? var body: some View { if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { Picker( @@ -31,8 +32,6 @@ extension AddTaskTriggerView { Text(interval.displayTitle).tag(Int(interval.rawValue)) } } - .pickerStyle(.menu) - .foregroundStyle(.primary) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift index d22c2cd97..21bf6b6e3 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift @@ -28,26 +28,31 @@ extension EditScheduledTaskView { // MARK: - Body var body: some View { - VStack(alignment: .leading) { - - Text(triggerDisplayText) - .fontWeight(.semibold) - - Group { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - Text( - L10n.timeLimitLabelWithHours( - timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + HStack { + VStack(alignment: .leading) { + + Text(triggerDisplayText) + .fontWeight(.semibold) + + Group { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + Text( + L10n.timeLimitLabelWithHours( + timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + ) ) - ) - } else { - Text("No runtime limit") + } else { + Text(L10n.noRuntimeLimit) + } } + .font(.subheadline) + .foregroundStyle(.secondary) } - .font(.subheadline) - .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: taskTriggerType.systemImage) + .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Trigger Display Text diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift index d04017821..470dc090d 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -9,12 +9,9 @@ import JellyfinAPI import SwiftUI -// TODO: last run details -// - result, show error if available // TODO: observe running status // - stop // - run -// - progress struct EditScheduledTaskView: View { @@ -29,25 +26,71 @@ struct EditScheduledTaskView: View { @State private var selectedTrigger: TaskTriggerInfo? + // MARK: - Task Details Section + + @ViewBuilder private var detailsSection: some View { Section(L10n.details) { if let category = observer.task.category { TextPairView(leading: L10n.category, trailing: category) } + } + } + + // MARK: - Last Run Details Section - if let lastEndTime = observer.task.lastExecutionResult?.endTimeUtc { - TextPairView(L10n.lastRun, value: Text("\(lastEndTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))")) + @ViewBuilder + private func lastRunSection(_ lastExecutionResult: TaskResult) -> some View { + Section(L10n.lastRun) { + if let status = lastExecutionResult.status { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + if let endTimeUtc = lastExecutionResult.endTimeUtc { + TextPairView(L10n.executed, value: Text("\(endTimeUtc, format: .relative(presentation: .numeric, unitsStyle: .narrow))")) .monospacedDigit() } } } + // MARK: - Last Error Details Section + + @ViewBuilder + private func lastErrorSection(_ lastExecutionResult: TaskResult) -> some View { + Section(L10n.errorDetails) { + if let errorMessage = lastExecutionResult.errorMessage { + Text(errorMessage) + } + if let longErrorMessage = lastExecutionResult.longErrorMessage { + Text(longErrorMessage) + } + } + } + + // MARK: - Task Current Running Details Section + + @ViewBuilder + private func currentRunningSection(_ task: TaskInfo) -> some View { + Section(L10n.progress) { + if let status = task.state { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + + if let currentProgressPercentage = task.currentProgressPercentage { + TextPairView( + L10n.taskCompleted, + value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") + ) + .monospacedDigit() + } + } + } + + // MARK: - Task Triggers Section + @ViewBuilder private var triggersSection: some View { Section(L10n.triggers) { - if let triggers = observer.task.triggers, - triggers.isNotEmpty - { + if let triggers = observer.task.triggers, !triggers.isEmpty { ForEach(triggers, id: \.self) { trigger in TriggerRow(taskTriggerInfo: trigger) .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -66,6 +109,14 @@ struct EditScheduledTaskView: View { } } + // MARK: - Trigger Haptic Feedback + + private func triggerHapticFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) { + UIDevice.impact(style) + } + + // MARK: - Body + var body: some View { List { ListTitleSection( @@ -75,15 +126,29 @@ struct EditScheduledTaskView: View { detailsSection + // Only Create the Last Run Section if there are Last Execution Results Available + if let lastExecutionResult = observer.task.lastExecutionResult { + lastRunSection(lastExecutionResult) + + // Only Create the Last Error Section if there are Errors Available + // Errors can only exist if there is Last Execution Results + if lastExecutionResult.errorMessage != nil { + lastErrorSection(lastExecutionResult) + } + } + + // Only Create the Current Running Section if there is an Active Status + if observer.task.state == .running || observer.task.state == .cancelling { + currentRunningSection(observer.task) + } + triggersSection } .navigationTitle(L10n.task) .topBarTrailing { - if let triggers = observer.task.triggers, - triggers.isNotEmpty - { + if observer.task.triggers?.isEmpty == false { Button(L10n.add) { - UIDevice.impact(.light) + triggerHapticFeedback() router.route(to: \.addScheduledTaskTrigger, observer) } .buttonStyle(.toolbarPill) @@ -97,7 +162,9 @@ struct EditScheduledTaskView: View { Button(L10n.cancel, role: .cancel) {} Button(L10n.delete, role: .destructive) { - // TODO: delete selected trigger + if let selectedTrigger = selectedTrigger { + observer.send(.removeTrigger(selectedTrigger)) + } } } message: { Text(L10n.deleteTriggerConfirmationMessage) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 42be4e42b7e66af287191d764776fca5c35fa3a1..7059fbb72b3767cb4b7422cb3c0d59a05c10a291 100644 GIT binary patch delta 1654 zcmbtUyGjE=6ulxSB$5W7XCm!KtZtZGdwe!-Pw%@yISmH?wpx>?zxYv&glE)=za2qMQENfq|pW)(=L^% zMpZf^gDS8!@IA#_qdn}_@hwu5Ozf4Zg;fS;Bcu=31)#QZ!k|MRP6u)ck3MA0;%tP4 za-Ro{Anv_5<=$_V?umtozJ%xvvI4~KM=A=#_AC=3gwjArT!9L~i2Ot=GGdSUuhPku zC7db;(4`(w7e7{KZgG@oXzLGDRzag0AUz7lTBu8>K9yNm!z)olUo@l^K{`ZYbuhNU zipJ12ywFSW*T>04jkaX_H4x(wawnQr{8eR(7GV*s>NKT^UXggt9%iDOSZVNu_T};C z6o2Z*)`GQs13_76X$^1jFNT6fX<7&G8vfJ#QDP{0J*e~cj!6ACH|sJV delta 9 QcmccD&pf4pd4tFS02N>a3;+NC From fe5c37bd3cdd7aef347135cc58e438507b592df0 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 9 Oct 2024 23:17:13 -0600 Subject: [PATCH 06/10] Clean up datatypes / strings outputs. Creation of a ServerTick helper struct. Creation of a ChevronInputButton object that probably needs some love... --- Shared/Objects/TaskTriggerInterval.swift | 60 ---------- Shared/Strings/Strings.swift | 10 +- Swiftfin.xcodeproj/project.pbxproj | 72 +++++++++-- Swiftfin/Components/ChevronInputButton.swift | 75 ++++++++++++ Swiftfin/Components/ServerTicks.swift | 84 +++++++++++++ .../AddTaskTriggerView.swift | 14 ++- .../Sections/DayOfWeekSection.swift | 2 +- .../Components/Sections/IntervalSection.swift | 7 +- .../Sections/TimeLimitSection.swift | 51 ++++++++ .../Components/Sections/TimeSection.swift | 38 ++++++ .../Sections/TriggerTypeSection.swift | 8 +- .../Sections/CurrentRunningSection.swift | 34 ++++++ .../Components/Sections/DetailsSection.swift | 25 ++++ .../Sections/LastErrorSection.swift | 29 +++++ .../Components/Sections/LastRunSection.swift | 33 +++++ .../Sections/TimeLimitSection.swift | 75 ------------ .../Components/Sections/TimeSection.swift | 54 --------- .../Components/Sections/TriggersSection.swift | 45 +++++++ .../Components/TriggerRow.swift | 32 ++--- .../EditScheduledTaskView.swift | 113 ++---------------- Translations/en.lproj/Localizable.strings | Bin 51166 -> 51618 bytes 21 files changed, 520 insertions(+), 341 deletions(-) delete mode 100644 Shared/Objects/TaskTriggerInterval.swift create mode 100644 Swiftfin/Components/ChevronInputButton.swift create mode 100644 Swiftfin/Components/ServerTicks.swift rename Swiftfin/Views/SettingsView/UserDashboardView/{EditScheduledTaskView/Components => AddTaskTriggerView}/AddTaskTriggerView.swift (89%) rename Swiftfin/Views/SettingsView/UserDashboardView/{EditScheduledTaskView => AddTaskTriggerView}/Components/Sections/DayOfWeekSection.swift (94%) rename Swiftfin/Views/SettingsView/UserDashboardView/{EditScheduledTaskView => AddTaskTriggerView}/Components/Sections/IntervalSection.swift (78%) create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift rename Swiftfin/Views/SettingsView/UserDashboardView/{EditScheduledTaskView => AddTaskTriggerView}/Components/Sections/TriggerTypeSection.swift (89%) create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift delete mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift delete mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift diff --git a/Shared/Objects/TaskTriggerInterval.swift b/Shared/Objects/TaskTriggerInterval.swift deleted file mode 100644 index 3f47089ce..000000000 --- a/Shared/Objects/TaskTriggerInterval.swift +++ /dev/null @@ -1,60 +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) 2024 Jellyfin & Jellyfin Contributors -// - -import Foundation - -enum TaskTriggerInterval: TimeInterval, CaseIterable, Identifiable { - case fifteenMinutes = 9_000_000_000 - case thirtyMinutes = 18_000_000_000 - case fortyFiveMinutes = 27_000_000_000 - case oneHour = 36_000_000_000 - case twoHours = 72_000_000_000 - case threeHours = 108_000_000_000 - case fourHours = 144_000_000_000 - case sixHours = 216_000_000_000 - case eightHours = 288_000_000_000 - case twelveHours = 432_000_000_000 - case twentyFourHours = 864_000_000_000 - - /// Use the number of ticks as the Id - var id: TimeInterval { - self.rawValue - } - - /// Number of seconds for the interval (1 tick = 0.1 microseconds) - var seconds: Int { - Int(rawValue / 10_000_000) - } - - var displayTitle: String { - switch self { - case .fifteenMinutes: - return L10n.intervalMinutes(15) - case .thirtyMinutes: - return L10n.intervalMinutes(30) - case .fortyFiveMinutes: - return L10n.intervalMinutes(45) - case .oneHour: - return L10n.intervalHours(1) - case .twoHours: - return L10n.intervalHours(2) - case .threeHours: - return L10n.intervalHours(3) - case .fourHours: - return L10n.intervalHours(4) - case .sixHours: - return L10n.intervalHours(6) - case .eightHours: - return L10n.intervalHours(8) - case .twelveHours: - return L10n.intervalHours(12) - case .twentyFourHours: - return L10n.intervalHours(24) - } - } -} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 01795e978..456c50cf4 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -882,16 +882,22 @@ internal enum L10n { internal static let taskStateIdle = L10n.tr("Localizable", "taskStateIdle", fallback: "Idle") /// Running internal static let taskStateRunning = L10n.tr("Localizable", "taskStateRunning", fallback: "Running") + /// Sets the maximum runtime (in hours) for this task trigger + internal static let taskTriggerTimeLimit = L10n.tr("Localizable", "taskTriggerTimeLimit", fallback: "Sets the maximum runtime (in hours) for this task trigger") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") /// Time internal static let time = L10n.tr("Localizable", "time", fallback: "Time") - /// Time Limit (Hours) - internal static let timeLimit = L10n.tr("Localizable", "timeLimit", fallback: "Time Limit (Hours)") + /// Time Limit + internal static let timeLimit = L10n.tr("Localizable", "timeLimit", fallback: "Time Limit") /// Time limit: %1$@ internal static func timeLimitLabelWithHours(_ p1: Any) -> String { return L10n.tr("Localizable", "timeLimitLabelWithHours", String(describing: p1), fallback: "Time limit: %1$@") } + /// Time Limit (%@) + internal static func timeLimitWithUnit(_ p1: Any) -> String { + return L10n.tr("Localizable", "timeLimitWithUnit", String(describing: p1), fallback: "Time Limit (%@)") + } /// Timestamp internal static let timestamp = L10n.tr("Localizable", "timestamp", fallback: "Timestamp") /// Timestamp Type diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 2ffa483b6..0f120f0cd 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -56,12 +56,17 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4E9DB3E62CB7023900D36A26 /* ChevronInputButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */; }; + 4E9DB3E82CB726DF00D36A26 /* ServerTicks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */; }; + 4E9DB3F62CB7969100D36A26 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */; }; + 4E9DB3F82CB796B100D36A26 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */; }; + 4E9DB3FA2CB796D000D36A26 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */; }; + 4E9DB3FC2CB796ED00D36A26 /* CurrentRunningSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */; }; + 4E9DB3FE2CB7972A00D36A26 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */; }; 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */; }; 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */; }; 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; - 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; - 4EA556B82CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */; }; 4EA556BB2CB4A1F500F71E7A /* TriggerTypeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */; }; 4EA556BD2CB4A21500F71E7A /* DayOfWeekSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */; }; 4EA556BF2CB4A22C00F71E7A /* TimeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */; }; @@ -1063,10 +1068,16 @@ 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronInputButton.swift; sourceTree = ""; }; + 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTicks.swift; sourceTree = ""; }; + 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = ""; }; + 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = ""; }; + 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = ""; }; + 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentRunningSection.swift; sourceTree = ""; }; + 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = ""; }; 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTaskTriggerView.swift; sourceTree = ""; }; 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = ""; }; 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; - 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerInterval.swift; sourceTree = ""; }; 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTypeSection.swift; sourceTree = ""; }; 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekSection.swift; sourceTree = ""; }; 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSection.swift; sourceTree = ""; }; @@ -1886,6 +1897,7 @@ children = ( 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, + 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */, 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, @@ -1971,6 +1983,35 @@ path = Components; sourceTree = ""; }; + 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */ = { + isa = PBXGroup; + children = ( + 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, + 4E9DB3F32CB7954700D36A26 /* Components */, + ); + path = AddTaskTriggerView; + sourceTree = ""; + }; + 4E9DB3F32CB7954700D36A26 /* Components */ = { + isa = PBXGroup; + children = ( + 4E9DB3F42CB7954E00D36A26 /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4E9DB3F42CB7954E00D36A26 /* Sections */ = { + isa = PBXGroup; + children = ( + 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */, + 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */, + 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */, + 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */, + 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */ = { isa = PBXGroup; children = ( @@ -1983,9 +2024,8 @@ 4EA556AE2CB48B6600F71E7A /* Components */ = { isa = PBXGroup; children = ( - 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, - 4EA556B92CB4A0BE00F71E7A /* Sections */, 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */, + 4EA556B92CB4A0BE00F71E7A /* Sections */, ); path = Components; sourceTree = ""; @@ -1993,11 +2033,11 @@ 4EA556B92CB4A0BE00F71E7A /* Sections */ = { isa = PBXGroup; children = ( - 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */, - 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */, - 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */, - 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */, - 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */, + 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */, + 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */, + 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */, + 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */, + 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */, ); path = Sections; sourceTree = ""; @@ -2210,7 +2250,6 @@ E1EF4C402911B783008CC695 /* StreamType.swift */, E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */, E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */, - 4EA556B62CB48E1400F71E7A /* TaskTriggerInterval.swift */, E1A1528428FD191A00600579 /* TextPair.swift */, E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, @@ -2465,6 +2504,7 @@ children = ( E1D8429429346C6400D1041A /* BasicStepper.swift */, E1A1528728FD229500600579 /* ChevronButton.swift */, + 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, @@ -2484,6 +2524,7 @@ E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, E1AA331C2782541500F6439C /* PrimaryButton.swift */, E1D3043428D1763100587289 /* SeeAllButton.swift */, + 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */, E17DC74C2BE7601E00B42379 /* SettingsBarButton.swift */, E1D5C39728DF914100CDBEFB /* Slider */, E1581E26291EF59800D6C640 /* SplitContentView.swift */, @@ -4521,7 +4562,6 @@ 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, BD0BA22C2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, E1575EA2293E7B1E001665B1 /* Color.swift in Sources */, - 4EA556B72CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */, E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */, E1575E72293E77B5001665B1 /* Utilities.swift in Sources */, E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */, @@ -4736,6 +4776,7 @@ 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */, + 4E9DB3F62CB7969100D36A26 /* DetailsSection.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, @@ -4761,6 +4802,7 @@ E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, + 4E9DB3FC2CB796ED00D36A26 /* CurrentRunningSection.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, @@ -4867,8 +4909,10 @@ E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */, E1A8FDEC2C0574A800D0A51C /* ListRow.swift in Sources */, 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */, + 4E9DB3F82CB796B100D36A26 /* LastRunSection.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */, + 4E9DB3E62CB7023900D36A26 /* ChevronInputButton.swift in Sources */, E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, @@ -4880,6 +4924,7 @@ E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, + 4E9DB3E82CB726DF00D36A26 /* ServerTicks.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, @@ -4942,6 +4987,7 @@ E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, + 4E9DB3FE2CB7972A00D36A26 /* TriggersSection.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, @@ -4991,6 +5037,7 @@ E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, + 4E9DB3FA2CB796D000D36A26 /* LastErrorSection.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, @@ -5004,7 +5051,6 @@ E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, E1D842912933F87500D1041A /* ItemFields.swift in Sources */, E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, - 4EA556B82CB48E1400F71E7A /* TaskTriggerInterval.swift in Sources */, E113132F28BDB66A00930F75 /* NavigationBarDrawerModifier.swift in Sources */, E1E750692A33E9B400B2C1EE /* MediaSourcesCard.swift in Sources */, E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */, diff --git a/Swiftfin/Components/ChevronInputButton.swift b/Swiftfin/Components/ChevronInputButton.swift new file mode 100644 index 000000000..18494b5fd --- /dev/null +++ b/Swiftfin/Components/ChevronInputButton.swift @@ -0,0 +1,75 @@ +// +// 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 Foundation +import SwiftUI + +struct ChevronInputButton: View where Value: LosslessStringConvertible & Equatable { + + @Binding + private var value: Value + @State + private var temporaryInputValue: String + @State + private var isSelected = false + + private let title: String + private let description: String? + private let subtitle: String + private let helpText: String? + private let keyboardType: UIKeyboardType + + init( + title: String, + subtitle: String, + description: String? = nil, + helpText: String? = nil, + value: Binding, + keyboard: UIKeyboardType = .default + ) { + self.title = title + self.subtitle = subtitle + self.description = description + self.helpText = helpText + self._value = value + self._temporaryInputValue = State(initialValue: value.wrappedValue.description) + self.keyboardType = keyboard + } + + // MARK: - Body + + // TODO: Likely want to redo this but better. Needed in + var body: some View { + ChevronButton( + title, + subtitle: subtitle + ) + .onSelect { + temporaryInputValue = value.description + isSelected = true + } + .alert(title, isPresented: $isSelected) { + TextField(helpText ?? title, text: $temporaryInputValue) + .keyboardType(keyboardType) + + Button(L10n.save) { + if let newValue = Value(temporaryInputValue) { + value = newValue + } + isSelected = false + } + Button(L10n.cancel, role: .cancel) { + isSelected = false + } + } message: { + if let description = description { + Text(description) + } + } + } +} diff --git a/Swiftfin/Components/ServerTicks.swift b/Swiftfin/Components/ServerTicks.swift new file mode 100644 index 000000000..2f9b22fbc --- /dev/null +++ b/Swiftfin/Components/ServerTicks.swift @@ -0,0 +1,84 @@ +// +// 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 Foundation + +struct ServerTicks { + private var ticksValue: Int + + // MARK: - Conversion Constants + + private let ticksPerSecond = 10_000_000 + private let ticksPerMinute = 600_000_000 + private let ticksPerHour = 36_000_000_000 + private let ticksPerDay = 864_000_000_000 + + // MARK: - Initializers + + init(ticks: Int? = nil) { + self.ticksValue = ticks ?? 0 + } + + init(seconds: Int? = nil) { + self.ticksValue = (seconds ?? 0) * ticksPerSecond + } + + init(minutes: Int? = nil) { + self.ticksValue = (minutes ?? 0) * ticksPerMinute + } + + init(hours: Int? = nil) { + self.ticksValue = (hours ?? 0) * ticksPerHour + } + + init(days: Int? = nil) { + self.ticksValue = (days ?? 0) * ticksPerDay + } + + init(timeInterval: TimeInterval? = nil) { + self.ticksValue = Int((timeInterval ?? 0) * Double(ticksPerSecond)) + } + + init(date: Date) { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + let totalSeconds = TimeInterval((components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60) + self.ticksValue = Int(totalSeconds * 10_000_000) + } + + // MARK: - Computed Properties + + var ticks: Int { + ticksValue + } + + var seconds: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerSecond) + } + + var minutes: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerMinute) + } + + var hours: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerHour) + } + + var days: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerDay) + } + + var date: Date { + let totalSeconds = TimeInterval(ticksValue) / 10_000_000 + let hours = Int(totalSeconds) / 3600 + let minutes = (Int(totalSeconds) % 3600) / 60 + var components = DateComponents() + components.hour = hours + components.minute = minutes + return Calendar.current.date(from: components) ?? Date() + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift similarity index 89% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift index 885d2efcf..859f67917 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/AddTaskTriggerView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift @@ -25,10 +25,20 @@ struct AddTaskTriggerView: View { @State private var isPresentingNotSaved = false + // MARK: - Default Trigger Values + + static let defaultTimeOfDayTicks = 0 + static let defaultDayOfWeek: DayOfWeek = .sunday + static let defaultIntervalTicks = 36_000_000_000 + + // MARK: - Unsaved Changes Validation + private var hasUnsavedChanges: Bool { taskTriggerInfo != emptyTaskTriggerInfo } + // MARK: - Init + init(observer: ServerTaskObserver) { self.observer = observer @@ -44,9 +54,11 @@ struct AddTaskTriggerView: View { self.emptyTaskTriggerInfo = newTrigger } + // MARK: - Body + var body: some View { Form { - TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo, allowedTriggerTypes: TaskTriggerType.allCases) + TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo) DayOfWeekSection(taskTriggerInfo: $taskTriggerInfo) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift similarity index 94% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift index fbd143d39..519585545 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DayOfWeekSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift @@ -16,7 +16,7 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo - private let defaultDayOfWeek: DayOfWeek = .sunday + // MARK: - Body var body: some View { if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift similarity index 78% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift index f8802bda1..8ee9e6c04 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/IntervalSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift @@ -16,9 +16,8 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo - private let defaultIntervalTicks = 36_000_000_000 + // MARK: - Body - // TODO: Make Normal Numbers? var body: some View { if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { Picker( @@ -28,8 +27,8 @@ extension AddTaskTriggerView { set: { taskTriggerInfo.intervalTicks = $0 } ) ) { - ForEach(TaskTriggerInterval.allCases) { interval in - Text(interval.displayTitle).tag(Int(interval.rawValue)) + ForEach(Array(stride(from: 900, to: 86400 + 1, by: 900)), id: \.self) { interval in + Text(TimeInterval(interval).formatted(.hourMinute)).tag(ServerTicks(seconds: interval).ticks) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift new file mode 100644 index 000000000..f3e4d7506 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift @@ -0,0 +1,51 @@ +// +// 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 AddTaskTriggerView { + + struct TimeLimitSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + Section { + ChevronInputButton( + title: L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit, + helpText: L10n.hours, + value: Binding( + get: { + Int(ServerTicks(ticks: taskTriggerInfo.maxRuntimeTicks).hours) + }, + set: { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: $0).ticks + } + ), + keyboard: .numberPad + ) + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(ticks: maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.disabled + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift new file mode 100644 index 000000000..0f32eafc0 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift @@ -0,0 +1,38 @@ +// +// 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 AddTaskTriggerView { + + struct TimeSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.daily.rawValue || taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + DatePicker( + L10n.time, + selection: Binding( + get: { + ServerTicks( + ticks: taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks + ).date + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks + } + ), + displayedComponents: .hourAndMinute + ) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift similarity index 89% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift index e02aa0c47..cf56666b5 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggerTypeSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift @@ -16,12 +16,6 @@ extension AddTaskTriggerView { @Binding var taskTriggerInfo: TaskTriggerInfo - let allowedTriggerTypes: [TaskTriggerType] - - private let defaultTimeOfDayTicks = 0 - private let defaultDayOfWeek: DayOfWeek = .sunday - private let defaultIntervalTicks = 36_000_000_000 - var body: some View { Picker( L10n.triggerType, @@ -36,7 +30,7 @@ extension AddTaskTriggerView { } ) ) { - ForEach(allowedTriggerTypes, id: \.self) { type in + ForEach(TaskTriggerType.allCases, id: \.self) { type in Text(type.displayTitle) .tag(type as TaskTriggerType?) } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift new file mode 100644 index 000000000..0f726670c --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift @@ -0,0 +1,34 @@ +// +// 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 EditScheduledTaskView { + + struct CurrentRunningSection: View { + + var task: TaskInfo + + var body: some View { + Section(L10n.progress) { + if let status = task.state { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + + if let currentProgressPercentage = task.currentProgressPercentage { + TextPairView( + L10n.taskCompleted, + value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") + ) + .monospacedDigit() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift new file mode 100644 index 000000000..3b659a3d9 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift @@ -0,0 +1,25 @@ +// +// 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 EditScheduledTaskView { + + struct DetailsSection: View { + + var category: String? + + var body: some View { + Section(L10n.details) { + if let category = category { + TextPairView(leading: L10n.category, trailing: category) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift new file mode 100644 index 000000000..73d4a8f9c --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift @@ -0,0 +1,29 @@ +// +// 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 EditScheduledTaskView { + + struct LastErrorSection: View { + + var lastExecutionResult: TaskResult + + var body: some View { + Section(L10n.errorDetails) { + if let errorMessage = lastExecutionResult.errorMessage { + Text(errorMessage) + } + if let longErrorMessage = lastExecutionResult.longErrorMessage { + Text(longErrorMessage) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift new file mode 100644 index 000000000..dccb0245d --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift @@ -0,0 +1,33 @@ +// +// 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 EditScheduledTaskView { + + struct LastRunSection: View { + + var lastExecutionResult: TaskResult + + var body: some View { + Section(L10n.lastRun) { + if let status = lastExecutionResult.status { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + if let endTimeUtc = lastExecutionResult.endTimeUtc { + TextPairView( + L10n.executed, + value: Text("\(endTimeUtc, format: .relative(presentation: .numeric, unitsStyle: .narrow))") + ) + .monospacedDigit() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift deleted file mode 100644 index d111c922e..000000000 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeLimitSection.swift +++ /dev/null @@ -1,75 +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) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TimeLimitSection: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - @State - private var isPresentingTimeLimitAlert = false - @State - private var inputValue: Int = 0 - - var body: some View { - Section { - ChevronButton( - L10n.timeLimit, - subtitle: timeLimitSubtitle - ) - .onSelect { - isPresentingTimeLimitAlert = true - inputValue = hoursFromTicks(taskTriggerInfo.maxRuntimeTicks) - } - .alert(L10n.timeLimit, isPresented: $isPresentingTimeLimitAlert) { - TextField( - L10n.timeLimit, - value: $inputValue, - format: .number - ) - .keyboardType(.numberPad) - - Button(L10n.save) { - taskTriggerInfo.maxRuntimeTicks = ticksFromHours(inputValue) - isPresentingTimeLimitAlert = false - } - Button(L10n.cancel, role: .cancel) { - isPresentingTimeLimitAlert = false - } - } - } - } - - private var timeLimitSubtitle: Text { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks, maxRuntimeTicks > 0 { - return Text(timeFromTicks(maxRuntimeTicks)) - } else { - return Text(L10n.disabled) - } - } - - private func hoursFromTicks(_ ticks: Int?) -> Int { - guard let ticks = ticks else { return 0 } - return ticks / 36_000_000_000 - } - - private func ticksFromHours(_ hours: Int) -> Int? { - hours > 0 ? hours * 36_000_000_000 : nil - } - - private func timeFromTicks(_ ticks: Int) -> String { - let timeInterval = TimeInterval(ticks) / 10_000_000 - return timeInterval.formatted(.hourMinute) - } - } -} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift deleted file mode 100644 index 48a8529ab..000000000 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TimeSection.swift +++ /dev/null @@ -1,54 +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) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TimeSection: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - private let defaultTimeOfDayTicks = 0 - - var body: some View { - if taskTriggerInfo.type == TaskTriggerType.daily.rawValue || taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { - DatePicker( - L10n.time, - selection: Binding( - get: { - dateFromTimeOfDayTicks(taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks) - }, - set: { date in - taskTriggerInfo.timeOfDayTicks = timeOfDayTicksFromDate(date) - } - ), - displayedComponents: .hourAndMinute - ) - } - } - - private func dateFromTimeOfDayTicks(_ ticks: Int) -> Date { - let totalSeconds = TimeInterval(ticks) / 10_000_000 - let hours = Int(totalSeconds) / 3600 - let minutes = (Int(totalSeconds) % 3600) / 60 - var components = DateComponents() - components.hour = hours - components.minute = minutes - return Calendar.current.date(from: components) ?? Date() - } - - private func timeOfDayTicksFromDate(_ date: Date) -> Int { - let components = Calendar.current.dateComponents([.hour, .minute], from: date) - let totalSeconds = TimeInterval((components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60) - return Int(totalSeconds * 10_000_000) - } - } -} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift new file mode 100644 index 000000000..bf3fea7f4 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift @@ -0,0 +1,45 @@ +// +// 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 EditScheduledTaskView { + + struct TriggersSection: View { + + var triggers: [TaskTriggerInfo]? + @Binding + var isPresentingDeleteConfirmation: Bool + @Binding + var selectedTrigger: TaskTriggerInfo? + var deleteAction: (TaskTriggerInfo) -> Void + var addAction: () -> Void + + var body: some View { + Section(L10n.triggers) { + if let triggers = triggers, !triggers.isEmpty { + ForEach(triggers, id: \.self) { trigger in + TriggerRow(taskTriggerInfo: trigger) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(L10n.delete) { + selectedTrigger = trigger + isPresentingDeleteConfirmation = true + } + .tint(.red) + } + } + } else { + Button(L10n.addTaskTrigger) { + addAction() + } + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift index 21bf6b6e3..985c46b14 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift @@ -17,6 +17,7 @@ extension EditScheduledTaskView { let taskTriggerInfo: TaskTriggerInfo // TODO: remove after `TaskTriggerType` is provided by SDK + private var taskTriggerType: TaskTriggerType { if let type = taskTriggerInfo.type { return TaskTriggerType(rawValue: type)! @@ -38,7 +39,8 @@ extension EditScheduledTaskView { if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { Text( L10n.timeLimitLabelWithHours( - timeIntervalFromTicks(maxRuntimeTicks).formatted(.hourMinute) + ServerTicks(ticks: maxRuntimeTicks) + .seconds.formatted(.hourMinute) ) ) } else { @@ -63,7 +65,8 @@ extension EditScheduledTaskView { if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { return L10n.itemAtItem( taskTriggerType.displayTitle, - timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + ServerTicks(ticks: timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) ) } case .weekly: @@ -72,38 +75,21 @@ extension EditScheduledTaskView { { return L10n.itemAtItem( dayOfWeek.rawValue.capitalized, - timeFromTicks(timeOfDayTicks).formatted(date: .omitted, time: .shortened) + ServerTicks(ticks: timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) ) } case .interval: if let intervalTicks = taskTriggerInfo.intervalTicks { return L10n.everyInterval( - timeIntervalFromTicks(intervalTicks).formatted(.hourMinute) + ServerTicks(ticks: intervalTicks) + .seconds.formatted(.hourMinute) ) } case .startup: return taskTriggerType.displayTitle } - return L10n.unknown } - - // MARK: - Convert Ticks to TimeInterval - - private func timeIntervalFromTicks(_ ticks: Int) -> TimeInterval { - TimeInterval(ticks) / 10_000_000 - } - - // MARK: - Convert Ticks to Time - - private func timeFromTicks(_ ticks: Int) -> Date { - let totalSeconds = timeIntervalFromTicks(ticks) - let hours = Int(totalSeconds) / 3600 - let minutes = (Int(totalSeconds) % 3600) / 60 - var components = DateComponents() - components.hour = hours - components.minute = minutes - return Calendar.current.date(from: components) ?? Date() - } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift index 470dc090d..2d315064c 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -26,97 +26,6 @@ struct EditScheduledTaskView: View { @State private var selectedTrigger: TaskTriggerInfo? - // MARK: - Task Details Section - - @ViewBuilder - private var detailsSection: some View { - Section(L10n.details) { - if let category = observer.task.category { - TextPairView(leading: L10n.category, trailing: category) - } - } - } - - // MARK: - Last Run Details Section - - @ViewBuilder - private func lastRunSection(_ lastExecutionResult: TaskResult) -> some View { - Section(L10n.lastRun) { - if let status = lastExecutionResult.status { - TextPairView(L10n.status, value: Text(status.displayTitle)) - } - if let endTimeUtc = lastExecutionResult.endTimeUtc { - TextPairView(L10n.executed, value: Text("\(endTimeUtc, format: .relative(presentation: .numeric, unitsStyle: .narrow))")) - .monospacedDigit() - } - } - } - - // MARK: - Last Error Details Section - - @ViewBuilder - private func lastErrorSection(_ lastExecutionResult: TaskResult) -> some View { - Section(L10n.errorDetails) { - if let errorMessage = lastExecutionResult.errorMessage { - Text(errorMessage) - } - if let longErrorMessage = lastExecutionResult.longErrorMessage { - Text(longErrorMessage) - } - } - } - - // MARK: - Task Current Running Details Section - - @ViewBuilder - private func currentRunningSection(_ task: TaskInfo) -> some View { - Section(L10n.progress) { - if let status = task.state { - TextPairView(L10n.status, value: Text(status.displayTitle)) - } - - if let currentProgressPercentage = task.currentProgressPercentage { - TextPairView( - L10n.taskCompleted, - value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") - ) - .monospacedDigit() - } - } - } - - // MARK: - Task Triggers Section - - @ViewBuilder - private var triggersSection: some View { - Section(L10n.triggers) { - if let triggers = observer.task.triggers, !triggers.isEmpty { - ForEach(triggers, id: \.self) { trigger in - TriggerRow(taskTriggerInfo: trigger) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(L10n.delete) { - selectedTrigger = trigger - isPresentingDeleteConfirmation = true - } - .tint(.red) - } - } - } else { - Button(L10n.addTaskTrigger) { - router.route(to: \.addScheduledTaskTrigger, observer) - } - } - } - } - - // MARK: - Trigger Haptic Feedback - - private func triggerHapticFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .light) { - UIDevice.impact(style) - } - - // MARK: - Body - var body: some View { List { ListTitleSection( @@ -124,31 +33,33 @@ struct EditScheduledTaskView: View { description: observer.task.description ) - detailsSection + DetailsSection(category: observer.task.category) - // Only Create the Last Run Section if there are Last Execution Results Available if let lastExecutionResult = observer.task.lastExecutionResult { - lastRunSection(lastExecutionResult) + LastRunSection(lastExecutionResult: lastExecutionResult) - // Only Create the Last Error Section if there are Errors Available - // Errors can only exist if there is Last Execution Results if lastExecutionResult.errorMessage != nil { - lastErrorSection(lastExecutionResult) + LastErrorSection(lastExecutionResult: lastExecutionResult) } } - // Only Create the Current Running Section if there is an Active Status if observer.task.state == .running || observer.task.state == .cancelling { - currentRunningSection(observer.task) + CurrentRunningSection(task: observer.task) } - triggersSection + TriggersSection( + triggers: observer.task.triggers, + isPresentingDeleteConfirmation: $isPresentingDeleteConfirmation, + selectedTrigger: $selectedTrigger, + deleteAction: { trigger in observer.send(.removeTrigger(trigger)) }, + addAction: { router.route(to: \.addScheduledTaskTrigger, observer) } + ) } .navigationTitle(L10n.task) .topBarTrailing { if observer.task.triggers?.isEmpty == false { Button(L10n.add) { - triggerHapticFeedback() + UIDevice.impact(.light) router.route(to: \.addScheduledTaskTrigger, observer) } .buttonStyle(.toolbarPill) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 7059fbb72b3767cb4b7422cb3c0d59a05c10a291..9b52b71a495e347fb76fb5f00f0a9c8bf815786b 100644 GIT binary patch delta 241 zcmccD&%9_d^M;u1?BxuZ3?&R1lOJvreNusvaN(Pp8|m$vf=Oy=w2n5+=TGI>D}&*Uc?B_>~3 z!@-#dG!ke5L+a#%hh>FBfClA)t?>b~Ko%9Q66OzKC<5}+fiM-w3YpxvU79@@$Sz?h zo~-CEZCV6&Rta3U29T8pG&zGIA1G7AP|TnS Date: Thu, 10 Oct 2024 21:16:54 -0600 Subject: [PATCH 07/10] Device Management. Imported the Device SVGs from Jellyfin-Web. Fixed sizing for Albums Active Sessions --- Shared/Coordinators/SettingsCoordinator.swift | 7 + Shared/Strings/Strings.swift | 28 ++ Shared/ViewModels/DevicesViewModel.swift | 242 ++++++++++++++++++ Swiftfin.xcodeproj/project.pbxproj | 60 ++++- Swiftfin/Components/DeviceTypes.swift | 176 +++++++++++++ .../DeviceIcons/Browsers/Contents.json | 6 + .../Contents.json | 21 ++ .../Device-browser-chrome.imageset/chrome.svg | 1 + .../Contents.json | 21 ++ .../Device-browser-edge.imageset/edge.svg | 1 + .../Contents.json | 21 ++ .../edgechromium.svg | 1 + .../Contents.json | 21 ++ .../firefox.svg | 1 + .../Contents.json | 21 ++ .../Device-browser-msie.imageset/msie.svg | 1 + .../Contents.json | 21 ++ .../Device-browser-opera.imageset/opera.svg | 1 + .../Contents.json | 21 ++ .../Device-browser-safari.imageset/safari.svg | 1 + .../DeviceIcons/Clients/Contents.json | 6 + .../Device-android.imageset/Contents.json | 21 ++ .../Device-android.imageset/android.svg | 4 + .../Device-apple.imageset/Contents.json | 21 ++ .../Clients/Device-apple.imageset/apple.svg | 1 + .../Device-finamp.imageset/Contents.json | 21 ++ .../Clients/Device-finamp.imageset/finamp.svg | 7 + .../Device-kodi.imageset/Contents.json | 21 ++ .../Clients/Device-kodi.imageset/kodi.svg | 11 + .../Device-playstation.imageset/Contents.json | 21 ++ .../playstation.svg | 1 + .../Device-roku.imageset/Contents.json | 21 ++ .../Clients/Device-roku.imageset/roku.svg | 7 + .../Device-samsungtv.imageset/Contents.json | 21 ++ .../Device-samsungtv.imageset/samsungtv.svg | 1 + .../Device-windows.imageset/Contents.json | 21 ++ .../Device-windows.imageset/windows.svg | 1 + .../Device-xbox.imageset/Contents.json | 21 ++ .../Clients/Device-xbox.imageset/xbox.svg | 1 + .../Assets.xcassets/DeviceIcons/Contents.json | 6 + .../DeviceIcons/Other/Contents.json | 6 + .../Contents.json | 21 ++ .../home-assistant.svg | 1 + .../Other/Device-html5.imageset/Contents.json | 21 ++ .../Other/Device-html5.imageset/html5.svg | 1 + .../Other/Device-other.imageset/Contents.json | 21 ++ .../Other/Device-other.imageset/other.svg | 1 + .../Components/ActiveSessionRow.swift | 53 ++-- .../{ => Sections}/ProgressSection.swift | 0 .../DevicesView/Components/DeviceRow.swift | 117 +++++++++ .../DevicesView/DevicesView.swift | 203 +++++++++++++++ .../Components/Sections/TriggersSection.swift | 4 +- .../{ => ServerLogsView}/ServerLogsView.swift | 0 .../UserDashboardView/UserDashboardView.swift | 5 + Translations/en.lproj/Localizable.strings | Bin 51618 -> 54984 bytes 55 files changed, 1339 insertions(+), 23 deletions(-) create mode 100644 Shared/ViewModels/DevicesViewModel.swift create mode 100644 Swiftfin/Components/DeviceTypes.swift create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json create mode 100644 Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg rename Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/{ => Sections}/ProgressSection.swift (100%) create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift create mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift rename Swiftfin/Views/SettingsView/UserDashboardView/{ => ServerLogsView}/ServerLogsView.swift (100%) diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 5491243e6..b3aca2dd2 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -59,6 +59,8 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.modal) var itemOverviewView = makeItemOverviewView @Route(.push) + var devices = makeDevices + @Route(.push) var tasks = makeTasks @Route(.push) var editScheduledTask = makeEditScheduledTask @@ -190,6 +192,11 @@ final class SettingsCoordinator: NavigationCoordinatable { ScheduledTasksView() } + @ViewBuilder + func makeDevices() -> some View { + DevicesView() + } + @ViewBuilder func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { EditScheduledTaskView(observer: observer) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 456c50cf4..6c880d2fa 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -36,6 +36,10 @@ internal enum L10n { internal static func airWithDate(_ p1: UnsafePointer) -> String { return L10n.tr("Localizable", "airWithDate", p1, fallback: "Airs %s") } + /// All Devices + internal static let allDevices = L10n.tr("Localizable", "allDevices", fallback: "All Devices") + /// Devices are all the hardware that have connected to the server, including both current and past connections. You can view device details such as name, app version, and the associated user. + internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "Devices are all the hardware that have connected to the server, including both current and past connections. You can view device details such as name, app version, and the associated user.") /// All Genres internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media @@ -218,6 +222,8 @@ internal enum L10n { internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// Custom internal static let custom = L10n.tr("Localizable", "custom", fallback: "Custom") + /// Custom Device Name + internal static let customDeviceName = L10n.tr("Localizable", "customDeviceName", fallback: "Custom Device Name") /// The custom device profiles will be added to the default Swiftfin device profiles internal static let customDeviceProfileAdd = L10n.tr("Localizable", "customDeviceProfileAdd", fallback: "The custom device profiles will be added to the default Swiftfin device profiles") /// Dictates back to the Jellyfin Server what this device hardware is capable of playing @@ -256,6 +262,22 @@ internal enum L10n { internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Delete internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") + /// Delete All + internal static let deleteAll = L10n.tr("Localizable", "deleteAll", fallback: "Delete All") + /// Delete All Devices + internal static let deleteAllDevices = L10n.tr("Localizable", "deleteAllDevices", fallback: "Delete All Devices") + /// Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in. + internal static let deleteAllDevicesWarning = L10n.tr("Localizable", "deleteAllDevicesWarning", fallback: "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.") + /// Delete Device + internal static let deleteDevice = L10n.tr("Localizable", "deleteDevice", fallback: "Delete Device") + /// Failed to Delete Device + internal static let deleteDeviceFailed = L10n.tr("Localizable", "deleteDeviceFailed", fallback: "Failed to Delete Device") + /// Cannot delete a session from the same device (%1$@). + internal static func deleteDeviceSelfDeletion(_ p1: Any) -> String { + return L10n.tr("Localizable", "deleteDeviceSelfDeletion", String(describing: p1), fallback: "Cannot delete a session from the same device (%1$@).") + } + /// Are you sure you wish to delete this device? This session will be logged out. This device will reappear the next time this device signs in. + internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out. This device will reappear the next time this device signs in.") /// Delete Server internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") /// Delete Trigger @@ -270,6 +292,8 @@ internal enum L10n { internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Device Profile internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile") + /// Devices + internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices") /// Direct Play internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") /// DIRECTOR @@ -304,6 +328,8 @@ internal enum L10n { internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") /// Enabled internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled") + /// Supply a custom display name or leave empty to use the name reported by the device. + internal static let enterCustomDeviceName = L10n.tr("Localizable", "enterCustomDeviceName", fallback: "Supply a custom display name or leave empty to use the name reported by the device.") /// Episode Landscape Poster internal static let episodeLandscapePoster = L10n.tr("Localizable", "episodeLandscapePoster", fallback: "Episode Landscape Poster") /// Episode %1$@ @@ -484,6 +510,8 @@ internal enum L10n { internal static let networking = L10n.tr("Localizable", "networking", fallback: "Networking") /// Network timed out internal static let networkTimedOut = L10n.tr("Localizable", "networkTimedOut", fallback: "Network timed out") + /// Never + internal static let never = L10n.tr("Localizable", "never", fallback: "Never") /// Never run internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") /// News diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift new file mode 100644 index 000000000..a9faf1d18 --- /dev/null +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -0,0 +1,242 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class DevicesViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case getDevices + case setCustomName(id: String, newName: String) + case deleteDevice(id: String) + case deleteAllDevices + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingDevices + case settingCustomName + case deletingDevice + case deletingAllDevices + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var devices: OrderedDictionary> = [:] + @Published + final var state: State = .initial + + private var deviceTask: AnyCancellable? + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .getDevices: + deviceTask?.cancel() + + deviceTask = Task { [weak self] in + await MainActor.run { + let _ = self?.backgroundStates.append(.gettingDevices) + } + + do { + try await self?.loadDevices() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + let _ = self?.backgroundStates.remove(.gettingDevices) + } + } + .asAnyCancellable() + + return state + + case let .setCustomName(id, newName): + deviceTask?.cancel() + + deviceTask = Task { [weak self] in + await MainActor.run { + let _ = self?.backgroundStates.append(.settingCustomName) + } + + do { + try await self?.setCustomName(id: id, newName: newName) + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + let _ = self?.backgroundStates.remove(.settingCustomName) + } + } + .asAnyCancellable() + + return state + + case let .deleteDevice(id): + deviceTask?.cancel() + + deviceTask = Task { [weak self] in + await MainActor.run { + let _ = self?.backgroundStates.append(.deletingDevice) + } + + do { + try await self?.deleteDevice(id: id) + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + let _ = self?.backgroundStates.remove(.deletingDevice) + } + } + .asAnyCancellable() + + return state + + case .deleteAllDevices: + deviceTask?.cancel() + + deviceTask = Task { [weak self] in + await MainActor.run { + let _ = self?.backgroundStates.append(.deletingAllDevices) + } + + do { + try await self?.deleteAllDevices() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + let _ = self?.backgroundStates.remove(.deletingAllDevices) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Devices + + private func loadDevices() async throws { + let request = Paths.getDevices() + let response = try await userSession.client.send(request) + + await MainActor.run { + if let devices = response.value.items { + for device in devices { + guard let id = device.id else { continue } + + if let existingDevice = self.devices[id] { + existingDevice.value = device + } else { + self.devices[id] = BindingBox( + source: .init(get: { device }, set: { _ in }) + ) + } + } + + self.devices.sort { x, y in + let device0 = x.value.value + let device1 = y.value.value + return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date()) + } + } + } + } + + // MARK: - Set Custom Name + + private func setCustomName(id: String, newName: String) async throws { + let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName)) + try await userSession.client.send(request) + + if let device = self.devices[id]?.value { + await MainActor.run { + self.devices[id]?.value?.name = newName + } + } + } + + // MARK: - Delete Device + + private func deleteDevice(id: String) async throws { + // Don't allow self-deletion + guard id != userSession.client.configuration.deviceID else { + return + } + + let request = Paths.deleteDevice(id: id) + try await userSession.client.send(request) + + await MainActor.run { + self.devices.removeValue(forKey: id) + } + } + + // MARK: - Delete All Devices + + private func deleteAllDevices() async throws { + let deviceIdsToDelete = self.devices.keys.filter { $0 != userSession.client.configuration.deviceID } + + for deviceId in deviceIdsToDelete { + print("Deleting: \(deviceId)") + // try await deleteDevice(id: deviceId) + } + + await MainActor.run { + self.devices = self.devices.filter { $0.key == userSession.client.configuration.deviceID } + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0f120f0cd..2d333fd14 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -49,6 +49,10 @@ 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; + 4E872ABE2CB84BF2008C17BC /* DeviceTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */; }; + 4E872AC32CB861F5008C17BC /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872AC12CB861F5008C17BC /* DevicesView.swift */; }; + 4E872AC42CB861F5008C17BC /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */; }; + 4E872AC62CB86308008C17BC /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; }; @@ -1063,6 +1067,10 @@ 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; + 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTypes.swift; sourceTree = ""; }; + 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; + 4E872AC12CB861F5008C17BC /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; + 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.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 = ""; }; @@ -1898,9 +1906,10 @@ 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */, + 4E872AC22CB861F5008C17BC /* DevicesView */, 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, - E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + 4E872AC72CB86381008C17BC /* ServerLogsView */, 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, ); path = UserDashboardView; @@ -1965,6 +1974,47 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E872ABC2CB843F9008C17BC /* Sections */ = { + isa = PBXGroup; + children = ( + 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; + 4E872AC02CB861F5008C17BC /* Components */ = { + isa = PBXGroup; + children = ( + 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */, + 4E872ACD2CB8A0A1008C17BC /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4E872AC22CB861F5008C17BC /* DevicesView */ = { + isa = PBXGroup; + children = ( + 4E872AC02CB861F5008C17BC /* Components */, + 4E872AC12CB861F5008C17BC /* DevicesView.swift */, + ); + path = DevicesView; + sourceTree = ""; + }; + 4E872AC72CB86381008C17BC /* ServerLogsView */ = { + isa = PBXGroup; + children = ( + E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + ); + path = ServerLogsView; + sourceTree = ""; + }; + 4E872ACD2CB8A0A1008C17BC /* Sections */ = { + isa = PBXGroup; + children = ( + ); + path = Sections; + sourceTree = ""; + }; 4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -2055,7 +2105,7 @@ isa = PBXGroup; children = ( 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */, - 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, + 4E872ABC2CB843F9008C17BC /* Sections */, ); path = Components; sourceTree = ""; @@ -2107,6 +2157,7 @@ 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, + 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, @@ -2507,6 +2558,7 @@ 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */, + 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, E1DE2B492B97ECB900F6715F /* ErrorView.swift */, E1921B7528E63306003A5238 /* GestureView.swift */, @@ -4828,6 +4880,9 @@ E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */, + 4E872ABE2CB84BF2008C17BC /* DeviceTypes.swift in Sources */, + 4E872AC32CB861F5008C17BC /* DevicesView.swift in Sources */, + 4E872AC42CB861F5008C17BC /* DeviceRow.swift in Sources */, E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, @@ -4875,6 +4930,7 @@ E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, + 4E872AC62CB86308008C17BC /* DevicesViewModel.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */, diff --git a/Swiftfin/Components/DeviceTypes.swift b/Swiftfin/Components/DeviceTypes.swift new file mode 100644 index 000000000..a71944016 --- /dev/null +++ b/Swiftfin/Components/DeviceTypes.swift @@ -0,0 +1,176 @@ +// +// 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 + +enum DeviceType: String, Displayable, SystemImageable, Codable, CaseIterable { + case android = "Device-android" + case apple = "Device-apple" + case chrome = "Device-browser-chrome" + case edge = "Device-browser-edge" + case edgechromium = "Device-browser-edgechromium" + case finamp = "Device-finamp" + case firefox = "Device-browser-firefox" + case homeAssistant = "Device-homeassistant" + case html5 = "Device-html5" + case kodi = "Device-kodi" + case msie = "Device-browser-msie" + case opera = "Device-browser-opera" + case playstation = "Device-playstation" + case roku = "Device-roku" + case safari = "Device-browser-safari" + case samsungtv = "Device-samsungtv" + case windows = "Device-windows" + case xbox = "Device-xbox" + case other = "Device-other" + + // MARK: - Initialize the Client + + init(client: String?, deviceName: String?) { + switch client { + case "Samsung Smart TV": + self = .samsungtv + case "Xbox One": + self = .xbox + case "Sony PS4": + self = .playstation + case "Kodi", "Kodi JellyCon": + self = .kodi + case "Jellyfin Android", "AndroidTV", "Android TV": + self = .android + case "Jellyfin Mobile (iOS)", "Jellyfin Mobile (iPadOS)", "Jellyfin iOS", "Jellyfin iPadOS", "Jellyfin tvOS", "Swiftfin iPadOS", + "Swiftfin iOS", "Swiftfin tvOS", "Infuse", "Infuse-Direct", "Infuse-Library": + self = .apple + case "Home Assistant": + self = .homeAssistant + case "Jellyfin Roku": + self = .roku + case "Finamp": + self = .finamp + case "Jellyfin Web", "Jellyfin Web (Vue)": + self = DeviceType(webBrowser: deviceName) + default: + self = .other + } + } + + // MARK: - Initialize the Browser if Jellyfin-Web + + private init(webBrowser: String?) { + switch webBrowser { + case "Opera", "Opera TV", "Opera Android": + self = .opera + case "Chrome", "Chrome Android": + self = .chrome + case "Firefox", "Firefox Android": + self = .firefox + case "Safari", "Safari iPad", "Safari iPhone": + self = .safari + case "Edge Chromium", "Edge Chromium Android", "Edge Chromium iPad", "Edge Chromium iPhone": + self = .edgechromium + case "Edge": + self = .edge + case "Internet Explorer": + self = .msie + default: + self = .html5 + } + } + + // MARK: - Client Image + + var systemImage: String { + rawValue + } + + // MARK: - Client Color + + var clientColor: Color { + switch self { + case .samsungtv: + return Color(red: 0.0, green: 0.44, blue: 0.74) // Samsung Blue + case .xbox: + return Color(red: 0.0, green: 0.5, blue: 0.0) // Xbox Green + case .playstation: + return Color(red: 0.0, green: 0.32, blue: 0.65) // PlayStation Blue + case .kodi: + return Color(red: 0.0, green: 0.58, blue: 0.83) // Kodi Blue + case .android: + return Color(red: 0.18, green: 0.8, blue: 0.44) // Android Green + case .apple: + return Color(red: 0.35, green: 0.35, blue: 0.35) // Apple Gray + case .homeAssistant: + return Color(red: 0.0, green: 0.55, blue: 0.87) // Home Assistant Blue + case .roku: + return Color(red: 0.31, green: 0.09, blue: 0.55) // Roku Purple + case .finamp: + return Color(red: 0.61, green: 0.32, blue: 0.88) // Finamp Purple + case .chrome: + return Color(red: 0.98, green: 0.75, blue: 0.18) // Chrome Yellow + case .firefox: + return Color(red: 1.0, green: 0.33, blue: 0.0) // Firefox Orange + case .safari: + return Color(red: 0.0, green: 0.48, blue: 1.0) // Safari Blue + case .edgechromium: + return Color(red: 0.0, green: 0.45, blue: 0.75) // Edge Chromium Blue + case .edge: + return Color(red: 0.19, green: 0.31, blue: 0.51) // Edge Gray + case .msie: + return Color(red: 0.0, green: 0.53, blue: 1.0) // Internet Explorer Blue + case .opera: + return Color(red: 1.0, green: 0.0, blue: 0.0) // Opera Red + default: + return Color.systemBackground + } + } + + // MARK: - Client Title + + var displayTitle: String { + switch self { + case .android: + return "Android" + case .apple: + return "Apple" + case .chrome: + return "Chrome" + case .edge: + return "Edge" + case .edgechromium: + return "Edge Chromium" + case .finamp: + return "Finamp" + case .firefox: + return "Firefox" + case .homeAssistant: + return "Home Assistant" + case .html5: + return "HTML5" + case .kodi: + return "Kodi" + case .msie: + return "Internet Explorer" + case .opera: + return "Opera" + case .playstation: + return "PlayStation" + case .roku: + return "Roku" + case .safari: + return "Safari" + case .samsungtv: + return "Samsung TV" + case .windows: + return "Windows" + case .xbox: + return "Xbox" + case .other: + return "Other" + } + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json new file mode 100644 index 000000000..400c04b35 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "chrome.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg new file mode 100644 index 000000000..fab308dc2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg @@ -0,0 +1 @@ +Google Chrome icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json new file mode 100644 index 000000000..84879e400 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "edge.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg new file mode 100644 index 000000000..8a552924d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json new file mode 100644 index 000000000..0585791bc --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "edgechromium.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg new file mode 100644 index 000000000..14d68a5d4 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json new file mode 100644 index 000000000..475295ee0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "firefox.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg new file mode 100644 index 000000000..7f468b3f0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg @@ -0,0 +1 @@ +Mozilla Firefox icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json new file mode 100644 index 000000000..2b9cb71f9 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "msie.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg new file mode 100644 index 000000000..f5b362d7c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg @@ -0,0 +1 @@ +Internet Explorer icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json new file mode 100644 index 000000000..01b1e08b3 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "opera.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg new file mode 100644 index 000000000..dd57f924a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg @@ -0,0 +1 @@ +Opera icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json new file mode 100644 index 000000000..d8d4b878c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "safari.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg new file mode 100644 index 000000000..12abbb95f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg @@ -0,0 +1 @@ +safari icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json new file mode 100644 index 000000000..e5cf7bcf6 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "android.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg new file mode 100644 index 000000000..24edc8bbf --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json new file mode 100644 index 000000000..e551ee4ef --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "apple.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg new file mode 100644 index 000000000..4477a4525 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg @@ -0,0 +1 @@ +Apple diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json new file mode 100644 index 000000000..dc9dc7f86 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "finamp.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg new file mode 100644 index 000000000..8bd3a90ca --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg @@ -0,0 +1,7 @@ + + Finamp icon + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json new file mode 100644 index 000000000..dce2be9a0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "kodi.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg new file mode 100644 index 000000000..3618149b1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json new file mode 100644 index 000000000..1fc27e4ec --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "playstation.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg new file mode 100644 index 000000000..c6595340e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg @@ -0,0 +1 @@ +PlayStation icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json new file mode 100644 index 000000000..b71427a78 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "roku.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg new file mode 100644 index 000000000..eb1e621b5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg @@ -0,0 +1,7 @@ + + Roku icon + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json new file mode 100644 index 000000000..6c174e7ac --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "samsungtv.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg new file mode 100644 index 000000000..afdd19e24 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg @@ -0,0 +1 @@ +Samsung icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json new file mode 100644 index 000000000..6bb53f6a1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "windows.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg new file mode 100644 index 000000000..531e72e1d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg @@ -0,0 +1 @@ +Windows icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json new file mode 100644 index 000000000..5430fc8e2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "xbox.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg new file mode 100644 index 000000000..640dd34a5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg @@ -0,0 +1 @@ +Xbox icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json new file mode 100644 index 000000000..774270372 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "home-assistant.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg new file mode 100644 index 000000000..a34be98de --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg @@ -0,0 +1 @@ + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json new file mode 100644 index 000000000..79ef7e746 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "html5.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg new file mode 100644 index 000000000..63704799c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg @@ -0,0 +1 @@ +HTML5 icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json new file mode 100644 index 000000000..e7592de30 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "other.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg new file mode 100644 index 000000000..91e1d9e25 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg @@ -0,0 +1 @@ + diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift index 52bb1ca40..19ebf2983 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -10,8 +10,6 @@ import Defaults import JellyfinAPI import SwiftUI -// TODO: inactive session device image - extension ActiveSessionsView { struct ActiveSessionRow: View { @@ -24,7 +22,6 @@ extension ActiveSessionsView { private let onSelect: () -> Void - // parent list won't show row if value is nil anyways private var session: SessionInfo { box.value ?? .init() } @@ -38,26 +35,42 @@ extension ActiveSessionsView { private var rowLeading: some View { // TODO: better handling for different poster types Group { - if session.nowPlayingItem?.type == .audio { + switch session.nowPlayingItem { + case .none: ZStack { - Color.clear - - ImageView(session.nowPlayingItem?.squareImageSources(maxWidth: 60) ?? []) - .failure { - SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) - } - } - .squarePosterStyle() - } else { - ZStack { - Color.clear - - ImageView(session.nowPlayingItem?.portraitImageSources(maxWidth: 60) ?? []) - .failure { - SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) - } + DeviceType( + client: session.client, + deviceName: session.deviceName + ).clientColor + + Image(DeviceType(client: session.client, deviceName: session.deviceName).systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) } .posterStyle(.portrait) + default: + if session.nowPlayingItem?.type == .audio { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.squareImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .squarePosterStyle() + } else { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.portraitImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .posterStyle(.portrait) + } } } .frame(width: 60) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift new file mode 100644 index 000000000..a63637315 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift @@ -0,0 +1,117 @@ +// +// 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 DevicesView { + + struct DeviceRow: View { + + @CurrentDate + private var currentDate: Date + + @ObservedObject + private var box: BindingBox + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Device Mapping + + private var device: DeviceInfo { + box.value ?? .init() + } + + // MARK: - Initializer + + init( + box: BindingBox, + onSelect editAction: @escaping () -> Void, + onDelete deleteAction: @escaping () -> Void + ) { + self.box = box + self.onSelect = editAction + self.onDelete = deleteAction + } + + // MARK: - Body + + var body: some View { + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + deviceDetails + } + .onSelect(perform: onSelect) + .swipeActions { + Button { + onDelete() + } label: { + Label(L10n.delete, systemImage: "trash") + } + .tint(.red) + } + } + + // MARK: - Row Leading Image + + @ViewBuilder + private var rowLeading: some View { + ZStack { + DeviceType( + client: device.appName, + deviceName: device.name + ).clientColor + + Image(DeviceType( + client: device.appName, + deviceName: device.name + ).systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + } + .posterStyle(.portrait) + .frame(width: 60) + .posterShadow() + .padding(.vertical, 8) + } + + // MARK: - Row Device Details + + @ViewBuilder + private var deviceDetails: some View { + VStack(alignment: .leading) { + Text(device.name ?? L10n.unknown) + .font(.headline) + + Text(device.lastUserName ?? L10n.unknown) + + TextPairView( + leading: device.appName ?? L10n.unknown, + trailing: device.appVersion ?? .emptyDash + ) + + TextPairView( + L10n.lastSeen, + value: { + if let dateLastActivity = device.dateLastActivity { + Text(dateLastActivity, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + } else { + Text(L10n.never) + } + }() + ) + .id(currentDate) + .monospacedDigit() + } + .font(.subheadline) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift new file mode 100644 index 000000000..0fd5c72f7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift @@ -0,0 +1,203 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct DevicesView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @StateObject + private var viewModel = DevicesViewModel() + + @State + private var isPresentingDeleteAllConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingRenameAlert = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedDevice: DeviceInfo? + @State + private var temporaryDeviceName: String = "" + @State + private var deviceToDelete: String? + + // MARK: - Body + + var body: some View { + Group { + mainContentView + } + .navigationTitle(L10n.activeDevices) + .onFirstAppear { + viewModel.send(.getDevices) + } + .topBarTrailing { + topBarContent + } + .confirmationDialog( + L10n.deleteAllDevices, + isPresented: $isPresentingDeleteAllConfirmation, + titleVisibility: .visible + ) { + deleteAllDevicesConfirmationActions + } message: { + Text(L10n.deleteAllDevicesWarning) + } + .confirmationDialog( + L10n.deleteDevice, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteDeviceConfirmationActions + } message: { + Text(L10n.deleteDeviceWarning) + } + .alert(isPresented: $isPresentingSelfDeleteError) { + deletionFailureAlert + } + .alert(L10n.customDeviceName, isPresented: $isPresentingRenameAlert) { + customDeviceNameAlert + } message: { + Text(L10n.enterCustomDeviceName) + } + } + + // MARK: - Main Content View + + private var mainContentView: some View { + Group { + switch viewModel.state { + case .content: + if viewModel.devices.isEmpty { + Text(L10n.none) + } else { + deviceListView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.getDevices) + } + case .initial: + DelayedProgressView() + } + } + } + + // MARK: - Top Bar Content + + private var topBarContent: some View { + Group { + if viewModel.backgroundStates.contains(.gettingDevices) { + ProgressView() + } else { + Button(L10n.deleteAll, role: .destructive) { + isPresentingDeleteAllConfirmation = true + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.devices.isEmpty) + } + } + } + + // MARK: - Device List View + + private var deviceListView: some View { + List { + ListTitleSection( + L10n.devices, + description: L10n.allDevicesDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/server/devices")!) + } + ForEach(Array(viewModel.devices.keys), id: \.self) { id in + if let deviceBox = viewModel.devices[id] { + DeviceRow(box: deviceBox) { + selectedDevice = deviceBox.value + temporaryDeviceName = selectedDevice?.name ?? "" + isPresentingRenameAlert = true + } onDelete: { + deviceToDelete = deviceBox.value?.id + selectedDevice = deviceBox.value + isPresentingDeleteConfirmation = true + } + } + } + } + } + + // MARK: - Delete All Devices Confirmation Actions + + private var deleteAllDevicesConfirmationActions: some View { + Group { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.send(.deleteAllDevices) + } + } + } + + // MARK: - Delete Device Confirmation Actions + + private var deleteDeviceConfirmationActions: some View { + Group { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let deviceToDelete = deviceToDelete { + viewModel.send(.deleteDevice(id: deviceToDelete)) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if viewModel.devices[deviceToDelete] != nil { + isPresentingSelfDeleteError = true + } + } + } + } + } + } + + // MARK: - Rename Custom Device Name Alert + + private var customDeviceNameAlert: some View { + Group { + TextField(L10n.name, text: $temporaryDeviceName) + .keyboardType(.default) + + Button(L10n.save) { + if let deviceId = selectedDevice?.id { + viewModel.send(.setCustomName(id: deviceId, newName: temporaryDeviceName)) + } + isPresentingRenameAlert = false + } + + Button(L10n.cancel, role: .cancel) { + isPresentingRenameAlert = false + } + } + } + + // MARK: - Deletion Failure Alert + + private var deletionFailureAlert: Alert { + Alert( + title: Text(L10n.deleteDeviceFailed), + message: Text(L10n.deleteDeviceSelfDeletion(selectedDevice?.name ?? L10n.unknown)), + dismissButton: .default(Text(L10n.ok)) + ) + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift index bf3fea7f4..1777fbcbb 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift @@ -27,9 +27,11 @@ extension EditScheduledTaskView { ForEach(triggers, id: \.self) { trigger in TriggerRow(taskTriggerInfo: trigger) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(L10n.delete) { + Button { selectedTrigger = trigger isPresentingDeleteConfirmation = true + } label: { + Label(L10n.delete, systemImage: "trash") } .tint(.red) } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift index f939796b9..e807ff24a 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift @@ -39,6 +39,11 @@ struct UserDashboardView: View { .onSelect { router.route(to: \.tasks) } + + ChevronButton(L10n.allDevices) + .onSelect { + router.route(to: \.devices) + } } } .navigationTitle(L10n.dashboard) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 9b52b71a495e347fb76fb5f00f0a9c8bf815786b..f9e0e575aedb5d742c3f4824d400d0d4a5bdbb24 100644 GIT binary patch delta 2284 zcmb7G&r4KM6h5GlnU?aJB*wv~laLjL%Lpm4AGAo20x1{yni*|id}H$?D+LwTL3W^J zq`8nH2!fzRt!-5;qmBIoMXRV)-*@lKyf->l^3eO(*b?yr&dNCc7vv`PT^W$1OiEfi<_>A^?7_Wx>B4S5{<`$n zVyu5aGI(4$3+>zRk-)kmuhFA&LK@-KGoGsaEG;?ud$B!IGl(v&$8~n4;o+0|&i~5) zZ;`a~ZfB}0Hfr)DXE3mt1(@74lB7&mG>Dc5%v`hHHdYCPr;m4LmjaITkk#yJ+uU)y zr-60?H5Vb_`vmrGz=pnDNQ#Kg_`dP$VWo#D#L1hTYX7!-dFh4DRd^{HAGC5!UsA}{ zg$L86InyTlS%AYd(y&b{Lc;J2;=i zr5CYB%U-lv3!^O;GWevB9IMTtaj0jJK!AJ=O})Pw)Aq5ZVJoY!(=c*#j4n9^t~G*j zN8$CnDV#bvd|knM#~?cm2!OjTylXqj{@IdkZwX7*=!YfKB~O$k@Su14V=dAgWHTfw z9UhDD*TC#A>>B0-=7;{S- zDv?MoyjU`D`lR9A$7)YRuihGUdcyfvqvJ|VZ1#roAtt*FCi>-Vr#_x(in87-z=*qc z;ssTHnsP2~S7l%m#_u21?x|F(OEb~dnia-^3d?;6S1|m%ExrTp_tjuVFsEys1pT?# zK`usuJi_t&)|~DgoxXU;db`MaX*R0!uj=(i;St8h^XL||^;&%YIY2?TIi!jUML+xTxw4&$k!LSjd!TdLjmfG;lx>3Xbs3tR{~5UpsMt6TIbK9|K=TUA8@XpRd|QXOya+g5nWkqZsf)Ks qs1}T(omqAs5Fz`kqhb!*6m;o>h4BWwQL?j|N$i$?ckpT Date: Thu, 10 Oct 2024 22:34:33 -0600 Subject: [PATCH 08/10] Crop albums to fit. Max width 60 for all posters. --- .../ActiveSessionsView/Components/ActiveSessionRow.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift index 19ebf2983..ebd8e2168 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -60,6 +60,7 @@ extension ActiveSessionsView { } } .squarePosterStyle() + .frame(width: 60, height: 60) } else { ZStack { Color.clear @@ -70,10 +71,11 @@ extension ActiveSessionsView { } } .posterStyle(.portrait) + .frame(width: 60, height: 90) } } } - .frame(width: 60) + .frame(width: 60, height: 90) .posterShadow() .padding(.vertical, 8) } From 4007142a7a7410b91572459a4875cc3888c757ac Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 10 Oct 2024 23:12:51 -0600 Subject: [PATCH 09/10] Uncomment Out Delete All Devices. TODO to replace CustomName --- Shared/ViewModels/DevicesViewModel.swift | 3 +-- .../UserDashboardView/DevicesView/Components/DeviceRow.swift | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift index a9faf1d18..1cb9b16cc 100644 --- a/Shared/ViewModels/DevicesViewModel.swift +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -231,8 +231,7 @@ final class DevicesViewModel: ViewModel, Stateful { let deviceIdsToDelete = self.devices.keys.filter { $0 != userSession.client.configuration.deviceID } for deviceId in deviceIdsToDelete { - print("Deleting: \(deviceId)") - // try await deleteDevice(id: deviceId) + try await deleteDevice(id: deviceId) } await MainActor.run { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift index a63637315..b9b3361de 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift @@ -88,6 +88,7 @@ extension DevicesView { @ViewBuilder private var deviceDetails: some View { VStack(alignment: .leading) { + // TODO: Change t0 (CustomName ?? DeviceName) when available Text(device.name ?? L10n.unknown) .font(.headline) From 9519901172ff53bc02c9adbfbc26f5463a29fefb Mon Sep 17 00:00:00 2001 From: Joe Date: Sun, 13 Oct 2024 22:18:30 -0600 Subject: [PATCH 10/10] Better Device Deletion Handling by comparing against the user session BEFORE attempting to delete. Creation of an Admin Session Indicator for showing if there are active sessions from the HomeView. Fix Day of Week localization based on https://github.com/jellyfin/Swiftfin/pull/1255#discussion_r1797778181 - Confirmed working! Created an activeSessions published value for usage with the Active Sessions Indicator. Migration of the ChevronInputButton to allow more input types than just TextFields. Creation of a Users section. Grid vs Row User Section. Allow for User Password Resets. Make sure the DeviceType backgrounds never clash with the SVG. General cleanup of previous work mostly labels and formatting based on changes mentioned above. Finally, migrated ALL of the UserDashboard -> AdminDashboard (Since it's admin only now) and created it's own coordinator. Now available from the ActiveSessionsIndicator. --- .../AdminDashboardCoordinator.swift | 89 ++++++++++ .../MainCoordinator/iOSMainCoordinator.swift | 6 + Shared/Coordinators/SettingsCoordinator.swift | 62 +------ Shared/Extensions/JellyfinAPI/DayOfWeek.swift | 24 +-- Shared/Strings/Strings.swift | 4 + .../ViewModels/ActiveSessionsViewModel.swift | 17 ++ Shared/ViewModels/DevicesViewModel.swift | 32 ++-- .../UserAdministrationObserver.swift | 119 +++++++++++++ .../UserAdministrationViewModel.swift | 105 +++++++++++ .../Components/ChevronInputButton.swift | 151 ++++++++++++++++ .../Components/Sections/HomeSection.swift | 32 ++-- Swiftfin.xcodeproj/project.pbxproj | 92 +++++++++- .../Components/ActiveSessionIndicator.swift | 164 ++++++++++++++++++ Swiftfin/Components/ChevronInputButton.swift | 138 +++++++++++---- Swiftfin/Components/DeviceTypes.swift | 2 +- .../ActiveSessionDetailView.swift | 0 .../Components/StreamSection.swift | 0 .../Components/TranscodeSection.swift | 0 .../ActiveSessionsView.swift | 2 +- .../Components/ActiveSessionRow.swift | 0 .../Components/Sections/ProgressSection.swift | 0 .../AddTaskTriggerView.swift | 0 .../Sections/DayOfWeekSection.swift | 2 +- .../Components/Sections/IntervalSection.swift | 0 .../Sections/TimeLimitSection.swift | 66 +++++++ .../Components/Sections/TimeSection.swift | 0 .../Sections/TriggerTypeSection.swift | 0 .../AdminDashboardView.swift} | 17 +- .../DevicesView/Components/DeviceRow.swift | 2 +- .../DevicesView/DevicesView.swift | 24 ++- .../Sections/CurrentRunningSection.swift | 0 .../Components/Sections/DetailsSection.swift | 0 .../Sections/LastErrorSection.swift | 0 .../Components/Sections/LastRunSection.swift | 0 .../Components/Sections/TriggersSection.swift | 0 .../Components/TriggerRow.swift | 0 .../EditScheduledTaskView.swift | 2 +- .../Components/ScheduledTaskButton.swift | 2 +- .../Components/ServerTaskButton.swift | 0 .../ScheduledTasksView.swift | 0 .../ServerLogsView/ServerLogsView.swift | 6 + .../Components/UserFunctionButton.swift | 45 +++++ .../UserAdministrationDetailView.swift | 82 +++++++++ .../Components/UserAdministrationButton.swift | 68 ++++++++ .../Components/UserAdministrationRow.swift | 68 ++++++++ .../Components/UserProfileView.swift | 37 ++++ .../UserAdministrationView.swift | 130 ++++++++++++++ Swiftfin/Views/HomeView/HomeView.swift | 18 +- .../Components/Sections/HomeSection.swift | 37 ++-- .../SettingsView/SettingsView.swift | 2 +- .../Sections/TimeLimitSection.swift | 51 ------ Translations/en.lproj/Localizable.strings | Bin 54984 -> 55426 bytes 52 files changed, 1454 insertions(+), 244 deletions(-) create mode 100644 Shared/Coordinators/AdminDashboardCoordinator.swift create mode 100644 Shared/ViewModels/UserAdministrationObserver.swift create mode 100644 Shared/ViewModels/UserAdministrationViewModel.swift create mode 100644 Swiftfin tvOS/Components/ChevronInputButton.swift create mode 100644 Swiftfin/Components/ActiveSessionIndicator.swift rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionDetailView/ActiveSessionDetailView.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionDetailView/Components/StreamSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionDetailView/Components/TranscodeSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionsView/ActiveSessionsView.swift (97%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionsView/Components/ActiveSessionRow.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ActiveSessionsView/Components/Sections/ProgressSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/AddTaskTriggerView/AddTaskTriggerView.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift (93%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/AddTaskTriggerView/Components/Sections/IntervalSection.swift (100%) create mode 100644 Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/AddTaskTriggerView/Components/Sections/TimeSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView/UserDashboardView.swift => AdminDashboardView/AdminDashboardView.swift} (76%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/DevicesView/Components/DeviceRow.swift (98%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/DevicesView/DevicesView.swift (90%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/Sections/DetailsSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/Sections/LastErrorSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/Sections/LastRunSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/Sections/TriggersSection.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/Components/TriggerRow.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/EditScheduledTaskView/EditScheduledTaskView.swift (97%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ScheduledTasksView/Components/ScheduledTaskButton.swift (98%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ScheduledTasksView/Components/ServerTaskButton.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ScheduledTasksView/ScheduledTasksView.swift (100%) rename Swiftfin/Views/{SettingsView/UserDashboardView => AdminDashboardView}/ServerLogsView/ServerLogsView.swift (90%) create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift create mode 100644 Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift delete mode 100644 Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift new file mode 100644 index 000000000..bcf548879 --- /dev/null +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -0,0 +1,89 @@ +// +// 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 PulseUI +import Stinsen +import SwiftUI + +final class AdminDashboardCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \AdminDashboardCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var activeSessions = makeActiveSessions + @Route(.push) + var activeDeviceDetails = makeActiveDeviceDetails + @Route(.push) + var devices = makeDevices + @Route(.push) + var tasks = makeTasks + @Route(.push) + var users = makeUsers + @Route(.push) + var userDetails = makeUserDetails + @Route(.push) + var editScheduledTask = makeEditScheduledTask + @Route(.modal) + var addScheduledTaskTrigger = makeAddScheduledTaskTrigger + @Route(.push) + var serverLogs = makeServerLogs + + @ViewBuilder + func makeActiveSessions() -> some View { + ActiveSessionsView() + } + + @ViewBuilder + func makeActiveDeviceDetails(box: BindingBox) -> some View { + ActiveSessionDetailView(box: box) + } + + @ViewBuilder + func makeDevices() -> some View { + DevicesView() + } + + @ViewBuilder + func makeTasks() -> some View { + ScheduledTasksView() + } + + @ViewBuilder + func makeUsers() -> some View { + UserAdministrationView() + } + + @ViewBuilder + func makeUserDetails(observer: UserAdministrationObserver) -> some View { + UserAdministrationDetailView(observer: observer) + } + + @ViewBuilder + func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { + EditScheduledTaskView(observer: observer) + } + + func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddTaskTriggerView(observer: observer) + } + } + + @ViewBuilder + func makeServerLogs() -> some View { + ServerLogsView() + } + + @ViewBuilder + func makeStart() -> some View { + AdminDashboardView() + } +} diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 71c868c7f..a0a47e187 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -39,6 +39,8 @@ final class MainCoordinator: NavigationCoordinatable { var liveVideoPlayer = makeLiveVideoPlayer @Route(.modal) var settings = makeSettings + @Route(.modal) + var adminDashboard = makeAdminDashboard @Route(.fullScreen) var videoPlayer = makeVideoPlayer @@ -134,6 +136,10 @@ final class MainCoordinator: NavigationCoordinatable { NavigationViewCoordinator(SettingsCoordinator()) } + func makeAdminDashboard() -> NavigationViewCoordinator { + NavigationViewCoordinator(AdminDashboardCoordinator()) + } + func makeMainTab() -> MainTabCoordinator { MainTabCoordinator() } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index b3aca2dd2..44d27105c 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -34,7 +34,6 @@ final class SettingsCoordinator: NavigationCoordinatable { var photoPicker = makePhotoPicker @Route(.push) var userProfile = makeUserProfileSettings - @Route(.push) var customizeViewsSettings = makeCustomizeViewsSettings @Route(.push) @@ -45,30 +44,14 @@ final class SettingsCoordinator: NavigationCoordinatable { var indicatorSettings = makeIndicatorSettings @Route(.push) var serverConnection = makeServerConnection + @Route(.modal) + var adminDashboard = makeAdminDashboard @Route(.push) var videoPlayerSettings = makeVideoPlayerSettings @Route(.push) var customDeviceProfileSettings = makeCustomDeviceProfileSettings - - @Route(.push) - var userDashboard = makeUserDashboard - @Route(.push) - var activeSessions = makeActiveSessions - @Route(.push) - var activeDeviceDetails = makeActiveDeviceDetails @Route(.modal) var itemOverviewView = makeItemOverviewView - @Route(.push) - var devices = makeDevices - @Route(.push) - var tasks = makeTasks - @Route(.push) - var editScheduledTask = makeEditScheduledTask - @Route(.modal) - var addScheduledTaskTrigger = makeAddScheduledTaskTrigger - @Route(.push) - var serverLogs = makeServerLogs - @Route(.modal) var editCustomDeviceProfile = makeEditCustomDeviceProfile @Route(.modal) @@ -166,19 +149,8 @@ final class SettingsCoordinator: NavigationCoordinatable { EditServerView(server: server) } - @ViewBuilder - func makeUserDashboard() -> some View { - UserDashboardView() - } - - @ViewBuilder - func makeActiveSessions() -> some View { - ActiveSessionsView() - } - - @ViewBuilder - func makeActiveDeviceDetails(box: BindingBox) -> some View { - ActiveSessionDetailView(box: box) + func makeAdminDashboard() -> NavigationViewCoordinator { + NavigationViewCoordinator(AdminDashboardCoordinator()) } func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { @@ -187,32 +159,6 @@ final class SettingsCoordinator: NavigationCoordinatable { } } - @ViewBuilder - func makeTasks() -> some View { - ScheduledTasksView() - } - - @ViewBuilder - func makeDevices() -> some View { - DevicesView() - } - - @ViewBuilder - func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { - EditScheduledTaskView(observer: observer) - } - - func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator { - NavigationViewCoordinator { - AddTaskTriggerView(observer: observer) - } - } - - @ViewBuilder - func makeServerLogs() -> some View { - ServerLogsView() - } - func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) .navigationTitle(L10n.filters) diff --git a/Shared/Extensions/JellyfinAPI/DayOfWeek.swift b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift index c7ac6eab9..92f5fffc3 100644 --- a/Shared/Extensions/JellyfinAPI/DayOfWeek.swift +++ b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift @@ -11,22 +11,14 @@ import JellyfinAPI extension DayOfWeek { - var displayTitle: String { - switch self { - case .sunday: - return L10n.dayOfWeekSunday - case .monday: - return L10n.dayOfWeekMonday - case .tuesday: - return L10n.dayOfWeekTuesday - case .wednesday: - return L10n.dayOfWeekWednesday - case .thursday: - return L10n.dayOfWeekThursday - case .friday: - return L10n.dayOfWeekFriday - case .saturday: - return L10n.dayOfWeekSaturday + var displayTitle: String? { + let newLineRemoved = self.rawValue.replacingOccurrences(of: "\n", with: "") + + guard let index = DateFormatter().weekdaySymbols.firstIndex(of: newLineRemoved) else { + return nil } + + let localCal = Calendar.current + return localCal.weekdaySymbols[index].localizedCapitalized } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 6c880d2fa..68b4d1ed8 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -478,6 +478,8 @@ internal enum L10n { } /// Logs internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") + /// Access the Jellyfin server logs for troubleshooting and monitoring purposes. + internal static let logsDescription = L10n.tr("Localizable", "logsDescription", fallback: "Access the Jellyfin server logs for troubleshooting and monitoring purposes.") /// Manual internal static let manual = L10n.tr("Localizable", "manual", fallback: "Manual") /// Maximum Bitrate @@ -986,6 +988,8 @@ internal enum L10n { } /// Username internal static let username = L10n.tr("Localizable", "username", fallback: "Username") + /// Users + internal static let users = L10n.tr("Localizable", "users", fallback: "Users") /// Version internal static let version = L10n.tr("Localizable", "version", fallback: "Version") /// Video diff --git a/Shared/ViewModels/ActiveSessionsViewModel.swift b/Shared/ViewModels/ActiveSessionsViewModel.swift index d2dae1244..0f7bc01bd 100644 --- a/Shared/ViewModels/ActiveSessionsViewModel.swift +++ b/Shared/ViewModels/ActiveSessionsViewModel.swift @@ -40,6 +40,8 @@ final class ActiveSessionsViewModel: ViewModel, Stateful { @Published final var sessions: OrderedDictionary> = [:] @Published + final var activeSessions: OrderedDictionary> = [:] + @Published final var state: State = .initial private let activeWithinSeconds: Int = 960 @@ -167,6 +169,21 @@ final class ActiveSessionsViewModel: ViewModel, Stateful { return (xs?.lastActivityDate ?? Date.now) > (ys?.lastActivityDate ?? Date.now) } } + + activeSessions = sessions.filter { _, session in + session.value?.nowPlayingItem != nil + } + + activeSessions.sort { x, y in + let xs = x.value.value + let ys = y.value.value + + if xs?.userName != ys?.userName { + return (xs?.userName ?? "") < (ys?.userName ?? "") + } else { + return (xs?.nowPlayingItem?.name ?? "") < (ys?.nowPlayingItem?.name ?? "") + } + } } } } diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift index 1cb9b16cc..feba2f6d5 100644 --- a/Shared/ViewModels/DevicesViewModel.swift +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -56,11 +56,9 @@ final class DevicesViewModel: ViewModel, Stateful { case .getDevices: deviceTask?.cancel() - deviceTask = Task { [weak self] in - await MainActor.run { - let _ = self?.backgroundStates.append(.gettingDevices) - } + backgroundStates.append(.gettingDevices) + deviceTask = Task { [weak self] in do { try await self?.loadDevices() await MainActor.run { @@ -74,7 +72,7 @@ final class DevicesViewModel: ViewModel, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.gettingDevices) + self?.backgroundStates.remove(.gettingDevices) } } .asAnyCancellable() @@ -84,11 +82,9 @@ final class DevicesViewModel: ViewModel, Stateful { case let .setCustomName(id, newName): deviceTask?.cancel() - deviceTask = Task { [weak self] in - await MainActor.run { - let _ = self?.backgroundStates.append(.settingCustomName) - } + backgroundStates.append(.settingCustomName) + deviceTask = Task { [weak self] in do { try await self?.setCustomName(id: id, newName: newName) await MainActor.run { @@ -102,7 +98,7 @@ final class DevicesViewModel: ViewModel, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.settingCustomName) + self?.backgroundStates.remove(.settingCustomName) } } .asAnyCancellable() @@ -112,11 +108,9 @@ final class DevicesViewModel: ViewModel, Stateful { case let .deleteDevice(id): deviceTask?.cancel() - deviceTask = Task { [weak self] in - await MainActor.run { - let _ = self?.backgroundStates.append(.deletingDevice) - } + backgroundStates.append(.deletingDevice) + deviceTask = Task { [weak self] in do { try await self?.deleteDevice(id: id) await MainActor.run { @@ -130,7 +124,7 @@ final class DevicesViewModel: ViewModel, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.deletingDevice) + self?.backgroundStates.remove(.deletingDevice) } } .asAnyCancellable() @@ -140,11 +134,9 @@ final class DevicesViewModel: ViewModel, Stateful { case .deleteAllDevices: deviceTask?.cancel() - deviceTask = Task { [weak self] in - await MainActor.run { - let _ = self?.backgroundStates.append(.deletingAllDevices) - } + backgroundStates.append(.deletingAllDevices) + deviceTask = Task { [weak self] in do { try await self?.deleteAllDevices() await MainActor.run { @@ -158,7 +150,7 @@ final class DevicesViewModel: ViewModel, Stateful { } await MainActor.run { - let _ = self?.backgroundStates.remove(.deletingAllDevices) + self?.backgroundStates.remove(.deletingAllDevices) } } .asAnyCancellable() diff --git a/Shared/ViewModels/UserAdministrationObserver.swift b/Shared/ViewModels/UserAdministrationObserver.swift new file mode 100644 index 000000000..112336bce --- /dev/null +++ b/Shared/ViewModels/UserAdministrationObserver.swift @@ -0,0 +1,119 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI + +final class UserAdministrationObserver: ViewModel, Stateful, Identifiable { + + enum Action: Equatable { + case resetPassword + case updatePassword(currentPassword: String?, newPassword: String) + case stopObserving + } + + enum State: Hashable { + case error(JellyfinAPIError) + case initial + case updating + case running + } + + @Published + final var state: State = .initial + @Published + private(set) var user: UserDto + + private var progressCancellable: AnyCancellable? + private var cancelCancellable: AnyCancellable? + + var id: String? { user.id } + + init(user: UserDto) { + self.user = user + } + + func respond(to action: Action) -> State { + switch action { + case .resetPassword: + if case .running = state { + return state + } + + progressCancellable = Task { + do { + try await resetPassword() + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + + case let .updatePassword(currentPassword, newPassword): + if case .running = state { + return state + } + + progressCancellable = Task { + do { + try await updatePassword( + currentPw: currentPassword, + newPw: newPassword + ) + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + + case .stopObserving: + progressCancellable?.cancel() + cancelCancellable?.cancel() + + return .initial + } + } + + // MARK: - Reset Password + + private func resetPassword() async throws { + guard let userId = user.id else { return } + let parameters = UpdateUserPassword(isResetPassword: true) + let updateRequest = Paths.updateUserPassword(userID: userId, parameters) + try await userSession.client.send(updateRequest) + } + + // MARK: - Update Password + + private func updatePassword(currentPw: String? = nil, newPw: String) async throws { + guard let userId = user.id else { return } + let parameters = UpdateUserPassword( + currentPw: currentPw, + newPw: newPw + ) + let updateRequest = Paths.updateUserPassword(userID: userId, parameters) + try await userSession.client.send(updateRequest) + } +} diff --git a/Shared/ViewModels/UserAdministrationViewModel.swift b/Shared/ViewModels/UserAdministrationViewModel.swift new file mode 100644 index 000000000..d9fafc6c5 --- /dev/null +++ b/Shared/ViewModels/UserAdministrationViewModel.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 Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class UserAdministrationViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case getUsers + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingUsers + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var users: OrderedDictionary> = [:] + + @Published + final var state: State = .initial + + private var userTask: AnyCancellable? + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .getUsers: + userTask?.cancel() + + backgroundStates.append(.gettingUsers) + + userTask = Task { [weak self] in + do { + try await self?.loadUsers() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.gettingUsers) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Users + + private func loadUsers() async throws { + let request = Paths.getUsers() + let response = try await userSession.client.send(request) + + await MainActor.run { + for user in response.value { + guard let id = user.id else { continue } + + if let existingUser = self.users[id] { + existingUser.value = user + } else { + self.users[id] = BindingBox( + source: .init(get: { user }, set: { _ in }) + ) + } + } + + self.users.sort { x, y in + let user0 = x.value.value + let user1 = y.value.value + return (user0?.name ?? "") < (user1?.name ?? "") + } + } + } +} diff --git a/Swiftfin tvOS/Components/ChevronInputButton.swift b/Swiftfin tvOS/Components/ChevronInputButton.swift new file mode 100644 index 000000000..c5bd7ee10 --- /dev/null +++ b/Swiftfin tvOS/Components/ChevronInputButton.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 + +struct ChevronInputButton: View where Content: View { + + @State + private var isSelected = false + + private let title: Text + private let subtitle: Text? + private var leadingView: () -> any View + private let menu: () -> Content + private let onSave: (() -> Void)? + private let onCancel: (() -> Void)? + private let description: String? + + // MARK: - Initializer: String Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: String Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Leading View Customization + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + var copy = self + copy.leadingView = content + return copy + } + + // MARK: - Body + + var body: some View { + Button { + isSelected = true + } label: { + HStack { + leadingView() + .eraseToAnyView() + + title + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + subtitle + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .alert(title, isPresented: $isSelected) { + menu() + + Button(L10n.save) { + onSave?() + isSelected = false + } + Button(L10n.cancel, role: .cancel) { + onCancel?() + isSelected = false + } + } message: { + if let description = description { + Text(description) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift index e7d76ee78..a56e62050 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -20,7 +20,7 @@ extension CustomizeViewsSettings { private var resumeNextUp @State - private var isPresentingNextUpDays = false + var tempNextUp: TimeInterval? var body: some View { Section(L10n.home) { @@ -29,9 +29,9 @@ extension CustomizeViewsSettings { Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) - ChevronButton( - L10n.nextUpDays, - subtitle: { + ChevronInputButton( + title: L10n.nextUpDays, + subtitleText: { if maxNextUp > 0 { return Text( Date.now.addingTimeInterval(-maxNextUp) ..< Date.now, @@ -40,23 +40,21 @@ extension CustomizeViewsSettings { } else { return Text(L10n.disabled) } - }() - ) - .onSelect { - isPresentingNextUpDays = true - } - .alert(L10n.nextUpDays, isPresented: $isPresentingNextUpDays) { - - // TODO: Validate whether this says Done or a Number + }(), + description: L10n.nextUpDaysDescription + ) { TextField( L10n.nextUpDays, - value: $maxNextUp, - format: .dayInterval(range: 0 ... 1000) + value: $tempNextUp, + format: .number ) .keyboardType(.numberPad) - - } message: { - L10n.nextUpDaysDescription.text + } onSave: { + if let tempNextUp = tempNextUp { + maxNextUp = tempNextUp + } + } onCancel: { + tempNextUp = maxNextUp } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 2d333fd14..cd1816f09 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -20,6 +20,17 @@ 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; + 4E24D5C12CBC44F4000FD1C0 /* AdminDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */; }; + 4E24D5C52CBC8742000FD1C0 /* ChevronInputButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */; }; + 4E24D5CC2CBC9846000FD1C0 /* UserAdministrationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */; }; + 4E24D5D12CBC9B47000FD1C0 /* UserAdministrationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */; }; + 4E24D5D32CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */; }; + 4E24D5D42CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */; }; + 4E24D5DE2CBCBED7000FD1C0 /* UserAdministrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */; }; + 4E24D5DF2CBCBED7000FD1C0 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */; }; + 4E24D5E02CBCBED7000FD1C0 /* UserAdministrationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */; }; + 4E24D5E12CBCBED7000FD1C0 /* UserAdministrationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */; }; + 4E24D5E32CBCC9E3000FD1C0 /* UserFunctionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */; }; 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; }; @@ -34,12 +45,13 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; }; 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; + 4E3A2BE72CBC1E7B00B241A7 /* ActiveSessionIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */; }; 4E59E92A2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; 4E59E92B2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; 4E59E92F2CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; 4E59E9302CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; - 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; + 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; 4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */; }; 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; }; @@ -1047,6 +1059,16 @@ 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; + 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDashboardCoordinator.swift; sourceTree = ""; }; + 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronInputButton.swift; sourceTree = ""; }; + 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationDetailView.swift; sourceTree = ""; }; + 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationObserver.swift; sourceTree = ""; }; + 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationViewModel.swift; sourceTree = ""; }; + 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationButton.swift; sourceTree = ""; }; + 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationRow.swift; sourceTree = ""; }; + 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationView.swift; sourceTree = ""; }; + 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFunctionButton.swift; sourceTree = ""; }; 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = ""; }; 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = ""; }; @@ -1056,10 +1078,11 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionIndicator.swift; sourceTree = ""; }; 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskInfo.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; - 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = ""; }; + 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; 4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; @@ -1880,6 +1903,42 @@ path = Components; sourceTree = ""; }; + 4E24D5C92CBC9846000FD1C0 /* Components */ = { + isa = PBXGroup; + children = ( + 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E24D5CB2CBC9846000FD1C0 /* UserAdministrationDetailView */ = { + isa = PBXGroup; + children = ( + 4E24D5C92CBC9846000FD1C0 /* Components */, + 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */, + ); + path = UserAdministrationDetailView; + sourceTree = ""; + }; + 4E24D5DB2CBCBED7000FD1C0 /* Components */ = { + isa = PBXGroup; + children = ( + 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */, + 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */, + 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E24D5DD2CBCBED7000FD1C0 /* UserAdministrationView */ = { + isa = PBXGroup; + children = ( + 4E24D5DB2CBCBED7000FD1C0 /* Components */, + 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */, + ); + path = UserAdministrationView; + sourceTree = ""; + }; 4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = { isa = PBXGroup; children = ( @@ -1900,19 +1959,21 @@ path = PlaybackBitrate; sourceTree = ""; }; - 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = { + 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */ = { isa = PBXGroup; children = ( 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */, + 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */, 4E872AC22CB861F5008C17BC /* DevicesView */, 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, 4E872AC72CB86381008C17BC /* ServerLogsView */, - 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, + 4E24D5CB2CBC9846000FD1C0 /* UserAdministrationDetailView */, + 4E24D5DD2CBCBED7000FD1C0 /* UserAdministrationView */, ); - path = UserDashboardView; + path = AdminDashboardView; sourceTree = ""; }; 4E699BB52CB33F4B007CBD5D /* CustomizeViewsSettings */ = { @@ -2176,6 +2237,8 @@ E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */, E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */, + 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, @@ -2319,6 +2382,7 @@ isa = PBXGroup; children = ( E12E30F229638B140022FAC9 /* ChevronButton.swift */, + 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */, E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */, E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */, @@ -2553,6 +2617,7 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */, E1D8429429346C6400D1041A /* BasicStepper.swift */, E1A1528728FD229500600579 /* ChevronButton.swift */, 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */, @@ -2646,6 +2711,7 @@ 62C29E9D26D0FE5900C1D2E7 /* Coordinators */ = { isa = PBXGroup; children = ( + 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */, E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */, E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */, 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */, @@ -3129,6 +3195,7 @@ isa = PBXGroup; children = ( E18E01F3288747580022598C /* AboutAppView.swift */, + 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */, E164A7F12BE471E700A54B18 /* AppSettingsView */, @@ -3976,7 +4043,6 @@ E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */, 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */, E1BE1CEB2BDB68BC008176A9 /* SettingsView */, - 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */, E1545BD62BDC559500D9578F /* UserProfileSettingsView */, E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */, ); @@ -4547,6 +4613,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */, + 4E24D5C52CBC8742000FD1C0 /* ChevronInputButton.swift in Sources */, E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, @@ -4596,6 +4663,7 @@ E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */, E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, + 4E24D5D42CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, @@ -4782,6 +4850,7 @@ E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */, E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */, C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */, + 4E24D5D12CBC9B47000FD1C0 /* UserAdministrationObserver.swift in Sources */, 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, @@ -4789,6 +4858,7 @@ 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, + 4E24D5D32CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */, E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, @@ -4814,6 +4884,7 @@ E1F5CF052CB09EA000607465 /* CurrentDate.swift in Sources */, E13316FE2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */, + 4E24D5CC2CBC9846000FD1C0 /* UserAdministrationDetailView.swift in Sources */, E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, @@ -4892,6 +4963,7 @@ E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, + 4E24D5DE2CBCBED7000FD1C0 /* UserAdministrationView.swift in Sources */, 4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */, E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, @@ -4975,6 +5047,7 @@ E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */, 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */, + 4E3A2BE72CBC1E7B00B241A7 /* ActiveSessionIndicator.swift in Sources */, 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, @@ -5021,6 +5094,7 @@ E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */, E178B0762BE435D70023651B /* HourMinutePicker.swift in Sources */, + 4E24D5DF2CBCBED7000FD1C0 /* UserProfileView.swift in Sources */, E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, 6267B3D626710B8900A7371D /* Collection.swift in Sources */, E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, @@ -5216,6 +5290,7 @@ E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, + 4E24D5E12CBCBED7000FD1C0 /* UserAdministrationButton.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */, E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, @@ -5223,6 +5298,7 @@ 4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */, E18E01E6288747230022598C /* CollectionItemView.swift in Sources */, E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */, + 4E24D5C12CBC44F4000FD1C0 /* AdminDashboardCoordinator.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, @@ -5236,6 +5312,7 @@ BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */, E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, + 4E24D5E32CBCC9E3000FD1C0 /* UserFunctionButton.swift in Sources */, E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, @@ -5258,7 +5335,7 @@ E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, - 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */, + 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, @@ -5266,6 +5343,7 @@ DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, 4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */, + 4E24D5E02CBCBED7000FD1C0 /* UserAdministrationRow.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */, diff --git a/Swiftfin/Components/ActiveSessionIndicator.swift b/Swiftfin/Components/ActiveSessionIndicator.swift new file mode 100644 index 000000000..36063f209 --- /dev/null +++ b/Swiftfin/Components/ActiveSessionIndicator.swift @@ -0,0 +1,164 @@ +// +// 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 + +// TODO: When selected, this crams together in a weird way + +struct ActiveSessionIndicator: View { + + @ObservedObject + var viewModel = ActiveSessionsViewModel() + + let action: () -> Void + + // MARK: - View Model Update Timer + + private let timer = Timer.publish(every: 60, on: .main, in: .common) + .autoconnect() + + // MARK: - Spinner States + + @State + private var isSpinning = false + @State + private var showSpinner = false + + // MARK: - Session States + + var activeSessions: Bool { + !viewModel.activeSessions.isEmpty + } + + var activeSessionsCount: Int { + viewModel.activeSessions.count + } + + // MARK: - Initializer + + init(action: @escaping () -> Void) { + self.action = action + self.viewModel.send(.getSessions) + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + contentView + .onReceive(timer) { _ in + viewModel.send(.getSessions) + } + } + } + + // MARK: - Content View + + var contentView: some View { + switch viewModel.state { + case .content, .initial: + AnyView(sessionsView) + default: + AnyView(errorView) + } + } + + // MARK: - Sessions View + + var sessionsView: some View { + HStack(alignment: .bottom) { + if activeSessions { + counterView + .offset(x: 5) + } + ZStack { + imageView + loadingSpinner + } + .onChange(of: viewModel.backgroundStates) { newState in + if newState.contains(.gettingSessions) { + showSpinner = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + if !viewModel.backgroundStates.contains(.gettingSessions) { + showSpinner = false + } + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showSpinner = false + } + } + } + } + } + + // MARK: - Image View + + var imageView: some View { + Image(systemName: "waveform.path.ecg") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + // TODO: Should this be a foregroundStyle? If so, which one? Potential + // issue if the AccentColor is too Light/Dark clashing with .primary + .foregroundColor(.primary) + .background( + Circle() + .fill(activeSessions ? Color.accentColor : .secondary) + ) + } + + // MARK: - Error View + + var errorView: some View { + Image(systemName: "exclamationmark.triangle") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + // TODO: Should this be a foregroundStyle? If so, which one? Potential + // issue if the AccentColor is too Light/Dark clashing with .primary + .foregroundColor(.primary) + .background( + Circle() + .fill(activeSessions ? Color.accentColor : .secondary) + ) + } + + // MARK: - Loading Spinner View + + var loadingSpinner: some View { + Circle() + .trim(from: 0.25, to: 0.75) + .stroke(showSpinner ? Color.accentColor : Color.clear, lineWidth: 3) + .frame(width: 35, height: 35) + .rotationEffect( + Angle(degrees: isSpinning ? 360 : 0) + ) + .animation( + .linear(duration: 1.5) + .repeatForever(autoreverses: false), + value: isSpinning + ) + .onAppear { + isSpinning = true + } + .onDisappear { + isSpinning = false + } + } + + // MARK: - Counter View + + var counterView: some View { + Text("\(activeSessionsCount)") + .padding(0) + .foregroundStyle(activeSessions ? Color.accentColor : .secondary) + } +} diff --git a/Swiftfin/Components/ChevronInputButton.swift b/Swiftfin/Components/ChevronInputButton.swift index 18494b5fd..c5bd7ee10 100644 --- a/Swiftfin/Components/ChevronInputButton.swift +++ b/Swiftfin/Components/ChevronInputButton.swift @@ -6,64 +6,140 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import Foundation import SwiftUI -struct ChevronInputButton: View where Value: LosslessStringConvertible & Equatable { +struct ChevronInputButton: View where Content: View { - @Binding - private var value: Value - @State - private var temporaryInputValue: String @State private var isSelected = false - private let title: String + private let title: Text + private let subtitle: Text? + private var leadingView: () -> any View + private let menu: () -> Content + private let onSave: (() -> Void)? + private let onCancel: (() -> Void)? private let description: String? - private let subtitle: String - private let helpText: String? - private let keyboardType: UIKeyboardType + + // MARK: - Initializer: String Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: String Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions init( title: String, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, subtitle: String, description: String? = nil, - helpText: String? = nil, - value: Binding, - keyboard: UIKeyboardType = .default + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil ) { - self.title = title - self.subtitle = subtitle + self.title = titleText + self.subtitle = Text(subtitle) self.description = description - self.helpText = helpText - self._value = value - self._temporaryInputValue = State(initialValue: value.wrappedValue.description) - self.keyboardType = keyboard + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Leading View Customization + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + var copy = self + copy.leadingView = content + return copy } // MARK: - Body - // TODO: Likely want to redo this but better. Needed in var body: some View { - ChevronButton( - title, - subtitle: subtitle - ) - .onSelect { - temporaryInputValue = value.description + Button { isSelected = true + } label: { + HStack { + leadingView() + .eraseToAnyView() + + title + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + subtitle + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } } .alert(title, isPresented: $isSelected) { - TextField(helpText ?? title, text: $temporaryInputValue) - .keyboardType(keyboardType) + menu() Button(L10n.save) { - if let newValue = Value(temporaryInputValue) { - value = newValue - } + onSave?() isSelected = false } Button(L10n.cancel, role: .cancel) { + onCancel?() isSelected = false } } message: { diff --git a/Swiftfin/Components/DeviceTypes.swift b/Swiftfin/Components/DeviceTypes.swift index a71944016..8dbd6da7d 100644 --- a/Swiftfin/Components/DeviceTypes.swift +++ b/Swiftfin/Components/DeviceTypes.swift @@ -125,7 +125,7 @@ enum DeviceType: String, Displayable, SystemImageable, Codable, CaseIterable { case .opera: return Color(red: 1.0, green: 0.0, blue: 0.0) // Opera Red default: - return Color.systemBackground + return Color.black } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/StreamSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/StreamSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift similarity index 97% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift index 49507b85b..9c7e21eed 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift @@ -16,7 +16,7 @@ import SwiftUI struct ActiveSessionsView: View { @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router @StateObject private var viewModel = ActiveSessionsViewModel() diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift rename to Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift similarity index 93% rename from Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift rename to Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift index 519585545..deb153e6f 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift @@ -28,7 +28,7 @@ extension AddTaskTriggerView { ) ) { ForEach(DayOfWeek.allCases, id: \.self) { day in - Text(day.displayTitle).tag(day) + Text(day.displayTitle ?? L10n.unknown).tag(day) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift rename to Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift new file mode 100644 index 000000000..b122895eb --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift @@ -0,0 +1,66 @@ +// +// 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 AddTaskTriggerView { + + struct TimeLimitSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + @State + var tempTimeLimit: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempTimeLimit = State(initialValue: taskTriggerInfo.wrappedValue.maxRuntimeTicks) + } + + // MARK: - Body + + var body: some View { + Section { + ChevronInputButton( + title: L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit + ) { + TextField( + L10n.timeLimit, + value: $tempTimeLimit, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempTimeLimit != nil && tempTimeLimit != 0 { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + } onCancel: { + tempTimeLimit = taskTriggerInfo.maxRuntimeTicks + } + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(ticks: maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.disabled + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift rename to Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift rename to Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift similarity index 76% rename from Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift rename to Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift index e807ff24a..825e24a31 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift @@ -8,10 +8,10 @@ import SwiftUI -struct UserDashboardView: View { +struct AdminDashboardView: View { @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router // MARK: - Body @@ -30,6 +30,11 @@ struct UserDashboardView: View { Section(L10n.advanced) { + ChevronButton(L10n.allDevices) + .onSelect { + router.route(to: \.devices) + } + ChevronButton(L10n.logs) .onSelect { router.route(to: \.serverLogs) @@ -40,12 +45,16 @@ struct UserDashboardView: View { router.route(to: \.tasks) } - ChevronButton(L10n.allDevices) + ChevronButton(L10n.users) .onSelect { - router.route(to: \.devices) + router.route(to: \.users) } } } .navigationTitle(L10n.dashboard) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift similarity index 98% rename from Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift rename to Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift index b9b3361de..5e9bb7f72 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift @@ -88,7 +88,7 @@ extension DevicesView { @ViewBuilder private var deviceDetails: some View { VStack(alignment: .leading) { - // TODO: Change t0 (CustomName ?? DeviceName) when available + // TODO: Change to (CustomName ?? DeviceName) when available Text(device.name ?? L10n.unknown) .font(.headline) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift similarity index 90% rename from Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift rename to Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift index 0fd5c72f7..a0044a74a 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift @@ -37,14 +37,14 @@ struct DevicesView: View { var body: some View { Group { - mainContentView + contentView } - .navigationTitle(L10n.activeDevices) + .navigationTitle(L10n.allDevices) .onFirstAppear { viewModel.send(.getDevices) } .topBarTrailing { - topBarContent + navigationBarView } .confirmationDialog( L10n.deleteAllDevices, @@ -74,9 +74,9 @@ struct DevicesView: View { } } - // MARK: - Main Content View + // MARK: - Content View - private var mainContentView: some View { + private var contentView: some View { Group { switch viewModel.state { case .content: @@ -96,9 +96,9 @@ struct DevicesView: View { } } - // MARK: - Top Bar Content + // MARK: - Navigation Bar Content - private var topBarContent: some View { + private var navigationBarView: some View { Group { if viewModel.backgroundStates.contains(.gettingDevices) { ProgressView() @@ -159,12 +159,10 @@ struct DevicesView: View { Button(L10n.delete, role: .destructive) { if let deviceToDelete = deviceToDelete { - viewModel.send(.deleteDevice(id: deviceToDelete)) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if viewModel.devices[deviceToDelete] != nil { - isPresentingSelfDeleteError = true - } + if deviceToDelete == viewModel.userSession.client.configuration.deviceID { + isPresentingSelfDeleteError = true + } else { + viewModel.send(.deleteDevice(id: deviceToDelete)) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/TriggerRow.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/Components/TriggerRow.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/TriggerRow.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift similarity index 97% rename from Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift rename to Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift index 2d315064c..a3246a5af 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -16,7 +16,7 @@ import SwiftUI struct EditScheduledTaskView: View { @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router @ObservedObject var observer: ServerTaskObserver diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift similarity index 98% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift index 4c5f92b4f..de33816a6 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift +++ b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift @@ -18,7 +18,7 @@ extension ScheduledTasksView { private var currentDate: Date @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router @ObservedObject var observer: ServerTaskObserver diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/ScheduledTasksView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/ScheduledTasksView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift similarity index 90% rename from Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift rename to Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift index 376ecc25a..180a72cb0 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift @@ -24,6 +24,12 @@ struct ServerLogsView: View { @ViewBuilder private var contentView: some View { List { + ListTitleSection( + L10n.logs, + description: L10n.logsDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/administration/troubleshooting")!) + } ForEach(viewModel.logs, id: \.self) { log in Button { let request = Paths.getLogFile(name: log.name!) diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift new file mode 100644 index 000000000..6a7600032 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift @@ -0,0 +1,45 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension UserAdministrationDetailView { + + struct UserFunctionButton: View { + + let title: String + let systemImage: String + let warningMessage: String + let isPresented: Binding + let isDestructive: Bool + let action: () -> Void + + // MARK: - Body + + var body: some View { + Button(role: isDestructive ? .destructive : .none) { + isPresented.wrappedValue = true + } label: { + Text(title) + } + .buttonStyle(.bordered) + .padding() + .confirmationDialog( + title, + isPresented: isPresented, + titleVisibility: .hidden + ) { + Button(title, role: .destructive, action: action) + } message: { + Text(warningMessage) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift new file mode 100644 index 000000000..2b8d6b4fa --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift @@ -0,0 +1,82 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct UserAdministrationDetailView: View { + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + @State + var isPasswordResetPresenting: Bool = false + @State + var isPasswordUpdatePresenting: Bool = false + @State + var tempPassword: String = "" + @State + var tempNewPassword: String = "" + @State + var tempPasswordConfirm: String = "" + + var body: some View { + VStack { + Text(observer.user.name ?? "") + .font(.title) + .padding() + + // Current Password Input + TextField("Current Password", text: $tempPassword) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + // New Password Input + TextField("New Password", text: $tempNewPassword) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + // Confirm Password Input + TextField("Confirm Password", text: $tempPasswordConfirm) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + HStack { + UserFunctionButton( + title: "Reset Password", + systemImage: "lock.rotation", + warningMessage: "Are you sure you want to reset \(observer.user.name ?? L10n.unknown)'s password?", + isPresented: $isPasswordResetPresenting, + isDestructive: true + ) { + observer.send(.resetPassword) + } + Spacer() + UserFunctionButton( + title: "Save Password", + systemImage: "lock.circle.dotted", + warningMessage: "Are you sure you want to update \(observer.user.name ?? L10n.unknown)'s password?", + isPresented: $isPasswordUpdatePresenting, + isDestructive: false + ) { + if tempNewPassword == tempPasswordConfirm { + observer.send(.updatePassword(currentPassword: tempPassword, newPassword: tempNewPassword)) + } else { + print("Passwords do not match") + } + } + } + } + .navigationTitle(L10n.user) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift new file mode 100644 index 000000000..79e9b87d6 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift @@ -0,0 +1,68 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +extension UserAdministrationView { + + struct UserAdministrationButton: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + var body: some View { + Button { + router.route(to: \.userDetails, observer) + } label: { + VStack(alignment: .leading, spacing: 8) { + UserProfileImage(observer: observer) + .aspectRatio(1, contentMode: .fill) + .clipShape(Rectangle()) + .frame(width: 150, height: 150) + + Text(observer.user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + TextPairView( + L10n.lastSeen, + value: Text(formatLastSeenDate(observer.user.lastActivityDate)) + ) + .font(.footnote) + } + .padding() + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(radius: 2) + } + .background(Color(.systemGray).opacity(0.1)) + .foregroundStyle(.primary, .secondary) + } + + // MARK: - Format Last Seen Date + + private func formatLastSeenDate(_ date: Date?) -> String { + guard let date = date else { + return L10n.never + } + + let timeInterval = Date().timeIntervalSince(date) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + + return formatter.localizedString(for: date, relativeTo: Date()) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift new file mode 100644 index 000000000..c3674e26b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift @@ -0,0 +1,68 @@ +// +// 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 SwiftUI + +extension UserAdministrationView { + + struct UserAdministrationRow: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + // MARK: - Body + + var body: some View { + Button { + router.route(to: \.userDetails, observer) + } label: { + VStack(spacing: 8) { + HStack { + UserProfileImage(observer: observer) + .frame(width: 60, height: 60) + VStack(alignment: .leading) { + Text(observer.user.name ?? L10n.unknown) + .foregroundStyle(.foreground) + .font(.headline) + Spacer() + TextPairView( + L10n.lastSeen, + value: Text(formatLastSeenDate(observer.user.lastActivityDate)) + ) + } + } + Divider() + } + } + } + + // MARK: - Format Last Seen Date + + private func formatLastSeenDate(_ date: Date?) -> String { + guard let date = date else { + return L10n.never + } + + let timeInterval = Date().timeIntervalSince(date) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + + return formatter.localizedString(for: date, relativeTo: Date()) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift new file mode 100644 index 000000000..9e6c9dd13 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift @@ -0,0 +1,37 @@ +// +// 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 SwiftUI + +extension UserAdministrationView { + + struct UserProfileImage: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @ObservedObject + var observer: UserAdministrationObserver + + @ViewBuilder + var body: some View { + ImageView(observer.user.profileImageSource(client: userSession.client)) + .pipeline(.Swiftfin.branding) + .placeholder { _ in + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + .failure { + SystemImageContentView(systemName: "person.fill", ratio: 0.5) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift new file mode 100644 index 000000000..b7dfeb1e3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift @@ -0,0 +1,130 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct UserAdministrationView: View { + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @StateObject + private var viewModel = UserAdministrationViewModel() + + @State + private var libraryDisplayType: LibraryDisplayType = .grid + @State + private var layout: CollectionVGridLayout + + // MARK: - Init + + init() { + _layout = State(initialValue: Self.gridLayout) + } + + // MARK: - Grid and List Layout + + private static var gridLayout: CollectionVGridLayout { + if UIDevice.current.userInterfaceIdiom == .pad { + return .minWidth(150, insets: .edgeInsets, itemSpacing: 16, lineSpacing: 2) + } else { + return .columns(2, insets: .edgeInsets, itemSpacing: 8, lineSpacing: 8) + } + } + + private static var listLayout: CollectionVGridLayout { + .columns(1, insets: .edgeInsets, itemSpacing: 8, lineSpacing: 8) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .navigationTitle(L10n.users) + .onFirstAppear { + viewModel.send(.getUsers) + } + .refreshable { + viewModel.send(.getUsers) + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.gettingUsers) { + ProgressView() + } + + Button(action: { + toggleView() + }) { + Image(systemName: libraryDisplayType == .list ? "square.grid.2x2" : "list.bullet") + } + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.users.isEmpty { + Text(L10n.none) + } else { + CollectionVGrid( + viewModel.users.keys, + layout: $layout + ) { id in + if let user = viewModel.users[id]?.value { + if libraryDisplayType == .grid { + UserAdministrationButton( + observer: UserAdministrationObserver(user: user) + ) + .frame(maxWidth: .infinity) + } else { + UserAdministrationRow( + observer: UserAdministrationObserver(user: user) + ) + .frame(maxWidth: .infinity) + } + } + } + } + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.getUsers) + } + } + + // MARK: - Toggle Between Grid and List Views + + private func toggleView() { + switch libraryDisplayType { + case .list: + libraryDisplayType = .grid + layout = Self.gridLayout + case .grid: + libraryDisplayType = .list + layout = Self.listLayout + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 64da0366a..4f9e2d20c 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -87,11 +87,19 @@ struct HomeView: View { ProgressView() } - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - mainRouter.route(to: \.settings) + HStack { + if viewModel.userSession.user.isAdministrator { + ActiveSessionIndicator { + mainRouter.route(to: \.adminDashboard) + } + } + + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + mainRouter.route(to: \.settings) + } } } .sinceLastDisappear { interval in diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift index bb7da2d09..2a9db5fdf 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -20,7 +20,15 @@ extension CustomizeViewsSettings { private var resumeNextUp @State - private var isPresentingNextUpDays = false + var tempNextUp: TimeInterval? + + // MARK: - Init + + init() { + _tempNextUp = State(initialValue: maxNextUp) + } + + // MARK: - Body var body: some View { Section(L10n.home) { @@ -29,9 +37,9 @@ extension CustomizeViewsSettings { Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) - ChevronButton( - L10n.nextUpDays, - subtitle: { + ChevronInputButton( + title: L10n.nextUpDays, + subtitleText: { if maxNextUp > 0 { return Text( Date.now.addingTimeInterval(-maxNextUp) ..< Date.now, @@ -40,22 +48,21 @@ extension CustomizeViewsSettings { } else { return Text(L10n.disabled) } - }() - ) - .onSelect { - isPresentingNextUpDays = true - } - .alert(L10n.nextUpDays, isPresented: $isPresentingNextUpDays) { - + }(), + description: L10n.nextUpDaysDescription + ) { TextField( L10n.nextUpDays, - value: $maxNextUp, + value: $tempNextUp, format: .dayInterval(range: 0 ... 1000) ) .keyboardType(.numberPad) - - } message: { - L10n.nextUpDaysDescription.text + } onSave: { + if let tempNextUp = tempNextUp { + maxNextUp = tempNextUp + } + } onCancel: { + tempNextUp = maxNextUp } } } diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index 22fa795c4..c807c0b40 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -46,7 +46,7 @@ struct SettingsView: View { if viewModel.userSession.user.isAdministrator { ChevronButton(L10n.dashboard) .onSelect { - router.route(to: \.userDashboard) + router.route(to: \.adminDashboard) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift deleted file mode 100644 index f3e4d7506..000000000 --- a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift +++ /dev/null @@ -1,51 +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) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TimeLimitSection: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - // MARK: - Body - - var body: some View { - Section { - ChevronInputButton( - title: L10n.timeLimit, - subtitle: subtitleString, - description: L10n.taskTriggerTimeLimit, - helpText: L10n.hours, - value: Binding( - get: { - Int(ServerTicks(ticks: taskTriggerInfo.maxRuntimeTicks).hours) - }, - set: { - taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: $0).ticks - } - ), - keyboard: .numberPad - ) - } - } - - // MARK: - Create Subtitle String - - private var subtitleString: String { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - ServerTicks(ticks: maxRuntimeTicks).seconds.formatted(.hourMinute) - } else { - L10n.disabled - } - } - } -} diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index f9e0e575aedb5d742c3f4824d400d0d4a5bdbb24..d2c35afee2a1061e165744cd5161f9a98f63d5d1 100644 GIT binary patch delta 227 zcmX@Hmbqyo^M(`GCeOOaGx@_3mC0-ge9Ary`3&g{#S97zWek}NsSM>nmKK9P0~do5 zLk>i4vSOv;L=B$F3Dq)_S4i^3Lxq6~(|`(!fOH9v%x5TNNCIj}1sawC