import UIKit import WidgetKit @MainActor final class AppContext { let core: GotchaCore lazy var notifications = NotificationCoordinator(context: self) private let window: UIWindow private(set) var tabs = UITabBarController() private(set) var navigationControllers: [UINavigationController] = [] init(window: UIWindow) { self.window = window let storageDirectory = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: "group.de.rfc1437.gotcha" )?.path core = GotchaCore(storageDirectory: storageDirectory) applyAppearance() } func makeRootController() -> UIViewController { rebuildTabs(preservingHomeStack: false) return tabs } func applyPrimaryDestinations() { rebuildTabs(preservingHomeStack: true) } private func rebuildTabs(preservingHomeStack: Bool) { let destinations = core.settings().primaryDestinations let home = preservingHomeStack ? navigationControllers.first : nil let roots = destinations.map(destinationController) let items = [("Home", "house", "house.fill")] + destinations.map(destinationItem) let destinationNavigations = zip(roots, items.dropFirst()).map { root, item in let navigation = UINavigationController(rootViewController: root) navigation.tabBarItem = UITabBarItem( title: item.0, image: UIImage(systemName: item.1), selectedImage: UIImage(systemName: item.2) ) return navigation } let homeNavigation = home ?? UINavigationController( rootViewController: HomeViewController(context: self) ) homeNavigation.tabBarItem = UITabBarItem( title: items[0].0, image: UIImage(systemName: items[0].1), selectedImage: UIImage(systemName: items[0].2) ) navigationControllers = [homeNavigation] + destinationNavigations tabs.viewControllers = navigationControllers (homeNavigation.viewControllers.first as? HomeViewController)?.refreshPrimaryDestinations() } func showStartupErrorIfNeeded() { guard let message = core.startupError() else { return } tabs.present(errorAlert(message), animated: true) } func selectServer(index: UInt32) throws { try core.selectServer(index: index) reloadAfterServerChange() } func reloadAfterServerChange() { rebuildTabs(preservingHomeStack: false) WidgetCenter.shared.reloadAllTimelines() } @discardableResult func route(widgetURL: URL) -> Bool { guard widgetURL.scheme == "gotcha" else { return false } switch widgetURL.host { case "home": tabs.selectedIndex = 0 navigationControllers[0].popToRootViewController(animated: false) case "pulls": if let index = primaryDestinations.firstIndex(of: .pullRequests).map({ $0 + 1 }) { tabs.selectedIndex = index navigationControllers[index].popToRootViewController(animated: false) } else { tabs.selectedIndex = 0 navigationControllers[0].pushViewController( PullsViewController(context: self), animated: false ) } default: return false } return true } var primaryDestinations: [PrimaryDestination] { core.settings().primaryDestinations } var secondaryDestinations: [PrimaryDestination] { PrimaryDestination.allCases.filter { !primaryDestinations.contains($0) } } func show(_ destination: PrimaryDestination, from navigation: UINavigationController?) { if let index = primaryDestinations.firstIndex(of: destination).map({ $0 + 1 }) { tabs.selectedIndex = index navigationControllers[index].popToRootViewController(animated: false) } else { navigation?.pushViewController(destinationController(destination), animated: true) } } func didAddServer(index: UInt32) throws { try selectServer(index: index) } func applyAppearance() { switch core.settings().appearance { case 1: window.overrideUserInterfaceStyle = .light case 2: window.overrideUserInterfaceStyle = .dark default: window.overrideUserInterfaceStyle = .unspecified } } func route(_ activity: ActivityRow) { route( serverId: nil, target: activity.target, owner: activity.owner, repository: activity.repository, number: activity.number, sha: activity.sha ) } func route(_ notification: NotificationRow) { route( serverId: notification.serverId, target: notification.target, owner: notification.owner, repository: notification.repository, number: notification.number, sha: notification.sha ) } func route(notificationUserInfo userInfo: [AnyHashable: Any]) { let target: ActivityTargetKind switch userInfo["target"] as? String { case "repository": target = .repository case "issue": target = .issue case "pull": target = .pullRequest case "commit": target = .commit default: target = .none } route( serverId: userInfo["serverId"] as? String, target: target, owner: userInfo["owner"] as? String ?? "", repository: userInfo["repository"] as? String ?? "", number: (userInfo["number"] as? NSNumber)?.int64Value ?? 0, sha: userInfo["sha"] as? String ?? "" ) } private func route( serverId: String?, target: ActivityTargetKind, owner: String, repository: String, number: Int64, sha: String ) { if let serverId { guard let index = core.servers().firstIndex(where: { $0.id == serverId }) else { tabs.present(errorAlert("That notification's server is no longer configured."), animated: true) return } if core.activeServerIndex() != UInt32(index) { do { try selectServer(index: UInt32(index)) } catch { tabs.present(errorAlert(errorMessage(error)), animated: true) return } } } let navigation = navigationControllers[0] tabs.selectedIndex = 0 switch target { case .repository: navigation.pushViewController( IssuesViewController( context: self, owner: owner, repository: repository ), animated: true ) case .issue: navigation.pushViewController( IssueViewController( context: self, owner: owner, repository: repository, number: number ), animated: true ) case .pullRequest: navigation.pushViewController( PullViewController( context: self, owner: owner, repository: repository, number: number ), animated: true ) case .commit: navigation.pushViewController( FilesViewController( context: self, owner: owner, repository: repository, sha: sha ), animated: true ) case .none: break } } func symbol(_ name: String) -> UIImage? { UIImage(systemName: name) } private func repositoryRoot(mode: RepositoryPane) -> UIViewController { if core.activeServerIndex() == nil { return ServersViewController(context: self) } return RepositoriesViewController(context: self, mode: mode) } private func destinationController(_ destination: PrimaryDestination) -> UIViewController { switch destination { case .issues: return repositoryRoot(mode: .issues) case .repositories: return repositoryRoot(mode: .commits) case .pullRequests: return PullsViewController(context: self) case .milestones: return repositoryRoot(mode: .milestones) case .actions: return repositoryRoot(mode: .actions) case .serverActivity: return ServerActivityViewController(context: self) } } private func destinationItem(_ destination: PrimaryDestination) -> (String, String, String) { switch destination { case .issues: return ("Issues", "exclamationmark.circle", "exclamationmark.circle.fill") case .repositories: return ("Repos", "books.vertical", "books.vertical.fill") case .pullRequests: return ("PRs", "arrow.triangle.pull", "arrow.triangle.pull") case .milestones: return ("Milestones", "flag", "flag.fill") case .actions: return ("Actions", "play.square.stack", "play.square.stack.fill") case .serverActivity: return ("Activity", "person.3", "person.3.fill") } } } extension PrimaryDestination: CaseIterable { public static var allCases: [PrimaryDestination] { [.issues, .repositories, .pullRequests, .milestones, .actions] } var title: String { switch self { case .issues: return "Issues" case .repositories: return "Repositories" case .pullRequests: return "Pull Requests" case .milestones: return "Milestones" case .actions: return "Actions" case .serverActivity: return "Server Activity" } } var symbolName: String { switch self { case .issues: return "exclamationmark.circle" case .repositories: return "books.vertical" case .pullRequests: return "arrow.triangle.pull" case .milestones: return "flag" case .actions: return "play.square.stack" case .serverActivity: return "person.3" } } }