Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[iOS] Admin Dashboard - Task Triggers, Device Management, & Device Posters #1255

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Shared/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ final class SettingsCoordinator: NavigationCoordinatable {
@Route(.modal)
var itemOverviewView = makeItemOverviewView
@Route(.push)
var devices = makeDevices
@Route(.push)
var tasks = makeTasks
@Route(.push)
var editScheduledTask = makeEditScheduledTask
@Route(.modal)
var addScheduledTaskTrigger = makeAddScheduledTaskTrigger
@Route(.push)
var serverLogs = makeServerLogs

Expand Down Expand Up @@ -188,11 +192,22 @@ final class SettingsCoordinator: NavigationCoordinatable {
ScheduledTasksView()
}

@ViewBuilder
func makeDevices() -> some View {
DevicesView()
}

@ViewBuilder
func makeEditScheduledTask(observer: ServerTaskObserver) -> some View {
EditScheduledTaskView(observer: observer)
}

func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
AddTaskTriggerView(observer: observer)
}
}

@ViewBuilder
func makeServerLogs() -> some View {
ServerLogsView()
Expand Down
32 changes: 32 additions & 0 deletions Shared/Extensions/JellyfinAPI/DayOfWeek.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation
import JellyfinAPI

extension DayOfWeek {

var displayTitle: String {
switch self {
case .sunday:
return L10n.dayOfWeekSunday
case .monday:
return L10n.dayOfWeekMonday
case .tuesday:
return L10n.dayOfWeekTuesday
case .wednesday:
return L10n.dayOfWeekWednesday
case .thursday:
return L10n.dayOfWeekThursday
case .friday:
return L10n.dayOfWeekFriday
case .saturday:
return L10n.dayOfWeekSaturday
JPKribs marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
24 changes: 24 additions & 0 deletions Shared/Extensions/JellyfinAPI/TaskInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation
import JellyfinAPI

extension TaskState {

var displayTitle: String {
switch self {
case .idle:
return L10n.taskStateIdle
case .cancelling:
return L10n.taskStateCancelling
case .running:
return L10n.taskStateRunning
}
}
}
45 changes: 45 additions & 0 deletions Shared/Extensions/TaskTriggerType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Foundation

// TODO: move to SDK as patch file

enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemImageable {

case daily = "DailyTrigger"
case weekly = "WeeklyTrigger"
case interval = "IntervalTrigger"
case startup = "StartupTrigger"

var displayTitle: String {
switch self {
case .daily:
return L10n.daily
case .weekly:
return L10n.weekly
case .interval:
return L10n.interval
case .startup:
return L10n.onApplicationStartup
}
}

var systemImage: String {
switch self {
case .daily:
return "clock"
case .weekly:
return "calendar"
case .interval:
return "timer"
case .startup:
return "power"
}
}
}
130 changes: 130 additions & 0 deletions Shared/Strings/Strings.swift

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions Shared/ViewModels/DevicesViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//

import Combine
import Foundation
import JellyfinAPI
import OrderedCollections
import SwiftUI

final class DevicesViewModel: ViewModel, Stateful {

// MARK: - Action

enum Action: Equatable {
case getDevices
case setCustomName(id: String, newName: String)
case deleteDevice(id: String)
case deleteAllDevices
}

// MARK: - BackgroundState

enum BackgroundState: Hashable {
case gettingDevices
case settingCustomName
case deletingDevice
case deletingAllDevices
}

// MARK: - State

enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
}

@Published
final var backgroundStates: OrderedSet<BackgroundState> = []
@Published
final var devices: OrderedDictionary<String, BindingBox<DeviceInfo?>> = [:]
@Published
final var state: State = .initial

private var deviceTask: AnyCancellable?

// MARK: - Respond to Action

func respond(to action: Action) -> State {
switch action {
case .getDevices:
deviceTask?.cancel()

deviceTask = Task { [weak self] in
await MainActor.run {
let _ = self?.backgroundStates.append(.gettingDevices)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be simplified a little by adding it to the states before creating the task?

}

do {
try await self?.loadDevices()
await MainActor.run {
self?.state = .content
}
} catch {
guard let self else { return }
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}

await MainActor.run {
let _ = self?.backgroundStates.remove(.gettingDevices)
}
}
.asAnyCancellable()

return state

case let .setCustomName(id, newName):
deviceTask?.cancel()

deviceTask = Task { [weak self] in
await MainActor.run {
let _ = self?.backgroundStates.append(.settingCustomName)
}

do {
try await self?.setCustomName(id: id, newName: newName)
await MainActor.run {
self?.state = .content
}
} catch {
guard let self else { return }
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}

await MainActor.run {
let _ = self?.backgroundStates.remove(.settingCustomName)
}
}
.asAnyCancellable()

return state

case let .deleteDevice(id):
deviceTask?.cancel()

deviceTask = Task { [weak self] in
await MainActor.run {
let _ = self?.backgroundStates.append(.deletingDevice)
}

do {
try await self?.deleteDevice(id: id)
await MainActor.run {
self?.state = .content
}
} catch {
guard let self else { return }
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}

await MainActor.run {
let _ = self?.backgroundStates.remove(.deletingDevice)
}
}
.asAnyCancellable()

return state

case .deleteAllDevices:
deviceTask?.cancel()

deviceTask = Task { [weak self] in
await MainActor.run {
let _ = self?.backgroundStates.append(.deletingAllDevices)
}

do {
try await self?.deleteAllDevices()
await MainActor.run {
self?.state = .content
}
} catch {
guard let self else { return }
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}

await MainActor.run {
let _ = self?.backgroundStates.remove(.deletingAllDevices)
}
}
.asAnyCancellable()

return state
}
}

// MARK: - Load Devices

private func loadDevices() async throws {
let request = Paths.getDevices()
let response = try await userSession.client.send(request)

await MainActor.run {
if let devices = response.value.items {
for device in devices {
guard let id = device.id else { continue }

if let existingDevice = self.devices[id] {
existingDevice.value = device
} else {
self.devices[id] = BindingBox<DeviceInfo?>(
source: .init(get: { device }, set: { _ in })
)
}
}

self.devices.sort { x, y in
let device0 = x.value.value
let device1 = y.value.value
return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date())
}
}
}
}

// MARK: - Set Custom Name

private func setCustomName(id: String, newName: String) async throws {
let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName))
try await userSession.client.send(request)

if let device = self.devices[id]?.value {
await MainActor.run {
self.devices[id]?.value?.name = newName
}
}
}

// MARK: - Delete Device

private func deleteDevice(id: String) async throws {
// Don't allow self-deletion
guard id != userSession.client.configuration.deviceID else {
return
}

let request = Paths.deleteDevice(id: id)
try await userSession.client.send(request)

await MainActor.run {
self.devices.removeValue(forKey: id)
}
}

// MARK: - Delete All Devices

private func deleteAllDevices() async throws {
let deviceIdsToDelete = self.devices.keys.filter { $0 != userSession.client.configuration.deviceID }

for deviceId in deviceIdsToDelete {
try await deleteDevice(id: deviceId)
}

await MainActor.run {
self.devices = self.devices.filter { $0.key == userSession.client.configuration.deviceID }
}
}
}
Loading
Loading