[tvOS] ErrorViews - Creation (#1414)

* Button cleanup & errorViews

* Change the Sign Out button to be `ListRowButton`. Sets a better height value using `maxHeight` to ensure that it doesn't exceed the `ListRow` sizing.

* deleteUsersButton needs to be manually set back to 75

* wip

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-02-15 15:22:30 -07:00 committed by GitHub
parent 6ee2b71cab
commit 846aabc868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 179 additions and 154 deletions

View File

@ -0,0 +1,52 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
// TODO: should use environment refresh instead?
struct ErrorView<ErrorType: Error>: View {
@Default(.accentColor)
private var accentColor
private let error: ErrorType
private var onRetry: (() -> Void)?
var body: some View {
VStack(spacing: 20) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 150))
.foregroundColor(Color.red)
Text(error.localizedDescription)
.frame(minWidth: 250, maxWidth: 750)
.multilineTextAlignment(.center)
if let onRetry {
ListRowButton(L10n.retry, action: onRetry)
.foregroundStyle(accentColor.overlayColor, accentColor)
.frame(maxWidth: 750)
}
}
}
}
extension ErrorView {
init(error: ErrorType) {
self.init(
error: error,
onRetry: nil
)
}
func onRetry(_ action: @escaping () -> Void) -> Self {
copy(modifying: \.onRetry, with: action)
}
}

View File

