[iOS] Admin Dashboard - User Activity (#1485)
* Very Very WIP * Details page. TODOs: - Duplicate ViewModels are initialized. - Routing Cleanup - Localizations for fields - Get Played Item Details (See ActiveSessionDetails) - Move all details to ActivityDetailsViewModel for Users & Items - Localizations for enums - Enum the types if possible * Details View complete. TODO: - Filters - Default with No Filters * Ready * Fix localization * cleanup --------- Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
parent
715ddca793
commit
d4330f130b
|
@ -17,6 +17,13 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
|
|||
@Root
|
||||
var start = makeStart
|
||||
|
||||
// MARK: - Route: User Activity
|
||||
|
||||
@Route(.push)
|
||||
var activity = makeActivityLogs
|
||||
@Route(.push)
|
||||
var activityDetails = makeActivityDetails
|
||||
|
||||
// MARK: - Route: Active Sessions
|
||||
|
||||
@Route(.push)
|
||||
|
@ -84,6 +91,18 @@ final class AdminDashboardCoordinator: NavigationCoordinatable {
|
|||
@Route(.push)
|
||||
var apiKeys = makeAPIKeys
|
||||
|
||||
// MARK: - Views: User Activity
|
||||
|
||||
@ViewBuilder
|
||||
func makeActivityLogs() -> some View {
|
||||
ServerActivityView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeActivityDetails(viewModel: ServerActivityDetailViewModel) -> some View {
|
||||
ServerActivityDetailsView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
// MARK: - Views: Active Sessions
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension ActivityLogEntry: Poster {
|
||||
var displayTitle: String {
|
||||
name ?? L10n.unknown
|
||||
}
|
||||
|
||||
var unwrappedIDHashOrZero: Int {
|
||||
id?.hashValue ?? 0
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
"text.document"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension LogLevel: SystemImageable, Displayable {
|
||||
public var color: Color {
|
||||
switch self {
|
||||
case .trace:
|
||||
return .gray.opacity(0.7)
|
||||
case .debug:
|
||||
return .gray
|
||||
case .information:
|
||||
return .blue
|
||||
case .warning:
|
||||
return .orange
|
||||
case .error:
|
||||
return .red
|
||||
case .critical:
|
||||
return .purple
|
||||
case .none:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
public var systemImage: String {
|
||||
switch self {
|
||||
case .trace:
|
||||
return "ant"
|
||||
case .debug:
|
||||
return "ladybug"
|
||||
case .information:
|
||||
return "info.circle"
|
||||
case .warning:
|
||||
return "exclamationmark.triangle"
|
||||
case .error:
|
||||
return "exclamationmark.circle"
|
||||
case .critical:
|
||||
return "xmark.octagon"
|
||||
case .none:
|
||||
return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
public var displayTitle: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
|
@ -14,6 +14,10 @@ extension Sequence {
|
|||
filter { $0[keyPath: keyPath] != nil }
|
||||
}
|
||||
|
||||
func first<V: Equatable>(property: (Element) -> V, equalTo value: V) -> Element? {
|
||||
first { property($0) == value }
|
||||
}
|
||||
|
||||
func intersection<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
|
||||
filter { other.contains($0[keyPath: keyPath]) }
|
||||
}
|
||||
|
@ -58,6 +62,10 @@ extension Sequence {
|
|||
|
||||
extension Sequence where Element: Equatable {
|
||||
|
||||
func first(equalTo other: Element) -> Element? {
|
||||
first { $0 == other }
|
||||
}
|
||||
|
||||
/// Returns an array containing the elements of the sequence that
|
||||
/// are also within the given sequence.
|
||||
func intersection(_ other: some Sequence<Element>) -> [Element] {
|
||||
|
|
|
@ -40,6 +40,8 @@ internal enum L10n {
|
|||
internal static let active = L10n.tr("Localizable", "active", fallback: "Active")
|
||||
/// Activity
|
||||
internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity")
|
||||
/// Activity log
|
||||
internal static let activityLog = L10n.tr("Localizable", "activityLog", fallback: "Activity log")
|
||||
/// Actor
|
||||
internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor")
|
||||
/// Add
|
||||
|
@ -392,6 +394,8 @@ internal enum L10n {
|
|||
internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard")
|
||||
/// Perform administrative tasks for your Jellyfin server.
|
||||
internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.")
|
||||
/// Date
|
||||
internal static let date = L10n.tr("Localizable", "date", fallback: "Date")
|
||||
/// Date Added
|
||||
internal static let dateAdded = L10n.tr("Localizable", "dateAdded", fallback: "Date Added")
|
||||
/// Date created
|
||||
|
@ -760,6 +764,8 @@ internal enum L10n {
|
|||
internal static let letterer = L10n.tr("Localizable", "letterer", fallback: "Letterer")
|
||||
/// Letter Picker
|
||||
internal static let letterPicker = L10n.tr("Localizable", "letterPicker", fallback: "Letter Picker")
|
||||
/// Level
|
||||
internal static let level = L10n.tr("Localizable", "level", fallback: "Level")
|
||||
/// Library
|
||||
internal static let library = L10n.tr("Localizable", "library", fallback: "Library")
|
||||
/// Light
|
||||
|
@ -1130,6 +1136,8 @@ internal enum L10n {
|
|||
internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset")
|
||||
/// Reset all settings back to defaults.
|
||||
internal static let resetAllSettings = L10n.tr("Localizable", "resetAllSettings", fallback: "Reset all settings back to defaults.")
|
||||
/// Reset the filter values to none.
|
||||
internal static let resetFilterFooter = L10n.tr("Localizable", "resetFilterFooter", fallback: "Reset the filter values to none.")
|
||||
/// Reset Settings
|
||||
internal static let resetSettings = L10n.tr("Localizable", "resetSettings", fallback: "Reset Settings")
|
||||
/// Reset Swiftfin user settings
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import JellyfinAPI
|
||||
|
||||
final class ServerActivityDetailViewModel: ViewModel, Stateful {
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case refresh
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
}
|
||||
|
||||
// MARK: - Stateful Variables
|
||||
|
||||
@Published
|
||||
var backgroundStates: Set<BackgroundState> = []
|
||||
@Published
|
||||
var state: State = .initial
|
||||
|
||||
// MARK: - Published Variables
|
||||
|
||||
@Published
|
||||
var log: ActivityLogEntry
|
||||
@Published
|
||||
var user: UserDto?
|
||||
@Published
|
||||
var item: BaseItemDto?
|
||||
|
||||
// MARK: - Cancellable
|
||||
|
||||
private var getActivityCancellable: AnyCancellable?
|
||||
|
||||
// MARK: - Initialize
|
||||
|
||||
init(log: ActivityLogEntry, user: UserDto?) {
|
||||
self.log = log
|
||||
self.user = user
|
||||
}
|
||||
|
||||
// MARK: - Respond
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case .refresh:
|
||||
getActivityCancellable?.cancel()
|
||||
getActivityCancellable = Task {
|
||||
do {
|
||||
|
||||
if let itemID = log.itemID {
|
||||
self.item = try await getItem(for: itemID)
|
||||
} else {
|
||||
self.item = nil
|
||||
}
|
||||
|
||||
if let userID = log.userID {
|
||||
self.user = try await getUser(for: userID)
|
||||
} else {
|
||||
self.user = nil
|
||||
}
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.state = .error(.init(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Get the Activity's Item
|
||||
|
||||
private func getItem(for itemID: String) async throws -> BaseItemDto? {
|
||||
let request = Paths.getItem(itemID: itemID)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
return response.value
|
||||
}
|
||||
|
||||
// MARK: - Get the Activity's User
|
||||
|
||||
private func getUser(for userID: String) async throws -> UserDto? {
|
||||
let request = Paths.getUserByID(userID: userID)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
return response.value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import IdentifiedCollections
|
||||
import JellyfinAPI
|
||||
|
||||
@MainActor
|
||||
final class ServerActivityViewModel: PagingLibraryViewModel<ActivityLogEntry> {
|
||||
|
||||
@Published
|
||||
var hasUserId: Bool? {
|
||||
didSet {
|
||||
self.send(.refresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
var minDate: Date? {
|
||||
didSet {
|
||||
self.send(.refresh)
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var users: IdentifiedArrayOf<UserDto> = []
|
||||
|
||||
private var userTask: AnyCancellable?
|
||||
|
||||
override func respond(to action: Action) -> State {
|
||||
|
||||
switch action {
|
||||
case .refresh:
|
||||
userTask?.cancel()
|
||||
userTask = Task {
|
||||
do {
|
||||
let users = try await getUsers()
|
||||
|
||||
print("here")
|
||||
|
||||
await MainActor.run {
|
||||
self.users = users
|
||||
_ = super.respond(to: action)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.send(.error(.init(L10n.unknownError)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .refreshing
|
||||
default:
|
||||
return super.respond(to: action)
|
||||
}
|
||||
}
|
||||
|
||||
override func get(page: Int) async throws -> [ActivityLogEntry] {
|
||||
var parameters = Paths.GetLogEntriesParameters()
|
||||
parameters.limit = pageSize
|
||||
parameters.hasUserID = hasUserId
|
||||
parameters.minDate = minDate
|
||||
parameters.startIndex = page * pageSize
|
||||
|
||||
let request = Paths.getLogEntries(parameters: parameters)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
return response.value.items ?? []
|
||||
}
|
||||
|
||||
private func getUsers() async throws -> IdentifiedArrayOf<UserDto> {
|
||||
let request = Paths.getUsers()
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
return IdentifiedArray(uniqueElements: response.value)
|
||||
}
|
||||
}
|
|
@ -10,7 +10,6 @@ import Combine
|
|||
import Foundation
|
||||
import IdentifiedCollections
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
|
||||
|
|
|
@ -59,6 +59,16 @@
|
|||
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; };
|
||||
4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; };
|
||||
4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; };
|
||||
4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */; };
|
||||
4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; };
|
||||
4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3892DA426710004736A /* ActivityLogEntry.swift */; };
|
||||
4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE38D2DA427870004736A /* ServerActivityView.swift */; };
|
||||
4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */; };
|
||||
4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; };
|
||||
4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3922DA432BC0004736A /* LogLevel.swift */; };
|
||||
4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */; };
|
||||
4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */; };
|
||||
4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2CE39E2DA496270004736A /* MediaItemSection.swift */; };
|
||||
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */; };
|
||||
4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */; };
|
||||
4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; };
|
||||
|
@ -1318,6 +1328,14 @@
|
|||
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
|
||||
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
|
||||
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
|
||||
4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityViewModel.swift; sourceTree = "<group>"; };
|
||||
4E2CE3892DA426710004736A /* ActivityLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogEntry.swift; sourceTree = "<group>"; };
|
||||
4E2CE38D2DA427870004736A /* ServerActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityView.swift; sourceTree = "<group>"; };
|
||||
4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityEntry.swift; sourceTree = "<group>"; };
|
||||
4E2CE3922DA432BC0004736A /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = "<group>"; };
|
||||
4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailsView.swift; sourceTree = "<group>"; };
|
||||
4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerActivityDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
4E2CE39E2DA496270004736A /* MediaItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItemSection.swift; sourceTree = "<group>"; };
|
||||
4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementRow.swift; sourceTree = "<group>"; };
|
||||
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementView.swift; sourceTree = "<group>"; };
|
||||
4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = "<group>"; };
|
||||
|
@ -2308,6 +2326,8 @@
|
|||
4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */,
|
||||
E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */,
|
||||
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */,
|
||||
4E2CE39A2DA479AF0004736A /* ServerActivityDetailViewModel.swift */,
|
||||
4E2CE3872DA424CA0004736A /* ServerActivityViewModel.swift */,
|
||||
E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */,
|
||||
4EC50D602C934B3A00FC3D0E /* ServerTasksViewModel.swift */,
|
||||
4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */,
|
||||
|
@ -2370,6 +2390,40 @@
|
|||
path = MediaComponents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E2CE38C2DA427790004736A /* ServerActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E2CE3962DA446810004736A /* ServerActivityDetailsView */,
|
||||
4E2CE3952DA446730004736A /* ServerActivityView */,
|
||||
);
|
||||
path = ServerActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E2CE38F2DA42B250004736A /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E2CE3902DA42B280004736A /* ServerActivityEntry.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E2CE3952DA446730004736A /* ServerActivityView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E2CE38F2DA42B250004736A /* Components */,
|
||||
4E2CE38D2DA427870004736A /* ServerActivityView.swift */,
|
||||
);
|
||||
path = ServerActivityView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E2CE3962DA446810004736A /* ServerActivityDetailsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E2CE3972DA4468A0004736A /* ServerActivityDetailsView.swift */,
|
||||
);
|
||||
path = ServerActivityDetailsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E31EF972CFFB9B70053DFE7 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2551,6 +2605,7 @@
|
|||
4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */,
|
||||
4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */,
|
||||
E1DE64902CC6F06C00E423B6 /* Components */,
|
||||
4E2CE38C2DA427790004736A /* ServerActivity */,
|
||||
4EFE80852D3EF8270029CCB6 /* ServerDevices */,
|
||||
4E35CE622CBED3FF00DBD886 /* ServerLogsView */,
|
||||
4ECF5D8C2D0A780F00F066B1 /* ServerTasks */,
|
||||
|
@ -5073,6 +5128,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */,
|
||||
4E2CE3892DA426710004736A /* ActivityLogEntry.swift */,
|
||||
E1D37F5B2B9CF02600343D2B /* BaseItemDto */,
|
||||
E1343DAC2D4EE4C8003145A8 /* BaseItemKind.swift */,
|
||||
E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */,
|
||||
|
@ -5093,6 +5149,7 @@
|
|||
E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */,
|
||||
E12A9EF729499E0100731C3A /* JellyfinClient.swift */,
|
||||
4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */,
|
||||
4E2CE3922DA432BC0004736A /* LogLevel.swift */,
|
||||
4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */,
|
||||
E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */,
|
||||
E122A9122788EAAD0060FA63 /* MediaStream.swift */,
|
||||
|
@ -5383,6 +5440,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */,
|
||||
4E2CE39E2DA496270004736A /* MediaItemSection.swift */,
|
||||
4E10C81C2CC0465F0012CC9F /* UserSection.swift */,
|
||||
);
|
||||
path = Components;
|
||||
|
@ -6004,6 +6062,7 @@
|
|||
E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */,
|
||||
E187A60529AD2E25008387E6 /* StepperView.swift in Sources */,
|
||||
E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */,
|
||||
4E2CE38B2DA426720004736A /* ActivityLogEntry.swift in Sources */,
|
||||
4EF36F652D962A430065BB79 /* ItemSortBy.swift in Sources */,
|
||||
E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */,
|
||||
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
|
||||
|
@ -6019,6 +6078,7 @@
|
|||
E1575E6A293E77B5001665B1 /* RoundedCorner.swift in Sources */,
|
||||
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
||||
E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
|
||||
4E2CE3942DA432C00004736A /* LogLevel.swift in Sources */,
|
||||
E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
|
||||
4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */,
|
||||
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
|
||||
|
@ -6503,6 +6563,7 @@
|
|||
4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */,
|
||||
4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */,
|
||||
4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */,
|
||||
4E2CE39B2DA479BC0004736A /* ServerActivityDetailViewModel.swift in Sources */,
|
||||
4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */,
|
||||
4E661A162CEFE46300025C99 /* ParentialRatingsSection.swift in Sources */,
|
||||
4E661A172CEFE46300025C99 /* OverviewSection.swift in Sources */,
|
||||
|
@ -6527,6 +6588,7 @@
|
|||
4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */,
|
||||
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */,
|
||||
E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */,
|
||||
4E2CE3982DA446900004736A /* ServerActivityDetailsView.swift in Sources */,
|
||||
E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */,
|
||||
E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */,
|
||||
E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */,
|
||||
|
@ -6673,9 +6735,11 @@
|
|||
E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
|
||||
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */,
|
||||
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
|
||||
4E2CE3932DA432C00004736A /* LogLevel.swift in Sources */,
|
||||
4EE07CBB2D08B19700B0B636 /* ErrorMessage.swift in Sources */,
|
||||
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
|
||||
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
|
||||
4E2CE39F2DA4962A0004736A /* MediaItemSection.swift in Sources */,
|
||||
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */,
|
||||
E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */,
|
||||
E1401CB129386C9200E8B599 /* UIColor.swift in Sources */,
|
||||
|
@ -6716,6 +6780,7 @@
|
|||
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
|
||||
E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */,
|
||||
4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */,
|
||||
4E2CE38E2DA427880004736A /* ServerActivityView.swift in Sources */,
|
||||
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */,
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
|
||||
|
@ -6783,6 +6848,7 @@
|
|||
4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */,
|
||||
4E49DED32CE54D6D00352DCD /* ActiveSessionsPolicy.swift in Sources */,
|
||||
4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */,
|
||||
4E2CE3882DA424CE0004736A /* ServerActivityViewModel.swift in Sources */,
|
||||
4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */,
|
||||
4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */,
|
||||
4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */,
|
||||
|
@ -6922,6 +6988,7 @@
|
|||
E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
|
||||
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
|
||||
4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */,
|
||||
4E2CE38A2DA426720004736A /* ActivityLogEntry.swift in Sources */,
|
||||
535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */,
|
||||
E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */,
|
||||
E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */,
|
||||
|
@ -6963,6 +7030,7 @@
|
|||
E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */,
|
||||
E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
|
||||
4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */,
|
||||
4E2CE3912DA42B320004736A /* ServerActivityEntry.swift in Sources */,
|
||||
E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */,
|
||||
E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */,
|
||||
E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */,
|
||||
|
|
|
@ -53,7 +53,7 @@ struct ActiveSessionDetailView: View {
|
|||
) -> some View {
|
||||
List {
|
||||
|
||||
nowPlayingSection(item: nowPlayingItem)
|
||||
AdminDashboardView.MediaItemSection(item: nowPlayingItem)
|
||||
|
||||
Section(L10n.progress) {
|
||||
ActiveSessionsView.ProgressSection(
|
||||
|
@ -101,67 +101,6 @@ struct ActiveSessionDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Now Playing Section
|
||||
|
||||
@ViewBuilder
|
||||
private func nowPlayingSection(item: BaseItemDto) -> some View {
|
||||
Section {
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
Group {
|
||||
if item.type == .audio {
|
||||
ZStack {
|
||||
Color.clear
|
||||
|
||||
ImageView(item.squareImageSources(maxWidth: 60))
|
||||
.failure {
|
||||
SystemImageContentView(systemName: item.systemImage)
|
||||
}
|
||||
}
|
||||
.squarePosterStyle()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.clear
|
||||
|
||||
ImageView(item.portraitImageSources(maxWidth: 60))
|
||||
.failure {
|
||||
SystemImageContentView(systemName: item.systemImage)
|
||||
}
|
||||
}
|
||||
.posterStyle(.portrait)
|
||||
}
|
||||
}
|
||||
.frame(width: 100)
|
||||
.accessibilityIgnoresInvertColors()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
if let parent = item.parentTitle {
|
||||
Text(parent)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Text(item.displayTitle)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let subtitle = item.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowCornerRadius(0)
|
||||
.listRowInsets(.zero)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let session = box.value {
|
||||
|
|
|
@ -28,6 +28,9 @@ struct AdminDashboardView: View {
|
|||
}
|
||||
|
||||
Section(L10n.activity) {
|
||||
ChevronButton(L10n.activity) {
|
||||
router.route(to: \.activity)
|
||||
}
|
||||
ChevronButton(L10n.devices) {
|
||||
router.route(to: \.devices)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension AdminDashboardView {
|
||||
|
||||
struct MediaItemSection: View {
|
||||
|
||||
let item: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
Group {
|
||||
if item.type == .audio {
|
||||
ZStack {
|
||||
Color.clear
|
||||
|
||||
ImageView(item.squareImageSources(maxWidth: 60))
|
||||
.failure {
|
||||
SystemImageContentView(systemName: item.systemImage)
|
||||
}
|
||||
}
|
||||
.squarePosterStyle()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.clear
|
||||
|
||||
ImageView(item.portraitImageSources(maxWidth: 60))
|
||||
.failure {
|
||||
SystemImageContentView(systemName: item.systemImage)
|
||||
}
|
||||
}
|
||||
.posterStyle(.portrait)
|
||||
}
|
||||
}
|
||||
.frame(width: 100)
|
||||
.accessibilityIgnoresInvertColors()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
if let parent = item.parentTitle {
|
||||
Text(parent)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Text(item.displayTitle)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let subtitle = item.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowCornerRadius(0)
|
||||
.listRowInsets(.zero)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct ServerActivityDetailsView: View {
|
||||
|
||||
// MARK: - Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: AdminDashboardCoordinator.Router
|
||||
|
||||
// MARK: - Activity Log Entry Variable
|
||||
|
||||
@StateObject
|
||||
var viewModel: ServerActivityDetailViewModel
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
/// Item (If Available)
|
||||
if let item = viewModel.item {
|
||||
AdminDashboardView.MediaItemSection(item: item)
|
||||
}
|
||||
|
||||
/// User (If Available)
|
||||
if let user = viewModel.user {
|
||||
AdminDashboardView.UserSection(
|
||||
user: user,
|
||||
lastActivityDate: viewModel.log.date
|
||||
) {
|
||||
router.route(to: \.userDetails, user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Event Name & Overview
|
||||
Section(L10n.overview) {
|
||||
if let name = viewModel.log.name, name.isNotEmpty {
|
||||
Text(name)
|
||||
}
|
||||
if let overview = viewModel.log.overview, overview.isNotEmpty {
|
||||
Text(overview)
|
||||
} else if let shortOverview = viewModel.log.shortOverview, shortOverview.isNotEmpty {
|
||||
Text(shortOverview)
|
||||
}
|
||||
}
|
||||
|
||||
/// Event Details
|
||||
Section(L10n.details) {
|
||||
if let severity = viewModel.log.severity {
|
||||
TextPairView(
|
||||
leading: L10n.level,
|
||||
trailing: severity.displayTitle
|
||||
)
|
||||
}
|
||||
if let type = viewModel.log.type {
|
||||
TextPairView(
|
||||
leading: L10n.type,
|
||||
trailing: type
|
||||
)
|
||||
}
|
||||
if let date = viewModel.log.date {
|
||||
TextPairView(
|
||||
leading: L10n.date,
|
||||
trailing: date.formatted(date: .long, time: .shortened)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(
|
||||
L10n.activityLog
|
||||
.localizedCapitalized
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onFirstAppear {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension ServerActivityView {
|
||||
|
||||
struct LogEntry: View {
|
||||
|
||||
// MARK: - Activity Log Entry Variable
|
||||
|
||||
@StateObject
|
||||
var viewModel: ServerActivityDetailViewModel
|
||||
|
||||
// MARK: - Action Variable
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ListRow {
|
||||
userImage
|
||||
} content: {
|
||||
rowContent
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.onSelect(perform: action)
|
||||
}
|
||||
|
||||
// MARK: - User Image
|
||||
|
||||
@ViewBuilder
|
||||
private var userImage: some View {
|
||||
let imageSource = viewModel.user?.profileImageSource(client: viewModel.userSession.client, maxWidth: 60) ?? .init()
|
||||
|
||||
UserProfileImage(
|
||||
userID: viewModel.log.userID ?? viewModel.userSession?.user.id,
|
||||
source: imageSource
|
||||
) {
|
||||
SystemImageContentView(
|
||||
systemName: viewModel.user != nil ? "person.fill" : "gearshape.fill",
|
||||
ratio: 0.5
|
||||
)
|
||||
}
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
|
||||
// MARK: - User Image
|
||||
|
||||
@ViewBuilder
|
||||
private var rowContent: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
/// Event Severity & Username / System
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: viewModel.log.severity?.systemImage ?? "questionmark.circle")
|
||||
.foregroundStyle(viewModel.log.severity?.color ?? .gray)
|
||||
|
||||
if viewModel.user != nil {
|
||||
Text(viewModel.user?.name ?? L10n.unknown)
|
||||
} else {
|
||||
Text(L10n.system)
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
|
||||
/// Event Name
|
||||
Text(viewModel.log.name ?? .emptyDash)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Group {
|
||||
if let eventDate = viewModel.log.date {
|
||||
Text(eventDate.formatted(date: .abbreviated, time: .standard))
|
||||
} else {
|
||||
Text(String.emptyTime)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.padding()
|
||||
.font(.body.weight(.regular))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
//
|
||||
// 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) 2025 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CollectionVGrid
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct ServerActivityView: View {
|
||||
|
||||
// MARK: - Environment Objects
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: AdminDashboardCoordinator.Router
|
||||
|
||||
// MARK: - State Objects
|
||||
|
||||
@StateObject
|
||||
private var viewModel = ServerActivityViewModel()
|
||||
|
||||
// MARK: - Dialog States
|
||||
|
||||
@State
|
||||
private var isDatePickerShowing: Bool = false
|
||||
@State
|
||||
private var tempDate: Date?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .content:
|
||||
contentView
|
||||
case let .error(error):
|
||||
ErrorView(error: error)
|
||||
.onRetry {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
case .initial, .refreshing:
|
||||
DelayedProgressView()
|
||||
}
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: viewModel.state)
|
||||
.navigationTitle(L10n.activity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarMenuButton(
|
||||
isLoading: viewModel.backgroundStates.contains(.gettingNextPage)
|
||||
) {
|
||||
Section(L10n.filters) {
|
||||
startDateButton
|
||||
userFilterButton
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
viewModel.send(.refresh)
|
||||
}
|
||||
.sheet(isPresented: $isDatePickerShowing, onDismiss: { isDatePickerShowing = false }) {
|
||||
startDatePickerSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
if viewModel.elements.isEmpty {
|
||||
Text(L10n.none)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
} else {
|
||||
CollectionVGrid(
|
||||
uniqueElements: viewModel.elements,
|
||||
id: \.unwrappedIDHashOrZero,
|
||||
layout: .columns(1)
|
||||
) { log in
|
||||
|
||||
let user = viewModel.users.first(
|
||||
property: \.id,
|
||||
equalTo: log.userID
|
||||
)
|
||||
|
||||
let logViewModel = ServerActivityDetailViewModel(
|
||||
log: log,
|
||||
user: user
|
||||
)
|
||||
|
||||
LogEntry(viewModel: logViewModel) {
|
||||
router.route(to: \.activityDetails, logViewModel)
|
||||
}
|
||||
}
|
||||
.onReachedBottomEdge(offset: .offset(300)) {
|
||||
viewModel.send(.getNextPage)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Filter Button
|
||||
|
||||
@ViewBuilder
|
||||
private var userFilterButton: some View {
|
||||
Menu(
|
||||
L10n.type,
|
||||
systemImage: viewModel.hasUserId == true ? "person.fill" :
|
||||
viewModel.hasUserId == false ? "gearshape.fill" : "line.3.horizontal"
|
||||
) {
|
||||
Picker(L10n.type, selection: $viewModel.hasUserId) {
|
||||
Section {
|
||||
Label(
|
||||
L10n.all,
|
||||
systemImage: "line.3.horizontal"
|
||||
)
|
||||
.tag(nil as Bool?)
|
||||
}
|
||||
|
||||
Label(
|
||||
L10n.users,
|
||||
systemImage: "person"
|
||||
)
|
||||
.tag(true as Bool?)
|
||||
|
||||
Label(
|
||||
L10n.system,
|
||||
systemImage: "gearshape"
|
||||
)
|
||||
.tag(false as Bool?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Start Date Button
|
||||
|
||||
@ViewBuilder
|
||||
private var startDateButton: some View {
|
||||
Button(L10n.startDate, systemImage: "calendar") {
|
||||
if let minDate = viewModel.minDate {
|
||||
tempDate = minDate
|
||||
} else {
|
||||
tempDate = .now
|
||||
}
|
||||
isDatePickerShowing = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Start Date Picker Sheet
|
||||
|
||||
@ViewBuilder
|
||||
private var startDatePickerSheet: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
DatePicker(
|
||||
L10n.date,
|
||||
selection: $tempDate.coalesce(.now),
|
||||
in: ...Date.now,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
/// Reset button to remove the filter
|
||||
if viewModel.minDate != nil {
|
||||
Section {
|
||||
ListRowButton(L10n.reset, role: .destructive) {
|
||||
viewModel.minDate = nil
|
||||
isDatePickerShowing = false
|
||||
}
|
||||
} footer: {
|
||||
Text(L10n.resetFilterFooter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.startDate.localizedCapitalized)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
isDatePickerShowing = false
|
||||
}
|
||||
.topBarTrailing {
|
||||
let startOfDay = Calendar.current
|
||||
.startOfDay(for: tempDate ?? .now)
|
||||
|
||||
Button(L10n.save) {
|
||||
viewModel.minDate = startOfDay
|
||||
isDatePickerShowing = false
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Loading…
Reference in New Issue