diff --git a/Core/Core/Common/CommonModels/AppEnvironment/BrandStyle/Brand.swift b/Core/Core/Common/CommonModels/AppEnvironment/BrandStyle/Brand.swift index f65b497091..fa744507c1 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/BrandStyle/Brand.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/BrandStyle/Brand.swift @@ -241,14 +241,28 @@ public struct Brand: Equatable { } } - public func headerImageView() -> UIImageView { - let logoView = UIImageView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) - logoView.contentMode = .scaleAspectFit - logoView.heightAnchor.constraint(equalToConstant: 44).isActive = true - logoView.widthAnchor.constraint(equalToConstant: 44).isActive = true - logoView.image = headerImage - logoView.backgroundColor = headerImageBackground - logoView.accessibilityElementsHidden = true - return logoView - } + @available(iOS, deprecated: 26, message: "Remove conditional code when iOS 18 support is dropped") + public func headerImageView() -> UIImageView { + if #available(iOS 26, *) { + let logoView = UIImageView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) + logoView.contentMode = .scaleAspectFit + logoView.heightAnchor.constraint(equalToConstant: 44).isActive = true + logoView.widthAnchor.constraint(equalToConstant: 44).isActive = true + logoView.image = headerImage + logoView.backgroundColor = headerImageBackground + logoView.layer.cornerRadius = 8 + logoView.clipsToBounds = true + logoView.accessibilityElementsHidden = true + return logoView + } else { + let logoView = UIImageView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) + logoView.contentMode = .scaleAspectFit + logoView.heightAnchor.constraint(equalToConstant: 44).isActive = true + logoView.widthAnchor.constraint(equalToConstant: 44).isActive = true + logoView.image = headerImage + logoView.backgroundColor = headerImageBackground + logoView.accessibilityElementsHidden = true + return logoView + } + } } diff --git a/Core/Core/Common/CommonModels/Router/Router.swift b/Core/Core/Common/CommonModels/Router/Router.swift index 8f0445ce81..3d4d87a2ff 100644 --- a/Core/Core/Common/CommonModels/Router/Router.swift +++ b/Core/Core/Common/CommonModels/Router/Router.swift @@ -257,7 +257,7 @@ open class Router { if view is UIAlertController { return from.present(view, animated: true, completion: completion) } - if let displayModeButton = from.splitDisplayModeButtonItem, + if let displayModeButton = from.splitDisplayModeButtonItem, #unavailable(iOS 26), from.splitViewController?.isCollapsed == false, options.isDetail || from.isInSplitViewDetail, !options.isModal { diff --git a/Core/Core/Common/CommonUI/EmbeddedWebPage/View/EmbeddedWebPageContainerScreen.swift b/Core/Core/Common/CommonUI/EmbeddedWebPage/View/EmbeddedWebPageContainerScreen.swift index 42ee7d2d80..19b791be74 100644 --- a/Core/Core/Common/CommonUI/EmbeddedWebPage/View/EmbeddedWebPageContainerScreen.swift +++ b/Core/Core/Common/CommonUI/EmbeddedWebPage/View/EmbeddedWebPageContainerScreen.swift @@ -40,22 +40,42 @@ public struct EmbeddedWebPageContainerScreen: View { } public var body: some View { - WebSession(url: viewModel.url) { sessionURL in - WebView( - url: sessionURL, - features: features, - canToggleTheme: true, - configuration: viewModel.webViewConfig - ) - .onProvisionalNavigationStarted { webView, navigation in - viewModel.webView(webView, didStartProvisionalNavigation: navigation) + if #available(iOS 26, *) { + WebSession(url: viewModel.url) { sessionURL in + WebView( + url: sessionURL, + features: features, + canToggleTheme: true, + configuration: viewModel.webViewConfig + ) + .onProvisionalNavigationStarted { webView, navigation in + viewModel.webView(webView, didStartProvisionalNavigation: navigation) + } + .navBarItems(leading: viewModel.leadingNavigationButton) + .onAppear { + viewModel.viewController = viewController.value + } } - .navBarItems(leading: viewModel.leadingNavigationButton) - .onAppear { - viewModel.viewController = viewController.value + .navigationTitle(viewModel.navTitle) + .optionalNavigationSubtitle(viewModel.subTitle) + } else { + WebSession(url: viewModel.url) { sessionURL in + WebView( + url: sessionURL, + features: features, + canToggleTheme: true, + configuration: viewModel.webViewConfig + ) + .onProvisionalNavigationStarted { webView, navigation in + viewModel.webView(webView, didStartProvisionalNavigation: navigation) + } + .navBarItems(leading: viewModel.leadingNavigationButton) + .onAppear { + viewModel.viewController = viewController.value + } } + .navigationBarTitleView(title: viewModel.navTitle, subtitle: viewModel.subTitle) + .navigationBarStyle(.color(viewModel.contextColor)) } - .navigationBarTitleView(title: viewModel.navTitle, subtitle: viewModel.subTitle) - .navigationBarStyle(.color(viewModel.contextColor)) } } diff --git a/Core/Core/Common/CommonUI/InstUI/Views/NavigationBarTitleView.swift b/Core/Core/Common/CommonUI/InstUI/Views/NavigationBarTitleView.swift index 0ecb8023c4..ac0b02b98e 100644 --- a/Core/Core/Common/CommonUI/InstUI/Views/NavigationBarTitleView.swift +++ b/Core/Core/Common/CommonUI/InstUI/Views/NavigationBarTitleView.swift @@ -20,6 +20,7 @@ import SwiftUI extension InstUI { + @available(iOS, deprecated: 26) public struct NavigationBarTitleView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.navBarColors) private var navBarColors diff --git a/Core/Core/Common/CommonUI/NavigationBar/SwiftUI/NavigationBarViewModifiers.swift b/Core/Core/Common/CommonUI/NavigationBar/SwiftUI/NavigationBarViewModifiers.swift index 77a420aec4..02a331bb32 100644 --- a/Core/Core/Common/CommonUI/NavigationBar/SwiftUI/NavigationBarViewModifiers.swift +++ b/Core/Core/Common/CommonUI/NavigationBar/SwiftUI/NavigationBarViewModifiers.swift @@ -18,10 +18,12 @@ import SwiftUI +@available(iOS, deprecated: 26) protocol NavigationBarStyled: AnyObject { var navigationBarStyle: NavigationBarStyle { get set } } +@available(iOS, deprecated: 26) struct NavigationBarStyleModifier: ViewModifier { let style: NavigationBarStyle @@ -47,6 +49,7 @@ struct RightBarButtonItemModifier: ViewModifier { } } +@available(iOS, deprecated: 26) struct GlobalNavigationBarModifier: ViewModifier { @Environment(\.viewController) var controller @@ -56,6 +59,7 @@ struct GlobalNavigationBarModifier: ViewModifier { } } +@available(iOS, deprecated: 26) struct NavBarBackButtonModifier: ViewModifier { @Environment(\.viewController) private var controller @@ -67,6 +71,26 @@ struct NavBarBackButtonModifier: ViewModifier { } extension View { + @available(iOS, introduced: 26) + @ViewBuilder + public func optionalNavigationSubtitle(_ subtitle: String?) -> some View { + if let subtitle { + self.navigationSubtitle(subtitle) + } else { + self + } + } + + @available(iOS, introduced: 26) + @ViewBuilder + public func optionalNavigationTitle(_ title: String?) -> some View { + if let title { + self.navigationTitle(title) + } else { + self + } + } + /// Sets the navigation bar's background color, title color & font, button color & font. /// - Warning: Make sure to call this method AFTER calling `navigationBarTitleView()` to affect it. /// - Parameters: @@ -75,6 +99,7 @@ extension View { /// - `.modal` is primarily used on modal screens, but also on some screen which doesn't belong to a context, but not considered global. /// - `.color()` is used on non-modal screens within a context (typically a course or group), and in some other cases. /// - Use `.color(nil)` to keep the navigation bar's current context background color but ensure the proper title color is set. + @available(iOS, deprecated: 26) public func navigationBarStyle(_ style: NavigationBarStyle) -> some View { modifier(NavigationBarStyleModifier(style: style)) } @@ -84,6 +109,7 @@ extension View { /// - Parameters: /// - title: The line is always displayed, even if this is empty. (This should not happen normally.) /// - subtitle: The subtitle line is only displayed if this is not empty. + @available(iOS, deprecated: 26) public func navigationBarTitleView(title: String, subtitle: String?) -> some View { toolbar { ToolbarItem(placement: .principal) { @@ -94,12 +120,14 @@ extension View { /// Sets the navigation bar's title, using the proper font. Please use this one instead of the native `navigationTitle()` method. /// - Warning: Make sure to call `navigationBarStyle()` _**AFTER**_ this method to set the proper text color. + @available(iOS, deprecated: 26) public func navigationBarTitleView(_ title: String) -> some View { navigationBarTitleView(title: title, subtitle: nil) } /// Sets the navigation bar's background color, button color to match the `Brand.shared` colors, /// sets the button font and sets the brand logo as the titleView. + @available(iOS, deprecated: 26) public func navigationBarGlobal() -> some View { modifier(GlobalNavigationBarModifier()) } diff --git a/Core/Core/Common/CommonUI/NavigationBar/UIKit/ColoredNavViewProtocol.swift b/Core/Core/Common/CommonUI/NavigationBar/UIKit/ColoredNavViewProtocol.swift index 9c3a8c7608..972381b5c4 100644 --- a/Core/Core/Common/CommonUI/NavigationBar/UIKit/ColoredNavViewProtocol.swift +++ b/Core/Core/Common/CommonUI/NavigationBar/UIKit/ColoredNavViewProtocol.swift @@ -18,6 +18,7 @@ import UIKit +@available(iOS, deprecated: 26) public protocol ColoredNavViewProtocol: AnyObject { var color: UIColor? { get set } var navigationController: UINavigationController? { get } @@ -28,13 +29,19 @@ public protocol ColoredNavViewProtocol: AnyObject { } extension ColoredNavViewProtocol { + @available(iOS, deprecated: 26) public func setupTitleViewInNavbar(title: String) { + guard #unavailable(iOS 26) else { return } + navigationItem.titleView = titleSubtitleView titleSubtitleView.title = title titleSubtitleView.accessibilityTraits = .header } + @available(iOS, deprecated: 26) public func updateNavBar(subtitle: String?, color: UIColor?) { + guard #unavailable(iOS 26) else { return } + self.color = color titleSubtitleView.subtitle = subtitle navigationController?.navigationBar.useContextColor(color) diff --git a/Core/Core/Common/CommonUI/NavigationBar/UIKit/TitleSubtitleView.swift b/Core/Core/Common/CommonUI/NavigationBar/UIKit/TitleSubtitleView.swift index e4a85fee92..9b87a82baf 100644 --- a/Core/Core/Common/CommonUI/NavigationBar/UIKit/TitleSubtitleView.swift +++ b/Core/Core/Common/CommonUI/NavigationBar/UIKit/TitleSubtitleView.swift @@ -18,6 +18,7 @@ import UIKit +@available(iOS, deprecated: 26) public class TitleSubtitleView: UIView { @IBOutlet public weak var titleLabel: UILabel! @IBOutlet public weak var subtitleLabel: UILabel! @@ -51,27 +52,37 @@ public class TitleSubtitleView: UIView { } public static func create() -> Self { - let view = loadFromXib() - view.titleLabel.text = "" - view.subtitleLabel.text = "" - view.titleLabel.font = .scaledNamedFont(.semibold16) - view.subtitleLabel.font = .scaledNamedFont(.regular14) - view.titleLabel.accessibilityElementsHidden = true - view.subtitleLabel.accessibilityElementsHidden = true - view.accessibilityTraits = [.header] - view.showsLargeContentViewer = true - view.addInteraction(UILargeContentViewerInteraction()) - return view + let view = loadFromXib() + + if #available(iOS 26, *) { + view.tintColor = .textDarkest + } + + view.titleLabel.text = "" + view.subtitleLabel.text = "" + view.titleLabel.font = .scaledNamedFont(.semibold16) + view.subtitleLabel.font = .scaledNamedFont(.regular14) + view.titleLabel.accessibilityElementsHidden = true + view.subtitleLabel.accessibilityElementsHidden = true + view.accessibilityTraits = [.header] + view.showsLargeContentViewer = true + view.addInteraction(UILargeContentViewerInteraction()) + return view } public func recreate() -> TitleSubtitleView { let copy = TitleSubtitleView.create() + + if #available(iOS 26, *) { + copy.tintColor = .textDarkest + } copy.title = title copy.subtitle = subtitle return copy } public override func tintColorDidChange() { + guard #available(iOS 26, *) else { return } let title = (superview?.superview as? UINavigationBar)?.titleTextAttributes?[.foregroundColor] as? UIColor let color = title ?? tintColor titleLabel.textColor = color diff --git a/Core/Core/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensions.swift b/Core/Core/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensions.swift index e9003865cb..3ed530e6a9 100644 --- a/Core/Core/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensions.swift +++ b/Core/Core/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensions.swift @@ -33,8 +33,9 @@ extension UINavigationBar { } } + @available(iOS, deprecated: 26) public func useContextColor(_ color: UIColor?) { - guard let color else { return } + guard let color, #unavailable(iOS 26) else { return } let foreground = UIColor.textLightest let background = color titleTextAttributes = [.foregroundColor: foreground] @@ -45,7 +46,10 @@ extension UINavigationBar { applyAppearanceChanges(backgroundColor: background, foregroundColor: foreground) } + @available(iOS, deprecated: 26) public func useGlobalNavStyle(brand: Brand = Brand.shared) { + guard #unavailable(iOS 26) else { return } + // TODO: Remove the isHorizon condition once horizon-specific logic is no longer needed. let isHorizon = AppEnvironment.shared.app == .horizon let background: UIColor = isHorizon ? .backgroundLightest : brand.navBackground diff --git a/Core/Core/Common/CommonUI/UIViews/CoreSplitViewController.swift b/Core/Core/Common/CommonUI/UIViews/CoreSplitViewController.swift index 6cf0c49fa1..08b1289210 100644 --- a/Core/Core/Common/CommonUI/UIViews/CoreSplitViewController.swift +++ b/Core/Core/Common/CommonUI/UIViews/CoreSplitViewController.swift @@ -98,6 +98,7 @@ public class CoreSplitViewController: UISplitViewController { } } + @available(iOS, deprecated: 26, message: "iOS26 has a default implementation") public func prettyDisplayModeButtonItem(_ displayMode: DisplayMode) -> UIBarButtonItem { let defaultButton = self.displayModeButtonItem let collapse = displayMode == .oneOverSecondary || displayMode == .secondaryOnly @@ -121,9 +122,13 @@ extension CoreSplitViewController: UISplitViewControllerDelegate { public func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) { if svc.viewControllers.count == 2 { let top = (svc.viewControllers.last as? UINavigationController)?.topViewController - top?.navigationItem.leftItemsSupplementBackButton = true + if #unavailable(iOS 26) { + top?.navigationItem.leftItemsSupplementBackButton = true + } if top?.isKind(of: EmptyViewController.self) == false { - top?.navigationItem.leftBarButtonItem = prettyDisplayModeButtonItem(displayMode) + if #unavailable(iOS 26) { + top?.navigationItem.leftBarButtonItem = prettyDisplayModeButtonItem(displayMode) + } NotificationCenter.default.post(name: NSNotification.Name.SplitViewControllerWillChangeDisplayModeNotification, object: self) } } @@ -179,9 +184,12 @@ extension CoreSplitViewController: UISplitViewControllerDelegate { } let viewControllers = (newDeets as? UINavigationController)?.viewControllers ?? [newDeets] - for vc in viewControllers { - vc.navigationItem.leftItemsSupplementBackButton = true - vc.navigationItem.leftBarButtonItem = prettyDisplayModeButtonItem(splitViewController.displayMode) + + if #unavailable(iOS 26) { + for vc in viewControllers { + vc.navigationItem.leftItemsSupplementBackButton = true + vc.navigationItem.leftBarButtonItem = prettyDisplayModeButtonItem(splitViewController.displayMode) + } } if let nav = newDeets as? UINavigationController { diff --git a/Core/Core/Common/CommonUI/UIViews/EmptyViewController.swift b/Core/Core/Common/CommonUI/UIViews/EmptyViewController.swift index e7d2cc5c5a..550c5f4209 100644 --- a/Core/Core/Common/CommonUI/UIViews/EmptyViewController.swift +++ b/Core/Core/Common/CommonUI/UIViews/EmptyViewController.swift @@ -30,9 +30,9 @@ public class EmptyViewController: UIViewController { logoImageView.tintColor = .textDark logoImageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - logoImageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.1), + logoImageView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + logoImageView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + logoImageView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor, multiplier: 0.2), logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor) ]) } diff --git a/Core/Core/Common/CommonUI/UIViews/HorizontalMenuViewController.swift b/Core/Core/Common/CommonUI/UIViews/HorizontalMenuViewController.swift index 4e9e083d0b..03e2600f3b 100644 --- a/Core/Core/Common/CommonUI/UIViews/HorizontalMenuViewController.swift +++ b/Core/Core/Common/CommonUI/UIViews/HorizontalMenuViewController.swift @@ -32,7 +32,7 @@ open class HorizontalMenuViewController: ScreenViewTrackerViewController { public weak var delegate: HorizontalPagedMenuDelegate? public private(set) var selectedIndexPath: IndexPath = IndexPath(item: 0, section: 0) - private var itemCount: Int { + public var itemCount: Int { return delegate?.numberOfMenuItems ?? 0 } @@ -78,8 +78,16 @@ open class HorizontalMenuViewController: ScreenViewTrackerViewController { } else { setupMenu() setupPages() - setupUnderline() setupBottomBorder() + + // Only show the underline on iOS 26 if there are more than one tabs + if #available(iOS 26, *) { + if itemCount > 1 { + setupUnderline() + } + } else { + setupUnderline() + } } } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/ListWithoutVerticalScrollIndicator.swift b/Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.swift similarity index 55% rename from Core/Core/Common/CommonUI/SwiftUIViews/ListWithoutVerticalScrollIndicator.swift rename to Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.swift index bc0cefa5cc..2cf39829aa 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/ListWithoutVerticalScrollIndicator.swift +++ b/Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.swift @@ -1,6 +1,6 @@ // // This file is part of Canvas. -// Copyright (C) 2022-present Instructure, Inc. +// Copyright (C) 2019-present Instructure, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as @@ -16,20 +16,21 @@ // along with this program. If not, see . // -import SwiftUI +import UIKit -public struct ListWithoutVerticalScrollIndicator: View { - private let scrollIndicatorWidth: CGFloat = 6 - private let content: () -> Content +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +public class LegacySectionHeaderView: UITableViewHeaderFooterView { + @IBOutlet weak public var titleLabel: UILabel! + @IBOutlet weak var bgView: UIView! - public init(content: @escaping () -> Content) { - self.content = content + public override func awakeFromNib() { + super.awakeFromNib() + bgView.backgroundColor = .backgroundLight } - public var body: some View { - List { - content().padding(.horizontal, scrollIndicatorWidth) - } - .padding(.horizontal, -scrollIndicatorWidth) + public static func create(title: String, section: Int) -> LegacySectionHeaderView { + let view = loadFromXib() + view.titleLabel.text = title + return view } } diff --git a/Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.xib b/Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.xib new file mode 100644 index 0000000000..6e787d8fe1 --- /dev/null +++ b/Core/Core/Common/CommonUI/UIViews/LegacySectionHeaderView.xib @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.swift b/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.swift index d138e5bc55..c187cf3af6 100644 --- a/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.swift +++ b/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.swift @@ -18,14 +18,9 @@ import UIKit +@available(iOS, introduced: 26, message: "Legacy version exists") public class SectionHeaderView: UITableViewHeaderFooterView { @IBOutlet weak public var titleLabel: UILabel! - @IBOutlet weak var bgView: UIView! - - public override func awakeFromNib() { - super.awakeFromNib() - bgView.backgroundColor = .backgroundLight - } public static func create(title: String, section: Int) -> SectionHeaderView { let view = loadFromXib() diff --git a/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.xib b/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.xib index d1c4b583ef..aa00c1a84e 100644 --- a/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.xib +++ b/Core/Core/Common/CommonUI/UIViews/SectionHeaderView.xib @@ -1,9 +1,8 @@ - + - - + @@ -14,21 +13,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Core/Core/Common/CommonUI/ViewModifiers/Tint.swift b/Core/Core/Common/CommonUI/ViewModifiers/Tint.swift index 631e10fc2a..0c01f7c4be 100644 --- a/Core/Core/Common/CommonUI/ViewModifiers/Tint.swift +++ b/Core/Core/Common/CommonUI/ViewModifiers/Tint.swift @@ -34,4 +34,24 @@ extension View { public func applyTint() -> some View { foregroundStyle(.tint) } + + @available(iOS, deprecated: 26, message: "Intended for temporary compatibility before full iOS 26 adoption.") + @ViewBuilder + public func tintBelow26(_ tint: Color?) -> some View { + if #available(iOS 26, *) { + self + } else { + self.tint(tint) + } + } + + @available(iOS, deprecated: 26, message: "Intended for temporary compatibility before full iOS 26 adoption.") + @ViewBuilder + public func foregroundStyleBelow26(_ style: S) -> some View where S: ShapeStyle { + if #available(iOS 26, *) { + self + } else { + self.foregroundStyle(style) + } + } } diff --git a/Core/Core/Common/Extensions/InstIconExtensions.swift b/Core/Core/Common/Extensions/InstIconExtensions.swift index 07b38f9626..a314c34fd7 100644 --- a/Core/Core/Common/Extensions/InstIconExtensions.swift +++ b/Core/Core/Common/Extensions/InstIconExtensions.swift @@ -40,6 +40,7 @@ public extension UIImage { static var arrowOpenRightSolid: UIImage { UIImage(named: "arrowOpenRightSolid", in: .core, compatibleWith: nil)! } static var arrowOpenUpLine: UIImage { UIImage(named: "arrowOpenUpLine", in: .core, compatibleWith: nil)! } static var arrowOpenUpSolid: UIImage { UIImage(named: "arrowOpenUpSolid", in: .core, compatibleWith: nil)! } + static var arrowUpLine: UIImage { UIImage(named: "arrowUpLine", in: .core, compatibleWith: nil)! } static var assignmentLine: UIImage { UIImage(named: "assignmentLine", in: .core, compatibleWith: nil)! } static var assignmentSolid: UIImage { UIImage(named: "assignmentSolid", in: .core, compatibleWith: nil)! } static var audioLine: UIImage { UIImage(named: "audioLine", in: .core, compatibleWith: nil)! } @@ -307,6 +308,7 @@ public extension Image { static var arrowOpenRightSolid: Image { Image("arrowOpenRightSolid", bundle: .core) } static var arrowOpenUpLine: Image { Image("arrowOpenUpLine", bundle: .core) } static var arrowOpenUpSolid: Image { Image("arrowOpenUpSolid", bundle: .core) } + static var arrowUpLine: Image { Image("arrowUpLine", bundle: .core) } static var assignmentLine: Image { Image("assignmentLine", bundle: .core) } static var assignmentSolid: Image { Image("assignmentSolid", bundle: .core) } static var audioLine: Image { Image("audioLine", bundle: .core) } diff --git a/Core/Core/Common/Extensions/UIKit/UIBarButtonItemExtensions.swift b/Core/Core/Common/Extensions/UIKit/UIBarButtonItemExtensions.swift index 48798f6348..62bdd25b37 100644 --- a/Core/Core/Common/Extensions/UIKit/UIBarButtonItemExtensions.swift +++ b/Core/Core/Common/Extensions/UIKit/UIBarButtonItemExtensions.swift @@ -28,16 +28,28 @@ public extension UIBarButtonItem { } static func back(actionHandler: @escaping () -> Void) -> UIBarButtonItem { - let config = UIImage.SymbolConfiguration(weight: .semibold) - let backImage = UIImage(systemName: "chevron.backward", withConfiguration: config) - let barButton = UIBarButtonItemWithCompletion( - image: backImage, - landscapeImagePhone: backImage, - style: .plain, - actionHandler: actionHandler - ) - barButton.imageInsets = .init(top: 0, left: -7.5, bottom: 0, right: 0) - barButton.landscapeImagePhoneInsets = .init(top: 0, left: -8, bottom: 0, right: 0) - return barButton + if #available(iOS 26, *) { + let backImage = UIImage(systemName: "chevron.backward") + let barButton = UIBarButtonItemWithCompletion( + image: backImage, + landscapeImagePhone: backImage, + style: .plain, + actionHandler: actionHandler + ) + barButton.landscapeImagePhoneInsets = .init(top: 0, left: -8, bottom: 0, right: 0) + return barButton + } else { + let config = UIImage.SymbolConfiguration(weight: .semibold) + let backImage = UIImage(systemName: "chevron.backward", withConfiguration: config) + let barButton = UIBarButtonItemWithCompletion( + image: backImage, + landscapeImagePhone: backImage, + style: .plain, + actionHandler: actionHandler + ) + barButton.imageInsets = .init(top: 0, left: -7.5, bottom: 0, right: 0) + barButton.landscapeImagePhoneInsets = .init(top: 0, left: -8, bottom: 0, right: 0) + return barButton + } } } diff --git a/Core/Core/Common/Extensions/UIKit/UIViewControllerExtensions.swift b/Core/Core/Common/Extensions/UIKit/UIViewControllerExtensions.swift index 2fa485ed5f..70f6256089 100644 --- a/Core/Core/Common/Extensions/UIKit/UIViewControllerExtensions.swift +++ b/Core/Core/Common/Extensions/UIKit/UIViewControllerExtensions.swift @@ -57,6 +57,7 @@ extension UIViewController { return false } + @available(iOS, deprecated: 26) public var splitDisplayModeButtonItem: UIBarButtonItem? { guard let splitView = splitViewController else { return nil } let defaultButton = splitView.displayModeButtonItem diff --git a/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListScreen.swift b/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListScreen.swift index 1f74ec2d0e..15e6487135 100644 --- a/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListScreen.swift +++ b/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListScreen.swift @@ -36,33 +36,60 @@ public struct AssignmentListScreen: View, ScreenViewTrackable { } public var body: some View { - VStack(spacing: 0) { - switch viewModel.state { - case .empty, .error: - gradingPeriodTitle - emptyPanda - case .loading: - loadingView - case .data: - gradingPeriodTitle - assignmentList - } - } - .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) - .tint(viewModel.courseColor?.asColor) - .navigationBarTitleView( - title: String(localized: "Assignments", bundle: .core), - subtitle: viewModel.courseName - ) - .navigationBarGenericBackButton() - .navBarItems( - trailing: .filterIcon(isBackgroundContextColor: true, isSolid: viewModel.isFilterIconSolid) { - viewModel.navigateToPreferences(viewController: controller) - } - ) - .navigationBarStyle(.color(viewModel.courseColor)) - .onAppear(perform: viewModel.viewDidAppear) - .onReceive(viewModel.$defaultDetailViewRoute, perform: setupDefaultSplitDetailView) + if #available(iOS 26, *) { + VStack(spacing: 0) { + switch viewModel.state { + case .empty, .error: + gradingPeriodTitle + emptyPanda + case .loading: + loadingView + case .data: + assignmentList + } + } + .navigationTitle(.init("Assignments", bundle: .core)) + .optionalNavigationSubtitle(viewModel.courseName) + .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) + .tint(viewModel.courseColor?.asColor) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + InstUI.NavigationBarButton.filterIcon(isSolid: viewModel.isFilterIconSolid) { + viewModel.navigateToPreferences(viewController: controller) + } + } + } + .onAppear(perform: viewModel.viewDidAppear) + .onReceive(viewModel.$defaultDetailViewRoute, perform: setupDefaultSplitDetailView) + } else { + VStack(spacing: 0) { + switch viewModel.state { + case .empty, .error: + gradingPeriodTitle + emptyPanda + case .loading: + loadingView + case .data: + gradingPeriodTitle + assignmentList + } + } + .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) + .tint(viewModel.courseColor?.asColor) + .navigationBarTitleView( + title: String(localized: "Assignments", bundle: .core), + subtitle: viewModel.courseName + ) + .navigationBarGenericBackButton() + .navBarItems( + trailing: .filterIcon(isBackgroundContextColor: true, isSolid: viewModel.isFilterIconSolid) { + viewModel.navigateToPreferences(viewController: controller) + } + ) + .navigationBarStyle(.color(viewModel.courseColor)) + .onAppear(perform: viewModel.viewDidAppear) + .onReceive(viewModel.$defaultDetailViewRoute, perform: setupDefaultSplitDetailView) + } } private var gradingPeriodTitle: some View { diff --git a/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListView.swift b/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListView.swift index 10b68c06f5..eaac0c449a 100644 --- a/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListView.swift +++ b/Core/Core/Features/Assignments/AssignmentList/View/AssignmentListView.swift @@ -42,9 +42,17 @@ struct AssignmentListView: View { } var body: some View { - LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { - ForEach(sections) { section in - sectionView(with: section) + if #available(iOS 26, *) { + LazyVStack(spacing: 0) { + ForEach(sections) { section in + sectionView(with: section) + } + } + } else { + LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { + ForEach(sections) { section in + sectionView(with: section) + } } } } diff --git a/Core/Core/Features/Conferences/ConferenceDetailsViewController.swift b/Core/Core/Features/Conferences/ConferenceDetailsViewController.swift index 458e68ddfc..8f70085ccf 100644 --- a/Core/Core/Features/Conferences/ConferenceDetailsViewController.swift +++ b/Core/Core/Features/Conferences/ConferenceDetailsViewController.swift @@ -72,7 +72,11 @@ public class ConferenceDetailsViewController: ScreenViewTrackableViewController, view.backgroundColor = .backgroundLightest tableView.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Conference Details", bundle: .core)) + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Conference Details", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Conference Details", bundle: .core)) + } detailsHeadingLabel.text = String(localized: "Description", bundle: .core) @@ -109,7 +113,11 @@ public class ConferenceDetailsViewController: ScreenViewTrackableViewController, } spinnerView.color = color refreshControl.color = color - updateNavBar(subtitle: name, color: color) + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } } func update() { diff --git a/Core/Core/Features/Conferences/ConferenceListViewController.swift b/Core/Core/Features/Conferences/ConferenceListViewController.swift index 7563abbbd4..5b76fb60a3 100644 --- a/Core/Core/Features/Conferences/ConferenceListViewController.swift +++ b/Core/Core/Features/Conferences/ConferenceListViewController.swift @@ -57,7 +57,12 @@ public class ConferenceListViewController: ScreenViewTrackableViewController, Co public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Conferences", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Conferences", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Conferences", bundle: .core)) + } emptyMessageLabel.text = String(localized: "There are no conferences to display yet.", bundle: .core) emptyTitleLabel.text = String(localized: "No Conferences", bundle: .core) @@ -67,7 +72,11 @@ public class ConferenceListViewController: ScreenViewTrackableViewController, Co tableView.backgroundColor = .backgroundLightest refreshControl.addTarget(self, action: #selector(refresh), for: .primaryActionTriggered) tableView.refreshControl = refreshControl - tableView.registerHeaderFooterView(SectionHeaderView.self) + if #available(iOS 26, *) { + tableView.registerHeaderFooterView(SectionHeaderView.self) + } else { + tableView.registerHeaderFooterView(LegacySectionHeaderView.self) + } tableView.separatorColor = .borderMedium colors.refresh() @@ -84,7 +93,10 @@ public class ConferenceListViewController: ScreenViewTrackableViewController, Co if let selected = tableView.indexPathForSelectedRow { tableView.deselectRow(at: selected, animated: true) } - navigationController?.navigationBar.useContextColor(color) + + if #unavailable(iOS 26) { + navigationController?.navigationBar.useContextColor(color) + } } func updateNavBar() { @@ -97,7 +109,12 @@ public class ConferenceListViewController: ScreenViewTrackableViewController, Co view.tintColor = color spinnerView.color = color refreshControl.color = color - updateNavBar(subtitle: name, color: color) + + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } } func update() { @@ -128,12 +145,23 @@ extension ConferenceListViewController: UITableViewDataSource, UITableViewDelega } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let view = tableView.dequeueHeaderFooter(SectionHeaderView.self) - view.titleLabel?.text = conferences[IndexPath(row: 0, section: section)]?.isConcluded == true - ? String(localized: "Concluded Conferences", bundle: .core) - : String(localized: "New Conferences", bundle: .core) - view.titleLabel?.accessibilityIdentifier = "ConferencesList.header-\(section)" - return view + if #available(iOS 26, *) { + let view = tableView.dequeueHeaderFooter(SectionHeaderView.self) + view.titleLabel?.text = conferences[IndexPath(row: 0, section: section)]?.isConcluded == true + ? String(localized: "Concluded Conferences", bundle: .core) + : String(localized: "New Conferences", bundle: .core) + view.titleLabel?.accessibilityIdentifier = "ConferencesList.header-\(section)" + + return view + } else { + let view = tableView.dequeueHeaderFooter(LegacySectionHeaderView.self) + view.titleLabel?.text = conferences[IndexPath(row: 0, section: section)]?.isConcluded == true + ? String(localized: "Concluded Conferences", bundle: .core) + : String(localized: "New Conferences", bundle: .core) + view.titleLabel?.accessibilityIdentifier = "ConferencesList.header-\(section)" + + return view + } } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { diff --git a/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsHeaderView.swift b/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsHeaderView.swift index 0c8949d142..990079da2a 100644 --- a/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsHeaderView.swift +++ b/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsHeaderView.swift @@ -18,6 +18,7 @@ import SwiftUI +@available(iOS, introduced: 26, message: "Legacy version exists") struct CourseDetailsHeaderView: View { @ObservedObject private var viewModel: CourseDetailsHeaderViewModel private let width: CGFloat @@ -28,7 +29,7 @@ struct CourseDetailsHeaderView: View { } public var body: some View { - ZStack { + ZStack(alignment: .bottomLeading) { Color(viewModel.courseColor) .frame(width: width, height: viewModel.height) if let url = viewModel.imageURL { @@ -36,7 +37,7 @@ struct CourseDetailsHeaderView: View { .opacity(viewModel.imageOpacity) .accessibility(hidden: true) } - VStack(spacing: 3) { + VStack(alignment: .leading, spacing: 3) { Text(viewModel.courseName) .font(.semibold23) .accessibility(identifier: "course-details.title-lbl") @@ -44,6 +45,7 @@ struct CourseDetailsHeaderView: View { color: viewModel.courseTitleShadow.color, radius: viewModel.courseTitleShadow.radius ) + .multilineTextAlignment(.leading) Text(viewModel.termName) .font(.semibold14) .accessibility(identifier: "course-details.subtitle-lbl") @@ -51,20 +53,20 @@ struct CourseDetailsHeaderView: View { color: viewModel.courseTitleShadow.color, radius: viewModel.courseTitleShadow.radius ) + .multilineTextAlignment(.leading) } .padding() .multilineTextAlignment(.center) .foregroundColor(.textLightest) .opacity(viewModel.titleOpacity) } - .frame(height: viewModel.height) + .frame(width: width, height: viewModel.height) .clipped() - .offset(x: 0, y: viewModel.verticalOffset) } } #if DEBUG - +@available(iOS, introduced: 26, message: "Legacy version exists") struct CourseDetailsHeaderView_Previews: PreviewProvider { private static let env = AppEnvironment.shared private static let context = env.globalDatabase.viewContext diff --git a/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsView.swift b/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsView.swift index 3eba424404..3f22bda840 100644 --- a/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsView.swift +++ b/Core/Core/Features/Courses/CourseDetails/View/CourseDetailsView.swift @@ -23,14 +23,17 @@ public struct CourseDetailsView: View, ScreenViewTrackable { @Environment(\.appEnvironment) private var env @Environment(\.viewController) private var controller @ObservedObject private var viewModel: CourseDetailsViewModel - @ObservedObject private var headerViewModel: CourseDetailsHeaderViewModel @ObservedObject private var selectionViewModel: ListSelectionViewModel + @ObservedObject private var headerViewModel: CourseDetailsHeaderViewModel + @available(iOS, deprecated: 26) + @ObservedObject private var legacyHeaderViewModel: LegacyCourseDetailsHeaderViewModel public let screenViewTrackingParameters: ScreenViewTrackingParameters public init(viewModel: CourseDetailsViewModel) { self.viewModel = viewModel self.headerViewModel = viewModel.headerViewModel + self.legacyHeaderViewModel = viewModel.legacyHeaderViewModel self.selectionViewModel = viewModel.selectionViewModel screenViewTrackingParameters = ScreenViewTrackingParameters( @@ -39,35 +42,79 @@ public struct CourseDetailsView: View, ScreenViewTrackable { } public var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - switch viewModel.state { - case .empty(let title, let message): - imageHeader(geometry: geometry) - errorView(title: title, message: message) - case .loading: - imageHeader(geometry: geometry) - loadingView - case .data(let tabViewModels): - tabList(tabViewModels, geometry: geometry) + if #available(iOS 26, *) { + GeometryReader { geometry in + VStack(spacing: 0) { + switch viewModel.state { + case .empty(let title, let message): + imageHeader(geometry: geometry) + errorView(title: title, message: message) + case .loading: + imageHeader(geometry: geometry) + loadingView + case .data(let tabViewModels): + tabList(tabViewModels, geometry: geometry) + } } - } - .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) - .navigationBarTitleView(viewModel.navigationBarTitle) - .navigationBarGenericBackButton() - .navigationBarItems(trailing: viewModel.showSettings ? settingsButton : nil) - .navigationBarStyle(.color(viewModel.courseColor)) - .onAppear { - viewModel.viewDidAppear() - viewModel.splitModeObserver.splitViewController = controller.value.splitViewController - } - } - .onPreferenceChange(ViewBoundsKey.self, perform: headerViewModel.scrollPositionChanged) - .onReceive(viewModel.$homeRoute, perform: setupDefaultSplitDetailView) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) + .navigationTitle(viewModel.navigationBarTitle) + .toolbar { + if viewModel.showSettings { + settingsButton + } + } + .onAppear { + viewModel.viewDidAppear() + viewModel.splitModeObserver.splitViewController = controller.value.splitViewController + } + } + .onReceive(viewModel.$homeRoute, perform: setupDefaultSplitDetailView) + } else { + GeometryReader { geometry in + VStack(spacing: 0) { + switch viewModel.state { + case .empty(let title, let message): + legacyImageHeader(geometry: geometry) + errorView(title: title, message: message) + case .loading: + legacyImageHeader(geometry: geometry) + loadingView + case .data(let tabViewModels): + legacyTabList(tabViewModels, geometry: geometry) + } + } + .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) + .navigationBarTitleView(viewModel.navigationBarTitle) + .navigationBarGenericBackButton() + .navigationBarItems(trailing: viewModel.showSettings ? legacySettingsButton : nil) + .navigationBarStyle(.color(viewModel.courseColor)) + .onAppear { + viewModel.viewDidAppear() + viewModel.splitModeObserver.splitViewController = controller.value.splitViewController + } + } + .onPreferenceChange(ViewBoundsKey.self, perform: legacyHeaderViewModel.scrollPositionChanged) + .onReceive(viewModel.$homeRoute, perform: setupDefaultSplitDetailView) + } } + @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") + private var settingsButton: some View { + Button { + if let url = viewModel.settingsRoute { + env.router.route(to: url, from: controller, options: .modal(.formSheet, isDismissable: false, embedInNav: true)) + } + } label: { + Image.settingsSolid + } + .accessibility(label: Text("Edit course settings", bundle: .core)) + } + @ViewBuilder - private var settingsButton: some View { + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacySettingsButton: some View { Button { if let url = viewModel.settingsRoute { env.router.route(to: url, from: controller, options: .modal(.formSheet, isDismissable: false, embedInNav: true)) @@ -146,10 +193,39 @@ public struct CourseDetailsView: View, ScreenViewTrackable { Spacer() } - private func tabList(_ tabViewModels: [CourseDetailsCellViewModel], geometry: GeometryProxy) -> some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private func tabList(_ tabViewModels: [CourseDetailsCellViewModel], geometry: GeometryProxy) -> some View { + ScrollView { + VStack(spacing: 0) { + imageHeader(geometry: geometry) + .padding(.bottom, 16) + if viewModel.showHome { + homeView + Divider() + } + ForEach(tabViewModels, id: \.id) { tabViewModel in + CourseDetailsCellView(viewModel: tabViewModel) + Divider() + } + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .background(Color.backgroundLightest) + } + .scrollIndicators(.hidden) + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { + await viewModel.refresh() + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private func legacyTabList(_ tabViewModels: [CourseDetailsCellViewModel], geometry: GeometryProxy) -> some View { ZStack(alignment: .top) { - imageHeader(geometry: geometry) - ListWithoutVerticalScrollIndicator { + legacyImageHeader(geometry: geometry) + ScrollView { VStack(spacing: 0) { if viewModel.showHome { homeView @@ -164,13 +240,14 @@ public struct CourseDetailsView: View, ScreenViewTrackable { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .background(Color.backgroundLightest) - .padding(.top, headerViewModel.visibleHeight) + .padding(.top, legacyHeaderViewModel.visibleHeight) // Save the frame of the content so we can inspect its y position and move course image based on that .transformAnchorPreference(key: ViewBoundsKey.self, value: .bounds) { preferences, bounds in preferences = [.init(viewId: 0, bounds: geometry[bounds])] } } .listStyle(.plain) + .scrollIndicators(.hidden) .scrollContentBackground(.hidden) .refreshable { await viewModel.refresh() @@ -178,10 +255,17 @@ public struct CourseDetailsView: View, ScreenViewTrackable { } } - @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") private func imageHeader(geometry: GeometryProxy) -> some View { - if headerViewModel.shouldShowHeader(in: geometry.size) { - CourseDetailsHeaderView(viewModel: headerViewModel, width: geometry.size.width) + CourseDetailsHeaderView(viewModel: headerViewModel, width: geometry.size.width - 32) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + @ViewBuilder + private func legacyImageHeader(geometry: GeometryProxy) -> some View { + if legacyHeaderViewModel.shouldShowHeader(in: geometry.size) { + LegacyCourseDetailsHeaderView(viewModel: legacyHeaderViewModel, width: geometry.size.width) } } @@ -209,7 +293,14 @@ struct CourseDetailsView_Previews: PreviewProvider { private static let env = AppEnvironment.shared private static let context = env.globalDatabase.viewContext private static var contentViewModel: CourseDetailsViewModel { - let course = Course.save(.make(default_view: .assignments, term: .init(id: "1", name: "Default Term", start_at: nil, end_at: nil)), in: context) + let course = Course.save( + .make( + name: "Long name to brake the layout of the title", + default_view: .assignments, + term: .init(id: "1", name: "Default Term", start_at: nil, end_at: nil) + ), + in: context + ) let tab1: Tab = Tab(context: context) tab1.save(.make(), in: context, context: .course("1")) let tab2: Tab = Tab(context: context) diff --git a/Core/Core/Features/Courses/CourseDetails/View/CourseSettingsView.swift b/Core/Core/Features/Courses/CourseDetails/View/CourseSettingsView.swift index 52c702f07a..f7302c3d82 100644 --- a/Core/Core/Features/Courses/CourseDetails/View/CourseSettingsView.swift +++ b/Core/Core/Features/Courses/CourseDetails/View/CourseSettingsView.swift @@ -34,41 +34,99 @@ public struct CourseSettingsView: View, ScreenViewTrackable { } public var body: some View { - GeometryReader { geometry in - switch viewModel.state { - case .loading: - ProgressView() - .progressViewStyle(.indeterminateCircle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .ready, .saving: - editor(width: geometry.size.width) + if #available(iOS 26, *) { + GeometryReader { geometry in + switch viewModel.state { + case .loading: + ProgressView() + .progressViewStyle(.indeterminateCircle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .ready, .saving: + editor(width: geometry.size.width) + } } - } - .navigationBarTitleView( - title: String(localized: "Customize Course", bundle: .core), - subtitle: viewModel.courseName - ) - .navBarItems( - leading: { - Button(action: cancelTapped) { - Text("Cancel", bundle: .core).fontWeight(.regular) + .navigationTitle(.init("Customize Course", bundle: .core)) + .optionalNavigationSubtitle(viewModel.courseName) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: cancelTapped) { + Text("Cancel", bundle: .core) + .fontWeight(.regular) + } } - }, - trailing: { - Button(action: doneTapped) { - Text("Done", bundle: .core).bold() + + ToolbarItem(placement: .topBarTrailing) { + Button(action: doneTapped) { + Text("Done", bundle: .core) + .bold() + } + .disabled(viewModel.state != .ready) } - .disabled(viewModel.state != .ready) } - ) - .navigationBarStyle(.modal) - .onAppear(perform: viewModel.viewDidAppear) - .alert(isPresented: $viewModel.showError) { - Alert(title: Text(viewModel.errorText ?? String(localized: "Something went wrong", bundle: .core))) + .onAppear(perform: viewModel.viewDidAppear) + .alert(isPresented: $viewModel.showError) { + Alert(title: Text(viewModel.errorText ?? String(localized: "Something went wrong", bundle: .core))) + } + } else { + GeometryReader { geometry in + switch viewModel.state { + case .loading: + ProgressView() + .progressViewStyle(.indeterminateCircle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .ready, .saving: + legacyEditor(width: geometry.size.width) + } + } + .navigationBarTitleView( + title: String(localized: "Customize Course", bundle: .core), + subtitle: viewModel.courseName + ) + .navBarItems( + leading: { + Button(action: cancelTapped) { + Text("Cancel", bundle: .core).fontWeight(.regular) + } + }, + trailing: { + Button(action: doneTapped) { + Text("Done", bundle: .core).bold() + } + .disabled(viewModel.state != .ready) + } + ) + .navigationBarStyle(.modal) + .onAppear(perform: viewModel.viewDidAppear) + .alert(isPresented: $viewModel.showError) { + Alert(title: Text(viewModel.errorText ?? String(localized: "Something went wrong", bundle: .core))) + } } } + @available(iOS, introduced: 26, message: "Legacy version exists") private func editor(width: CGFloat) -> some View { + EditorForm(isSpinning: viewModel.state == .saving) { + let height: CGFloat = 235 + ZStack { + Color(viewModel.courseColor ?? .textDark).frame(width: width, height: height) + if let url = viewModel.imageURL { + RemoteImage(url, width: width, height: height, shouldHandleAnimatedGif: true) + .opacity(viewModel.hideColorOverlay == true ? 1 : 0.4) + .accessibility(hidden: true) + } + } + .frame(height: height) + .clipped() + + nameRow + Divider() + defaultViewButtonRow + Divider() + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private func legacyEditor(width: CGFloat) -> some View { EditorForm(isSpinning: viewModel.state == .saving) { let height: CGFloat = 235 ZStack { diff --git a/Core/Core/Features/Courses/CourseDetails/View/LegacyCourseDetailsHeaderView.swift b/Core/Core/Features/Courses/CourseDetails/View/LegacyCourseDetailsHeaderView.swift new file mode 100644 index 0000000000..ab9776e909 --- /dev/null +++ b/Core/Core/Features/Courses/CourseDetails/View/LegacyCourseDetailsHeaderView.swift @@ -0,0 +1,81 @@ +// +// This file is part of Canvas. +// Copyright (C) 2022-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +struct LegacyCourseDetailsHeaderView: View { + @ObservedObject private var viewModel: LegacyCourseDetailsHeaderViewModel + private let width: CGFloat + + public init(viewModel: LegacyCourseDetailsHeaderViewModel, width: CGFloat) { + self.viewModel = viewModel + self.width = width + } + + public var body: some View { + ZStack { + Color(viewModel.courseColor) + .frame(width: width, height: viewModel.height) + if let url = viewModel.imageURL { + RemoteImage(url, width: width, height: viewModel.height, shouldHandleAnimatedGif: true) + .opacity(viewModel.imageOpacity) + .accessibility(hidden: true) + } + VStack(spacing: 3) { + Text(viewModel.courseName) + .font(.semibold23) + .accessibility(identifier: "course-details.title-lbl") + .shadow( + color: viewModel.courseTitleShadow.color, + radius: viewModel.courseTitleShadow.radius + ) + Text(viewModel.termName) + .font(.semibold14) + .accessibility(identifier: "course-details.subtitle-lbl") + .shadow( + color: viewModel.courseTitleShadow.color, + radius: viewModel.courseTitleShadow.radius + ) + } + .padding() + .multilineTextAlignment(.center) + .foregroundColor(.textLightest) + .opacity(viewModel.titleOpacity) + } + .frame(height: viewModel.height) + .clipped() + .offset(x: 0, y: viewModel.verticalOffset) + } +} + +#if DEBUG + +struct LegacyCourseDetailsHeaderView_Previews: PreviewProvider { + private static let env = AppEnvironment.shared + private static let context = env.globalDatabase.viewContext + + static var previews: some View { + let course = Course.save(.make(term: .make()), in: context) + let viewModel = LegacyCourseDetailsHeaderViewModel() + viewModel.courseUpdated(course) + return LegacyCourseDetailsHeaderView(viewModel: viewModel, width: 400) + } +} + +#endif diff --git a/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModel.swift b/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModel.swift index 4609cc6d88..762c8442d2 100644 --- a/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModel.swift +++ b/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModel.swift @@ -19,6 +19,7 @@ import Foundation import SwiftUI +// MARK: Legacy version exists public class CourseDetailsHeaderViewModel: ObservableObject { @Published public private(set) var hideColorOverlay: Bool = false @Published public private(set) var verticalOffset: CGFloat = 0 @@ -42,10 +43,6 @@ public class CourseDetailsHeaderViewModel: ObservableObject { self?.hideColorOverlay = self?.settings.first?.hideDashcardColorOverlays == true } - private var shouldShow: Bool = false - private var checkedWidth: CGFloat = .nan - private var keyboard = KeyboardObserved() - public func viewDidAppear() { settings.refresh() } @@ -55,38 +52,6 @@ public class CourseDetailsHeaderViewModel: ObservableObject { imageURL = course.imageDownloadURL termName = course.termName ?? "" courseColor = course.color - } - - public func scrollPositionChanged(_ bounds: ViewBoundsKey.Value) { - guard let frame = bounds.first?.bounds else { return } - scrollPositionYChanged(to: frame.minY) - } - - public func shouldShowHeader(in availableSize: CGSize) -> Bool { - let isRotating = checkedWidth.isFinite && checkedWidth != availableSize.width - guard isRotating || keyboard.isHiding else { return shouldShow } - - shouldShow = self.height < availableSize.height / 2 - checkedWidth = availableSize.width - return shouldShow - } - - public var visibleHeight: CGFloat { - shouldShow ? height : 0 - } - - private func scrollPositionYChanged(to value: CGFloat) { - if value <= 0 { // scrolling down to content - verticalOffset = min(0, value / 2) - - // Starts from 0 and reaches 1 when the image is fully pushed out of screen - let offsetRatio = abs(verticalOffset) / (height / 2) - imageOpacity = hideColorOverlay ? 1 : (1 - offsetRatio) * Self.originalImageOpacity - titleOpacity = 1 - offsetRatio - } else { // pull to refresh gesture, we allow the image to move along with the content - verticalOffset = value - imageOpacity = hideColorOverlay ? 1 : Self.originalImageOpacity - titleOpacity = 1 - } + imageOpacity = hideColorOverlay ? 1 : Self.originalImageOpacity } } diff --git a/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsViewModel.swift b/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsViewModel.swift index 5fc3220f88..4fa3ec5f4c 100644 --- a/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsViewModel.swift +++ b/Core/Core/Features/Courses/CourseDetails/ViewModel/CourseDetailsViewModel.swift @@ -36,6 +36,9 @@ public class CourseDetailsViewModel: ObservableObject { @Published public private(set) var homeRoute: URL? @Published public private(set) var showHome: Bool + @available(iOS, deprecated: 26) + public let legacyHeaderViewModel = LegacyCourseDetailsHeaderViewModel() + public var showSettings: Bool { isTeacher } public var showStudentView: Bool { isTeacher } public var courseName: String { course.first?.name ?? "" } @@ -94,6 +97,7 @@ public class CourseDetailsViewModel: ObservableObject { public func viewDidAppear() { selectionViewModel.viewDidAppear() headerViewModel.viewDidAppear() + legacyHeaderViewModel.viewDidAppear() requestAttendanceTool() permissions.refresh() customGradeStatuses.refresh() @@ -160,6 +164,7 @@ public class CourseDetailsViewModel: ObservableObject { private func courseDidUpdate() { guard let course = course.first else { return } headerViewModel.courseUpdated(course) + legacyHeaderViewModel.courseUpdated(course) courseColor = course.color setupHome(course: course) tabs.exhaust() diff --git a/Core/Core/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModel.swift b/Core/Core/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModel.swift new file mode 100644 index 0000000000..32d50e015b --- /dev/null +++ b/Core/Core/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModel.swift @@ -0,0 +1,93 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation +import SwiftUI + +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +public class LegacyCourseDetailsHeaderViewModel: ObservableObject { + @Published public private(set) var hideColorOverlay: Bool = false + @Published public private(set) var verticalOffset: CGFloat = 0 + @Published public private(set) var imageOpacity: CGFloat = originalImageOpacity + @Published public private(set) var titleOpacity: CGFloat = 1 + @Published public private(set) var courseName = "" + @Published public private(set) var courseColor: UIColor = .clear + @Published public private(set) var termName = "" + @Published public private(set) var imageURL: URL? + + private static let originalImageOpacity: CGFloat = 0.16 + + public let courseTitleShadow = ( + color: Color(UIColor.backgroundDarkest.withAlphaComponent(0.80)), + radius: 4 as CGFloat + ) + public let height: CGFloat = 235 + + private let env = AppEnvironment.shared + private lazy var settings: Store = env.subscribe(GetUserSettings(userID: "self")) { [weak self] in + self?.hideColorOverlay = self?.settings.first?.hideDashcardColorOverlays == true + } + + private var shouldShow: Bool = false + private var checkedWidth: CGFloat = .nan + private var keyboard = KeyboardObserved() + + public func viewDidAppear() { + settings.refresh() + } + + public func courseUpdated(_ course: Course) { + courseName = course.name ?? "" + imageURL = course.imageDownloadURL + termName = course.termName ?? "" + courseColor = course.color + } + + public func scrollPositionChanged(_ bounds: ViewBoundsKey.Value) { + guard let frame = bounds.first?.bounds else { return } + scrollPositionYChanged(to: frame.minY) + } + + public func shouldShowHeader(in availableSize: CGSize) -> Bool { + let isRotating = checkedWidth.isFinite && checkedWidth != availableSize.width + guard isRotating || keyboard.isHiding else { return shouldShow } + + shouldShow = self.height < availableSize.height / 2 + checkedWidth = availableSize.width + return shouldShow + } + + public var visibleHeight: CGFloat { + shouldShow ? height : 0 + } + + private func scrollPositionYChanged(to value: CGFloat) { + if value <= 0 { // scrolling down to content + verticalOffset = min(0, value / 2) + + // Starts from 0 and reaches 1 when the image is fully pushed out of screen + let offsetRatio = abs(verticalOffset) / (height / 2) + imageOpacity = hideColorOverlay ? 1 : (1 - offsetRatio) * Self.originalImageOpacity + titleOpacity = 1 - offsetRatio + } else { // pull to refresh gesture, we allow the image to move along with the content + verticalOffset = value + imageOpacity = hideColorOverlay ? 1 : Self.originalImageOpacity + titleOpacity = 1 + } + } +} diff --git a/Core/Core/Features/Dashboard/Container/View/DashboardContainerView.swift b/Core/Core/Features/Dashboard/Container/View/DashboardContainerView.swift index 6fb5899863..08b9e15eda 100644 --- a/Core/Core/Features/Dashboard/Container/View/DashboardContainerView.swift +++ b/Core/Core/Features/Dashboard/Container/View/DashboardContainerView.swift @@ -77,7 +77,23 @@ public struct DashboardContainerView: View, ScreenViewTrackable { } .background(Color.backgroundLightest.edgesIgnoringSafeArea(.all)) .navigationBarDashboard() - .navigationBarItems(leading: profileMenuButton, trailing: rightNavBarButtons) + .toolbar { + if #available(iOS 26, *) { + ToolbarItem(placement: .topBarLeading) { + profileMenuButton + } + ToolbarItem(placement: .topBarTrailing) { + rightNavBarButtons + } + } else { + ToolbarItem(placement: .topBarLeading) { + legacyProfileMenuButton + } + ToolbarItem(placement: .topBarTrailing) { + legacyRightNavBarButtons + } + } + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in fileUploadNotificationCardViewModel.sceneDidBecomeActive.send() } @@ -119,7 +135,19 @@ public struct DashboardContainerView: View, ScreenViewTrackable { // MARK: - Nav Bar Buttons - private var profileMenuButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private var profileMenuButton: some View { + Button { + env.router.route(to: "/profile", from: controller, options: .modal()) + } label: { + Image.hamburgerSolid + } + .identifier("Dashboard.profileButton") + .accessibility(label: Text("Profile Menu, Closed", bundle: .core, comment: "Accessibility text describing the Profile Menu button and its state")) + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyProfileMenuButton: some View { Button { env.router.route(to: "/profile", from: controller, options: .modal()) } label: { @@ -131,19 +159,58 @@ public struct DashboardContainerView: View, ScreenViewTrackable { .accessibility(label: Text("Profile Menu, Closed", bundle: .core, comment: "Accessibility text describing the Profile Menu button and its state")) } + @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") + private var rightNavBarButtons: some View { + if courseCardListViewModel.shouldShowSettingsButton { + if offlineModeViewModel.isOfflineFeatureEnabled, env.app == .student { + optionsKebabMenu + } else { + dashboardSettingsButton + } + } + } + @ViewBuilder - private var rightNavBarButtons: some View { + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyRightNavBarButtons: some View { if courseCardListViewModel.shouldShowSettingsButton { if offlineModeViewModel.isOfflineFeatureEnabled, env.app == .student { - optionsKebabButton + legacyOptionsKebabButton } else { - dashboardSettingsButton + legacyDashboardSettingsButton } } } + @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") + private var optionsKebabMenu: some View { + Menu { + Button(.init("Manage Offline Content", bundle: .core)) { + if offlineModeViewModel.isOffline { + UIAlertController.showItemNotAvailableInOfflineAlert() + } else { + env.router.route(to: "/offline/sync_picker", from: controller, options: .modal(isDismissable: false, embedInNav: true)) + } + } + + Button(.init("Dashboard Settings", bundle: .core)) { + guard controller.value.presentedViewController == nil else { + controller.value.presentedViewController?.dismiss(animated: true) + return + } + viewModel.settingsButtonTapped.send() + } + } label: { + Image.moreSolid + } + .accessibilityLabel(Text("Dashboard Options", bundle: .core)) + } + @ViewBuilder - private var optionsKebabButton: some View { + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyOptionsKebabButton: some View { Button { // Dismiss dashboard settings popover guard controller.value.presentedViewController == nil else { @@ -180,8 +247,26 @@ public struct DashboardContainerView: View, ScreenViewTrackable { } } + @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") + private var dashboardSettingsButton: some View { + Button { + guard controller.value.presentedViewController == nil else { + controller.value.presentedViewController?.dismiss(animated: true) + return + } + + viewModel.settingsButtonTapped.send() + } label: { + Image.settingsSolid + } + .accessibilityLabel(Text("Dashboard settings", bundle: .core)) + .accessibilityIdentifier("Dashboard.settingsButton") + } + @ViewBuilder - private var dashboardSettingsButton: some View { + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyDashboardSettingsButton: some View { Button { guard controller.value.presentedViewController == nil else { controller.value.presentedViewController?.dismiss(animated: true) diff --git a/Core/Core/Features/Dashboard/Container/View/DashboardNavigationBar.swift b/Core/Core/Features/Dashboard/Container/View/DashboardNavigationBar.swift index 1a7517a29c..1805158a12 100644 --- a/Core/Core/Features/Dashboard/Container/View/DashboardNavigationBar.swift +++ b/Core/Core/Features/Dashboard/Container/View/DashboardNavigationBar.swift @@ -27,13 +27,23 @@ struct DashboardNavigationBar: ViewModifier { @State private var isElevatedTabBar = false func body(content: Content) -> some View { - if #available(iOS 18.0, *) { + if #available(iOS 26, *) { + content + .toolbar { + ToolbarItem(placement: .principal) { + navBarLogo + } + } + .onChange(of: horizontalSizeClass, initial: true) { _, newValue in + updateNavBarLogoVisibility(horizontalSizeClass: newValue) + } + } else if #available(iOS 18.0, *) { content .toolbarBackgroundVisibility(.visible, for: .navigationBar) .toolbarBackground(Color(uiColor: Brand.shared.navBackground), for: .navigationBar) .toolbar { ToolbarItem(placement: .principal) { - navBarLogo + legacyNavBarLogo } } .onChange(of: horizontalSizeClass, initial: true) { _, newValue in @@ -50,8 +60,27 @@ struct DashboardNavigationBar: ViewModifier { isElevatedTabBar = horizontalSizeClass == .regular } + @ViewBuilder + @available(iOS, introduced: 26, message: "Legacy version exists") + fileprivate var navBarLogo: some View { + if !isElevatedTabBar, let headerImage = Brand.shared.headerImage { + ZStack { + Brand.shared.headerImageBackground.asColor + + Image(uiImage: headerImage) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 44, height: 44) + .shadow(radius: 8) + } + } + @ViewBuilder - private var navBarLogo: some View { + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + fileprivate var legacyNavBarLogo: some View { if !isElevatedTabBar, let headerImage = Brand.shared.headerImage { ZStack { Color(Brand.shared.headerImageBackground) diff --git a/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift b/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift index 549e26e9ce..ccb1685c5e 100644 --- a/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift +++ b/Core/Core/Features/Dashboard/CourseCardList/View/CustomizeCourseView.swift @@ -134,15 +134,17 @@ struct CustomizeCourseView: View { #if DEBUG #Preview { - CustomizeCourseView( - viewModel: CustomizeCourseViewModel( - courseId: "1", - courseImage: nil, - courseColor: .course1, - courseName: "Test Course", - hideColorOverlay: false + NavigationStack { + CustomizeCourseView( + viewModel: CustomizeCourseViewModel( + courseId: "1", + courseImage: nil, + courseColor: .course1, + courseName: "Test Course", + hideColorOverlay: false + ) ) - ) + } } #endif diff --git a/Core/Core/Features/Dashboard/K5/View/K5DashboardView.swift b/Core/Core/Features/Dashboard/K5/View/K5DashboardView.swift index 3100029bd1..ae3af0a954 100644 --- a/Core/Core/Features/Dashboard/K5/View/K5DashboardView.swift +++ b/Core/Core/Features/Dashboard/K5/View/K5DashboardView.swift @@ -48,7 +48,7 @@ public struct K5DashboardView: View { viewModel.profileButtonPressed(router: env.router, viewController: controller) }, label: { Image.hamburgerSolid - .foregroundColor(Color(Brand.shared.navTextColor)) + .foregroundStyleBelow26(Brand.shared.navTextColor.asColor) }) .identifier("Dashboard.profileButton") .accessibility(label: Text("Profile Menu, Closed", bundle: .core, comment: "Accessibility text describing the Profile Menu button and its state")) diff --git a/Core/Core/Features/Discussions/AnnouncementList/AnnouncementListViewController.swift b/Core/Core/Features/Discussions/AnnouncementList/AnnouncementListViewController.swift index 5bbdc47ebf..14e891145a 100644 --- a/Core/Core/Features/Discussions/AnnouncementList/AnnouncementListViewController.swift +++ b/Core/Core/Features/Discussions/AnnouncementList/AnnouncementListViewController.swift @@ -60,7 +60,12 @@ public class AnnouncementListViewController: ScreenViewTrackableViewController, public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: String(localized: "Announcements", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Announcements", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Announcements", bundle: .core)) + } addButton.accessibilityLabel = String(localized: "Create Announcement", bundle: .core) @@ -110,12 +115,18 @@ public class AnnouncementListViewController: ScreenViewTrackableViewController, } func updateNavBar() { - if colors.pending == false, - let name = course?.first?.name ?? group?.first?.name, - let color = course?.first?.color ?? group?.first?.color { - updateNavBar(subtitle: name, color: color) - view.tintColor = color - } + if #available(iOS 26, *) { + if let name = course?.first?.name ?? group?.first?.name { + navigationItem.subtitle = name + } + } else { + if colors.pending == false, + let name = course?.first?.name ?? group?.first?.name, + let color = course?.first?.color ?? group?.first?.color { + updateNavBar(subtitle: name, color: color) + view.tintColor = color + } + } let canAdd = (course?.first?.canCreateAnnouncement ?? group?.first?.canCreateAnnouncement) == true navigationItem.rightBarButtonItem = canAdd ? addButton : nil } diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 6194521392..4cac65f510 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -124,10 +124,18 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: isAnnouncement - ? String(localized: "Announcement Details", bundle: .core) - : String(localized: "Discussion Details", bundle: .core) - ) + + if #available(iOS 26, *) { + navigationItem.title = isAnnouncement + ? String(localized: "Announcement Details", bundle: .core) + : String(localized: "Discussion Details", bundle: .core) + } else { + setupTitleViewInNavbar(title: isAnnouncement + ? String(localized: "Announcement Details", bundle: .core) + : String(localized: "Discussion Details", bundle: .core) + ) + } + courseSectionsView.isHidden = true optionsButton.accessibilityLabel = String(localized: "Options", bundle: .core) @@ -162,9 +170,15 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, webView.handle("ready") { [weak self] _ in self?.ready() } if showRepliesToEntryID != nil { - titleSubtitleView.title = isAnnouncement - ? String(localized: "Announcement Replies", bundle: .core) - : String(localized: "Discussion Replies", bundle: .core) + if #available(iOS 26, *) { + navigationItem.title = isAnnouncement + ? String(localized: "Announcement Replies", bundle: .core) + : String(localized: "Discussion Replies", bundle: .core) + } else { + titleSubtitleView.title = isAnnouncement + ? String(localized: "Announcement Replies", bundle: .core) + : String(localized: "Discussion Replies", bundle: .core) + } navigationItem.rightBarButtonItem = nil } @@ -235,16 +249,35 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, } spinnerView.color = color refreshControl.color = color - titleSubtitleView.title = showRepliesToEntryID != nil ? ( - isAnnouncement - ? String(localized: "Announcement Replies", bundle: .core) - : String(localized: "Discussion Replies", bundle: .core) - ) : ( - isAnnouncement - ? String(localized: "Announcement Details", bundle: .core) - : String(localized: "Discussion Details", bundle: .core) - ) - updateNavBar(subtitle: name, color: color) + if #available(iOS 26, *) { + navigationItem.title = showRepliesToEntryID != nil ? ( + isAnnouncement + ? String(localized: "Announcement Replies", bundle: .core) + : String(localized: "Discussion Replies", bundle: .core) + ) : ( + isAnnouncement + ? String(localized: "Announcement Details", bundle: .core) + : String(localized: "Discussion Details", bundle: .core) + ) + } else { + titleSubtitleView.title = showRepliesToEntryID != nil ? ( + isAnnouncement + ? String(localized: "Announcement Replies", bundle: .core) + : String(localized: "Discussion Replies", bundle: .core) + ) : ( + isAnnouncement + ? String(localized: "Announcement Details", bundle: .core) + : String(localized: "Discussion Details", bundle: .core) + ) + } + + if #available(iOS 26, *) { + navigationItem.subtitle = name + // WebView needs this color + self.color = color + } else { + updateNavBar(subtitle: name, color: color) + } } func update() { diff --git a/Core/Core/Features/Discussions/DiscussionEditor/ViewModel/DiscussionCreateWebViewModel.swift b/Core/Core/Features/Discussions/DiscussionEditor/ViewModel/DiscussionCreateWebViewModel.swift index 8ca059e326..2a9fbd1472 100644 --- a/Core/Core/Features/Discussions/DiscussionEditor/ViewModel/DiscussionCreateWebViewModel.swift +++ b/Core/Core/Features/Discussions/DiscussionEditor/ViewModel/DiscussionCreateWebViewModel.swift @@ -46,8 +46,14 @@ public struct DiscussionCreateWebViewModel: EmbeddedWebPageViewModel { } public func leadingNavigationButton(host: UIViewController) -> InstUI.NavigationBarButton? { - .cancel(isBackgroundContextColor: true) { [router] in - router.dismiss(host) + if #available(iOS 26, *) { + .cancel { [router] in + router.dismiss(host) + } + } else { + .cancel(isBackgroundContextColor: true) { [router] in + router.dismiss(host) + } } } diff --git a/Core/Core/Features/Discussions/DiscussionList/DiscussionListViewController.swift b/Core/Core/Features/Discussions/DiscussionList/DiscussionListViewController.swift index 939857760b..cf27858d86 100644 --- a/Core/Core/Features/Discussions/DiscussionList/DiscussionListViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionList/DiscussionListViewController.swift @@ -73,7 +73,12 @@ public class DiscussionListViewController: ScreenViewTrackableViewController, Co public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: String(localized: "Discussions", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Discussions", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Discussions", bundle: .core)) + } addButton.accessibilityLabel = String(localized: "Create Discussion", bundle: .core) addButton.accessibilityIdentifier = "DiscussionList.newButton" @@ -121,7 +126,11 @@ public class DiscussionListViewController: ScreenViewTrackableViewController, Co if colors.pending == false, let name = course?.first?.name ?? group?.first?.name, let color = course?.first?.color ?? group?.first?.color { - updateNavBar(subtitle: name, color: color) + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } view.tintColor = color } let canAdd = (course?.first?.canCreateDiscussionTopic ?? group?.first?.canCreateDiscussionTopic) == true @@ -207,16 +216,29 @@ extension DiscussionListViewController: UITableViewDataSource, UITableViewDelega } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - switch topics.sections?[section].name { - case "0": - return SectionHeaderView.create(title: String(localized: "Pinned Discussions", bundle: .core), section: section) - case "1": - return SectionHeaderView.create(title: String(localized: "Discussions", bundle: .core), section: section) - case "2": - return SectionHeaderView.create(title: String(localized: "Closed for Comments", bundle: .core), section: section) - default: - return nil - } + if #available(iOS 26, *) { + switch topics.sections?[section].name { + case "0": + return SectionHeaderView.create(title: String(localized: "Pinned Discussions", bundle: .core), section: section) + case "1": + return SectionHeaderView.create(title: String(localized: "Discussions", bundle: .core), section: section) + case "2": + return SectionHeaderView.create(title: String(localized: "Closed for Comments", bundle: .core), section: section) + default: + return nil + } + } else { + switch topics.sections?[section].name { + case "0": + return LegacySectionHeaderView.create(title: String(localized: "Pinned Discussions", bundle: .core), section: section) + case "1": + return LegacySectionHeaderView.create(title: String(localized: "Discussions", bundle: .core), section: section) + case "2": + return LegacySectionHeaderView.create(title: String(localized: "Closed for Comments", bundle: .core), section: section) + default: + return nil + } + } } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/Core/Core/Features/Files/View/FileList/FileListViewController.swift b/Core/Core/Features/Files/View/FileList/FileListViewController.swift index 5645409648..e761fcd964 100644 --- a/Core/Core/Features/Files/View/FileList/FileListViewController.swift +++ b/Core/Core/Features/Files/View/FileList/FileListViewController.swift @@ -32,7 +32,27 @@ public class FileListViewController: ScreenViewTrackableViewController, ColoredN @IBOutlet weak var loadingView: CircleProgressView! @IBOutlet weak var tableView: UITableView! - lazy var addButton = UIBarButtonItem(image: .addSolid, style: .plain, target: self, action: #selector(addItem)) + // Legacy version exists, cannot be marked unavailable + lazy var addButton: UIBarButtonItem = { + var button = UIBarButtonItem(image: .addSolid) + + let addFolderAction = UIAction(title: .init(localized: "Add Folder", bundle: .core), image: .folderLine) { [weak self] _ in + self?.addFolder() + } + addFolderAction.accessibilityIdentifier = "FileList.addFolderButton" + + let addFileAction = UIAction(title: .init(localized: "Add File", bundle: .core), image: .addDocumentLine) { [weak self] _ in + guard let self = self else { return } + self.filePicker.pick(from: self) + } + addFileAction.accessibilityIdentifier = "FileList.addFileButton" + + let menu = UIMenu(children: !isStudentAccessRestricted ? [addFolderAction, addFileAction] : [addFolderAction]) + return UIBarButtonItem(image: .addSolid, menu: menu) + }() + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + lazy var legacyAddButton = UIBarButtonItem(image: .addSolid, style: .plain, target: self, action: #selector(addItem)) lazy var editButton = UIBarButtonItem( title: String(localized: "Edit", bundle: .core), style: .plain, target: self, action: #selector(edit) @@ -103,10 +123,17 @@ public class FileListViewController: ScreenViewTrackableViewController, ColoredN public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Files", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Files", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Files", bundle: .core)) + } addButton.accessibilityIdentifier = "FileList.addButton" addButton.accessibilityLabel = String(localized: "Add Item", bundle: .core) + legacyAddButton.accessibilityIdentifier = "FileList.addButton" + legacyAddButton.accessibilityLabel = String(localized: "Add Item", bundle: .core) editButton.accessibilityIdentifier = "FileList.editButton" emptyImageView.image = UIImage(named: Panda.FilePicker.name, in: .core, compatibleWith: nil) @@ -146,21 +173,32 @@ public class FileListViewController: ScreenViewTrackableViewController, ColoredN super.viewWillAppear(animated) keyboard = KeyboardTransitioning(view: view, space: keyboardSpace) tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - if context.contextType == .user { - navigationController?.navigationBar.useGlobalNavStyle() - } else { - navigationController?.navigationBar.useContextColor(color) - } + + if #unavailable(iOS 26) { + if context.contextType == .user { + navigationController?.navigationBar.useGlobalNavStyle() + } else { + navigationController?.navigationBar.useContextColor(color) + } + } } func updateNavBar() { - if let course = course?.first { - updateNavBar(subtitle: course.name, color: course.color) - } else if let group = group?.first { - updateNavBar(subtitle: group.name, color: group.color) - } else if context.contextType == .user { - color = .textDark - } + if #available(iOS 26, *) { + if let course = course?.first { + navigationItem.subtitle = course.name + } else if let group = group?.first { + navigationItem.subtitle = group.name + } + } else { + if let course = course?.first { + updateNavBar(subtitle: course.name, color: course.color) + } else if let group = group?.first { + updateNavBar(subtitle: group.name, color: group.color) + } else if context.contextType == .user { + color = .textDark + } + } view.tintColor = color updateNavButtons() } @@ -183,7 +221,13 @@ public class FileListViewController: ScreenViewTrackableViewController, ColoredN loadingView.isHidden = !folder.pending || !folder.isEmpty || folder.error != nil || refreshControl.isRefreshing errorView.isHidden = folder.error == nil let title = (path.isEmpty ? nil : folder.first?.name) ?? String(localized: "Files", bundle: .core) - setupTitleViewInNavbar(title: title) + + if #available(iOS 26, *) { + navigationItem.title = title + } else { + setupTitleViewInNavbar(title: title) + } + updateNavButtons() guard let folder = folder.first, items == nil else { return update() } @@ -310,8 +354,9 @@ extension FileListViewController: UISearchBarDelegate { extension FileListViewController: FilePickerDelegate { func updateNavButtons() { + let button = if #available(iOS 26, *) { addButton } else { legacyAddButton } navigationItem.rightBarButtonItems = [ - canAddItem ? addButton : nil, + canAddItem ? button : nil, canEditFolder ? editButton : nil ].compactMap { $0 } } @@ -334,6 +379,7 @@ extension FileListViewController: FilePickerDelegate { folder.first?.canUpload == true } + @available(iOS, deprecated: 26) @objc func addItem() { let sheet = BottomSheetPickerViewController.create() sheet.addAction( diff --git a/Core/Core/Features/Grades/View/GradeFilterScreen.swift b/Core/Core/Features/Grades/View/GradeFilterScreen.swift index b7bf20cb88..e42c99bffa 100644 --- a/Core/Core/Features/Grades/View/GradeFilterScreen.swift +++ b/Core/Core/Features/Grades/View/GradeFilterScreen.swift @@ -31,21 +31,44 @@ public struct GradeFilterScreen: View { // MARK: - Body public var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - if viewModel.isShowGradingPeriodsView { - gradingPeriodSection - } - sortBySection - } - .navigationBarTitleView( - title: String(localized: "Grade List Preferences", bundle: .core), - subtitle: viewModel.courseName - ) - .navigationBarItems(leading: cancelButton, trailing: sendButton) - .navigationBarStyle(.modal) - } - .background(Color.backgroundLightest) + if #available(iOS 26, *) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if viewModel.isShowGradingPeriodsView { + gradingPeriodSection + } + sortBySection + } + .navigationTitle(.init("Grade List Preferences", bundle: .core)) + .optionalNavigationSubtitle(viewModel.courseName) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + cancelButton + } + + ToolbarItem(placement: .topBarTrailing) { + sendButton + } + } + } + .background(Color.backgroundLightest) + } else { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if viewModel.isShowGradingPeriodsView { + gradingPeriodSection + } + sortBySection + } + .navigationBarTitleView( + title: String(localized: "Grade List Preferences", bundle: .core), + subtitle: viewModel.courseName + ) + .navigationBarItems(leading: cancelButton, trailing: legacySendButton) + .navigationBarStyle(.modal) + } + .background(Color.backgroundLightest) + } } private var gradingPeriodSection: some View { @@ -64,7 +87,23 @@ public struct GradeFilterScreen: View { ) } - private var sendButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + @ViewBuilder + private var sendButton: some View { + if viewModel.saveButtonIsEnabled { + Button { + viewModel.saveButtonTapped(viewController: viewController) + } label: { + Text(String(localized: "Done", bundle: .core)) + .font(.semibold16) + } + .buttonStyle(.glassProminent) + .accessibilityIdentifier("GradeFilter.saveButton") + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacySendButton: some View { Button { viewModel.saveButtonTapped(viewController: viewController) } label: { diff --git a/Core/Core/Features/Grades/View/GradeListScreen.swift b/Core/Core/Features/Grades/View/GradeListScreen.swift index e6a58df9e9..1fa2647356 100644 --- a/Core/Core/Features/Grades/View/GradeListScreen.swift +++ b/Core/Core/Features/Grades/View/GradeListScreen.swift @@ -64,51 +64,95 @@ public struct GradeListScreen: View, ScreenViewTrackable { // MARK: - Components public var body: some View { - ZStack { - ScrollView(showsIndicators: false) { - contentView - } - .background(Color.backgroundLight) - .accessibilityHidden(isScoreEditorPresented) - .refreshable { - await withCheckedContinuation { continuation in - viewModel.pullToRefreshDidTrigger.accept { - continuation.resume() - } - } - } + if #available(iOS 26, *) { + ZStack { + ScrollView(showsIndicators: false) { + contentView + } + .background(Color.backgroundLight) + .accessibilityHidden(isScoreEditorPresented) + .refreshable { + await withCheckedContinuation { continuation in + viewModel.pullToRefreshDidTrigger.accept { + continuation.resume() + } + } + } - whatIfScoreEditorView - } - .animation(.smooth, value: isScoreEditorPresented) - .safeAreaInset(edge: .top, spacing: 0) { - switch viewModel.state { - case .data, .empty: GradeListHeaderView( - viewModel: viewModel, - toggleViewIsVisible: toggleViewIsVisible - ) - default: SwiftUI.EmptyView() - } - } - .background(Color.backgroundLightest) - .tint(viewModel.courseColor?.asColor) - .navigationBarTitleView( - title: String(localized: "Grades", bundle: .core), - subtitle: viewModel.courseName - ) - .toolbar { - RevertWhatIfScoreButton(isWhatIfScoreModeOn: viewModel.isWhatIfScoreModeOn) { - viewModel.isShowingRevertDialog = true - } - ToolbarItem(placement: .primaryAction) { - GradeListFilterButton(viewModel: viewModel) - } - } - .navigationBarStyle(.color(nil)) - .confirmationAlert( - isPresented: $viewModel.isShowingRevertDialog, - presenting: viewModel.confirmRevertAlertViewModel - ) + whatIfScoreEditorView + } + .animation(.smooth, value: isScoreEditorPresented) + .safeAreaInset(edge: .top, spacing: 0) { + switch viewModel.state { + case .data, .empty: GradeListHeaderView( + viewModel: viewModel, + toggleViewIsVisible: toggleViewIsVisible + ) + default: SwiftUI.EmptyView() + } + } + .background(Color.backgroundLightest) + .navigationTitle(.init("Grades", bundle: .core)) + .optionalNavigationSubtitle(viewModel.courseName) + .toolbar { + RevertWhatIfScoreButton(isWhatIfScoreModeOn: viewModel.isWhatIfScoreModeOn) { + viewModel.isShowingRevertDialog = true + } + ToolbarItem { + GradeListFilterButton(viewModel: viewModel) + } + } + .navigationBarStyle(.color(nil)) + .confirmationAlert( + isPresented: $viewModel.isShowingRevertDialog, + presenting: viewModel.confirmRevertAlertViewModel + ) + } else { + ZStack { + ScrollView(showsIndicators: false) { + contentView + } + .background(Color.backgroundLight) + .accessibilityHidden(isScoreEditorPresented) + .refreshable { + await withCheckedContinuation { continuation in + viewModel.pullToRefreshDidTrigger.accept { + continuation.resume() + } + } + } + + whatIfScoreEditorView + } + .animation(.smooth, value: isScoreEditorPresented) + .safeAreaInset(edge: .top, spacing: 0) { + switch viewModel.state { + case .data, .empty: GradeListHeaderView( + viewModel: viewModel, + toggleViewIsVisible: toggleViewIsVisible + ) + default: SwiftUI.EmptyView() + } + } + .background(Color.backgroundLightest) + .navigationBarTitleView( + title: String(localized: "Grades", bundle: .core), + subtitle: viewModel.courseName + ) + .toolbar { + RevertWhatIfScoreButton(isWhatIfScoreModeOn: viewModel.isWhatIfScoreModeOn) { + viewModel.isShowingRevertDialog = true + } + ToolbarItem(placement: .primaryAction) { + LegacyGradeListFilterButton(viewModel: viewModel) + } + } + .navigationBarStyle(.color(nil)) + .confirmationAlert( + isPresented: $viewModel.isShowingRevertDialog, + presenting: viewModel.confirmRevertAlertViewModel + ) + } } @ViewBuilder @@ -227,14 +271,14 @@ private struct RevertWhatIfScoreButton: ToolbarContent { let buttonDidTap: () -> Void var body: some ToolbarContent { - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItem(placement: .navigationBarTrailing) { if isWhatIfScoreModeOn { Button(action: { buttonDidTap() }) { Image(uiImage: .replyLine) .resizable() - .foregroundColor(.textLightest.variantForLightMode) + .foregroundStyleBelow26(.textLightest.variantForLightMode) } .frame(alignment: .leading) .accessibilityLabel(Text("Revert", bundle: .core)) diff --git a/Core/Core/Features/Grades/View/Header/GradeListFilterButton.swift b/Core/Core/Features/Grades/View/Header/GradeListFilterButton.swift index 64913f2f7e..79bb134865 100644 --- a/Core/Core/Features/Grades/View/Header/GradeListFilterButton.swift +++ b/Core/Core/Features/Grades/View/Header/GradeListFilterButton.swift @@ -18,7 +18,29 @@ import SwiftUI +@available(iOS, introduced: 26, message: "Legacy version exists") struct GradeListFilterButton: View { + @Environment(\.viewController) var viewController + @ObservedObject var viewModel: GradeListViewModel + + var body: some View { + if viewModel.state != .initialLoading { + Button { + viewModel.navigateToFilter(viewController: viewController) + } label: { + Image.filterLine + .size(24) + .offset(y: 2) + } + .accessibilityLabel(Text("Filter", bundle: .core)) + .accessibilityHint(Text("Filter grades options", bundle: .core)) + .accessibilityIdentifier("GradeList.filterButton") + } + } +} + +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +struct LegacyGradeListFilterButton: View { @Environment(\.viewController) var viewController @ObservedObject var viewModel: GradeListViewModel diff --git a/Core/Core/Features/Grades/View/Header/GradeListHeaderView.swift b/Core/Core/Features/Grades/View/Header/GradeListHeaderView.swift index 47a890f3ce..d524302179 100644 --- a/Core/Core/Features/Grades/View/Header/GradeListHeaderView.swift +++ b/Core/Core/Features/Grades/View/Header/GradeListHeaderView.swift @@ -28,7 +28,7 @@ struct GradeListHeaderView: View { HStack(alignment: .center) { gradeDetailsView if viewModel.isParentApp { - GradeListFilterButton(viewModel: viewModel) + LegacyGradeListFilterButton(viewModel: viewModel) .paddingStyle(.leading, .standard) } } @@ -64,10 +64,15 @@ struct GradeListHeaderView: View { } .padding(.horizontal, 16) .padding(.vertical, verticalSizeClass == .regular ? 20 : 5) - .background( - Color.backgroundLightest - .cornerRadius(6) - ) + .background { + if #available(iOS 26, *) { + Color.backgroundLightest + .cornerRadius(24) + } else { + Color.backgroundLightest + .cornerRadius(6) + } + } .shadow(color: Color.textDark.opacity(0.2), radius: 5, x: 0, y: 0) } diff --git a/Core/Core/Features/Inbox/ComposeMessage/View/ComposeMessageView.swift b/Core/Core/Features/Inbox/ComposeMessage/View/ComposeMessageView.swift index 7351f2c16b..57520eb8e7 100644 --- a/Core/Core/Features/Inbox/ComposeMessage/View/ComposeMessageView.swift +++ b/Core/Core/Features/Inbox/ComposeMessage/View/ComposeMessageView.swift @@ -46,110 +46,198 @@ public struct ComposeMessageView: View, ScreenViewTrackable { } public var body: some View { - InstUI.BaseScreen(state: model.state, config: model.screenConfig) { geometry in - VStack(spacing: 0) { - headerView - .background( - GeometryReader { proxy in - Color.clear - .onAppear { - headerHeight = proxy.size.height - model.showSearchRecipientsView = false - focusedInput = nil - } - } - ) - separator - courseView - separator - ZStack(alignment: .topLeading) { - VStack(spacing: 0) { - propertiesView - separator - - bodyView(geometry: geometry) - attachmentsView - if !model.includedMessages.isEmpty { - includedMessages - } - // This Rectangle adds extra height to ensure smoother display of the list of recipients - // without affecting the UI or any logic. - Rectangle() - .fill(Color.clear) - .frame(height: 150) - .allowsHitTesting(false) - } - if model.showSearchRecipientsView { - RecipientFilterView(recipients: model.searchedRecipients) { selectedRecipient in - model.showSearchRecipientsView = false - model.textRecipientSearch = "" - model.didSelectRecipient.accept(selectedRecipient) - } - .accessibilityHidden(true) - .offset(y: model.recipients.isEmpty ? searchTextFieldHeight : recipientViewHeight + searchTextFieldHeight) - .padding(.horizontal, 35) - .fixedSize(horizontal: false, vertical: true) - .animation(.smooth, value: model.showSearchRecipientsView) - } - - } - } - .font(.regular12) - .foregroundColor(.textDarkest) - .background( - GeometryReader { reader in - Color - .backgroundLightest - .onTapGesture { - model.clearSearchedRecipients() - focusedInput = nil - } - .preference(key: ViewSizeKey.self, value: -reader.frame(in: .named("scroll")).origin.y) - } - ) - .navigationBarItems(leading: cancelButton, trailing: extraSendButton) - .navigationBarStyle(.modal) - } - .onPreferenceChange(ViewSizeKey.self) { offset in - model.showExtraSendButton = offset > headerHeight - } - .coordinateSpace(name: "scroll") - .background(Color.backgroundLightest) - .fileImporter( - isPresented: $model.isFilePickerVisible, - allowedContentTypes: [.item], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let urls): - model.addFiles(urls: urls) - case .failure: - break - } - } - .sheet(isPresented: $model.isImagePickerVisible) { - AttachmentPickerAssembly.makeImagePicker(onSelect: model.addFile) - } - .sheet(isPresented: $model.isTakePhotoVisible) { - AttachmentPickerAssembly.makeImageRecorder(onSelect: model.addFile) - .interactiveDismissDisabled() - } - .sheet(isPresented: $model.isAudioRecordVisible) { - AttachmentPickerAssembly.makeAudioRecorder(env: model.env, onSelect: model.addFile) - .interactiveDismissDisabled() - } - .confirmationAlert( - isPresented: $model.isShowingCancelDialog, - presenting: model.confirmAlert - ) - .confirmationAlert( - isPresented: $model.isShowingErrorDialog, - presenting: model.errorAlert - ) + if #available(iOS 26, *) { + InstUI.BaseScreen(state: model.state, config: model.screenConfig) { geometry in + baseScreenView(geometry) + .font(.regular12) + .foregroundColor(.textDarkest) + .background( + GeometryReader { reader in + Color + .backgroundLightest + .onTapGesture { + model.clearSearchedRecipients() + focusedInput = nil + } + .preference(key: ViewSizeKey.self, value: -reader.frame(in: .named("scroll")).origin.y) + } + ) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + cancelButton + } + + ToolbarItem(placement: .topBarTrailing) { + extraSendButton + } + } + } + .onPreferenceChange(ViewSizeKey.self) { offset in + model.showExtraSendButton = offset > headerHeight + } + .coordinateSpace(name: "scroll") + .background(Color.backgroundLightest) + .fileImporter( + isPresented: $model.isFilePickerVisible, + allowedContentTypes: [.item], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + model.addFiles(urls: urls) + case .failure: + break + } + } + .sheet(isPresented: $model.isImagePickerVisible) { + AttachmentPickerAssembly.makeImagePicker(onSelect: model.addFile) + } + .sheet(isPresented: $model.isTakePhotoVisible) { + AttachmentPickerAssembly.makeImageRecorder(onSelect: model.addFile) + .interactiveDismissDisabled() + } + .sheet(isPresented: $model.isAudioRecordVisible) { + AttachmentPickerAssembly.makeAudioRecorder(env: model.env, onSelect: model.addFile) + .interactiveDismissDisabled() + } + .confirmationAlert( + isPresented: $model.isShowingCancelDialog, + presenting: model.confirmAlert + ) + .confirmationAlert( + isPresented: $model.isShowingErrorDialog, + presenting: model.errorAlert + ) + } else { + InstUI.BaseScreen(state: model.state, config: model.screenConfig) { geometry in + baseScreenView(geometry) + .font(.regular12) + .foregroundColor(.textDarkest) + .background( + GeometryReader { reader in + Color + .backgroundLightest + .onTapGesture { + model.clearSearchedRecipients() + focusedInput = nil + } + .preference(key: ViewSizeKey.self, value: -reader.frame(in: .named("scroll")).origin.y) + } + ) + .navigationBarItems(leading: legacyCancelButton, trailing: legacyExtraSendButton) + .navigationBarStyle(.modal) + } + .onPreferenceChange(ViewSizeKey.self) { offset in + model.showExtraSendButton = offset > headerHeight + } + .coordinateSpace(name: "scroll") + .background(Color.backgroundLightest) + .fileImporter( + isPresented: $model.isFilePickerVisible, + allowedContentTypes: [.item], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + model.addFiles(urls: urls) + case .failure: + break + } + } + .sheet(isPresented: $model.isImagePickerVisible) { + AttachmentPickerAssembly.makeImagePicker(onSelect: model.addFile) + } + .sheet(isPresented: $model.isTakePhotoVisible) { + AttachmentPickerAssembly.makeImageRecorder(onSelect: model.addFile) + .interactiveDismissDisabled() + } + .sheet(isPresented: $model.isAudioRecordVisible) { + AttachmentPickerAssembly.makeAudioRecorder(env: model.env, onSelect: model.addFile) + .interactiveDismissDisabled() + } + .confirmationAlert( + isPresented: $model.isShowingCancelDialog, + presenting: model.confirmAlert + ) + .confirmationAlert( + isPresented: $model.isShowingErrorDialog, + presenting: model.errorAlert + ) + } } + @ViewBuilder + private func baseScreenView(_ geometry: GeometryProxy) -> some View { + VStack(spacing: 0) { + headerView + .background( + GeometryReader { proxy in + Color.clear + .onAppear { + headerHeight = proxy.size.height + model.showSearchRecipientsView = false + focusedInput = nil + } + } + ) + separator + courseView + separator + ZStack(alignment: .topLeading) { + VStack(spacing: 0) { + propertiesView + separator + + bodyView(geometry: geometry) + attachmentsView + if !model.includedMessages.isEmpty { + includedMessages + } + // This Rectangle adds extra height to ensure smoother display of the list of recipients + // without affecting the UI or any logic. + Rectangle() + .fill(Color.clear) + .frame(height: 150) + .allowsHitTesting(false) + } + if model.showSearchRecipientsView { + RecipientFilterView(recipients: model.searchedRecipients) { selectedRecipient in + model.showSearchRecipientsView = false + model.textRecipientSearch = "" + model.didSelectRecipient.accept(selectedRecipient) + } + .accessibilityHidden(true) + .offset(y: model.recipients.isEmpty ? searchTextFieldHeight : recipientViewHeight + searchTextFieldHeight) + .padding(.horizontal, 35) + .fixedSize(horizontal: false, vertical: true) + .animation(.smooth, value: model.showSearchRecipientsView) + } + + } + } + } + + @available(iOS, introduced: 26, message: "Legacy version exists") + @ViewBuilder + private var extraSendButton: some View { + if model.showExtraSendButton { + Button { + model.didTapSend.accept(controller) + } label: { + Image.arrowUpLine + .resizable() + } + .buttonStyle(.glassProminent) + .accessibility(label: Text("Send", bundle: .core)) + .disabled(!model.sendButtonActive) + .frame(maxHeight: .infinity, alignment: .top) + .accessibilityIdentifier("ComposeMessage.send") + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") @ViewBuilder - private var extraSendButton: some View { + private var legacyExtraSendButton: some View { if model.showExtraSendButton { sendButton } else { @@ -162,7 +250,19 @@ public struct ComposeMessageView: View, ScreenViewTrackable { .frame(height: 0.5) } - private var cancelButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private var cancelButton: some View { + Button { + model.didTapCancel.accept(controller) + } label: { + Text("Cancel", bundle: .core) + .font(.regular16) + .accessibilityIdentifier("ComposeMessage.cancel") + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyCancelButton: some View { Button { model.didTapCancel.accept(controller) } label: { @@ -517,7 +617,9 @@ struct ComposeMessageView_Previews: PreviewProvider { static let env = PreviewEnvironment() static var previews: some View { - ComposeMessageAssembly.makePreview(env: env) + NavigationStack { + ComposeMessageAssembly.makePreview(env: env) + } } } diff --git a/Core/Core/Features/Inbox/MessageDetails/View/LegacyMessageView.swift b/Core/Core/Features/Inbox/MessageDetails/View/LegacyMessageView.swift new file mode 100644 index 0000000000..df55ba5fef --- /dev/null +++ b/Core/Core/Features/Inbox/MessageDetails/View/LegacyMessageView.swift @@ -0,0 +1,134 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI + +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +public struct LegacyMessageView: View { + @Environment(\.viewController) private var controller + @ScaledMetric private var uiScale: CGFloat = 1 + + private var model: MessageViewModel + private let isReplyButtonVisible: Bool + private var replyDidTap: () -> Void + private var moreDidTap: () -> Void + + public init( + model: MessageViewModel, + isReplyButtonVisible: Bool, + replyDidTap: @escaping () -> Void, + moreDidTap: @escaping () -> Void + ) { + self.model = model + self.replyDidTap = replyDidTap + self.moreDidTap = moreDidTap + self.isReplyButtonVisible = isReplyButtonVisible + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + headerView + bodyView + if isReplyButtonVisible { + replyButton + } + } + } + + private var replyButton: some View { + Button { + replyDidTap() + } label: { + Text("Reply", bundle: .core) + .font(.regular16) + .foregroundColor(Color(Brand.shared.linkColor)) + .accessibilityIdentifier("MessageDetails.replyButton") + } + } + + private var headerView: some View { + HStack(alignment: .top, spacing: 4) { + Avatar(name: model.avatarName, url: model.avatarURL, size: 36, isAccessible: false) + VStack(alignment: .leading) { + Text(model.author) + .font(.regular16) + .foregroundColor(.textDarkest) + .lineLimit(1) + .accessibilityIdentifier("MessageDetails.author") + + Text(model.date) + .foregroundColor(.textDark) + .font(.regular12) + .accessibilityIdentifier("MessageDetails.date") + } + Spacer() + if isReplyButtonVisible { + replyIconButton + } + Button { + moreDidTap() + } label: { + Image + .moreLine + .size(uiScale.iconScale * 20) + .foregroundColor(.textDark) + .padding(.horizontal, 6) + .accessibilityLabel(Text("Conversation options", bundle: .core)) + .accessibilityIdentifier("MessageDetails.options") + } + } + } + + private var replyIconButton: some View { + Button { + replyDidTap() + } label: { + Image + .replyLine + .size(uiScale.iconScale * 20) + .foregroundColor(.textDark) + .padding(.leading, 6) + .accessibilityLabel(Text("Reply", bundle: .core)) + .accessibilityIdentifier("MessageDetails.replyImage") + } + } + + private var bodyView: some View { + VStack(alignment: .leading, spacing: 16) { + SelectableText( + attributedText: model.body.toAttributedStringWithLinks(), + font: .regular16, + lineHeight: .fit, + textColor: .textDarkest + ) + .accessibilityIdentifier("MessageDetails.body") + + if model.showAttachments { + AttachmentsView( + attachments: model.attachments, + mediaComment: model.mediaComment, + didSelectAttachment: model.handleFileNavigation + ) + } + } + .onAppear { + model.controller = controller + } + .environment(\.openURL, OpenURLAction(handler: model.handleURL)) + } +} diff --git a/Core/Core/Features/Inbox/MessageDetails/View/MessageDetailsView.swift b/Core/Core/Features/Inbox/MessageDetails/View/MessageDetailsView.swift index 50461cb043..0606b5feee 100644 --- a/Core/Core/Features/Inbox/MessageDetails/View/MessageDetailsView.swift +++ b/Core/Core/Features/Inbox/MessageDetails/View/MessageDetailsView.swift @@ -28,32 +28,60 @@ public struct MessageDetailsView: View { } public var body: some View { - RefreshableScrollView { - switch model.state { - case .loading: - loadingIndicator - case .data: - detailsView - case .empty, .error: - VStack(alignment: .center, spacing: 0) { - Text("There was an error loading the message. Pull to refresh to try again.", bundle: .core) - .multilineTextAlignment(.center) - .padding(.horizontal, 12) - } - .padding(.horizontal, 12) - .padding(.vertical, 24) - } - } - refreshAction: { onComplete in - model.refreshDidTrigger.send { - onComplete() - } - } - .background(Color.backgroundLightest) - .navigationBarTitleView(model.title) - .navigationBarItems(trailing: moreButton) - .navigationBarStyle(.global) - .snackBar(viewModel: model.snackBarViewModel) + if #available(iOS 26, *) { + RefreshableScrollView { + switch model.state { + case .loading: + loadingIndicator + case .data: + detailsView + case .empty, .error: + VStack(alignment: .center, spacing: 0) { + Text("There was an error loading the message. Pull to refresh to try again.", bundle: .core) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + } + .padding(.horizontal, 12) + .padding(.vertical, 24) + } + } + refreshAction: { onComplete in + model.refreshDidTrigger.send { + onComplete() + } + } + .background(Color.backgroundLightest) + .navigationTitle(model.title) + .snackBar(viewModel: model.snackBarViewModel) + .toolbar { moreButton } + } else { + RefreshableScrollView { + switch model.state { + case .loading: + loadingIndicator + case .data: + detailsView + case .empty, .error: + VStack(alignment: .center, spacing: 0) { + Text("There was an error loading the message. Pull to refresh to try again.", bundle: .core) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + } + .padding(.horizontal, 12) + .padding(.vertical, 24) + } + } + refreshAction: { onComplete in + model.refreshDidTrigger.send { + onComplete() + } + } + .background(Color.backgroundLightest) + .navigationBarTitleView(model.title) + .navigationBarItems(trailing: legacyMoreButton) + .navigationBarStyle(.global) + .snackBar(viewModel: model.snackBarViewModel) + } } private var loadingIndicator: some View { @@ -89,7 +117,71 @@ public struct MessageDetailsView: View { .padding(.horizontal, 16) } - private var moreButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private var moreButton: some View { + Menu { + if model.isReplyButtonVisible { + Button(.init("Reply", bundle: .core), image: .replyLine) { + model.replyTapped(message: nil, viewController: controller) + } + .accessibilityIdentifier("MessageDetails.reply") + } + + if !model.isStudentAccessRestricted { + Button(.init("Reply All", bundle: .core), image: .replyAllLine) { + model.replyAllTapped(message: nil, viewController: controller) + } + .accessibilityIdentifier("MessageDetails.replyAll") + } + + Button(.init("Forward", bundle: .core), image: .forwardLine) { + model.forwardTapped(viewController: controller) + } + .accessibilityIdentifier("MessageDetails.forward") + + if model.conversations.first?.workflowState == .read { + Button(.init("Mark as Unread", bundle: .core), image: .nextUnreadLine) { + model.updateState.send(.unread) + } + .accessibilityIdentifier("MessageDetails.markAsUnread") + } else { + Button(.init("Mark as Read", bundle: .core), image: .emailLine) { + model.updateState.send(.read) + } + .accessibilityIdentifier("MessageDetails.markAsRead") + } + + if model.conversations.first?.workflowState != .archived, model.allowArchive { + Button(.init("Archive", bundle: .core), image: .archiveLine) { + model.updateState.send(.archived) + } + .accessibilityIdentifier("MessageDetails.archive") + } + + if model.conversations.first?.workflowState == .archived, model.allowArchive { + Button(.init("Unarchive", bundle: .core), image: .unarchiveLine) { + model.updateState.send(.read) + } + .accessibilityIdentifier("MessageDetails.unarchive") + } + + if !model.isStudentAccessRestricted { + Button(.init("Delete Conversation", bundle: .core), image: .trashLine) { + if let conversationId = model.conversations.first?.id { + model.deleteConversationDidTap.send((conversationId, controller)) + } + } + .accessibilityIdentifier("MessageDetails.delete") + } + } label: { + Image.moreSolid + } + .accessibilityIdentifier("MessageDetails.more") + .accessibility(label: Text("More options", bundle: .core)) + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyMoreButton: some View { Button(action: { model.conversationMoreTapped(viewController: controller) }, label: { @@ -133,12 +225,24 @@ public struct MessageDetailsView: View { Color.borderMedium .frame(height: 0.5) - MessageView(model: message, - isReplyButtonVisible: model.isReplyButtonVisible, - replyDidTap: { model.replyTapped(message: message.conversationMessage, viewController: controller) }, - moreDidTap: { model.messageMoreTapped(message: message.conversationMessage, viewController: controller) }) - .padding(16) + if #available(iOS 26, *) { + MessageView(model: message, + isReplyButtonVisible: model.isReplyButtonVisible, + isStudentAccessRestricted: model.isStudentAccessRestricted, + replyDidTap: { model.messageReplyTapped(message: message.conversationMessage, viewController: controller) }, + replyAllDidTap: { model.messageReplyAllTapped(message: message.conversationMessage, viewController: controller) }, + forwardDidTap: { model.forwardTapped(message: message.conversationMessage, viewController: controller)}, + deleteDidTap: { model.deleteMessageTapped(message: message.conversationMessage, viewController: controller) } + ) + .padding(16) + } else { + LegacyMessageView(model: message, + isReplyButtonVisible: model.isReplyButtonVisible, + replyDidTap: { model.replyTapped(message: message.conversationMessage, viewController: controller) }, + moreDidTap: { model.messageMoreTapped(message: message.conversationMessage, viewController: controller) }) + .padding(16) + } } } } @@ -151,7 +255,9 @@ struct MessageDetailsView_Previews: PreviewProvider { static let context = env.globalDatabase.viewContext static var previews: some View { - MessageDetailsAssembly.makePreview(env: env, subject: "Message Title", messages: .make(count: 5, body: InstUI.PreviewData.loremIpsumLong, in: context)) + NavigationStack { + MessageDetailsAssembly.makePreview(env: env, subject: "Message Title", messages: .make(count: 5, body: InstUI.PreviewData.loremIpsumLong, in: context)) + } } } diff --git a/Core/Core/Features/Inbox/MessageDetails/View/MessageView.swift b/Core/Core/Features/Inbox/MessageDetails/View/MessageView.swift index 851ff292c2..9e61dabf64 100644 --- a/Core/Core/Features/Inbox/MessageDetails/View/MessageView.swift +++ b/Core/Core/Features/Inbox/MessageDetails/View/MessageView.swift @@ -18,25 +18,35 @@ import SwiftUI +@available(iOS, introduced: 26, message: "Legacy version exists") public struct MessageView: View { @Environment(\.viewController) private var controller @ScaledMetric private var uiScale: CGFloat = 1 private var model: MessageViewModel private let isReplyButtonVisible: Bool - private var replyDidTap: () -> Void - private var moreDidTap: () -> Void + private let isStudentAccessRestricted: Bool + private let replyDidTap: () -> Void + private let replyAllDidTap: () -> Void + private let forwardDidTap: () -> Void + public var deleteDidTap: () -> Void public init( model: MessageViewModel, isReplyButtonVisible: Bool, + isStudentAccessRestricted: Bool, replyDidTap: @escaping () -> Void, - moreDidTap: @escaping () -> Void + replyAllDidTap: @escaping () -> Void, + forwardDidTap: @escaping () -> Void, + deleteDidTap: @escaping () -> Void ) { self.model = model self.replyDidTap = replyDidTap - self.moreDidTap = moreDidTap + self.isStudentAccessRestricted = isStudentAccessRestricted + self.replyAllDidTap = replyAllDidTap self.isReplyButtonVisible = isReplyButtonVisible + self.forwardDidTap = forwardDidTap + self.deleteDidTap = deleteDidTap } public var body: some View { @@ -79,8 +89,32 @@ public struct MessageView: View { if isReplyButtonVisible { replyIconButton } - Button { - moreDidTap() + Menu { + if isReplyButtonVisible { + Button(.init("Reply", bundle: .core), image: .replyLine, action: replyDidTap) + .accessibilityIdentifier("MessageDetails.reply") + + if !isStudentAccessRestricted { + Button( + .init("Reply All", bundle: .core), + image: .replyAllLine, + action: replyAllDidTap + ) + .accessibilityIdentifier("MessageDetails.replyAll") + } + + Button(.init("Forward", bundle: .core), image: .forwardLine, action: forwardDidTap) + .accessibilityIdentifier("MessageDetails.forward") + + if !isStudentAccessRestricted { + Button( + .init("Delete Message", bundle: .core), + image: .trashLine, + action: deleteDidTap + ) + .accessibilityIdentifier("MessageDetails.delete") + } + } } label: { Image .moreLine @@ -131,3 +165,18 @@ public struct MessageView: View { .environment(\.openURL, OpenURLAction(handler: model.handleURL)) } } + +#if DEBUG + +#Preview { + let env = PreviewEnvironment() + let context = env.globalDatabase.viewContext + + MessageDetailsAssembly.makePreview( + env: env, + subject: "Message Title", + messages: .make(count: 5, body: InstUI.PreviewData.loremIpsumLong, in: context) + ) +} + +#endif diff --git a/Core/Core/Features/Inbox/MessageDetails/ViewModel/MessageDetailsViewModel.swift b/Core/Core/Features/Inbox/MessageDetails/ViewModel/MessageDetailsViewModel.swift index bebc740cf1..e34e8c14c1 100644 --- a/Core/Core/Features/Inbox/MessageDetails/ViewModel/MessageDetailsViewModel.swift +++ b/Core/Core/Features/Inbox/MessageDetails/ViewModel/MessageDetailsViewModel.swift @@ -28,6 +28,7 @@ class MessageDetailsViewModel: ObservableObject { @Published public private(set) var starred: Bool = false @Published public private(set) var isReplyButtonVisible: Bool = false @Published public private(set) var isStudentAccessRestricted: Bool = false + public let allowArchive: Bool public let snackBarViewModel = SnackBarViewModel() @@ -55,7 +56,6 @@ class MessageDetailsViewModel: ObservableObject { private let studentAccessInteractor: StudentAccessInteractor private let env: AppEnvironment private let myID: String - private let allowArchive: Bool public init(interactor: MessageDetailsInteractor, studentAccessInteractor: StudentAccessInteractor, myID: String, allowArchive: Bool, env: AppEnvironment) { self.interactor = interactor @@ -69,6 +69,7 @@ class MessageDetailsViewModel: ObservableObject { bindStudentAccessRestriction() } + @available(iOS, deprecated: 26) public func conversationMoreTapped(viewController: WeakViewController) { let sheet = BottomSheetPickerViewController.create() if isReplyButtonVisible { @@ -143,6 +144,28 @@ class MessageDetailsViewModel: ObservableObject { env.router.show(sheet, from: viewController, options: .modal()) } + @available(iOS, introduced: 26, message: "Legacy version exists") + public func messageReplyTapped(message: ConversationMessage?, viewController: WeakViewController) { + if let message { + replyTapped(message: message, viewController: viewController) + } + } + + @available(iOS, introduced: 26, message: "Legacy version exists") + public func messageReplyAllTapped(message: ConversationMessage?, viewController: WeakViewController) { + if let message { + replyAllTapped(message: message, viewController: viewController) + } + } + + @available(iOS, introduced: 26, message: "Legacy version exists") + public func deleteMessageTapped(message: ConversationMessage?, viewController: WeakViewController) { + if let conversationId = conversations.first?.id, let messageId = message?.id { + deleteConversationMessageDidTap.send((conversationId: conversationId, messageId: messageId, viewController: viewController)) + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") public func messageMoreTapped(message: ConversationMessage?, viewController: WeakViewController) { let sheet = BottomSheetPickerViewController.create() if isReplyButtonVisible { diff --git a/Core/Core/Features/Inbox/View/InboxView.swift b/Core/Core/Features/Inbox/View/InboxView.swift index e453521649..7675b829c7 100644 --- a/Core/Core/Features/Inbox/View/InboxView.swift +++ b/Core/Core/Features/Inbox/View/InboxView.swift @@ -35,46 +35,98 @@ public struct InboxView: View, ScreenViewTrackable { } public var body: some View { - VStack(spacing: 0) { - InboxFilterBarView(model: model) - Color.borderMedium - .frame(height: 0.5) - if case .loading = model.state { - loadingIndicator - } else { - GeometryReader { geometry in - List { - switch model.state { - case .data: - messagesList - .listRowBackground(SwiftUI.EmptyView()) - nextPageLoadingIndicator(geometry: geometry) - .onAppear { - model.contentDidScrollToBottom.send() - } - case .empty: - panda(geometry: geometry, data: model.emptyState) - case .error: - panda(geometry: geometry, data: model.errorState) - case .loading: - SwiftUI.EmptyView() - } - } - .refreshable { - await withCheckedContinuation { continuation in - model.refreshDidTrigger.send { - continuation.resume() - } - } - } - .listStyle(PlainListStyle()) - .animation(.default, value: model.messages) - } - } - } - .background(Color.backgroundLightest) - .navigationBarItems(leading: model.isShowMenuButton ? menuButton : nil, trailing: newMessageButton) - .navigationBarStyle(.global) + if #available(iOS 26, *) { + VStack(spacing: 0) { + InboxFilterBarView(model: model) + Color.borderMedium + .frame(height: 0.5) + if case .loading = model.state { + loadingIndicator + } else { + GeometryReader { geometry in + List { + switch model.state { + case .data: + messagesList + .listRowBackground(SwiftUI.EmptyView()) + nextPageLoadingIndicator(geometry: geometry) + .onAppear { + model.contentDidScrollToBottom.send() + } + case .empty: + panda(geometry: geometry, data: model.emptyState) + case .error: + panda(geometry: geometry, data: model.errorState) + case .loading: + SwiftUI.EmptyView() + } + } + .refreshable { + await withCheckedContinuation { continuation in + model.refreshDidTrigger.send { + continuation.resume() + } + } + } + .listStyle(PlainListStyle()) + .animation(.default, value: model.messages) + } + } + } + .background(Color.backgroundLightest) + .toolbar { + if model.isShowMenuButton { + ToolbarItem(placement: .topBarLeading) { + menuButton + } + } + + ToolbarItem(placement: .topBarTrailing) { + newMessageButton + } + } + } else { + VStack(spacing: 0) { + InboxFilterBarView(model: model) + Color.borderMedium + .frame(height: 0.5) + if case .loading = model.state { + loadingIndicator + } else { + GeometryReader { geometry in + List { + switch model.state { + case .data: + messagesList + .listRowBackground(SwiftUI.EmptyView()) + nextPageLoadingIndicator(geometry: geometry) + .onAppear { + model.contentDidScrollToBottom.send() + } + case .empty: + panda(geometry: geometry, data: model.emptyState) + case .error: + panda(geometry: geometry, data: model.errorState) + case .loading: + SwiftUI.EmptyView() + } + } + .refreshable { + await withCheckedContinuation { continuation in + model.refreshDidTrigger.send { + continuation.resume() + } + } + } + .listStyle(PlainListStyle()) + .animation(.default, value: model.messages) + } + } + } + .background(Color.backgroundLightest) + .navigationBarItems(leading: model.isShowMenuButton ? legacyMenuButton : nil, trailing: legacyNewMessageButton) + .navigationBarStyle(.global) + } } private var messagesList: some View { @@ -209,7 +261,20 @@ public struct InboxView: View, ScreenViewTrackable { .listRowSeparator(.hidden) } - private var menuButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private var menuButton: some View { + Button { + model.menuDidTap.send(controller) + } label: { + // TODO: Remove the condition once horizon-specific logic is no longer needed. + Image.hamburgerSolid + } + .identifier("Inbox.profileButton") + .accessibility(label: Text("Profile Menu, Closed", bundle: .core, comment: "Accessibility text describing the Profile Menu button and its state")) + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyMenuButton: some View { Button { model.menuDidTap.send(controller) } label: { @@ -222,7 +287,20 @@ public struct InboxView: View, ScreenViewTrackable { .accessibility(label: Text("Profile Menu, Closed", bundle: .core, comment: "Accessibility text describing the Profile Menu button and its state")) } - private var newMessageButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + private var newMessageButton: some View { + Button { + model.newMessageDidTap.send(controller) + } label: { + Image.addSolid + // TODO: Remove the condition once horizon-specific logic is no longer needed. + } + .identifier("Inbox.newMessageButton") + .accessibility(label: Text("New Message", bundle: .core)) + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + private var legacyNewMessageButton: some View { Button { model.newMessageDidTap.send(controller) } label: { diff --git a/Core/Core/Features/LTI/View/LTIViewController.swift b/Core/Core/Features/LTI/View/LTIViewController.swift index c04c43b509..6494e31592 100644 --- a/Core/Core/Features/LTI/View/LTIViewController.swift +++ b/Core/Core/Features/LTI/View/LTIViewController.swift @@ -60,7 +60,13 @@ public class LTIViewController: UIViewController, ErrorViewController, ColoredNa scrollView.backgroundColor = .backgroundLightest spinnerView.isHidden = true nameLabel.text = name ?? String(localized: "LTI Tool", bundle: .core) - setupTitleViewInNavbar(title: String(localized: "External Tool", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "External Tool", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "External Tool", bundle: .core)) + } + if name == nil { // try to get a more descriptive name of the tool tools.getSessionlessLaunch { [weak self] response in @@ -86,7 +92,12 @@ public class LTIViewController: UIViewController, ErrorViewController, ColoredNa return } let course = courses?.first - updateNavBar(subtitle: course?.name, color: course?.color) + + if #available(iOS 26, *) { + navigationItem.subtitle = course?.name + } else { + updateNavBar(subtitle: course?.name, color: course?.color) + } } @IBAction func openButtonPressed(_ sender: UIButton) { diff --git a/Core/Core/Features/Login/LoginFindSchoolViewController.swift b/Core/Core/Features/Login/LoginFindSchoolViewController.swift index c028e3e4a9..167a556144 100644 --- a/Core/Core/Features/Login/LoginFindSchoolViewController.swift +++ b/Core/Core/Features/Login/LoginFindSchoolViewController.swift @@ -25,7 +25,11 @@ class LoginFindSchoolViewController: UIViewController { @IBOutlet weak var resultsTableView: UITableView! @IBOutlet weak var searchField: UITextField! - private lazy var nextButton = UIBarButtonItem(title: String(localized: "Next", bundle: .core), style: .done, target: self, action: #selector(nextPressed)) + private lazy var nextButton = if #available(iOS 26, *) { + UIBarButtonItem(title: String(localized: "Next", bundle: .core), style: .prominent, target: self, action: #selector(nextPressed)) + } else { + UIBarButtonItem(title: String(localized: "Next", bundle: .core), style: .done, target: self, action: #selector(nextPressed)) + } var accounts = [APIAccountResult]() var api: API = API() diff --git a/Core/Core/Features/Login/LoginNavigationController.swift b/Core/Core/Features/Login/LoginNavigationController.swift index 677ffbcb9e..b9442c6095 100644 --- a/Core/Core/Features/Login/LoginNavigationController.swift +++ b/Core/Core/Features/Login/LoginNavigationController.swift @@ -32,7 +32,9 @@ public class LoginNavigationController: UINavigationController { public override func viewDidLoad() { super.viewDidLoad() - navigationBar.useStyle(.global) + if #unavailable(iOS 26) { + navigationBar.useStyle(.global) + } isNavigationBarHidden = true } diff --git a/Core/Core/Features/Modules/ModuleItems/ModuleItemDetailsViewController.swift b/Core/Core/Features/Modules/ModuleItems/ModuleItemDetailsViewController.swift index 675fcb6ac5..65a8e4dd98 100644 --- a/Core/Core/Features/Modules/ModuleItems/ModuleItemDetailsViewController.swift +++ b/Core/Core/Features/Modules/ModuleItems/ModuleItemDetailsViewController.swift @@ -64,7 +64,11 @@ public final class ModuleItemDetailsViewController: UIViewController, ColoredNav public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Module Item", bundle: .core)) + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Module Item", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Module Item", bundle: .core)) + } Analytics.shared.logEvent("module_item", parameters: ["moduleID": moduleID!, "itemID": itemID!]) errorView.isHidden = true errorView.retryButton.addTarget(self, action: #selector(retryButtonPressed), for: .primaryActionTriggered) @@ -107,7 +111,12 @@ public final class ModuleItemDetailsViewController: UIViewController, ColoredNav // When embedded view controllers adapt course color for their own spinner view, // we should enable this line below. // spinnerView.color = course.first?.color - updateNavBar(subtitle: course.first?.name, color: course.first?.color) + if #available(iOS 26, *) { + navigationItem.subtitle = course.first?.name + } else { + updateNavBar(subtitle: course.first?.name, color: course.first?.color) + } + let title: String switch item?.type { case .assignment: @@ -127,7 +136,13 @@ public final class ModuleItemDetailsViewController: UIViewController, ColoredNav case nil, .subHeader: title = String(localized: "Module Item", bundle: .core) } - setupTitleViewInNavbar(title: title) + + if #available(iOS 26, *) { + navigationItem.title = title + } else { + setupTitleViewInNavbar(title: title) + } + if item?.completionRequirementType == .must_mark_done { navigationItem.rightBarButtonItems = [] navigationItem.rightBarButtonItems?.append(optionsButton) diff --git a/Core/Core/Features/Modules/ModuleItems/ModuleItemSequenceViewController.swift b/Core/Core/Features/Modules/ModuleItems/ModuleItemSequenceViewController.swift index 01b278d3f7..b878856d85 100644 --- a/Core/Core/Features/Modules/ModuleItems/ModuleItemSequenceViewController.swift +++ b/Core/Core/Features/Modules/ModuleItems/ModuleItemSequenceViewController.swift @@ -69,6 +69,15 @@ public final class ModuleItemSequenceViewController: UIViewController { leftBarButtonItems = navigationItem.leftBarButtonItems rightBarButtonItems = navigationItem.rightBarButtonItems + if #available(iOS 26, *) { + // Since titleSubtitleView is in use, we need to set the toolbar appearance to match the screen's + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = .backgroundLightest + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + showSequenceButtons(prev: false, next: false) pages.scrollView.isScrollEnabled = false embed(pages, in: pagesContainer) @@ -97,6 +106,18 @@ public final class ModuleItemSequenceViewController: UIViewController { store.refresh(force: true) } + public override func viewWillDisappear(_ animated: Bool) { + if #available(iOS 26, *) { + // Remove the toolbar appearance override since it is for this screen only + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + + super.viewWillDisappear(animated) + } + private func update(embed: Bool) { if store.requested, store.pending { return diff --git a/Core/Core/Features/Modules/ModuleList/ModuleListViewController.swift b/Core/Core/Features/Modules/ModuleList/ModuleListViewController.swift index afff2c0507..648581dc78 100644 --- a/Core/Core/Features/Modules/ModuleList/ModuleListViewController.swift +++ b/Core/Core/Features/Modules/ModuleList/ModuleListViewController.swift @@ -71,7 +71,12 @@ public final class ModuleListViewController: ScreenViewTrackableViewController, public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: String(localized: "Modules", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Modules", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Modules", bundle: .core)) + } collapsedIDs[courseID] = collapsedIDs[courseID] ?? [] if let moduleID = moduleID { @@ -111,7 +116,9 @@ public final class ModuleListViewController: ScreenViewTrackableViewController, if let selectedIndexPath = tableView.indexPathForSelectedRow { tableView.deselectRow(at: selectedIndexPath, animated: false) } - navigationController?.navigationBar.useContextColor(color) + if #unavailable(iOS 26) { + navigationController?.navigationBar.useContextColor(color) + } } private func update() { @@ -176,7 +183,11 @@ public final class ModuleListViewController: ScreenViewTrackableViewController, } private func reloadCourse() { - updateNavBar(subtitle: courses.first?.name, color: courses.first?.color) + if #available(iOS 26, *) { + navigationItem.subtitle = courses.first?.name + } else { + updateNavBar(subtitle: courses.first?.name, color: courses.first?.color) + } view.tintColor = color } diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 20d39be5f1..0ecf3ab29c 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -80,7 +80,13 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Page Details", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Page Details", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Page Details", bundle: .core)) + } + webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) webView.linkDelegate = self @@ -132,12 +138,21 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, let color = context.contextType == .course ? courses.first?.color : groups.first?.color, env.app != .parent else { return } - updateNavBar(subtitle: name, color: color) + + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } } private func update() { guard let page = page else { return } - setupTitleViewInNavbar(title: page.title) + if #available(iOS 26, *) { + navigationItem.title = page.title + } else { + setupTitleViewInNavbar(title: page.title) + } optionsButton.accessibilityIdentifier = "PageDetails.options" navigationItem.rightBarButtonItem = canEdit ? optionsButton : nil diff --git a/Core/Core/Features/Pages/PageList/PageListViewController.swift b/Core/Core/Features/Pages/PageList/PageListViewController.swift index 593aa3a572..21d038588f 100644 --- a/Core/Core/Features/Pages/PageList/PageListViewController.swift +++ b/Core/Core/Features/Pages/PageList/PageListViewController.swift @@ -64,8 +64,14 @@ public class PageListViewController: ScreenViewTrackableViewController, ColoredN public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: String(localized: "Pages", bundle: .core)) - if canCreatePage { + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Pages", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Pages", bundle: .core)) + } + + if canCreatePage { let item = UIBarButtonItem(image: .addSolid, style: .plain, target: self, action: #selector(createPage)) item.accessibilityIdentifier = "PageList.add" navigationItem.rightBarButtonItem = item @@ -111,7 +117,11 @@ public class PageListViewController: ScreenViewTrackableViewController, ColoredN loadingView.color = color refreshControl.color = color view.tintColor = color - updateNavBar(subtitle: name, color: color) + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } } func update() { @@ -210,6 +220,12 @@ extension PageListViewController: UITableViewDataSource, UITableViewDelegate { if cell is LoadingCell { pages.getNextPage() } + + if #available(iOS 26, *) { + if indexPath.section == 0 && indexPath.row == 0 { + cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .greatestFiniteMagnitude) + } + } } public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { diff --git a/Core/Core/Features/People/ContextCard/Course/ContextCardView.swift b/Core/Core/Features/People/ContextCard/Course/ContextCardView.swift index 915643dd7a..60ef834041 100644 --- a/Core/Core/Features/People/ContextCard/Course/ContextCardView.swift +++ b/Core/Core/Features/People/ContextCard/Course/ContextCardView.swift @@ -27,20 +27,48 @@ public struct ContextCardView: View { } public var body: some View { - contextCard - .background(Color.backgroundLightest) - .navigationBarTitleView( - title: model.user.first?.name ?? "", - subtitle: model.course.first?.name - ) - .navigationBarItems(trailing: emailButton) - .navigationBarStyle(model.isModal ? .modal : .color(nil)) - .onAppear { - model.viewAppeared() - } + if #available(iOS 26, *) { + contextCard + .background(Color.backgroundLightest) + .optionalNavigationTitle(model.user.first?.name) + .optionalNavigationSubtitle(model.course.first?.name) + .toolbar { + emailButton + } + .navigationBarStyle(model.isModal ? .modal : .color(nil)) + .onAppear { + model.viewAppeared() + } + } else { + contextCard + .background(Color.backgroundLightest) + .navigationBarTitleView( + title: model.user.first?.name ?? "", + subtitle: model.course.first?.name + ) + .navigationBarItems(trailing: legacyEmailButton) + .navigationBarStyle(model.isModal ? .modal : .color(nil)) + .onAppear { + model.viewAppeared() + } + } } - @ViewBuilder var emailButton: some View { + @available(iOS, introduced: 26, message: "Legacy version exists") + @ViewBuilder + var emailButton: some View { + if model.permissions.first?.sendMessages == true, model.isViewingAnotherUser { + Button { model.openNewMessageComposer(controller: controller.value) } label: { + Image.emailLine + } + .accessibility(label: Text("Send message", bundle: .core)) + .identifier("ContextCard.emailContact") + } + } + + @available(iOS, deprecated: 26, message: "Non-legacy version exists") + @ViewBuilder + var legacyEmailButton: some View { if model.permissions.first?.sendMessages == true, model.isViewingAnotherUser { Button(action: { model.openNewMessageComposer(controller: controller.value) }, label: { let color = model.isModal ? Brand.shared.buttonPrimaryBackground : Brand.shared.buttonPrimaryText diff --git a/Core/Core/Features/People/PeopleListViewController.swift b/Core/Core/Features/People/PeopleListViewController.swift index fa423e8ed2..8edc76e267 100644 --- a/Core/Core/Features/People/PeopleListViewController.swift +++ b/Core/Core/Features/People/PeopleListViewController.swift @@ -46,12 +46,16 @@ public class PeopleListViewController: ScreenViewTrackableViewController, Colore lazy var customStatuses: Store? = { guard case .course = context.contextType else { return nil } return env.subscribe(GetCustomGradeStatuses(courseID: context.id)) { [weak self] in - self?.updateNavBar() + if #unavailable(iOS 26) { + self?.updateNavBar() + } } }() lazy var colors = env.subscribe(GetCustomColors()) { [weak self] in - self?.updateNavBar() + if #unavailable(iOS 26) { + self?.updateNavBar() + } } lazy var course = env.subscribe(GetCourse(courseID: context.id)) { [weak self] in self?.updateNavBar() @@ -76,7 +80,12 @@ public class PeopleListViewController: ScreenViewTrackableViewController, Colore public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "People", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "People", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "People", bundle: .core)) + } emptyMessageLabel.text = String(localized: "We couldn’t find somebody like that.", bundle: .core) emptyTitleLabel.text = String(localized: "No Results", bundle: .core) @@ -136,7 +145,11 @@ public class PeopleListViewController: ScreenViewTrackableViewController, Colore } spinnerView.color = color refreshControl.color = color - updateNavBar(subtitle: name, color: color) + if #available(iOS 26, *) { + navigationItem.subtitle = name + } else { + updateNavBar(subtitle: name, color: color) + } } func update() { diff --git a/Core/Core/Features/Planner/CalendarEvent/View/CalendarEventDetailsScreen.swift b/Core/Core/Features/Planner/CalendarEvent/View/CalendarEventDetailsScreen.swift index b5c67d0b1d..28ccd80b9f 100644 --- a/Core/Core/Features/Planner/CalendarEvent/View/CalendarEventDetailsScreen.swift +++ b/Core/Core/Features/Planner/CalendarEvent/View/CalendarEventDetailsScreen.swift @@ -29,39 +29,78 @@ public struct CalendarEventDetailsScreen: View, ScreenViewTrackable { } public var body: some View { - InstUI.BaseScreen( - state: viewModel.state, - refreshAction: viewModel.reload - ) { _ in - eventContent - } - .navigationBarTitleView(title: viewModel.pageTitle, subtitle: viewModel.pageSubtitle) - .navBarItems( - trailing: viewModel.shouldShowMenuButton - ? InstUI.NavigationBarButton.moreIcon( - isBackgroundContextColor: true, - isEnabled: viewModel.isMoreButtonEnabled, - isAvailableOffline: false, - menuContent: { - InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } - InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } + if #available(iOS 26, *) { + InstUI.BaseScreen( + state: viewModel.state, + refreshAction: viewModel.reload + ) { _ in + eventContent + } + .navigationTitle(viewModel.pageTitle) + .optionalNavigationSubtitle(viewModel.pageSubtitle) + .toolbar { + if viewModel.shouldShowMenuButton { + InstUI.NavigationBarButton.moreIcon( + isEnabled: viewModel.isMoreButtonEnabled, + isAvailableOffline: false, + menuContent: { + InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } + InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } + } + ) + .confirmation( + isPresented: $viewModel.shouldShowDeleteConfirmation, + presenting: viewModel.deleteConfirmation + ) } + } + .errorAlert( + isPresented: $viewModel.shouldShowDeleteError, + presenting: .init( + title: String(localized: "Deletion not completed", bundle: .core), + message: String(localized: "We couldn't delete your Event at this time. You can try it again.", bundle: .core), + buttonTitle: String(localized: "OK", bundle: .core) + ) ) .confirmation( isPresented: $viewModel.shouldShowDeleteConfirmation, presenting: viewModel.deleteConfirmation ) - : nil - ) - .navigationBarStyle(.color(viewModel.contextColor)) - .errorAlert( - isPresented: $viewModel.shouldShowDeleteError, - presenting: .init( - title: String(localized: "Deletion not completed", bundle: .core), - message: String(localized: "We couldn't delete your Event at this time. You can try it again.", bundle: .core), - buttonTitle: String(localized: "OK", bundle: .core) + } else { + InstUI.BaseScreen( + state: viewModel.state, + refreshAction: viewModel.reload + ) { _ in + eventContent + } + .navigationBarTitleView(title: viewModel.pageTitle, subtitle: viewModel.pageSubtitle) + .navBarItems( + trailing: viewModel.shouldShowMenuButton + ? InstUI.NavigationBarButton.moreIcon( + isBackgroundContextColor: true, + isEnabled: viewModel.isMoreButtonEnabled, + isAvailableOffline: false, + menuContent: { + InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } + InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } + } + ) + .confirmation( + isPresented: $viewModel.shouldShowDeleteConfirmation, + presenting: viewModel.deleteConfirmation + ) + : nil ) - ) + .navigationBarStyle(.color(viewModel.contextColor)) + .errorAlert( + isPresented: $viewModel.shouldShowDeleteError, + presenting: .init( + title: String(localized: "Deletion not completed", bundle: .core), + message: String(localized: "We couldn't delete your Event at this time. You can try it again.", bundle: .core), + buttonTitle: String(localized: "OK", bundle: .core) + ) + ) + } } private var eventContent: some View { @@ -77,19 +116,21 @@ public struct CalendarEventDetailsScreen: View, ScreenViewTrackable { #if DEBUG #Preview { - let event = CalendarEvent.save( - .make( - id: "", - title: "Creative Machines and Innovative Instrumentation Conference", - description: "We should meet 10 minutes before the event. Click here!", - location_name: "UCF Department of Mechanical and Aerospace Engineering", - location_address: "12760 Pegasus Dr\nOrlando, FL 32816" - ), - in: PreviewEnvironment().database.viewContext - ) - let contextColor: UIColor = .red + NavigationStack { + let event = CalendarEvent.save( + .make( + id: "", + title: "Creative Machines and Innovative Instrumentation Conference", + description: "We should meet 10 minutes before the event. Click here!", + location_name: "UCF Department of Mechanical and Aerospace Engineering", + location_address: "12760 Pegasus Dr\nOrlando, FL 32816" + ), + in: PreviewEnvironment().database.viewContext + ) + let contextColor: UIColor = .red - return PlannerAssembly.makeEventDetailsScreenPreview(event: event, contextColor: contextColor) + return PlannerAssembly.makeEventDetailsScreenPreview(event: event, contextColor: contextColor) + } } #endif diff --git a/Core/Core/Features/Planner/CalendarMain/PlannerViewController.swift b/Core/Core/Features/Planner/CalendarMain/PlannerViewController.swift index 86e1e173ea..1954151d8f 100644 --- a/Core/Core/Features/Planner/CalendarMain/PlannerViewController.swift +++ b/Core/Core/Features/Planner/CalendarMain/PlannerViewController.swift @@ -68,7 +68,7 @@ public class PlannerViewController: VisibilityObservedViewController { super.viewDidLoad() view.backgroundColor = .backgroundLightest - navigationItem.titleView = Brand.shared.headerImageView() + navigationItem.titleView = Brand.shared.headerImageView() profileButton.accessibilityIdentifier = "PlannerCalendar.profileButton" profileButton.accessibilityLabel = String(localized: "Profile Menu", bundle: .core) diff --git a/Core/Core/Features/Planner/CalendarToDo/View/CalendarToDoDetailsScreen.swift b/Core/Core/Features/Planner/CalendarToDo/View/CalendarToDoDetailsScreen.swift index d76fd6fc5e..485146597e 100644 --- a/Core/Core/Features/Planner/CalendarToDo/View/CalendarToDoDetailsScreen.swift +++ b/Core/Core/Features/Planner/CalendarToDo/View/CalendarToDoDetailsScreen.swift @@ -28,34 +28,55 @@ public struct CalendarToDoDetailsScreen: View { } public var body: some View { - InstUI.BaseScreen(state: viewModel.state, config: viewModel.screenConfig) { _ in - eventContent - } - .navigationBarTitleView(viewModel.navigationTitle) - .navBarItems( - trailing: .moreIcon( - isBackgroundContextColor: true, - isEnabled: viewModel.isMoreButtonEnabled, - isAvailableOffline: false, - menuContent: { - InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } - InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } - } - ) - ) - .navigationBarStyle(.color(viewModel.navBarColor)) - .confirmationAlert( - isPresented: $viewModel.shouldShowDeleteConfirmation, - presenting: viewModel.deleteConfirmationAlert - ) - .errorAlert( - isPresented: $viewModel.shouldShowDeleteError, - presenting: .init( - title: String(localized: "Deletion not completed", bundle: .core), - message: String(localized: "We couldn't delete your To-do at this time. You can try it again.", bundle: .core), - buttonTitle: String(localized: "OK", bundle: .core) - ) - ) + SwiftUI.Group { + if #available(iOS 26, *) { + InstUI.BaseScreen(state: viewModel.state, config: viewModel.screenConfig) { _ in + eventContent + } + .navigationTitle(viewModel.navigationTitle) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + InstUI.NavigationBarButton.moreIcon( + isEnabled: viewModel.isMoreButtonEnabled, + isAvailableOffline: false, + menuContent: { + InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } + InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } + } + ) + } + } + } else { + InstUI.BaseScreen(state: viewModel.state, config: viewModel.screenConfig) { _ in + eventContent + } + .navigationBarTitleView(viewModel.navigationTitle) + .navBarItems( + trailing: .moreIcon( + isBackgroundContextColor: true, + isEnabled: viewModel.isMoreButtonEnabled, + isAvailableOffline: false, + menuContent: { + InstUI.MenuItem.edit { viewModel.didTapEdit.send(controller) } + InstUI.MenuItem.delete { viewModel.didTapDelete.send(controller) } + } + ) + ) + .navigationBarStyle(.color(viewModel.navBarColor)) + } + } + .confirmationAlert( + isPresented: $viewModel.shouldShowDeleteConfirmation, + presenting: viewModel.deleteConfirmationAlert + ) + .errorAlert( + isPresented: $viewModel.shouldShowDeleteError, + presenting: .init( + title: String(localized: "Deletion not completed", bundle: .core), + message: String(localized: "We couldn't delete your To Do at this time. You can try it again.", bundle: .core), + buttonTitle: String(localized: "OK", bundle: .core) + ) + ) } @ViewBuilder diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 00fb78db2e..898ad4e879 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -77,7 +77,12 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Quiz Details", bundle: .core)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Quiz Details", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Quiz Details", bundle: .core)) + } attemptsLabel.text = String(localized: "Allowed Attempts:", bundle: .core) dueHeadingLabel.text = String(localized: "Due", bundle: .core) @@ -118,7 +123,9 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - navigationController?.navigationBar.useContextColor(color) + if #unavailable(iOS 26) { + navigationController?.navigationBar.useContextColor(color) + } } @objc func refresh() { @@ -131,8 +138,12 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController func updateNavBar() { guard let course = courses.first, !colors.pending else { return } - updateNavBar(subtitle: course.name, color: course.color) - view.tintColor = color + if #available(iOS 26, *) { + navigationItem.subtitle = course.name + } else { + updateNavBar(subtitle: course.name, color: course.color) + } + view.tintColor = color } func update() { diff --git a/Core/Core/Features/Quizzes/QuizListViewController.swift b/Core/Core/Features/Quizzes/QuizListViewController.swift index c8e81d0a9f..feb9455980 100644 --- a/Core/Core/Features/Quizzes/QuizListViewController.swift +++ b/Core/Core/Features/Quizzes/QuizListViewController.swift @@ -56,7 +56,11 @@ public class QuizListViewController: ScreenViewTrackableViewController, ColoredN public override func viewDidLoad() { super.viewDidLoad() - setupTitleViewInNavbar(title: String(localized: "Quizzes", bundle: .core)) + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Quizzes", bundle: .core) + } else { + setupTitleViewInNavbar(title: String(localized: "Quizzes", bundle: .core)) + } emptyMessageLabel.text = String(localized: "It looks like quizzes haven’t been created in this space yet.", bundle: .core) emptyTitleLabel.text = String(localized: "No Quizzes", bundle: .core) @@ -80,7 +84,9 @@ public class QuizListViewController: ScreenViewTrackableViewController, ColoredN public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) tableView.selectRow(at: nil, animated: false, scrollPosition: .none) - navigationController?.navigationBar.useContextColor(color) + if #unavailable(iOS 26) { + navigationController?.navigationBar.useContextColor(color) + } } @objc func refresh() { @@ -96,7 +102,11 @@ public class QuizListViewController: ScreenViewTrackableViewController, ColoredN func update() { if let course = course.first, colors.pending == false { - updateNavBar(subtitle: course.name, color: course.color) + if #available(iOS 26, *) { + navigationItem.subtitle = course.name + } else { + updateNavBar(subtitle: course.name, color: course.color) + } view.tintColor = course.color } loadingView.isHidden = quizzes.state != .loading || refreshControl.isRefreshing @@ -120,7 +130,11 @@ extension QuizListViewController: UITableViewDataSource, UITableViewDelegate { public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let typeRaw = quizzes.sections?[section].name, let type = QuizType(rawValue: typeRaw) else { return nil } - return SectionHeaderView.create(title: type.sectionTitle, section: section) + return if #available(iOS 26, *) { + SectionHeaderView.create(title: type.sectionTitle, section: section) + } else { + LegacySectionHeaderView.create(title: type.sectionTitle, section: section) + } } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/Core/Core/Features/Search/View/CoreSearchHostingController.swift b/Core/Core/Features/Search/View/CoreSearchHostingController.swift index a5361b6256..7fb8de611d 100644 --- a/Core/Core/Features/Search/View/CoreSearchHostingController.swift +++ b/Core/Core/Features/Search/View/CoreSearchHostingController.swift @@ -69,7 +69,9 @@ public class CoreSearchHostingController< } ) ).with { - $0.tintColor = .textLightest + if #unavailable(iOS 26) { + $0.tintColor = .textLightest + } $0.accessibilityLabel = String(localized: "Close", bundle: .core) $0.accessibilityIdentifier = "close_bar_button" } @@ -82,7 +84,9 @@ public class CoreSearchHostingController< } ) ).with { - $0.tintColor = .textLightest + if #unavailable(iOS 26) { + $0.tintColor = .textLightest + } $0.accessibilityIdentifier = "filter_bar_button" $0.accessibilityLabel = String(localized: "Filter", bundle: .core) } @@ -100,16 +104,22 @@ public class CoreSearchHostingController< ) ) .with { - $0.tintColor = .textLightest + if #unavailable(iOS 26) { + $0.tintColor = .textLightest + } $0.accessibilityIdentifier = "support_bar_button" $0.accessibilityLabel = String(localized: "Help", bundle: .core) } }() private lazy var searchFieldView: UISearchField = { - let searchView = UISearchField( - frame: CGRect(origin: .zero, size: CGSize(width: 400, height: 100)) - ) + let frame = if #available(iOS 26, *) { + CGRect(origin: .zero, size: CGSize(width: 400, height: 56)) + } else { + CGRect(origin: .zero, size: CGSize(width: 400, height: 100)) + } + + let searchView = UISearchField(frame: frame) searchView.field.placeholder = searchContext.searchPrompt searchView.field.accessibilityIdentifier = "ui_search_field" searchView.field.delegate = self @@ -140,7 +150,7 @@ public class CoreSearchHostingController< super.init(SearchHostingBaseView(content: content, searchContext: searchContext)) - if let contextColor = attributes.accentColor { + if #unavailable(iOS 26), let contextColor = attributes.accentColor { navigationBarStyle = .color(contextColor) } } @@ -186,7 +196,9 @@ public class CoreSearchHostingController< // Reset large enough length to re-fit search field in // navigation title view let length = view.bounds.width - searchFieldView.frame.size = CGSize(width: length, height: length) + if #unavailable(iOS 26) { + searchFieldView.frame.size = CGSize(width: length, height: length) + } navigationItem.leftBarButtonItems = [closeBarItem] navigationItem.hidesBackButton = true diff --git a/Core/Core/Features/Search/View/SearchContentContainerView.swift b/Core/Core/Features/Search/View/SearchContentContainerView.swift index 81f0fc8c5a..e0d7c17a7a 100644 --- a/Core/Core/Features/Search/View/SearchContentContainerView.swift +++ b/Core/Core/Features/Search/View/SearchContentContainerView.swift @@ -73,7 +73,7 @@ struct SearchContentContainerView + + diff --git a/Core/Core/Resources/Info.plist b/Core/Core/Resources/Info.plist index 9f1cbe4274..1007fd9dd7 100644 --- a/Core/Core/Resources/Info.plist +++ b/Core/Core/Resources/Info.plist @@ -20,7 +20,5 @@ $(CURRENT_PROJECT_VERSION) NSPrincipalClass - UIDesignRequiresCompatibility - diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index 3234fd8edd..e9338c1485 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -403233,7 +403233,6 @@ } }, "We couldn't delete your To Do at this time. You can try it again." : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -403490,6 +403489,7 @@ } }, "We couldn't delete your To-do at this time. You can try it again." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Core/CoreTests/Common/CommonModels/Router/RouterTests.swift b/Core/CoreTests/Common/CommonModels/Router/RouterTests.swift index 191584ed62..eb0017009a 100644 --- a/Core/CoreTests/Common/CommonModels/Router/RouterTests.swift +++ b/Core/CoreTests/Common/CommonModels/Router/RouterTests.swift @@ -203,7 +203,11 @@ class RouterTests: CoreTestCase { XCTAssert(mockView.shown?.isKind(of: UIViewController.self) == true) } - func testDetailSplitViewButtons() { + func testDetailSplitViewButtons() throws { + if #available(iOS 26, *) { + throw XCTSkip("Default implementation provided above iOS 26") + } + let mockView = MockViewController() mockView.navigationItem.leftItemsSupplementBackButton = false mockView.navigationItem.leftBarButtonItems = nil diff --git a/Core/CoreTests/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensionsTests.swift b/Core/CoreTests/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensionsTests.swift index 8710517483..537121c145 100644 --- a/Core/CoreTests/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensionsTests.swift +++ b/Core/CoreTests/Common/CommonUI/NavigationBar/UIKit/UINavigationBarExtensionsTests.swift @@ -21,16 +21,25 @@ import UIKit @testable import Core class UINavigationBarExtensionsTests: XCTestCase { - func testUseContextColor() { + func testUseContextColor() throws { + if #available(iOS 26, *) { + throw XCTSkip("No toolbar colors above iOS 26") + } + let bar = UINavigationBar(frame: .zero) bar.useContextColor(.backgroundDarkest) + XCTAssertEqual((bar.titleTextAttributes?[.foregroundColor] as? UIColor)?.hexString, UIColor.textLightest.variantForLightMode.hexString) XCTAssertEqual(bar.tintColor.hexString, UIColor.textLightest.variantForLightMode.hexString) XCTAssertEqual(bar.barTintColor?.hexString, UIColor.backgroundDarkest.hexString) XCTAssertEqual(bar.barStyle, .black) } - func testUseGlobalNavStyle() { + func testUseGlobalNavStyle() throws { + if #available(iOS 26, *) { + throw XCTSkip("No toolbar colors above iOS 26") + } + let bar = UINavigationBar(frame: .zero) bar.useGlobalNavStyle() XCTAssertEqual((bar.titleTextAttributes?[.foregroundColor] as? UIColor)!.hexString, Brand.shared.navTextColor.hexString) diff --git a/Core/CoreTests/Common/CommonUI/Presenter/ColoredNavViewProtocol.swift b/Core/CoreTests/Common/CommonUI/Presenter/ColoredNavViewProtocol.swift index e9fecd8140..7f76454ee4 100644 --- a/Core/CoreTests/Common/CommonUI/Presenter/ColoredNavViewProtocol.swift +++ b/Core/CoreTests/Common/CommonUI/Presenter/ColoredNavViewProtocol.swift @@ -19,6 +19,7 @@ import XCTest @testable import Core +@available(iOS, deprecated: 26) class ColoredNavViewProtocolTests: XCTestCase, ColoredNavViewProtocol { var color: UIColor? var navigationController: UINavigationController? = UINavigationController(rootViewController: UIViewController()) @@ -32,13 +33,21 @@ class ColoredNavViewProtocolTests: XCTestCase, ColoredNavViewProtocol { titleSubtitleView = TitleSubtitleView.create() } - func testSetupTitleViewInNavbar() { + func testSetupTitleViewInNavbar() throws { + if #available(iOS 26, *) { + throw XCTSkip("ColoredNavViewProtocol is not used above iOS 26") + } + setupTitleViewInNavbar(title: self.name) XCTAssertEqual(titleSubtitleView.title, self.name) XCTAssertEqual(navigationItem.titleView, titleSubtitleView) } - func testUpdateNavBar() { + func testUpdateNavBar() throws { + if #available(iOS 26, *) { + throw XCTSkip("ColoredNavViewProtocol is not used above iOS 26") + } + let expectedColor: UIColor = .red.darkenToEnsureContrast(against: .textLightest.variantForLightMode) updateNavBar(subtitle: subtitle, color: expectedColor) diff --git a/Core/CoreTests/Common/CommonUI/UIViews/EmptyViewControllerTests.swift b/Core/CoreTests/Common/CommonUI/UIViews/EmptyViewControllerTests.swift index 406aacd3d4..712124d94d 100644 --- a/Core/CoreTests/Common/CommonUI/UIViews/EmptyViewControllerTests.swift +++ b/Core/CoreTests/Common/CommonUI/UIViews/EmptyViewControllerTests.swift @@ -34,7 +34,9 @@ class EmptyViewControllerTests: CoreTestCase { } wait(for: [waitExpectation], timeout: 3.0) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, Brand.shared.navBackground.hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, Brand.shared.navBackground.hexString) + } XCTAssertEqual(controller.view.subviews.count, 1) XCTAssert(controller.view.subviews.first is UIImageView) diff --git a/Core/CoreTests/Common/Extensions/UIKit/UIBarButtonItemExtensionsTests.swift b/Core/CoreTests/Common/Extensions/UIKit/UIBarButtonItemExtensionsTests.swift index c965b76f14..41dcb651e8 100644 --- a/Core/CoreTests/Common/Extensions/UIKit/UIBarButtonItemExtensionsTests.swift +++ b/Core/CoreTests/Common/Extensions/UIKit/UIBarButtonItemExtensionsTests.swift @@ -22,8 +22,11 @@ import XCTest class UIBarButtonItemExtensionsTests: XCTestCase { func testBackButton() { - let config = UIImage.SymbolConfiguration(weight: .semibold) - let backImage = UIImage(systemName: "chevron.backward", withConfiguration: config) + let backImage = if #available(iOS 26, *) { + UIImage(systemName: "chevron.backward") + } else { + UIImage(systemName: "chevron.backward", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)) + } var actionCallsCount = 0 let testee = UIBarButtonItem.back { actionCallsCount += 1 } diff --git a/Core/CoreTests/Common/Extensions/UIKit/UITableViewExtensionsTests.swift b/Core/CoreTests/Common/Extensions/UIKit/UITableViewExtensionsTests.swift index 15b8ec19c5..dbacd2c346 100644 --- a/Core/CoreTests/Common/Extensions/UIKit/UITableViewExtensionsTests.swift +++ b/Core/CoreTests/Common/Extensions/UIKit/UITableViewExtensionsTests.swift @@ -35,6 +35,14 @@ class UITableViewExtensionsTests: XCTestCase { XCTAssertNotNil(cell) } + func testRegisterLegacyHeaderWithNib() { + let table = UITableView(frame: .zero) + table.registerHeaderFooterView(LegacySectionHeaderView.self) + let header: LegacySectionHeaderView = table.dequeueHeaderFooter(LegacySectionHeaderView.self) + XCTAssertNotNil(header) + } + + @available(iOS 26, *) func testRegisterHeaderWithNib() { let table = UITableView(frame: .zero) table.registerHeaderFooterView(SectionHeaderView.self) diff --git a/Core/CoreTests/Features/Conferences/ConferenceDetailsViewControllerTests.swift b/Core/CoreTests/Features/Conferences/ConferenceDetailsViewControllerTests.swift index 7e185349c3..3238109c92 100644 --- a/Core/CoreTests/Features/Conferences/ConferenceDetailsViewControllerTests.swift +++ b/Core/CoreTests/Features/Conferences/ConferenceDetailsViewControllerTests.swift @@ -55,9 +55,15 @@ class ConferenceDetailsViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Conference Details") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") - XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, UIColor(hexString: "#f00")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Conference Details") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Conference Details") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, UIColor(hexString: "#f00")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } XCTAssertEqual(controller.titleLabel.text, "Pandemic playthrough") XCTAssertEqual(controller.statusLabel.text, "Not Started") diff --git a/Core/CoreTests/Features/Conferences/ConferenceListViewControllerTests.swift b/Core/CoreTests/Features/Conferences/ConferenceListViewControllerTests.swift index e5523ed568..7ea7492860 100644 --- a/Core/CoreTests/Features/Conferences/ConferenceListViewControllerTests.swift +++ b/Core/CoreTests/Features/Conferences/ConferenceListViewControllerTests.swift @@ -52,9 +52,14 @@ class ConferenceListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Conferences") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") - XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, UIColor(hexString: "#f00")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Conferences") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Conferences") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, UIColor(hexString: "#f00")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 3) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 1), 1) diff --git a/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncAnnouncementsInteractorLiveTests.swift b/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncAnnouncementsInteractorLiveTests.swift index 2575b3c58e..1174a514e3 100644 --- a/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncAnnouncementsInteractorLiveTests.swift +++ b/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncAnnouncementsInteractorLiveTests.swift @@ -45,7 +45,11 @@ class CourseSyncAnnouncementsInteractorLiveTests: CoreTestCase { testee.viewWillAppear(false) // MARK: - THEN - XCTAssertEqual((testee.navigationItem.titleView as? TitleSubtitleView)?.subtitle, "Course One") + if #available(iOS 26, *) { + XCTAssertEqual(testee.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual((testee.navigationItem.titleView as? TitleSubtitleView)?.subtitle, "Course One") + } guard let announcementCell = testee.tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? AnnouncementListCell else { return XCTFail() diff --git a/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncConferencesInteractorLiveTests.swift b/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncConferencesInteractorLiveTests.swift index d34573f993..3d6c394810 100644 --- a/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncConferencesInteractorLiveTests.swift +++ b/Core/CoreTests/Features/CourseSync/CourseSyncDownloader/Model/Downloaders/CourseSyncConferencesInteractorLiveTests.swift @@ -31,7 +31,70 @@ class CourseSyncConferencesInteractorLiveTests: CoreTestCase { XCTAssertEqual(CourseSyncConferencesInteractorLive(envResolver: envResolver).associatedTabType, .conferences) } - func testSavedDataPopulatesViewController() { + @available(iOS 26, *) + func testSavedDataPopulatesViewController() throws { + if #unavailable(iOS 26) { + throw XCTSkip("Skip below iOS 26") + } + + // MARK: - GIVEN + api.mock(GetCustomColors(), value: .init(custom_colors: [:])) + api.mock(GetCourse(courseID: "testCourse"), value: .make(id: "testCourse")) + api.mock(GetConferences(context: .course("testCourse")), value: .init(conferences: [ + .make(context_id: "course_testCourse", + description: "this test conference ended", + ended_at: .distantPast, + id: "1", + title: "ended conference"), + .make(context_id: "course_testCourse", + description: "this is an ongoing test conference", + id: "2", + started_at: Date().addingTimeInterval(-1), + title: "ongoing conference") + ])) + XCTAssertFinish(CourseSyncConferencesInteractorLive(envResolver: envResolver).getContent(courseId: "testCourse")) + API.resetMocks() + + // MARK: - WHEN + OfflineModeAssembly.mock(AlwaysOfflineModeInteractor()) + let testee = ConferenceListViewController.create(context: .course("testCourse")) + testee.view.layoutIfNeeded() + testee.viewWillAppear(false) + drainMainQueue() + + // MARK: - THEN + XCTAssertEqual(testee.tableView.numberOfSections, 2) // new and concluded conferences sections + + guard let newConferencesHeader = testee.tableView.headerView(forSection: 0) as? SectionHeaderView else { + return XCTFail() + } + XCTAssertEqual(newConferencesHeader.titleLabel.text, "New Conferences") + + guard let concludedSectionHeader = testee.tableView.headerView(forSection: 1) as? SectionHeaderView else { + return XCTFail() + } + XCTAssertEqual(concludedSectionHeader.titleLabel.text, "Concluded Conferences") + + guard let ongoingConferenceCell = testee.tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? ConferenceListCell else { + return XCTFail() + } + XCTAssertEqual(ongoingConferenceCell.titleLabel.text, "ongoing conference") + XCTAssertEqual(ongoingConferenceCell.detailsLabel.text, "this is an ongoing test conference") + XCTAssertEqual(ongoingConferenceCell.statusLabel.text, "In Progress") + + guard let concludedConferenceCell = testee.tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? ConferenceListCell else { + return XCTFail() + } + XCTAssertEqual(concludedConferenceCell.titleLabel.text, "ended conference") + XCTAssertEqual(concludedConferenceCell.detailsLabel.text, "this test conference ended") + XCTAssertEqual(concludedConferenceCell.statusLabel.text?.hasPrefix("Concluded"), true) + } + + func testSavedDataPopulatesViewControllerWithLegacyHeaders() throws { + if #available(iOS 26, *) { + throw XCTSkip("Skip above iOS 26") + } + // MARK: - GIVEN api.mock(GetCustomColors(), value: .init(custom_colors: [:])) api.mock(GetCourse(courseID: "testCourse"), value: .make(id: "testCourse")) @@ -60,12 +123,12 @@ class CourseSyncConferencesInteractorLiveTests: CoreTestCase { // MARK: - THEN XCTAssertEqual(testee.tableView.numberOfSections, 2) // new and concluded conferences sections - guard let newConferencesHeader = testee.tableView.headerView(forSection: 0) as? SectionHeaderView else { + guard let newConferencesHeader = testee.tableView.headerView(forSection: 0) as? LegacySectionHeaderView else { return XCTFail() } XCTAssertEqual(newConferencesHeader.titleLabel.text, "New Conferences") - guard let concludedSectionHeader = testee.tableView.headerView(forSection: 1) as? SectionHeaderView else { + guard let concludedSectionHeader = testee.tableView.headerView(forSection: 1) as? LegacySectionHeaderView else { return XCTFail() } XCTAssertEqual(concludedSectionHeader.titleLabel.text, "Concluded Conferences") diff --git a/Core/CoreTests/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModelTests.swift b/Core/CoreTests/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModelTests.swift similarity index 88% rename from Core/CoreTests/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModelTests.swift rename to Core/CoreTests/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModelTests.swift index c9d6527a61..b8abfa4566 100644 --- a/Core/CoreTests/Features/Courses/CourseDetails/ViewModel/CourseDetailsHeaderViewModelTests.swift +++ b/Core/CoreTests/Features/Courses/CourseDetails/ViewModel/LegacyCourseDetailsHeaderViewModelTests.swift @@ -20,14 +20,15 @@ import XCTest import TestsFoundation -class CourseDetailsHeaderViewModelTests: CoreTestCase { +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +class LegacyCourseDetailsHeaderViewModelTests: CoreTestCase { func testProperties() { api.mock(GetUserSettings(userID: "self"), value: .make(hide_dashcard_color_overlays: true)) let course = Course.save(.make(term: .make()), in: databaseClient) course.contextColor = ContextColor.save(.init(custom_colors: ["1": "#FF0000"]), in: databaseClient)[0] - let testee = CourseDetailsHeaderViewModel() + let testee = LegacyCourseDetailsHeaderViewModel() testee.viewDidAppear() testee.courseUpdated(course) @@ -42,7 +43,7 @@ class CourseDetailsHeaderViewModelTests: CoreTestCase { } func testHeaderVisibility() { - let testee = CourseDetailsHeaderViewModel() + let testee = LegacyCourseDetailsHeaderViewModel() // header would take half of the screen's height XCTAssertEqual(testee.shouldShowHeader(in: CGSize(width: 300, height: 2 * testee.height)), false) // there's more space for cells than what the header blocks @@ -50,7 +51,7 @@ class CourseDetailsHeaderViewModelTests: CoreTestCase { } func testPullToRefreshScrollCalculation() { - let testee = CourseDetailsHeaderViewModel() + let testee = LegacyCourseDetailsHeaderViewModel() // pull down as many points as high the header is testee.scrollPositionChanged([.init(viewId: 0, bounds: .init(x: 0, y: testee.height, width: 0, height: 0))]) @@ -60,7 +61,7 @@ class CourseDetailsHeaderViewModelTests: CoreTestCase { } func testScrollUpToCellsCalculation() { - let testee = CourseDetailsHeaderViewModel() + let testee = LegacyCourseDetailsHeaderViewModel() // scroll up as many points as high the header is let scrollOffset = -testee.height testee.scrollPositionChanged([.init(viewId: 0, bounds: .init(x: 0, y: scrollOffset, width: 0, height: 0))]) diff --git a/Core/CoreTests/Features/Discussions/AnnouncementList/AnnouncementListViewControllerTests.swift b/Core/CoreTests/Features/Discussions/AnnouncementList/AnnouncementListViewControllerTests.swift index 42126fb590..819f832349 100644 --- a/Core/CoreTests/Features/Discussions/AnnouncementList/AnnouncementListViewControllerTests.swift +++ b/Core/CoreTests/Features/Discussions/AnnouncementList/AnnouncementListViewControllerTests.swift @@ -81,9 +81,16 @@ class AnnouncementListViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Announcements") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Announcements") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Announcements") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + } + XCTAssertNil(controller.navigationItem.rightBarButtonItem) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 3) @@ -142,9 +149,16 @@ class AnnouncementListViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Announcements") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000000") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Announcements") + XCTAssertEqual(controller.navigationItem.subtitle, "Group One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Announcements") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000000") + } + XCTAssertNotNil(controller.navigationItem.rightBarButtonItem) _ = controller.addButton.target?.perform(controller.addButton.action) diff --git a/Core/CoreTests/Features/Discussions/DiscussionDetails/DiscussionDetailsViewControllerTests.swift b/Core/CoreTests/Features/Discussions/DiscussionDetails/DiscussionDetailsViewControllerTests.swift index c3d9029f43..bda8ec2e1f 100644 --- a/Core/CoreTests/Features/Discussions/DiscussionDetails/DiscussionDetailsViewControllerTests.swift +++ b/Core/CoreTests/Features/Discussions/DiscussionDetails/DiscussionDetailsViewControllerTests.swift @@ -136,9 +136,16 @@ class DiscussionDetailsViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") - XCTAssertEqual(controller.titleSubtitleView.title, "Discussion Details") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Discussion Details") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") + XCTAssertEqual(controller.titleSubtitleView.title, "Discussion Details") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } + XCTAssertEqual(controller.titleLabel.text, "What is a sandwich?") XCTAssertEqual(controller.pointsLabel.text, "95 pts") XCTAssertEqual(controller.pointsView.isHidden, false) @@ -255,7 +262,11 @@ class DiscussionDetailsViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() XCTAssertEqual(controller.pointsView.isHidden, true) XCTAssertEqual(controller.publishedView.isHidden, true) - XCTAssertEqual(controller.titleSubtitleView.title, "Discussion Replies") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Discussion Replies") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Discussion Replies") + } let html = getBodyHTML() XCTAssert(!html.contains("Is the cube rule of food valid?")) XCTAssert(html.contains("Why?")) diff --git a/Core/CoreTests/Features/Discussions/DiscussionList/DiscussionListViewControllerTests.swift b/Core/CoreTests/Features/Discussions/DiscussionList/DiscussionListViewControllerTests.swift index b7e7e66c7c..0bd21832c3 100644 --- a/Core/CoreTests/Features/Discussions/DiscussionList/DiscussionListViewControllerTests.swift +++ b/Core/CoreTests/Features/Discussions/DiscussionList/DiscussionListViewControllerTests.swift @@ -30,6 +30,7 @@ class DiscussionListViewControllerTests: CoreTestCase { lazy var controller = DiscussionListViewController.create(context: .course("1"), env: environment) + // swiftlint:disable:next function_body_length func testCourseDiscussions() { api.mock(GetCourse(courseID: "1"), value: .make(enrollments: [ .make( @@ -87,9 +88,14 @@ class DiscussionListViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Discussions") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Discussions") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Discussions") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + } XCTAssertNil(controller.navigationItem.rightBarButtonItem) XCTAssertEqual(controller.tableView.numberOfSections, 3) @@ -229,9 +235,14 @@ class DiscussionListViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "Discussions") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000000") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Discussions") + XCTAssertEqual(controller.navigationItem.subtitle, "Group One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Discussions") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000000") + } XCTAssertNotNil(controller.navigationItem.rightBarButtonItem) _ = controller.addButton.target?.perform(controller.addButton.action) diff --git a/Core/CoreTests/Features/Files/View/FileList/FileListViewControllerTests.swift b/Core/CoreTests/Features/Files/View/FileList/FileListViewControllerTests.swift index 0ff745edc9..428bc84121 100644 --- a/Core/CoreTests/Features/Files/View/FileList/FileListViewControllerTests.swift +++ b/Core/CoreTests/Features/Files/View/FileList/FileListViewControllerTests.swift @@ -55,8 +55,14 @@ class FileListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(controller.titleSubtitleView.title, "Folder A") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Folder A") + XCTAssertEqual(controller.navigationItem.subtitle, nil) + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Folder A") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "") + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 0) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 1), 0) @@ -136,7 +142,12 @@ class FileListViewControllerTests: CoreTestCase { index = IndexPath(row: 1, section: 2) cell = controller.tableView.cellForRow(at: index) as? FileListCell XCTAssertEqual(cell?.nameLabel.text, "Picture") - XCTAssertEqual(controller.titleSubtitleView.title, "Folder Refresh") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Folder Refresh") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Folder Refresh") + } XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.editButton), true) _ = controller.editButton.target?.perform(controller.editButton.action) @@ -158,8 +169,13 @@ class FileListViewControllerTests: CoreTestCase { controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(controller.titleSubtitleView.title, "Files") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Files") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Files") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 0) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 1), 0) @@ -178,8 +194,13 @@ class FileListViewControllerTests: CoreTestCase { controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(controller.titleSubtitleView.title, "Files") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Files") + XCTAssertEqual(controller.navigationItem.subtitle, "Group One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Files") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 0) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 1), 0) @@ -193,7 +214,11 @@ class FileListViewControllerTests: CoreTestCase { controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(controller.titleSubtitleView.title, "Folder A") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Folder A") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Folder A") + } api.mock(controller.folder, value: [ .make(full_name: "my files/Folder Z", id: "2", name: "Folder Z", parent_folder_id: "1") @@ -201,7 +226,12 @@ class FileListViewControllerTests: CoreTestCase { controller.errorView.retryButton.sendActions(for: .primaryActionTriggered) XCTAssertEqual(controller.tableView.numberOfRows(inSection: 2), 2) - XCTAssertEqual(controller.titleSubtitleView.title, "Folder Z") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Folder Z") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Folder Z") + } + XCTAssertEqual(controller.path, "Folder Z") XCTAssertEqual(controller.folder.useCase.path, "Folder Z") @@ -218,8 +248,12 @@ class FileListViewControllerTests: CoreTestCase { controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.addButton), true) - _ = controller.addButton.target?.perform(controller.addButton.action) + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.addButton), true) + } else { + XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.legacyAddButton), true) + } + _ = controller.legacyAddButton.target?.perform(controller.legacyAddButton.action) let sheet = router.presented as? BottomSheetPickerViewController XCTAssertEqual(sheet?.actions.first?.title, "Add Folder") sheet?.actions.first?.action() @@ -246,8 +280,13 @@ class FileListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.addButton), true) - _ = controller.addButton.target?.perform(controller.addButton.action) + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.addButton), true) + } else { + XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.contains(controller.legacyAddButton), true) + } + + _ = controller.legacyAddButton.target?.perform(controller.legacyAddButton.action) let sheet = router.presented as? BottomSheetPickerViewController XCTAssertEqual(sheet?.actions.last?.title, "Add File") router.calls = [] @@ -291,7 +330,7 @@ class FileListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - _ = controller.addButton.target?.perform(controller.addButton.action) + _ = controller.legacyAddButton.target?.perform(controller.legacyAddButton.action) let sheet = router.presented as? BottomSheetPickerViewController let titles = sheet?.actions.map { $0.title } ?? [] @@ -313,7 +352,7 @@ class FileListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - _ = controller.addButton.target?.perform(controller.addButton.action) + _ = controller.legacyAddButton.target?.perform(controller.legacyAddButton.action) let sheet = router.presented as? BottomSheetPickerViewController let titles = sheet?.actions.map { $0.title } ?? [] XCTAssertFalse(titles.contains("Add File"), "Add File should be hidden when restricted") diff --git a/Core/CoreTests/Features/LTI/View/LTIViewControllerTests.swift b/Core/CoreTests/Features/LTI/View/LTIViewControllerTests.swift index 08fbf598fc..b186da8677 100644 --- a/Core/CoreTests/Features/LTI/View/LTIViewControllerTests.swift +++ b/Core/CoreTests/Features/LTI/View/LTIViewControllerTests.swift @@ -32,8 +32,14 @@ class LTIViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() XCTAssertEqual(controller.nameLabel.text, "LTI Tool") XCTAssertTrue(controller.spinnerView.isHidden) - XCTAssertEqual(controller.titleSubtitleView.title, "External Tool") - XCTAssertNil(controller.titleSubtitleView.subtitle) + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "External Tool") + XCTAssertNil(controller.navigationItem.subtitle) + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "External Tool") + XCTAssertNil(controller.titleSubtitleView.subtitle) + } task.resume() XCTAssertEqual(controller.nameLabel.text, "So Descriptive") @@ -61,7 +67,12 @@ class LTIViewControllerTests: CoreTestCase { let controller = LTIViewController.create(env: environment, tools: tools) api.mock(controller.courses!, value: course) controller.view.layoutIfNeeded() - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Fancy Course") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.subtitle, "Fancy Course") + } else { + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Fancy Course") + } } func testTextsWhenIsQuizLTI() { diff --git a/Core/CoreTests/Features/Modules/ModuleItems/ModuleItemDetailsViewControllerTests.swift b/Core/CoreTests/Features/Modules/ModuleItems/ModuleItemDetailsViewControllerTests.swift index 0f8714f6f7..52851028f1 100644 --- a/Core/CoreTests/Features/Modules/ModuleItems/ModuleItemDetailsViewControllerTests.swift +++ b/Core/CoreTests/Features/Modules/ModuleItems/ModuleItemDetailsViewControllerTests.swift @@ -51,8 +51,14 @@ class ModuleItemDetailsViewControllerTests: CoreTestCase { XCTAssertTrue(controller.lockedView.isHidden) XCTAssertFalse(controller.container.isHidden) XCTAssertNotNil(controller.children.first as? FileDetailsViewController) - XCTAssertEqual(controller.titleSubtitleView.title, "File Details") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "File Details") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "File Details") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } } func testError() { diff --git a/Core/CoreTests/Features/Modules/ModuleList/ModuleListViewControllerTests.swift b/Core/CoreTests/Features/Modules/ModuleList/ModuleListViewControllerTests.swift index b6669c0892..6d59ae3231 100644 --- a/Core/CoreTests/Features/Modules/ModuleList/ModuleListViewControllerTests.swift +++ b/Core/CoreTests/Features/Modules/ModuleList/ModuleListViewControllerTests.swift @@ -126,11 +126,17 @@ class ModuleListViewControllerTests: CoreTestCase { XCTAssertEqual(item4.completedStatusView.image, .checkLine) XCTAssertNotNil(nav.viewControllers.first) - XCTAssertEqual(viewController.titleSubtitleView.title, "Modules") - XCTAssertEqual(viewController.titleSubtitleView.subtitle, "Course 1") - XCTAssertEqual( - viewController.navigationController?.navigationBar.barTintColor!.hexString, - UIColor(hexString: "#fff")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + + if #available(iOS 26, *) { + XCTAssertEqual(viewController.navigationItem.title, "Modules") + XCTAssertEqual(viewController.navigationItem.subtitle, "Course 1") + } else { + XCTAssertEqual(viewController.titleSubtitleView.title, "Modules") + XCTAssertEqual(viewController.titleSubtitleView.subtitle, "Course 1") + XCTAssertEqual( + viewController.navigationController?.navigationBar.barTintColor!.hexString, + UIColor(hexString: "#fff")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } } func testLockedForUserDoesNotApplyToTeachers() { diff --git a/Core/CoreTests/Features/Pages/PageDetails/PageDetailsViewControllerTests.swift b/Core/CoreTests/Features/Pages/PageDetails/PageDetailsViewControllerTests.swift index fc287d05de..8126b73b16 100644 --- a/Core/CoreTests/Features/Pages/PageDetails/PageDetailsViewControllerTests.swift +++ b/Core/CoreTests/Features/Pages/PageDetails/PageDetailsViewControllerTests.swift @@ -52,9 +52,14 @@ class PageDetailsViewControllerTests: CoreTestCase { controller.view.superview != nil } - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#008800") - XCTAssertEqual(controller.titleSubtitleView.title, "Test Page") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Test Page") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#008800") + XCTAssertEqual(controller.titleSubtitleView.title, "Test Page") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } XCTAssertEqual(controller.navigationItem.rightBarButtonItems?.count, 1) let optionsButton = controller.navigationItem.rightBarButtonItem @@ -94,7 +99,12 @@ class PageDetailsViewControllerTests: CoreTestCase { XCTAssertEqual(controller.webView.scrollView.refreshControl?.isRefreshing, true) RunLoop.main.run(until: Date() + 1.5) XCTAssertEqual(controller.webView.scrollView.refreshControl?.isRefreshing, false) - XCTAssertEqual(controller.titleSubtitleView.title, "Refreshed") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Refreshed") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Refreshed") + } XCTAssertNil(controller.navigationItem.rightBarButtonItem) } @@ -109,9 +119,14 @@ class PageDetailsViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") - XCTAssertEqual(controller.titleSubtitleView.title, "Test Page") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Test Page") + XCTAssertEqual(controller.navigationItem.subtitle, "Group One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") + XCTAssertEqual(controller.titleSubtitleView.title, "Test Page") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + } } func testFrontPage() { @@ -125,7 +140,12 @@ class PageDetailsViewControllerTests: CoreTestCase { title: "Front Page" )) controller.view.layoutIfNeeded() - XCTAssertEqual(controller.titleSubtitleView.title, "Front Page") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Front Page") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "Front Page") + } XCTAssertNil(controller.navigationItem.rightBarButtonItem) } } diff --git a/Core/CoreTests/Features/Pages/PageList/PageListViewControllerTests.swift b/Core/CoreTests/Features/Pages/PageList/PageListViewControllerTests.swift index 7918994d2c..73ea36ad10 100644 --- a/Core/CoreTests/Features/Pages/PageList/PageListViewControllerTests.swift +++ b/Core/CoreTests/Features/Pages/PageList/PageListViewControllerTests.swift @@ -43,9 +43,16 @@ class PageListViewControllerTests: CoreTestCase { split.preferredDisplayMode = .oneBesideSecondary controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") - XCTAssertEqual(controller.titleSubtitleView.title, "Pages") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Pages") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#000088") + XCTAssertEqual(controller.titleSubtitleView.title, "Pages") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } + XCTAssert(router.lastRoutedTo(.parse("/courses/42/pages/answers-page"))) let createButton = controller.navigationItem.rightBarButtonItem @@ -109,9 +116,15 @@ class PageListViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, UIColor(hexString: "#facade")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) - XCTAssertEqual(controller.titleSubtitleView.title, "Pages") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Pages") + XCTAssertEqual(controller.navigationItem.subtitle, "Group One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, UIColor(hexString: "#facade")!.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + XCTAssertEqual(controller.titleSubtitleView.title, "Pages") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Group One") + } XCTAssertNotNil(controller.navigationItem.rightBarButtonItem) } diff --git a/Core/CoreTests/Features/People/PeopleListViewControllerTests.swift b/Core/CoreTests/Features/People/PeopleListViewControllerTests.swift index 1dd056b10c..98bf3db556 100644 --- a/Core/CoreTests/Features/People/PeopleListViewControllerTests.swift +++ b/Core/CoreTests/Features/People/PeopleListViewControllerTests.swift @@ -54,8 +54,13 @@ class PeopleListViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(controller.titleSubtitleView.title, "People") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "People") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(controller.titleSubtitleView.title, "People") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 2) diff --git a/Core/CoreTests/Features/Planner/CalendarMain/PlannerViewControllerTests.swift b/Core/CoreTests/Features/Planner/CalendarMain/PlannerViewControllerTests.swift index 5375391eec..90cd53a690 100644 --- a/Core/CoreTests/Features/Planner/CalendarMain/PlannerViewControllerTests.swift +++ b/Core/CoreTests/Features/Planner/CalendarMain/PlannerViewControllerTests.swift @@ -74,7 +74,9 @@ class PlannerViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, Brand.shared.navBackground.hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor!.hexString, Brand.shared.navBackground.hexString) + } _ = controller.profileButton.target?.perform(controller.profileButton.action) XCTAssert(router.lastRoutedTo("/profile", withOptions: .modal())) diff --git a/Core/CoreTests/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewControllerTests.swift b/Core/CoreTests/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewControllerTests.swift index e620c494d8..cc4d42266a 100644 --- a/Core/CoreTests/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewControllerTests.swift +++ b/Core/CoreTests/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewControllerTests.swift @@ -38,9 +38,16 @@ class StudentQuizDetailsViewControllerTests: CoreTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") - XCTAssertEqual(controller.titleSubtitleView.title, "Quiz Details") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Quiz Details") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + XCTAssertEqual(controller.titleSubtitleView.title, "Quiz Details") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + } + XCTAssertEqual(controller.titleLabel.text, "What kind of pokemon are you?") XCTAssertEqual(controller.pointsLabel.text, "11.1 pts") XCTAssertEqual(controller.statusLabel.text, "Not Submitted") diff --git a/Core/CoreTests/Features/Quizzes/QuizListViewControllerTests.swift b/Core/CoreTests/Features/Quizzes/QuizListViewControllerTests.swift index 8ab2be5094..a186a22138 100644 --- a/Core/CoreTests/Features/Quizzes/QuizListViewControllerTests.swift +++ b/Core/CoreTests/Features/Quizzes/QuizListViewControllerTests.swift @@ -65,13 +65,17 @@ class QuizListViewControllerTests: CoreTestCase { ]) } - func testLayout() { - let nav = UINavigationController(rootViewController: controller) + @available(iOS 26, *) + func testLayout() throws { + if #unavailable(iOS 26) { + throw XCTSkip("Skip below iOS 26") + } + controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") - XCTAssertEqual(controller.titleSubtitleView.title, "Quizzes") - XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + XCTAssertEqual(controller.navigationItem.title, "Quizzes") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") XCTAssertNoThrow(controller.viewWillDisappear(false)) @@ -124,6 +128,71 @@ class QuizListViewControllerTests: CoreTestCase { XCTAssertEqual(controller.emptyTitleLabel.text, "No Quizzes") XCTAssertEqual(controller.emptyMessageLabel.text, "It looks like quizzes haven’t been created in this space yet.") } + + func testLayoutWithLegacyHeaders() throws { + if #available(iOS 26, *) { + throw XCTSkip("Skip above iOS 26") + } + + let nav = UINavigationController(rootViewController: controller) + controller.view.layoutIfNeeded() + controller.viewWillAppear(false) + + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#0000ff") + XCTAssertEqual(controller.titleSubtitleView.title, "Quizzes") + XCTAssertEqual(controller.titleSubtitleView.subtitle, "Course One") + + XCTAssertNoThrow(controller.viewWillDisappear(false)) + + var index = IndexPath(row: 0, section: 0) + var header = controller.tableView.headerView(forSection: index.section) as? LegacySectionHeaderView + XCTAssertEqual(header?.titleLabel.text, "Assignments") + var cell = controller.tableView.cellForRow(at: index) as? QuizListCell + XCTAssertEqual(cell?.titleLabel.text, "A") + XCTAssertEqual(cell?.dateLabel.text, "Due " + TestConstants.date0720.relativeDateTimeString) + XCTAssertEqual(cell?.pointsLabel.text, "111 pts") + XCTAssertEqual(cell?.questionsLabel.text, "111 Questions") + XCTAssertEqual(cell?.statusLabel.isHidden, true) + XCTAssertEqual(cell?.statusDot.isHidden, true) + + index = IndexPath(row: 0, section: 1) + header = controller.tableView.headerView(forSection: index.section) as? LegacySectionHeaderView + XCTAssertEqual(header?.titleLabel.text, "Practice Quizzes") + cell = controller.tableView.cellForRow(at: index) as? QuizListCell + XCTAssertEqual(cell?.titleLabel.text, "B") + XCTAssertEqual(cell?.dateLabel.text, "Due " + TestConstants.date0315.relativeDateTimeString) + XCTAssertEqual(cell?.pointsLabel.text, "Not Graded") + XCTAssertEqual(cell?.statusLabel.text, "Closed") + XCTAssertEqual(cell?.statusDot.isHidden, false) + + index = IndexPath(row: 0, section: 2) + header = controller.tableView.headerView(forSection: index.section) as? LegacySectionHeaderView + XCTAssertEqual(header?.titleLabel.text, "Graded Surveys") + cell = controller.tableView.cellForRow(at: index) as? QuizListCell + XCTAssertEqual(cell?.titleLabel.text, "C") + XCTAssertEqual(cell?.dateLabel.text, "No Due Date") + + index = IndexPath(row: 0, section: 3) + header = controller.tableView.headerView(forSection: index.section) as? LegacySectionHeaderView + XCTAssertEqual(header?.titleLabel.text, "Surveys") + cell = controller.tableView.cellForRow(at: index) as? QuizListCell + XCTAssertEqual(cell?.titleLabel.text, "D") + + controller.tableView.delegate?.tableView?(controller.tableView, didSelectRowAt: index) + XCTAssert(router.lastRoutedTo(URL(string: "/courses/1/quizzes/4")!)) + + api.mock(controller.quizzes, error: NSError.internalError()) + controller.tableView.refreshControl?.sendActions(for: .primaryActionTriggered) + XCTAssertEqual(controller.errorView.isHidden, false) + XCTAssertEqual(controller.errorView.messageLabel.text, "There was an error loading quizzes. Pull to refresh to try again.") + + api.mock(controller.quizzes, value: []) + controller.errorView.retryButton.sendActions(for: .primaryActionTriggered) + XCTAssertEqual(controller.errorView.isHidden, true) + XCTAssertEqual(controller.emptyView.isHidden, false) + XCTAssertEqual(controller.emptyTitleLabel.text, "No Quizzes") + XCTAssertEqual(controller.emptyMessageLabel.text, "It looks like quizzes haven’t been created in this space yet.") + } } class QuizListCellTests: CoreTestCase { diff --git a/Core/CoreTests/Features/Syllabus/SyllabusTabViewControllerTests.swift b/Core/CoreTests/Features/Syllabus/SyllabusTabViewControllerTests.swift index 525d9abe20..41aef1648e 100644 --- a/Core/CoreTests/Features/Syllabus/SyllabusTabViewControllerTests.swift +++ b/Core/CoreTests/Features/Syllabus/SyllabusTabViewControllerTests.swift @@ -35,10 +35,15 @@ class SyllabusTabViewControllerTests: CoreTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#ed0000") - let titleView = controller.navigationItem.titleView as? TitleSubtitleView - XCTAssertEqual(titleView?.title, "Course Syllabus") - XCTAssertEqual(titleView?.subtitle, "Course One") + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Course Syllabus") + XCTAssertEqual(controller.navigationItem.subtitle, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, "#ed0000") + let titleView = controller.navigationItem.titleView as? TitleSubtitleView + XCTAssertEqual(titleView?.title, "Course Syllabus") + XCTAssertEqual(titleView?.subtitle, "Course One") + } var cell = controller.collectionView(controller.menu!, cellForItemAt: IndexPath(item: 0, section: 0)) as? HorizontalMenuViewController.MenuCell XCTAssertEqual(cell?.title?.text, "Syllabus") diff --git a/Core/CoreTests/Features/Todos/TodoListViewControllerTests.swift b/Core/CoreTests/Features/Todos/TodoListViewControllerTests.swift index ef6c894ec4..ad83b2e19a 100644 --- a/Core/CoreTests/Features/Todos/TodoListViewControllerTests.swift +++ b/Core/CoreTests/Features/Todos/TodoListViewControllerTests.swift @@ -45,7 +45,9 @@ class TodoListViewControllerTests: CoreTestCase { let navigation = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(navigation.navigationBar.barTintColor!.hexString, Brand.shared.navBackground.hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(navigation.navigationBar.barTintColor!.hexString, Brand.shared.navBackground.hexString) + } XCTAssertEqual(controller.view.backgroundColor, .backgroundLightest) XCTAssertEqual(controller.tableView.backgroundColor, .backgroundLightest) XCTAssertNoThrow(controller.viewWillDisappear(false)) diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index e28ea06a75..58c9f98386 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -76,8 +76,10 @@ class CourseDetailsViewController: HorizontalMenuViewController { view.backgroundColor = .backgroundLightest colorScheme = ColorScheme.observee(studentID) navigationController?.setNavigationBarHidden(false, animated: true) - navigationController?.navigationBar.useContextColor(colorScheme?.color) - self.navigationItem.backBarButtonItem = UIBarButtonItem(title: String(localized: "Back", bundle: .parent), style: .plain, target: nil, action: nil) + if #unavailable(iOS 26) { + navigationController?.navigationBar.useContextColor(colorScheme?.color) + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: String(localized: "Back", bundle: .parent), style: .plain, target: nil, action: nil) + } delegate = self customStatuses.refresh() @@ -95,6 +97,19 @@ class CourseDetailsViewController: HorizontalMenuViewController { courseReady() } + override func viewWillDisappear(_ animated: Bool) { + if #available(iOS 26, *) { + // Remove Grade Screen's custom navigation bar color when navigating away + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = .backgroundLightest + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + + super.viewWillDisappear(animated) + } + override func setupPages() { super.setupPages() // This is to prevent the swipe to back gesture interfering with the collectionview horizontal scroll when on the first page @@ -184,6 +199,15 @@ class CourseDetailsViewController: HorizontalMenuViewController { } } + if #available(iOS 26, *), itemCount <= 1 { + // Set the toolbar background if the tab switcher is not present to match the Grade Screen's header + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = .backgroundLight + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + layoutViewControllers() configureComposeMessageButton() } diff --git a/Parent/Parent/Dashboard/DashboardViewController.swift b/Parent/Parent/Dashboard/DashboardViewController.swift index f7bbd8d9d2..9a87fb00a1 100644 --- a/Parent/Parent/Dashboard/DashboardViewController.swift +++ b/Parent/Parent/Dashboard/DashboardViewController.swift @@ -46,6 +46,7 @@ class DashboardViewController: ScreenViewTrackableViewController, ErrorViewContr let screenViewTrackingParameters = ScreenViewTrackingParameters(eventName: "/") let headerViewModel = StudentHeaderViewModel() var subscriptions = Set() + var headerViewController: UIViewController? lazy var addStudentController = AddStudentController(presentingViewController: self, handler: { [weak self] error in if error == nil { @@ -105,6 +106,9 @@ class DashboardViewController: ScreenViewTrackableViewController, ErrorViewContr override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + if #available(iOS 26, *) { + headerViewController?.view.alpha = 1 + } navigationController?.navigationBar.useContextColor(currentColor) navigationController?.setNavigationBarHidden(true, animated: true) updateBadge() @@ -113,6 +117,12 @@ class DashboardViewController: ScreenViewTrackableViewController, ErrorViewContr override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + if #available(iOS 26, *) { + // The default navigation animation fades out the navigation bar, so we fade out our custom one + UIView.animate(withDuration: 0.2) { + self.headerViewController?.view.alpha = 0 + } + } navigationController?.setNavigationBarHidden(false, animated: true) NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) } @@ -245,7 +255,13 @@ class DashboardViewController: ScreenViewTrackableViewController, ErrorViewContr } private func embedHeaderView() { - let headerViewController = CoreHostingController(StudentHeaderView(viewModel: headerViewModel)) + headerViewController = if #available(iOS 26, *) { + CoreHostingController(StudentHeaderView(viewModel: headerViewModel)) + } else { + CoreHostingController(LegacyStudentHeaderView(viewModel: headerViewModel)) + } + guard let headerViewController else { return } + embed(headerViewController, in: view) { [studentListView] header, superview in let headerView = header.view! headerView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Parent/Parent/Dashboard/View/LegacyStudentHeaderView.swift b/Parent/Parent/Dashboard/View/LegacyStudentHeaderView.swift new file mode 100644 index 0000000000..ac23c3fe5e --- /dev/null +++ b/Parent/Parent/Dashboard/View/LegacyStudentHeaderView.swift @@ -0,0 +1,200 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import SwiftUI + +@available(iOS, deprecated: 26, message: "Non-legacy version exists") +struct LegacyStudentHeaderView: View { + @ObservedObject private var viewModel: StudentHeaderViewModel + @Environment(\.viewController) private var controller + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.verticalSizeClass) private var verticalSizeClass + @ScaledMetric private var uiScale: CGFloat = 1 + @AccessibilityFocusState private var isStudentViewFocused: Bool + + private var isVerticallyCompact: Bool { + verticalSizeClass == .compact + } + private let horizontalPadding: CGFloat = 16 + private var menuIconSize: CGFloat { uiScale.iconScale * 24 } + private var avatarSize: CGFloat { isVerticallyCompact ? 32 : 48 } + private var navBarHeight: CGFloat { isVerticallyCompact ? 42 : 91 } + + init(viewModel: StudentHeaderViewModel) { + self.viewModel = viewModel + } + + var body: some View { + HStack(alignment: isVerticallyCompact ? .center : .top, spacing: horizontalPadding) { + menuButton + studentView + Color.clear.frame(width: horizontalPadding + menuIconSize) + } + .frame(height: navBarHeight, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(nil, value: viewModel.backgroundColor) + .background(viewModel.backgroundColor) + .animation(.default, value: viewModel.backgroundColor) + .foregroundStyle(Color.textLightest) + } + + private var studentView: some View { + Button { + viewModel.didTapStudentView.send(()) + } label: { + VStack { + viewBody + .id(viewModel.state) + .transition(.push(from: .bottom)) + .accessibilityElement() + .accessibilityAddTraits(.isHeader) + .accessibilityLabel(viewModel.accessibilityLabel) + .accessibilityValue(viewModel.accessibilityValue) + .accessibilityHint(viewModel.accessibilityHint) + } + // Match the animation we use for the student carousel appearance + .animation(.easeOut(duration: 0.3), value: viewModel.state) + .clipped() + } + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityFocused($isStudentViewFocused) + .onReceive(viewModel.focusStudentPicker) { + isStudentViewFocused = true + } + } + + @ViewBuilder + private var viewBody: some View { + if isVerticallyCompact { + HStack(spacing: 8) { + icon + label + } + } else { + VStack(spacing: 8) { + icon + label + } + } + } + + @ViewBuilder + private var icon: some View { + switch viewModel.state { + case .addStudent: + Circle() + .frame(width: avatarSize, height: avatarSize) + .overlay { + Image.addLine + .size(avatarSize / 2) + .foregroundStyle(viewModel.backgroundColor) + } + .dropShadow() + case .student(let name, let avatarURL): + Avatar(name: name, url: avatarURL, size: avatarSize) + .dropShadow() + } + } + + @ViewBuilder + private var label: some View { + switch viewModel.state { + case .addStudent: + addStudentLabel + case .student(let name, _): + studentNameWithDropDown(name: name) + } + } + + private func studentNameWithDropDown(name: String) -> some View { + HStack(spacing: 4) { + Text(name) + .font(.semibold16) + .lineLimit(1) + Image.dropdown + .size(uiScale.iconScale * 12) + .rotationEffect(.degrees(viewModel.isDropdownClosed ? 0 : -180)) + } + } + + private var addStudentLabel: some View { + Text("Add Student", bundle: .parent) + .font(.semibold16) + } + + private var menuButton: some View { + Button { + viewModel.didTapMenuButton.send(controller.value) + } label: { + Image.hamburgerSolid + .resizable() + .size(menuIconSize) + .foregroundColor(Color.textLightest) + .instBadge(viewModel.badgeCount) + } + .padding(.leading, horizontalPadding) + .padding(.top, isVerticallyCompact ? 0 : 12) + .identifier("Dashboard.profileButton") + .accessibilityLabel(Text("Profile Menu", bundle: .parent)) + .accessibilityValue(String(localized: "Closed", bundle: .core)) + .accessibilityHint(viewModel.menuAccessibilityHint) + } +} + +private extension View { + + func dropShadow() -> some View { + shadow( + color: .black.opacity(0.12), + radius: 8, + x: 0, + y: 4 + ) + } +} + +#if DEBUG + +#Preview { + VStack { + LegacyStudentHeaderView(viewModel: StudentHeaderViewModel()) + Spacer() + } +} + +#Preview { + let previewEnvironment = PreviewEnvironment() + let user = User.save( + // swiftlint:disable:next line_length + .make(short_name: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book."), + in: previewEnvironment.database.viewContext + ) + let viewModel = { + let model = StudentHeaderViewModel() + model.didSelectStudent.send(user) + return model + }() + + VStack { + LegacyStudentHeaderView(viewModel: viewModel) + Spacer() + } +} + +#endif diff --git a/Parent/Parent/Dashboard/View/StudentHeaderView.swift b/Parent/Parent/Dashboard/View/StudentHeaderView.swift index 0af281183b..7989608627 100644 --- a/Parent/Parent/Dashboard/View/StudentHeaderView.swift +++ b/Parent/Parent/Dashboard/View/StudentHeaderView.swift @@ -16,9 +16,12 @@ // along with this program. If not, see . // +import Foundation + import Core import SwiftUI +@available(iOS, introduced: 26, message: "Legacy version exists") struct StudentHeaderView: View { @ObservedObject private var viewModel: StudentHeaderViewModel @Environment(\.viewController) private var controller @@ -33,24 +36,17 @@ struct StudentHeaderView: View { private let horizontalPadding: CGFloat = 16 private var menuIconSize: CGFloat { uiScale.iconScale * 24 } private var avatarSize: CGFloat { isVerticallyCompact ? 32 : 48 } - private var navBarHeight: CGFloat { isVerticallyCompact ? 42 : 91 } + private var navBarHeight: CGFloat { isVerticallyCompact ? 56 : 94 } init(viewModel: StudentHeaderViewModel) { self.viewModel = viewModel } var body: some View { - HStack(alignment: isVerticallyCompact ? .center : .top, spacing: horizontalPadding) { - menuButton - studentView - Color.clear.frame(width: horizontalPadding + menuIconSize) - } - .frame(height: navBarHeight, alignment: .center) - .frame(maxWidth: .infinity, alignment: .leading) - .animation(nil, value: viewModel.backgroundColor) - .background(viewModel.backgroundColor) - .animation(.default, value: viewModel.backgroundColor) - .foregroundStyle(Color.textLightest) + studentView + .frame(height: navBarHeight, alignment: .center) + .frame(maxWidth: .infinity) + .background(.backgroundLightest) } private var studentView: some View { @@ -69,27 +65,35 @@ struct StudentHeaderView: View { } // Match the animation we use for the student carousel appearance .animation(.easeOut(duration: 0.3), value: viewModel.state) - .clipped() } - .frame(maxWidth: .infinity, alignment: .center) .accessibilityFocused($isStudentViewFocused) .onReceive(viewModel.focusStudentPicker) { isStudentViewFocused = true } + .buttonStyle(.plain) } @ViewBuilder private var viewBody: some View { if isVerticallyCompact { HStack(spacing: 8) { + menuButton + Spacer() icon label + Spacer() } } else { - VStack(spacing: 8) { + VStack(spacing: 4) { + Spacer() icon label } + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .topLeading) { + menuButton + } + .padding(.bottom, 30) } } @@ -99,8 +103,10 @@ struct StudentHeaderView: View { case .addStudent: Circle() .frame(width: avatarSize, height: avatarSize) + .foregroundStyle(.clear) + .glassEffect() .overlay { - Image.addLine + Image.addSolid .size(avatarSize / 2) .foregroundStyle(viewModel.backgroundColor) } @@ -113,12 +119,18 @@ struct StudentHeaderView: View { @ViewBuilder private var label: some View { - switch viewModel.state { - case .addStudent: - addStudentLabel - case .student(let name, _): - studentNameWithDropDown(name: name) + Group { + switch viewModel.state { + case .addStudent: + addStudentLabel + case .student(let name, _): + studentNameWithDropDown(name: name) + } } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .glassEffect() + .padding(.horizontal, isVerticallyCompact ? 0 : 64) } private func studentNameWithDropDown(name: String) -> some View { @@ -144,9 +156,12 @@ struct StudentHeaderView: View { Image.hamburgerSolid .resizable() .size(menuIconSize) - .foregroundColor(Color.textLightest) - .instBadge(viewModel.badgeCount) + .foregroundColor(Color.textDarkest) + // Could not find a better way to make the button a circle + .frame(height: 34) } + .instBadge(viewModel.badgeCount) + .buttonStyle(.glass) .padding(.leading, horizontalPadding) .padding(.top, isVerticallyCompact ? 0 : 12) .identifier("Dashboard.profileButton") @@ -170,14 +185,16 @@ private extension View { #if DEBUG -#Preview { +#Preview("Add student") { VStack { - StudentHeaderView(viewModel: StudentHeaderViewModel()) + if #available(iOS 26, *) { + StudentHeaderView(viewModel: StudentHeaderViewModel()) + } Spacer() } } -#Preview { +#Preview("Student Name") { let previewEnvironment = PreviewEnvironment() let user = User.save( // swiftlint:disable:next line_length @@ -191,7 +208,29 @@ private extension View { }() VStack { - StudentHeaderView(viewModel: viewModel) + if #available(iOS 26, *) { + StudentHeaderView(viewModel: viewModel) + } + Spacer() + } +} + +#Preview("Beyoncé") { + let previewEnvironment = PreviewEnvironment() + let user = User.save( + .make(short_name: "Beyoncé"), + in: previewEnvironment.database.viewContext + ) + let viewModel = { + let model = StudentHeaderViewModel() + model.didSelectStudent.send(user) + return model + }() + + VStack { + if #available(iOS 26, *) { + StudentHeaderView(viewModel: viewModel) + } Spacer() } } diff --git a/Parent/Parent/Info.plist b/Parent/Parent/Info.plist index 883a68db82..ee05a3fd5d 100644 --- a/Parent/Parent/Info.plist +++ b/Parent/Parent/Info.plist @@ -94,8 +94,6 @@ Frameworks/Core.framework/lato_bolditalic.ttf Frameworks/Core.framework/lato_regular.ttf - UIDesignRequiresCompatibility - UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/Parent/Parent/Planner/CalendarEventDetailsViewController.swift b/Parent/Parent/Planner/CalendarEventDetailsViewController.swift index 3c3d3764d5..1a37d5f2b4 100644 --- a/Parent/Parent/Planner/CalendarEventDetailsViewController.swift +++ b/Parent/Parent/Planner/CalendarEventDetailsViewController.swift @@ -70,7 +70,13 @@ class CalendarEventDetailsViewController: UIViewController, ColoredNavViewProtoc override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .backgroundLightest - setupTitleViewInNavbar(title: String(localized: "Event Details", bundle: .parent)) + + if #available(iOS 26, *) { + navigationItem.title = String(localized: "Event Details", bundle: .parent) + } else { + setupTitleViewInNavbar(title: String(localized: "Event Details", bundle: .parent)) + } + updateNavBar(subtitle: nil, color: ColorScheme.observee(studentID).color) webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) @@ -130,7 +136,11 @@ class CalendarEventDetailsViewController: UIViewController, ColoredNavViewProtoc func update() { guard let event = events.first else { return } if let title = event.contextName { - setupTitleViewInNavbar(title: title) + if #available(iOS 26, *) { + navigationItem.title = title + } else { + setupTitleViewInNavbar(title: title) + } } titleLabel.text = event.title diff --git a/Parent/ParentUnitTests/AccountNotifications/AccountNotificationDetailsViewControllerTests.swift b/Parent/ParentUnitTests/AccountNotifications/AccountNotificationDetailsViewControllerTests.swift index be8e18d0a2..78308cc41e 100644 --- a/Parent/ParentUnitTests/AccountNotifications/AccountNotificationDetailsViewControllerTests.swift +++ b/Parent/ParentUnitTests/AccountNotifications/AccountNotificationDetailsViewControllerTests.swift @@ -36,7 +36,10 @@ class AccountNotificationDetailsViewControllerTests: ParentTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) + + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) + } XCTAssertEqual(controller.title, "Announcement") XCTAssertEqual(controller.titleLabel.text, "Pandemic") diff --git a/Parent/ParentUnitTests/Assignments/View/ParentAssignmentDetailsViewControllerTests.swift b/Parent/ParentUnitTests/Assignments/View/ParentAssignmentDetailsViewControllerTests.swift index cd42e55204..5200ef1498 100644 --- a/Parent/ParentUnitTests/Assignments/View/ParentAssignmentDetailsViewControllerTests.swift +++ b/Parent/ParentUnitTests/Assignments/View/ParentAssignmentDetailsViewControllerTests.swift @@ -44,7 +44,9 @@ class ParentAssignmentDetailsViewControllerTests: ParentTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) + } XCTAssertEqual(controller.title, "Course One") XCTAssertEqual(controller.titleLabel.text, "some assignment") XCTAssertEqual(controller.dateLabel.text, dueAt.dateTimeString) diff --git a/Parent/ParentUnitTests/Discussions/DiscussionDetailsViewControllerTests.swift b/Parent/ParentUnitTests/Discussions/DiscussionDetailsViewControllerTests.swift index 86aff0e0bb..fcdbc33000 100644 --- a/Parent/ParentUnitTests/Discussions/DiscussionDetailsViewControllerTests.swift +++ b/Parent/ParentUnitTests/Discussions/DiscussionDetailsViewControllerTests.swift @@ -37,7 +37,9 @@ class DiscussionDetailsViewControllerTests: ParentTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } XCTAssertEqual(controller.title, "Course One") XCTAssertEqual(controller.titleLabel.text, "Pandemic") diff --git a/Parent/ParentUnitTests/ObserverAlerts/ObserverAlertListViewControllerTests.swift b/Parent/ParentUnitTests/ObserverAlerts/ObserverAlertListViewControllerTests.swift index a714e8f880..14cd03dd97 100644 --- a/Parent/ParentUnitTests/ObserverAlerts/ObserverAlertListViewControllerTests.swift +++ b/Parent/ParentUnitTests/ObserverAlerts/ObserverAlertListViewControllerTests.swift @@ -87,7 +87,9 @@ class ObserverAlertListViewControllerTests: ParentTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) drainMainQueue() - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } XCTAssertEqual(controller.tableView.numberOfRows(inSection: 0), 4) diff --git a/Parent/ParentUnitTests/Planner/CalendarEventDetailsViewControllerTests.swift b/Parent/ParentUnitTests/Planner/CalendarEventDetailsViewControllerTests.swift index ceb63db1cc..6b71f4d874 100644 --- a/Parent/ParentUnitTests/Planner/CalendarEventDetailsViewControllerTests.swift +++ b/Parent/ParentUnitTests/Planner/CalendarEventDetailsViewControllerTests.swift @@ -54,8 +54,14 @@ class CalendarEventDetailsViewControllerTests: ParentTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) - XCTAssertEqual(controller.titleSubtitleView.title, "Course One") + + if #available(iOS 26, *) { + XCTAssertEqual(controller.navigationItem.title, "Course One") + } else { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.hexString) + XCTAssertEqual(controller.titleSubtitleView.title, "Course One") + } + XCTAssertEqual(controller.titleLabel.text, "It's happening") XCTAssertEqual(controller.dateLabel.text, TestConstants.date10.dateOnlyString) XCTAssertEqual(controller.locationView.isHidden, false) diff --git a/Parent/ParentUnitTests/Students/StudentDetailsViewControllerTests.swift b/Parent/ParentUnitTests/Students/StudentDetailsViewControllerTests.swift index 541fc6a2b8..15ac3f9a1e 100644 --- a/Parent/ParentUnitTests/Students/StudentDetailsViewControllerTests.swift +++ b/Parent/ParentUnitTests/Students/StudentDetailsViewControllerTests.swift @@ -37,7 +37,9 @@ class StudentDetailsViewControllerTests: ParentTestCase { let nav = UINavigationController(rootViewController: controller) controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observee("1").color.darkenToEnsureContrast(against: .textLightest.variantForLightMode).hexString) + } XCTAssertEqual(controller.nameLabel.text, "Legion (They/Them)") for (index, type) in AlertThresholdType.allCases.enumerated() { diff --git a/Parent/ParentUnitTests/Students/StudentListViewControllerTests.swift b/Parent/ParentUnitTests/Students/StudentListViewControllerTests.swift index 13dbec6003..f556f7f413 100644 --- a/Parent/ParentUnitTests/Students/StudentListViewControllerTests.swift +++ b/Parent/ParentUnitTests/Students/StudentListViewControllerTests.swift @@ -37,7 +37,9 @@ class StudentListViewControllerTests: ParentTestCase { controller.view.layoutIfNeeded() controller.viewWillAppear(false) - XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observeeBlue.color.hexString) + if #unavailable(iOS 26) { + XCTAssertEqual(nav.navigationBar.barTintColor?.hexString, ColorScheme.observeeBlue.color.hexString) + } XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.action, #selector(controller.addStudentController.addStudent)) let index0 = IndexPath(row: 0, section: 0) diff --git a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.storyboard b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.storyboard index b6f5307b16..1cfe1302f7 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.storyboard +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.storyboard @@ -1,8 +1,8 @@ - + - + @@ -679,10 +679,10 @@