diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index 4222421d9..3c596ec99 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -33,10 +33,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { // MARK: - Route: Server Tasks - @Route(.push) - var editServerTask = makeEditServerTask @Route(.push) var tasks = makeTasks + @Route(.push) + var editServerTask = makeEditServerTask @Route(.modal) var addServerTaskTrigger = makeAddServerTaskTrigger @@ -51,6 +51,11 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { var users = makeUsers @Route(.push) var userDetails = makeUserDetails + @Route(.modal) + var addServerUser = makeAddServerUser + + // MARK: - Route: User Policy + @Route(.modal) var userDeviceAccess = makeUserDeviceAccess @Route(.modal) @@ -63,8 +68,10 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { var userParentalRatings = makeUserParentalRatings @Route(.modal) var resetUserPassword = makeResetUserPassword + @Route(.push) + var userEditAccessSchedules = makeUserEditAccessSchedules @Route(.modal) - var addServerUser = makeAddServerUser + var userAddAccessSchedule = makeUserAddAccessSchedule // MARK: - Route: API Keys @@ -138,6 +145,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } + // MARK: - Views: User Policy + func makeUserDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator { ServerUserDeviceAccessView(viewModel: viewModel) @@ -162,6 +171,17 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } + @ViewBuilder + func makeUserEditAccessSchedules(viewModel: ServerUserAdminViewModel) -> some View { + EditAccessScheduleView(viewModel: viewModel) + } + + func makeUserAddAccessSchedule(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddAccessScheduleView(viewModel: viewModel) + } + } + func makeUserParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator { ServerUserParentalRatingView(viewModel: viewModel) diff --git a/Shared/Extensions/JellyfinAPI/DynamicDayOfWeek.swift b/Shared/Extensions/JellyfinAPI/DynamicDayOfWeek.swift new file mode 100644 index 000000000..62735b664 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DynamicDayOfWeek.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 Foundation +import JellyfinAPI + +extension DynamicDayOfWeek { + + var displayTitle: String { + switch self { + case .sunday: + DayOfWeek.sunday.displayTitle ?? self.rawValue + case .monday: + DayOfWeek.monday.displayTitle ?? self.rawValue + case .tuesday: + DayOfWeek.tuesday.displayTitle ?? self.rawValue + case .wednesday: + DayOfWeek.wednesday.displayTitle ?? self.rawValue + case .thursday: + DayOfWeek.thursday.displayTitle ?? self.rawValue + case .friday: + DayOfWeek.friday.displayTitle ?? self.rawValue + case .saturday: + DayOfWeek.saturday.displayTitle ?? self.rawValue + case .everyday: + L10n.everyday + case .weekday: + L10n.weekday + case .weekend: + L10n.weekend + } + } +} diff --git a/Shared/Extensions/Optional.swift b/Shared/Extensions/Optional.swift new file mode 100644 index 000000000..fff501843 --- /dev/null +++ b/Shared/Extensions/Optional.swift @@ -0,0 +1,20 @@ +// +// 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 + +extension Optional where Wrapped: Collection { + + mutating func appendedOrInit(_ element: Wrapped.Element) -> [Wrapped.Element] { + if let self { + return self + [element] + } else { + return [element] + } + } +} diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 33be35a67..3f718732e 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -39,6 +39,8 @@ extension URL { static let jellyfinDocsUsers: URL = URL(string: "https://jellyfin.org/docs/general/server/users")! + static let jellyfinDocsManagingUsers: URL = URL(string: "https://jellyfin.org/docs/general/server/users/adding-managing-users")! + func isDirectoryAndReachable() throws -> Bool { guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else { return false diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 73bf60063..eb61fe726 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -22,10 +22,12 @@ internal enum L10n { internal static let access = L10n.tr("Localizable", "access", fallback: "Access") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") - /// Access schedule - internal static let accessSchedule = L10n.tr("Localizable", "accessSchedule", fallback: "Access schedule") - /// Create an access schedule to limit access to certain hours. - internal static let accessScheduleDescription = L10n.tr("Localizable", "accessScheduleDescription", fallback: "Create an access schedule to limit access to certain hours.") + /// The End Time must come after the Start Time. + internal static let accessScheduleInvalidTime = L10n.tr("Localizable", "accessScheduleInvalidTime", fallback: "The End Time must come after the Start Time.") + /// Access Schedules + internal static let accessSchedules = L10n.tr("Localizable", "accessSchedules", fallback: "Access Schedules") + /// Define the allowed hours for usage and restrict access outside those times. + internal static let accessSchedulesDescription = L10n.tr("Localizable", "accessSchedulesDescription", fallback: "Define the allowed hours for usage and restrict access outside those times.") /// Active internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// Active Devices @@ -36,14 +38,16 @@ internal enum L10n { internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor") /// Add internal static let add = L10n.tr("Localizable", "add", fallback: "Add") + /// Add Access Schedule + internal static let addAccessSchedule = L10n.tr("Localizable", "addAccessSchedule", fallback: "Add Access Schedule") /// Add API key internal static let addAPIKey = L10n.tr("Localizable", "addAPIKey", fallback: "Add API key") /// Additional security access for users signed in to this device. This does not change any Jellyfin server user settings. internal static let additionalSecurityAccessDescription = L10n.tr("Localizable", "additionalSecurityAccessDescription", fallback: "Additional security access for users signed in to this device. This does not change any Jellyfin server user settings.") /// Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") - /// Add trigger - internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add 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") /// Add User @@ -434,14 +438,22 @@ internal enum L10n { internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?") /// Are you sure you want to delete this item? This action cannot be undone. internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.") + /// Delete Schedule + internal static let deleteSchedule = L10n.tr("Localizable", "deleteSchedule", fallback: "Delete Schedule") + /// Are you sure you wish to delete this schedule? + internal static let deleteScheduleWarning = L10n.tr("Localizable", "deleteScheduleWarning", fallback: "Are you sure you wish to delete this schedule?") /// Are you sure you want to delete the selected items? internal static let deleteSelectedConfirmation = L10n.tr("Localizable", "deleteSelectedConfirmation", fallback: "Are you sure you want to delete the selected items?") /// Delete Selected Devices internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices") + /// Delete Selected Schedules + internal static let deleteSelectedSchedules = L10n.tr("Localizable", "deleteSelectedSchedules", fallback: "Delete Selected Schedules") /// Delete Selected Users internal static let deleteSelectedUsers = L10n.tr("Localizable", "deleteSelectedUsers", fallback: "Delete Selected Users") /// Are you sure you wish to delete all selected devices? All selected sessions will be logged out. internal static let deleteSelectionDevicesWarning = L10n.tr("Localizable", "deleteSelectionDevicesWarning", fallback: "Are you sure you wish to delete all selected devices? All selected sessions will be logged out.") + /// Are you sure you wish to delete all selected schedules? + internal static let deleteSelectionSchedulesWarning = L10n.tr("Localizable", "deleteSelectionSchedulesWarning", fallback: "Are you sure you wish to delete all selected schedules?") /// Are you sure you wish to delete all selected users? internal static let deleteSelectionUsersWarning = L10n.tr("Localizable", "deleteSelectionUsersWarning", fallback: "Are you sure you wish to delete all selected users?") /// Delete Server @@ -546,6 +558,8 @@ internal enum L10n { internal static let endDate = L10n.tr("Localizable", "endDate", fallback: "End Date") /// Ended internal static let ended = L10n.tr("Localizable", "ended", fallback: "Ended") + /// End Time + internal static let endTime = L10n.tr("Localizable", "endTime", fallback: "End Time") /// Engineer internal static let engineer = L10n.tr("Localizable", "engineer", fallback: "Engineer") /// Enter custom bitrate in Mbps @@ -582,6 +596,8 @@ internal enum L10n { internal static let errorDetails = L10n.tr("Localizable", "errorDetails", fallback: "Error Details") /// Every internal static let every = L10n.tr("Localizable", "every", fallback: "Every") + /// Everyday + internal static let everyday = L10n.tr("Localizable", "everyday", fallback: "Everyday") /// Every %1$@ internal static func everyInterval(_ p1: Any) -> String { return L10n.tr("Localizable", "everyInterval", String(describing: p1), fallback: "Every %1$@") @@ -1188,6 +1204,8 @@ internal enum L10n { internal static let saveUserWithoutAuthDescription = L10n.tr("Localizable", "saveUserWithoutAuthDescription", fallback: "Save the user to this device without any local authentication.") /// Scan All Libraries internal static let scanAllLibraries = L10n.tr("Localizable", "scanAllLibraries", fallback: "Scan All Libraries") + /// Schedule already exists + internal static let scheduleAlreadyExists = L10n.tr("Localizable", "scheduleAlreadyExists", fallback: "Schedule already exists") /// Scheduled Tasks internal static let scheduledTasks = L10n.tr("Localizable", "scheduledTasks", fallback: "Scheduled Tasks") /// Scrub Current Time @@ -1330,6 +1348,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") + /// Start Time + internal static let startTime = L10n.tr("Localizable", "startTime", fallback: "Start Time") /// Status internal static let status = L10n.tr("Localizable", "status", fallback: "Status") /// Stop @@ -1546,6 +1566,10 @@ internal enum L10n { internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") /// Video transcoding internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding") + /// Weekday + internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") + /// Weekend + internal static let weekend = L10n.tr("Localizable", "weekend", fallback: "Weekend") /// Weekly internal static let weekly = L10n.tr("Localizable", "weekly", fallback: "Weekly") /// Who's watching? diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index d191e0334..f151b3736 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -200,6 +200,11 @@ 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; + 4ECF5D882D0A3D0200F066B1 /* AddAccessScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */; }; + 4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */; }; + 4ECF5D8B2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */; }; + 4ED25CA12D07E3590010333C /* EditAccessScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */; }; + 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */; }; 4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE07CBC2D08B19700B0B636 /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; @@ -916,6 +921,8 @@ E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; }; E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; }; E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; + E1A5056A2D0B733F007EE305 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A505692D0B733F007EE305 /* Optional.swift */; }; + E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A505692D0B733F007EE305 /* Optional.swift */; }; E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */; }; @@ -1311,6 +1318,10 @@ 4EC50D602C934B3A00FC3D0E /* ServerTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTasksViewModel.swift; sourceTree = ""; }; 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = ""; }; 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = ""; }; + 4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessScheduleView.swift; sourceTree = ""; }; + 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicDayOfWeek.swift; sourceTree = ""; }; + 4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleView.swift; sourceTree = ""; }; + 4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleRow.swift; sourceTree = ""; }; 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; @@ -1782,6 +1793,7 @@ E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = ""; }; E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; + E1A505692D0B733F007EE305 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; E1A8FDEB2C0574A800D0A51C /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; @@ -2302,19 +2314,18 @@ 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, 4EB7C8D32CCED318000CC011 /* AddServerUserView */, - 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */, 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */, 4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */, E1DE64902CC6F06C00E423B6 /* Components */, 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */, 4EED87492CBF824B002354D2 /* DevicesView */, - 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, - 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, + 4ECF5D8C2D0A780F00F066B1 /* ServerTasks */, 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, 4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */, 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */, 4E537A8C2D04410E00659A1A /* ServerUserLiveTVAccessView */, + 4ED25C9F2D07E20C0010333C /* ServerUserAccessSchedule */, 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */, 4EB538B32CE3C75900EB72D5 /* ServerUserPermissionsView */, 4EC2B1992CC96E5E00D866BE /* ServerUsersView */, @@ -2633,6 +2644,50 @@ path = ServerUserDetailsView; sourceTree = ""; }; + 4ECF5D822D0A3D0200F066B1 /* AddAccessScheduleView */ = { + isa = PBXGroup; + children = ( + 4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */, + ); + path = AddAccessScheduleView; + sourceTree = ""; + }; + 4ECF5D8C2D0A780F00F066B1 /* ServerTasks */ = { + isa = PBXGroup; + children = ( + 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */, + 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, + 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, + ); + path = ServerTasks; + sourceTree = ""; + }; + 4ED25C9F2D07E20C0010333C /* ServerUserAccessSchedule */ = { + isa = PBXGroup; + children = ( + 4ECF5D822D0A3D0200F066B1 /* AddAccessScheduleView */, + 4ED25CA52D07E64F0010333C /* EditAccessScheduleView */, + ); + path = ServerUserAccessSchedule; + sourceTree = ""; + }; + 4ED25CA32D07E4990010333C /* Components */ = { + isa = PBXGroup; + children = ( + 4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4ED25CA52D07E64F0010333C /* EditAccessScheduleView */ = { + isa = PBXGroup; + children = ( + 4ED25CA32D07E4990010333C /* Components */, + 4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */, + ); + path = EditAccessScheduleView; + sourceTree = ""; + }; 4EED87472CBF824B002354D2 /* Components */ = { isa = PBXGroup; children = ( @@ -3162,6 +3217,7 @@ E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, E150C0B82BFD44E900944FFA /* Nuke */, + E1A505692D0B733F007EE305 /* Optional.swift */, E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, @@ -4235,8 +4291,8 @@ 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, 4E12F9152CBE9615006C217E /* DeviceType.swift */, - 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, + 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */, @@ -4253,6 +4309,7 @@ E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, + 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */, E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, @@ -5119,6 +5176,7 @@ E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */, E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */, 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, + 4ECF5D8B2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, E193D54B271941D300900D82 /* SelectServerView.swift in Sources */, @@ -5333,6 +5391,7 @@ E18E021C2887492B0022598C /* BlurView.swift in Sources */, E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, + E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */, E10B1ECB2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, @@ -5402,6 +5461,7 @@ 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, 4E2470082D078DD7009139D8 /* ServerUserParentalRatingView.swift in Sources */, E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, + 4ECF5D882D0A3D0200F066B1 /* AddAccessScheduleView.swift in Sources */, E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, @@ -5533,6 +5593,7 @@ 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */, + E1A5056A2D0B733F007EE305 /* Optional.swift in Sources */, E102314D2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E12CC1BB28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, E1BE1CEE2BDB68CD008176A9 /* UserProfileRow.swift in Sources */, @@ -5552,6 +5613,7 @@ 4EBE064F2C7ECE8D004A6C03 /* InlineEnumToggle.swift in Sources */, E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */, + 4ED25CA12D07E3590010333C /* EditAccessScheduleView.swift in Sources */, 62133890265F83A900A81A2A /* MediaView.swift in Sources */, E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */, 4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */, @@ -5618,6 +5680,7 @@ E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, + 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, 4E49DEE02CE55F7F00352DCD /* PhotoPicker.swift in Sources */, 4E49DEE12CE55F7F00352DCD /* SquareImageCropView.swift in Sources */, 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */, @@ -5686,6 +5749,7 @@ E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */, + 4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, diff --git a/Swiftfin/Views/AdminDashboardView/AddServerUserView/AddServerUserView.swift b/Swiftfin/Views/AdminDashboardView/AddServerUserView/AddServerUserView.swift index adc62b1ed..cdab09b52 100644 --- a/Swiftfin/Views/AdminDashboardView/AddServerUserView/AddServerUserView.swift +++ b/Swiftfin/Views/AdminDashboardView/AddServerUserView/AddServerUserView.swift @@ -46,11 +46,6 @@ struct AddServerUserView: View { @State private var confirmPassword: String = "" - // MARK: - Dialog State - - @State - private var isPresentingSuccess: Bool = false - // MARK: - Error State @State diff --git a/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift index 180a72cb0..b452c5296 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift @@ -25,7 +25,7 @@ struct ServerLogsView: View { private var contentView: some View { List { ListTitleSection( - L10n.logs, + L10n.serverLogs, description: L10n.logsDescription ) { UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/administration/troubleshooting")!) diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/DayOfWeekRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/DayOfWeekRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/IntervalRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/IntervalRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TimeLimitSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TimeLimitSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TimeRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TimeRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TriggerTypeRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/TriggerTypeRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/DetailsSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/LastErrorSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/Sections/TriggersSection.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/TriggerRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/Components/TriggerRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/EditServerTaskView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift similarity index 83% rename from Swiftfin/Views/AdminDashboardView/EditServerTaskView/EditServerTaskView.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift index 9e64012cf..d248e7cb7 100644 --- a/Swiftfin/Views/AdminDashboardView/EditServerTaskView/EditServerTaskView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift @@ -12,23 +12,24 @@ import SwiftUI struct EditServerTaskView: View { + // MARK: - Observed & Environment Objects + @EnvironmentObject private var router: AdminDashboardCoordinator.Router @ObservedObject var observer: ServerTaskObserver - // MARK: - State Variables + // MARK: - Trigger Variables - @State - private var isPresentingDeleteConfirmation = false - @State - private var isPresentingEventAlert = false - @State - private var error: JellyfinAPIError? @State private var selectedTrigger: TaskTriggerInfo? + // MARK: - Error State + + @State + private var error: Error? + // MARK: - Body var body: some View { @@ -78,17 +79,8 @@ struct EditServerTaskView: View { switch event { case let .error(eventError): error = eventError - isPresentingEventAlert = true } } - .alert( - L10n.error, - isPresented: $isPresentingEventAlert, - presenting: error - ) { _ in - - } message: { error in - Text(error.localizedDescription) - } + .errorMessage($error) } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasksView/Components/DestructiveServerTask.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/ServerTasksView/Components/DestructiveServerTask.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasksView/Components/ServerTaskRow.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/ServerTasksView/Components/ServerTaskRow.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift diff --git a/Swiftfin/Views/AdminDashboardView/ServerTasksView/ServerTasksView.swift b/Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift similarity index 100% rename from Swiftfin/Views/AdminDashboardView/ServerTasksView/ServerTasksView.swift rename to Swiftfin/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift new file mode 100644 index 000000000..c88ada76e --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift @@ -0,0 +1,188 @@ +// +// 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 AddAccessScheduleView: View { + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Access Schedule Variables + + @State + private var tempPolicy: UserPolicy + @State + private var selectedDay: DynamicDayOfWeek = .everyday + @State + private var startTime: Date = Calendar.current.startOfDay(for: Date()) + @State + private var endTime: Date = Calendar.current.startOfDay(for: Date()).addingTimeInterval(+3600) + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy! + } + + private var isValidRange: Bool { + startTime < endTime + } + + private var newSchedule: AccessSchedule? { + guard isValidRange else { return nil } + + let calendar = Calendar.current + let startComponents = calendar.dateComponents([.hour, .minute], from: startTime) + let endComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + guard let startHour = startComponents.hour, + let startMinute = startComponents.minute, + let endHour = endComponents.hour, + let endMinute = endComponents.minute + else { + return nil + } + + // AccessSchedule Hours are formatted as 23.5 == 11:30pm or 8.25 == 8:15am + let startDouble = Double(startHour) + Double(startMinute) / 60.0 + let endDouble = Double(endHour) + Double(endMinute) / 60.0 + + // AccessSchedule should have valid Start & End Hours + let newSchedule = AccessSchedule( + dayOfWeek: selectedDay, + endHour: endDouble, + startHour: startDouble, + userID: viewModel.user.id + ) + + return newSchedule + } + + private var isDuplicateSchedule: Bool { + guard let newSchedule, let existingSchedules = viewModel.user.policy?.accessSchedules else { + return false + } + + return existingSchedules.contains { other in + other.dayOfWeek == selectedDay && + other.startHour == newSchedule.startHour && + other.endHour == newSchedule.endHour + } + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.addAccessSchedule) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.refreshing) { + ProgressView() + } + if viewModel.backgroundStates.contains(.updating) { + Button(L10n.cancel) { + viewModel.send(.cancel) + } + .buttonStyle(.toolbarPill(.red)) + } else { + Button(L10n.save) { + saveSchedule() + } + .buttonStyle(.toolbarPill) + .disabled(!isValidRange) + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + private var contentView: some View { + Form { + Section(L10n.dayOfWeek) { + Picker(L10n.dayOfWeek, selection: $selectedDay) { + ForEach(DynamicDayOfWeek.allCases, id: \.self) { day in + + if day == .everyday { + Divider() + } + + Text(day.displayTitle).tag(day) + } + } + } + + Section(L10n.startTime) { + DatePicker(L10n.startTime, selection: $startTime, displayedComponents: .hourAndMinute) + } + + Section { + DatePicker(L10n.endTime, selection: $endTime, displayedComponents: .hourAndMinute) + } header: { + Text(L10n.endTime) + } footer: { + if !isValidRange { + Label(L10n.accessScheduleInvalidTime, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + + if isDuplicateSchedule { + Label(L10n.scheduleAlreadyExists, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + } + } + + // MARK: - Save Schedule + + private func saveSchedule() { + + guard isValidRange, let newSchedule else { + error = JellyfinAPIError(L10n.accessScheduleInvalidTime) + return + } + + guard !isDuplicateSchedule else { + error = JellyfinAPIError(L10n.scheduleAlreadyExists) + return + } + + tempPolicy.accessSchedules = tempPolicy.accessSchedules + .appendedOrInit(newSchedule) + + viewModel.send(.updatePolicy(tempPolicy)) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift new file mode 100644 index 000000000..a5a362d86 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift @@ -0,0 +1,108 @@ +// +// 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 EditAccessScheduleView { + + struct EditAccessScheduleRow: View { + + // MARK: - Environment Variables + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + // MARK: - Schedule Variable + + let schedule: AccessSchedule + + // MARK: - Schedule Actions + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Body + + var body: some View { + Button(action: onSelect) { + rowContent + } + .foregroundStyle(.primary, .secondary) + .swipeActions { + Button(L10n.delete, systemImage: "trash", action: onDelete) + .tint(.red) + } + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + if let dayOfWeek = schedule.dayOfWeek { + Text(dayOfWeek.rawValue) + .fontWeight(.semibold) + } + + Group { + if let startHour = schedule.startHour { + TextPairView( + leading: L10n.startTime, + trailing: doubleToTimeString(startHour) + ) + } + + if let endHour = schedule.endHour { + TextPairView( + leading: L10n.endTime, + trailing: doubleToTimeString(endHour) + ) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .foregroundStyle( + isEditing ? (isSelected ? .primary : .secondary) : .primary, + .secondary + ) + + Spacer() + + ListRowCheckbox() + } + } + + // MARK: - Convert Double to Date + + private func doubleToTimeString(_ double: Double) -> String { + let startHours = Int(double) + let startMinutes = Int(double.truncatingRemainder(dividingBy: 1) * 60) + + var dateComponents = DateComponents() + dateComponents.hour = startHours + dateComponents.minute = startMinutes + + let calendar = Calendar.current + + guard let date = calendar.date(from: dateComponents) else { + return .emptyTime + } + + let formatter = DateFormatter() + formatter.timeStyle = .short + + return formatter.string(from: date) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift new file mode 100644 index 000000000..6e2c27c89 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift @@ -0,0 +1,227 @@ +// +// 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 EditAccessScheduleView: View { + + // MARK: - Defaults + + @Default(.accentColor) + private var accentColor + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - Policy Variable + + @State + private var selectedSchedules: Set = [] + + // MARK: - Dialog States + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + + // MARK: - Editing State + + @State + private var isEditing: Bool = false + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.accessSchedules) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .topBarTrailing) { + if isEditing { + Button(L10n.cancel) { + isEditing.toggle() + selectedSchedules.removeAll() + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + .foregroundStyle(accentColor) + } + } + ToolbarItem(placement: .bottomBar) { + if isEditing { + Button(L10n.delete) { + isPresentingDeleteSelectionConfirmation = true + } + .buttonStyle(.toolbarPill(.red)) + .disabled(selectedSchedules.isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .navigationBarMenuButton( + isLoading: viewModel.backgroundStates.contains(.refreshing), + isHidden: isEditing || viewModel.user.policy?.accessSchedules == [] + ) { + Button(L10n.add, systemImage: "plus") { + router.route(to: \.userAddAccessSchedule, viewModel) + } + + Button(L10n.edit, systemImage: "checkmark.circle") { + isEditing = true + } + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + } + } + .confirmationDialog( + L10n.deleteSelectedSchedules, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedSchedulesConfirmationActions + } message: { + Text(L10n.deleteSelectionSchedulesWarning) + } + .confirmationDialog( + L10n.deleteSchedule, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteScheduleConfirmationActions + } message: { + Text(L10n.deleteScheduleWarning) + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + ListTitleSection( + L10n.accessSchedules.localizedCapitalized, + description: L10n.accessSchedulesDescription + ) { + UIApplication.shared.open(.jellyfinDocsManagingUsers) + } + + if viewModel.user.policy?.accessSchedules == [] { + Button(L10n.add) { + router.route(to: \.userAddAccessSchedule, viewModel) + } + } else { + ForEach(viewModel.user.policy?.accessSchedules ?? [], id: \.self) { schedule in + EditAccessScheduleRow(schedule: schedule) { + if isEditing { + selectedSchedules.toggle(value: schedule) + } + } onDelete: { + selectedSchedules = [schedule] + isPresentingDeleteConfirmation = true + } + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedSchedules.contains(schedule)) + } + } + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + + let isAllSelected: Bool = selectedSchedules.count == viewModel.user.policy?.accessSchedules?.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedSchedules = [] + } else { + selectedSchedules = Set(viewModel.user.policy?.accessSchedules ?? []) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + .foregroundStyle(accentColor) + } + + // MARK: - Delete Selected Schedules Confirmation Actions + + @ViewBuilder + private var deleteSelectedSchedulesConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + + var tempPolicy: UserPolicy = viewModel.user.policy! + + if selectedSchedules.isNotEmpty { + tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { !selectedSchedules.contains($0) + } + viewModel.send(.updatePolicy(tempPolicy)) + isEditing = false + selectedSchedules.removeAll() + } + } + } + + // MARK: - Delete Schedule Confirmation Actions + + @ViewBuilder + private var deleteScheduleConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + + var tempPolicy: UserPolicy = viewModel.user.policy! + + if let scheduleToDelete = selectedSchedules.first, + selectedSchedules.count == 1 + { + tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { + $0 != scheduleToDelete + } + viewModel.send(.updatePolicy(tempPolicy)) + isEditing = false + selectedSchedules.removeAll() + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift index 8695886b8..a31560e29 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift @@ -58,6 +58,9 @@ struct ServerUserMediaAccessView: View { .buttonStyle(.toolbarPill) .disabled(viewModel.user.policy == tempPolicy) } + .onFirstAppear { + viewModel.send(.loadLibraries()) + } .onReceive(viewModel.events) { event in switch event { case let .error(eventError): diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 587fa4174..b673d6db9 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -68,13 +68,12 @@ struct ServerUserDetailsView: View { } Section(L10n.parentalControls) { - // TODO: Access Schedules - accessSchedules - /* ChevronButton("Access schedule") - .onSelect { - router.route(to: \.userAccessSchedules, viewModel) - } - // TODO: Allow items SDK 10.10 - allowedTags - ChevronButton("Allow items") + ChevronButton(L10n.accessSchedules) + .onSelect { + router.route(to: \.userEditAccessSchedules, viewModel) + } + // TODO: Allow items SDK 10.10 - allowedTags + /* ChevronButton("Allow items") .onSelect { router.route(to: \.userAllowedTags, viewModel) } @@ -82,7 +81,7 @@ struct ServerUserDetailsView: View { ChevronButton("Block items") .onSelect { router.route(to: \.userBlockedTags, viewModel) - }*/ + } */ ChevronButton(L10n.ratings) .onSelect { router.route(to: \.userParentalRatings, viewModel) diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift index e8b0000c7..d80b06958 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift @@ -17,7 +17,7 @@ struct ServerUserLiveTVAccessView: View { @CurrentDate private var currentDate: Date - // MARK: - State & Environment Objects + // MARK: - Observed & Environment Objects @EnvironmentObject private var router: BasicNavigationViewCoordinator.Router diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 6d2f2b111..84ddeb8d9 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -2049,6 +2049,26 @@ // Represents a translator "translator" = "Translator"; +// Start Time - Label +// Label for selecting or displaying the start time +"startTime" = "Start Time"; + +// End Time - Label +// Label for selecting or displaying the end time +"endTime" = "End Time"; + +// Access Schedules - Label +// Label for configuring or viewing the access schedule +"accessSchedules" = "Access Schedules"; + +// Add Access Schedule - Label +// Label for adding a single Access Schedule +"addAccessSchedule" = "Add Access Schedule"; + +// Access Schedule Description - Label +// Description for viewing or listing multiple schedules +"accessSchedulesDescription" = "Define the allowed hours for usage and restrict access outside those times."; + // Parental controls - Section Title // Parental controls section & view titles "parentalControls" = "Parental controls"; @@ -2085,14 +2105,6 @@ // Parental ratings description for blocked tags "blockedTagsDescription" = "Hide media with at least one of the specified tags."; -// Access Schedule - View Title -// Parental ratings section for blocked titles -"accessSchedule" = "Access schedule"; - -// Access Schedule - Footer -// Parental ratings section for allowed titles -"accessScheduleDescription" = "Create an access schedule to limit access to certain hours."; - // Trailers - Section Title // Title for content classified as trailers "trailers" = "Trailers"; @@ -2252,3 +2264,39 @@ // Ages Group - Group Name // Label for content suitable for a specific age group "agesGroup" = "Age %@"; + +// End Time must come after Start Time - Access Schedule Error +// Error produced when trying to create +"accessScheduleInvalidTime" = "The End Time must come after the Start Time."; + +// Everyday - Label +// DynamicDayOfWeek label for Everyday +"everyday" = "Everyday"; + +// Weekday - Label +// DynamicDayOfWeek label for Weekdays +"weekday" = "Weekday"; + +// Weekend - Label +// DynamicDayOfWeek label for the Weekend +"weekend" = "Weekend"; + +// Schedule Already Exists - +// Message to indicate that an Access Schedule already exists +"scheduleAlreadyExists" = "Schedule already exists"; + +// Delete Selected Schedules Warning - Warning Message +// Warning message displayed when deleting all schedules +"deleteSelectionSchedulesWarning" = "Are you sure you wish to delete all selected schedules?"; + +// Delete Schedule Warning - Warning Message +// Warning message displayed when deleting a single schedules +"deleteScheduleWarning" = "Are you sure you wish to delete this schedule?"; + +// Delete Schedule - Action +// Message for deleting a single Access Schedule +"deleteSchedule" = "Delete Schedule"; + +// Delete Selected Schedules - Button +// Button label for deleting all selected Access Schedules +"deleteSelectedSchedules" = "Delete Selected Schedules";