[iOS] Admin Dashboard - User Access Schedules (#1358)

* Initial layout. No Add functionality yet.

* Cleanup ServerTasks. Get Access Schedules Fixed

* duplicate schedule warning, cleanup

* localize

* cleanup

* don't move to Title Case

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2024-12-12 13:24:34 -07:00 committed by GitHub
parent ba5c037ece
commit d001a96d6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 781 additions and 53 deletions

View File

@ -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<BasicNavigationViewCoordinator> {
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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AddAccessScheduleView(viewModel: viewModel)
}
}
func makeUserParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserParentalRatingView(viewModel: viewModel)

View File

@ -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
}
}
}

View File

@ -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]
}
}
}

View File

@ -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

View File

@ -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?

View File

@ -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 = "<group>"; };
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccessScheduleView.swift; sourceTree = "<group>"; };
4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicDayOfWeek.swift; sourceTree = "<group>"; };
4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleView.swift; sourceTree = "<group>"; };
4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleRow.swift; sourceTree = "<group>"; };
4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; };
4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = "<group>"; };
@ -1782,6 +1793,7 @@
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = "<group>"; };
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
E1A505692D0B733F007EE305 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = "<group>"; };
E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = "<group>"; };
E1A8FDEB2C0574A800D0A51C /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
4ECF5D822D0A3D0200F066B1 /* AddAccessScheduleView */ = {
isa = PBXGroup;
children = (
4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */,
);
path = AddAccessScheduleView;
sourceTree = "<group>";
};
4ECF5D8C2D0A780F00F066B1 /* ServerTasks */ = {
isa = PBXGroup;
children = (
4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */,
4E90F7622CC72B1F00417C31 /* EditServerTaskView */,
4E182C9A2C94991800FBEFD5 /* ServerTasksView */,
);
path = ServerTasks;
sourceTree = "<group>";
};
4ED25C9F2D07E20C0010333C /* ServerUserAccessSchedule */ = {
isa = PBXGroup;
children = (
4ECF5D822D0A3D0200F066B1 /* AddAccessScheduleView */,
4ED25CA52D07E64F0010333C /* EditAccessScheduleView */,
);
path = ServerUserAccessSchedule;
sourceTree = "<group>";
};
4ED25CA32D07E4990010333C /* Components */ = {
isa = PBXGroup;
children = (
4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */,
);
path = Components;
sourceTree = "<group>";
};
4ED25CA52D07E64F0010333C /* EditAccessScheduleView */ = {
isa = PBXGroup;
children = (
4ED25CA32D07E4990010333C /* Components */,
4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */,
);
path = EditAccessScheduleView;
sourceTree = "<group>";
};
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 */,

View File

@ -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

View File

@ -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")!)

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}
}

View File

@ -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<AccessSchedule> = []
// 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()
}
}
}
}

View File

@ -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):

View File

@ -68,13 +68,12 @@ struct ServerUserDetailsView: View {
}
Section(L10n.parentalControls) {
// TODO: Access Schedules - accessSchedules
/* ChevronButton("Access schedule")
ChevronButton(L10n.accessSchedules)
.onSelect {
router.route(to: \.userAccessSchedules, viewModel)
router.route(to: \.userEditAccessSchedules, viewModel)
}
// TODO: Allow items SDK 10.10 - allowedTags
ChevronButton("Allow items")
/* ChevronButton("Allow items")
.onSelect {
router.route(to: \.userAllowedTags, viewModel)
}

View File

@ -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

View File

@ -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";