@ -8,25 +8,42 @@
import SwiftUI
// TODO: on focus, make the cancel and destructive style
// match style like in an `alert`
struct ListRowButton: View {
// MARK: - Environment
@Environment(\.isEnabled)
private var isEnabled
// MARK: - Focus State
@FocusState
private var isFocused: Bool
// MARK: - Button Variables
let title: String
let role: ButtonRole?
let action: () -> Void
// MARK: - Initializer
init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) {
self.title = title
self.role = role
self.action = action
}
// MARK: - Body
var body: some View {
Button {
action()
} label: {
Button(action: action) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(secondaryStyle)
.brightness(isFocused ? 0.25 : 0)
Text(title)
.foregroundStyle(primaryStyle)
@ -34,24 +51,27 @@ struct ListRowButton: View {
}
}
.buttonStyle(.card)
.frame(height: 75)
.frame(maxHeight: 75)
.focused($isFocused)
}
// MARK: - Styles
// MARK: - Primary Style
private var primaryStyle: some ShapeStyle {
if role == .destructive {
if role == .destructive || role == .cancel {
return AnyShapeStyle(Color.red)
} else {
return AnyShapeStyle(.primary)
return AnyShapeStyle(HierarchicalShapeStyle.primary)
}
}
// MARK: - Secondary Style
private var secondaryStyle: some ShapeStyle {
if role == .destructive {
if role == .destructive || role == .cancel {
return AnyShapeStyle(Color.red.opacity(0.2))
} else {
return AnyShapeStyle(.secondary)
return AnyShapeStyle(HierarchicalShapeStyle.secondary)
}
}
}

View File

@ -18,9 +18,19 @@ struct AppLoadingView: View {
ZStack {
Color.clear
if didFailMigration {
Text("An internal error occurred.")
if !didFailMigration {
ProgressView()
}
if didFailMigration {
ErrorView(error: JellyfinAPIError("An internal error occurred."))
}
}
.topBarTrailing {
Button(L10n.advanced, systemImage: "gearshape.fill") {}
.foregroundStyle(.secondary)
.disabled(true)
.opacity(didFailMigration ? 0 : 1)
}
.onNotification(.didFailMigration) { _ in
didFailMigration = true

View File

@ -40,7 +40,7 @@ struct ChannelLibraryView: View {
}
var body: some View {
WrappedView {
ZStack {
switch viewModel.state {
case .content:
if viewModel.elements.isEmpty {
@ -49,11 +49,15 @@ struct ChannelLibraryView: View {
contentView
}
case let .error(error):
Text(error.localizedDescription)
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
case .initial, .refreshing:
ProgressView()
}
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea()
.onFirstAppear {
if viewModel.state == .initial {

View File

@ -1,70 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
// TODO: make general `ErrorView` like iOS
#warning("TODO: implement")
extension HomeView {
struct ErrorView: View {
@ObservedObject
var viewModel: HomeViewModel
var body: some View {
Text("TODO")
}
}
}
// extension HomeView {
//
// struct ErrorView: View {
//
// @ObservedObject
// var viewModel: HomeViewModel
//
// let errorMessage: ErrorMessage
//
// var body: some View {
// VStack {
// if viewModel.isLoading {
// ProgressView()
// .frame(width: 100, height: 100)
// .scaleEffect(2)
// } else {
// Image(systemName: "xmark.circle.fill")
// .font(.system(size: 72))
// .foregroundColor(Color.red)
// .frame(width: 100, height: 100)
// }
//
//// Text("\(errorMessage.code)")
//
// Text(errorMessage.message)
// .frame(minWidth: 50, maxWidth: 240)
// .multilineTextAlignment(.center)
//
// Button {
//// viewModel.refresh()
// } label: {
// L10n.retry.text
// .bold()
// .font(.callout)
// .frame(width: 400, height: 75)
// .background(Color.jellyfinPurple)
// }
// .buttonStyle(.card)
// }
// .offset(y: -50)
// }
// }
// }

View File

@ -50,19 +50,23 @@ struct HomeView: View {
}
var body: some View {
WrappedView {
Group {
ZStack {
// This keeps the ErrorView vertically aligned with the PagingLibraryView
Color.clear
switch viewModel.state {
case .content:
contentView
case let .error(error):
Text(error.localizedDescription)
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
case .initial, .refreshing:
ProgressView()
}
}
.transition(.opacity.animation(.linear(duration: 0.2)))
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.onFirstAppear {
viewModel.send(.refresh)
}

View File

@ -52,17 +52,20 @@ struct ItemView: View {
}
var body: some View {
WrappedView {
ZStack {
switch viewModel.state {
case .content:
contentView
case let .error(error):
Text(error.localizedDescription)
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
case .initial, .refreshing:
ProgressView()
}
}
.transition(.opacity.animation(.linear(duration: 0.2)))
.animation(.linear(duration: 0.1), value: viewModel.state)
.onFirstAppear {
viewModel.send(.refresh)
}

View File

@ -52,19 +52,23 @@ struct MediaView: View {
}
var body: some View {
WrappedView {
Group {
ZStack {
// This keeps the ErrorView vertically aligned with the PagingLibraryView
Color.clear
switch viewModel.state {
case .content:
contentView
case let .error(error):
Text(error.localizedDescription)
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
case .initial, .refreshing:
ProgressView()
}
}
.transition(.opacity.animation(.linear(duration: 0.2)))
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea()
.onFirstAppear {
viewModel.send(.refresh)

View File

@ -247,11 +247,10 @@ struct PagingLibraryView<Element: Poster & Identifiable>: View {
@ViewBuilder
private func errorView(with error: some Error) -> some View {
Text(error.localizedDescription)
/* ErrorView(error: error)
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
} */
}
}
// MARK: Grid View

View File

@ -78,7 +78,7 @@ struct ProgramsView: View {
}
var body: some View {
WrappedView {
ZStack {
switch programsViewModel.state {
case .content:
if programsViewModel.hasNoResults {
@ -87,11 +87,15 @@ struct ProgramsView: View {
contentView
}
case let .error(error):
Text(error.localizedDescription)
ErrorView(error: error)
.onRetry {
programsViewModel.send(.refresh)
}
case .initial, .refreshing:
ProgressView()
}
}
.animation(.linear(duration: 0.1), value: programsViewModel.state)
.ignoresSafeArea(edges: [.bottom, .horizontal])
.onFirstAppear {
if programsViewModel.state == .initial {

View File

@ -47,7 +47,7 @@ struct QuickConnectView: View {
}
var body: some View {
WrappedView {
ZStack {
switch viewModel.state {
case .idle, .authenticated:
Color.clear
@ -56,10 +56,13 @@ struct QuickConnectView: View {
case let .polling(code):
pollingView(code: code)
case let .error(error):
Text(error.localizedDescription)
// ErrorView(error: error)
ErrorView(error: error)
.onRetry {
viewModel.start()
}
}
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.edgePadding()
.navigationTitle(L10n.quickConnect)
.onFirstAppear {

View File

@ -110,11 +110,8 @@ struct SearchView: View {
}
var body: some View {
WrappedView {
Group {
ZStack {
switch viewModel.state {
case let .error(error):
Text(error.localizedDescription)
case .initial:
suggestionsView
case .content:
@ -123,12 +120,16 @@ struct SearchView: View {
} else {
resultsView
}
case let .error(error):
ErrorView(error: error)
.onRetry {
viewModel.send(.search(query: searchQuery))
}
case .searching:
ProgressView()
}
}
.transition(.opacity.animation(.linear(duration: 0.2)))
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea(edges: [.bottom, .horizontal])
.onFirstAppear {
viewModel.send(.getSuggestions)

View File

@ -78,7 +78,7 @@ extension SelectUserView {
ListRowButton(L10n.delete, role: .destructive) {
onDelete()
}
.frame(width: 400, height: 50)
.frame(width: 400, height: 75)
.disabled(!areUsersSelected)
}

View File

@ -44,22 +44,14 @@ struct SettingsView: View {
.onSelect {
router.route(to: \.serverDetail, viewModel.userSession.server)
}
}
Button {
Section {
ListRowButton(L10n.switchUser) {
viewModel.signOut()
} label: {
HStack {
Text(L10n.switchUser)
.foregroundColor(.jellyfinPurple)
Spacer()
Image(systemName: "chevron.right")
.font(.body.weight(.regular))
.foregroundColor(.secondary)
}
}
.foregroundStyle(Color.jellyfinPurple.overlayColor, Color.jellyfinPurple)
.listRowInsets(.zero)
}
Section(L10n.videoPlayer) {

View File

@ -85,10 +85,9 @@ struct UserSignInView: View {
}
if case .signingIn = viewModel.state {
ListRowButton(L10n.cancel) {
ListRowButton(L10n.cancel, role: .cancel) {
viewModel.send(.cancel)
}
.foregroundStyle(.red, accentColor)
.padding(.vertical)
} else {
ListRowButton(L10n.signIn) {

View File

@ -244,6 +244,7 @@
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; };
4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; };
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEEEA232CFA8E1500527D79 /* NavigationBarMenuButton.swift */; };
4EF0DCA92D49751B005A5194 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF0DCA82D49751B005A5194 /* ErrorView.swift */; };
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B252CB9934700343666 /* LibraryRow.swift */; };
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; };
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
@ -1015,7 +1016,6 @@
E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */; };
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */; };
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 */; };
@ -1456,6 +1456,7 @@
4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; };
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
4EEEEA232CFA8E1500527D79 /* NavigationBarMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarMenuButton.swift; sourceTree = "<group>"; };
4EF0DCA82D49751B005A5194 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = "<group>"; };
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
@ -1953,7 +1954,6 @@
E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedProgressView.swift; sourceTree = "<group>"; };
E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = "<group>"; };
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>"; };
@ -3361,6 +3361,7 @@
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */,
E1C92618288756BD002A7A66 /* DotHStack.swift */,
E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */,
4EF0DCA82D49751B005A5194 /* ErrorView.swift */,
E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */,
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */,
E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */,
@ -4968,7 +4969,6 @@
isa = PBXGroup;
children = (
E12CC1C328D12D6300678D5D /* Components */,
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */,
531690E6267ABD79005D8AB9 /* HomeView.swift */,
);
path = HomeView;
@ -5931,7 +5931,6 @@
4E2AC4C32C6C491200DD600D /* AudoCodec.swift in Sources */,
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
@ -6107,6 +6106,7 @@
E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */,
E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */,
E1ED7FE22CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */,
4EF0DCA92D49751B005A5194 /* ErrorView.swift in Sources */,
E1CB75762C80EAFA00217C76 /* ArrayBuilder.swift in Sources */,
E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */,
E1E6C45129B104850064123F /* Button.swift in Sources */,