import ComposableArchitecture import ComposeFeature import Models import Routing import SwiftUI import TabStackFeature import TwitterClient import TwitterUI package struct MainView: View { @Bindable var store: StoreOf /// Per-window, so each main window reopens on the tab it was showing. @SceneStorage("selectedTab") private var savedTab = MainFeature.Tab.home @Environment(\.scenePhase) private var scenePhase #if DEBUG @Environment(\.openWindow) private var openWindow #endif private static let isPhone = UIDevice.current.userInterfaceIdiom == .phone package init(store: StoreOf) { self.store = store } package var body: some View { VStack(spacing: 0) { MainTabs(store: store, selection: store.selectedTab, unreadNotifications: store.unreadNotifications) .ignoresSafeArea(.container, edges: .top) ClassicTabBar( tabs: [ ClassicSidebarTab("Home", icon: .home, id: MainFeature.Tab.home), ClassicSidebarTab( "Notifications", icon: .at, id: .notifications, badgeCount: store.unreadNotifications), ClassicSidebarTab("Bookmarks", icon: .bookmark, id: .bookmarks), ClassicSidebarTab("Profile", icon: .profile, id: .profile), ClassicSidebarTab("Explore", icon: .hash, id: .explore) ], selection: $store.selectedTab, reselect: { tabStack($0).send(.tabReselected) } ) } // iPhone composers slid up over the whole screen, square-edged; the iPad's form sheet is period-correct. .fullScreenCover(item: Self.isPhone ? $store.scope(state: \.compose, action: \.compose) : .constant(nil)) { composeStore in ComposeView(store: composeStore) } .sheet(item: Self.isPhone ? .constant(nil) : $store.scope(state: \.compose, action: \.compose)) { composeStore in ComposeView(store: composeStore) } .onAppear { store.selectedTab = savedTab #if DEBUG applyLaunchArguments() #endif if NotificationTap.consume() { store.selectedTab = .notifications } } .onReceive(NotificationCenter.default.publisher(for: NotificationTap.didTap)) { _ in if NotificationTap.consume() { store.selectedTab = .notifications } } .onChange(of: store.selectedTab) { savedTab = store.selectedTab } .onChange(of: scenePhase) { old, new in if old == .background, new != .background { store.send(.becameActive) } } .task { await store.send(.task).finish() } } private func tabStack(_ tab: MainFeature.Tab) -> StoreOf { switch tab { case .bookmarks: store.scope(state: \.bookmarks, action: \.bookmarks) case .explore: store.scope(state: \.explore, action: \.explore) case .home: store.scope(state: \.home, action: \.home) case .notifications: store.scope(state: \.notifications, action: \.notifications) case .profile: store.scope(state: \.profile, action: \.profile) } } } /// The tabs, in UIKit's tab bar controller (iPhone Duo puts its bar in the vertical bar). Each tab is its stack's /// column navigation controller with nothing in between: a SwiftUI `TabView` would keep the columns' toolbars out of /// the navigation bar. private struct MainTabs: UIViewControllerRepresentable { let store: StoreOf /// Read here so choosing a tab elsewhere (state restoration, launch arguments) updates the controller. let selection: MainFeature.Tab let unreadNotifications: Int func makeCoordinator() -> Coordinator { Coordinator(store: store) } func makeUIViewController(context: Context) -> UITabBarController { let controller = MainTabBarController() controller.delegate = context.coordinator controller.setTabBarHidden(true, animated: false) // As Twitter's: selected and not, the items are the text color; only the glyph's fill tells them apart. controller.tabBar.tintColor = .twitterPrimary controller.tabBar.unselectedItemTintColor = .twitterPrimary controller.compose = { [coordinator = context.coordinator] in coordinator.compose() } controller.selectTab = { [coordinator = context.coordinator] in coordinator.choose($0) } controller.tabs = MainFeature.Tab.allInOrder.map { tab in UITab(title: tab.title, image: tab.icon(isSelected: false), identifier: tab.rawValue) { [coordinator = context.coordinator] _ in coordinator.stack(for: tab).navigationController } } context.coordinator.select(selection, in: controller) return controller } func updateUIViewController(_ controller: UITabBarController, context: Context) { controller.setTabBarHidden(true, animated: false) context.coordinator.select(selection, in: controller) controller.tab(forIdentifier: MainFeature.Tab.notifications.rawValue)?.badgeValue = unreadNotifications > 0 ? unreadNotifications.formatted() : nil } @MainActor final class Coordinator: NSObject, UITabBarControllerDelegate { private let store: StoreOf private var stacks: [MainFeature.Tab: TabStackController] = [:] init(store: StoreOf) { self.store = store } func stack(for tab: MainFeature.Tab) -> TabStackController { if let stack = stacks[tab] { return stack } let stack = TabStackController(store: tabStack(tab), title: tab.titleKey) stacks[tab] = stack return stack } /// New Tweet, from the tab showing. func compose() { tabStack(store.selectedTab).send(.composeButtonTapped) } func choose(_ tab: MainFeature.Tab) { store.selectedTab = tab } func select(_ selection: MainFeature.Tab, in controller: UITabBarController) { for tab in controller.tabs { guard let value = MainFeature.Tab(rawValue: tab.identifier) else { continue } tab.image = value.icon(isSelected: value == selection) } if controller.selectedTab?.identifier != selection.rawValue, let tab = controller.tab(forIdentifier: selection.rawValue) { controller.selectedTab = tab } } /// Choosing the tab already showing takes it back to its root, or its timeline to the top (rather than the /// tab bar controller popping the stack itself). func tabBarController(_ controller: UITabBarController, shouldSelectTab tab: UITab) -> Bool { guard let value = MainFeature.Tab(rawValue: tab.identifier) else { return false } if value == store.selectedTab { tabStack(value).send(.tabReselected) return false } return true } func tabBarController(_ controller: UITabBarController, didSelectTab tab: UITab, previousTab: UITab?) { guard let value = MainFeature.Tab(rawValue: tab.identifier), value != store.selectedTab else { return } store.selectedTab = value } private func tabStack(_ tab: MainFeature.Tab) -> StoreOf { switch tab { case .bookmarks: store.scope(state: \.bookmarks, action: \.bookmarks) case .explore: store.scope(state: \.explore, action: \.explore) case .home: store.scope(state: \.home, action: \.home) case .notifications: store.scope(state: \.notifications, action: \.notifications) case .profile: store.scope(state: \.profile, action: \.profile) } } } } /// The tab bar controller, with Twitter for iPad's keyboard shortcuts: ⌘N for a new Tweet, ⌘1 to ⌘5 for the tabs. private final class MainTabBarController: UITabBarController { var compose: () -> Void = {} var selectTab: (MainFeature.Tab) -> Void = { _ in } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() for child in children { let reserved = child.view.safeAreaInsets.bottom - child.additionalSafeAreaInsets.bottom - view.safeAreaInsets.bottom let inset = -max(reserved, 0) if child.additionalSafeAreaInsets.bottom != inset { child.additionalSafeAreaInsets.bottom = inset } } } override var keyCommands: [UIKeyCommand]? { let newTweet = UIKeyCommand( title: String(localized: "New Tweet"), image: TwitterIcon.compose.uiImage, action: #selector(newTweet), input: "n", modifierFlags: .command) let tabs = MainFeature.Tab.allInOrder.enumerated().map { index, tab in UIKeyCommand( title: tab.title, action: #selector(selectTab(_:)), input: "\(index + 1)", modifierFlags: .command, propertyList: tab.rawValue) } return [newTweet] + tabs } @objc private func newTweet() { compose() } @objc private func selectTab(_ command: UIKeyCommand) { guard let rawValue = command.propertyList as? String, let tab = MainFeature.Tab(rawValue: rawValue) else { return } selectTab(tab) } } extension MainFeature.Tab { fileprivate static let allInOrder: [Self] = [.home, .notifications, .bookmarks, .profile, .explore] fileprivate var title: String { switch self { case .bookmarks: String(localized: "Bookmarks") case .explore: String(localized: "Explore") case .home: String(localized: "Home") case .notifications: String(localized: "Notifications") case .profile: String(localized: "Profile") } } fileprivate var titleKey: LocalizedStringKey { switch self { case .bookmarks: "Bookmarks" case .explore: "Explore" case .home: "Home" case .notifications: "Notifications" case .profile: "Profile" } } /// Twitter's glyph: outlined, and filled while selected. fileprivate func icon(isSelected: Bool) -> UIImage? { let icon: TwitterIcon = switch self { case .bookmarks: isSelected ? .bookmark : .bookmarkStroke case .explore: isSelected ? .search : .searchStroke case .home: isSelected ? .home : .homeStroke case .notifications: isSelected ? .notifications : .notificationsStroke case .profile: isSelected ? .person : .personStroke } return icon.uiImage } } #if DEBUG extension MainView { /// `--tab `, `--open-thread`, `--open-web`, `--open-profile` and `--open-window-thread` put the app on a given screen at /// launch, for capturing it without tapping there. fileprivate func applyLaunchArguments() { let arguments = ProcessInfo.processInfo.arguments if let index = arguments.firstIndex(of: "--tab"), index + 1 < arguments.count, let tab = MainFeature.Tab(rawValue: arguments[index + 1]) { store.selectedTab = tab } if arguments.contains("--open-thread"), let tweet = Tweet.mocks.first { store.send(.home(.root(.feeds(.delegate(.open(.thread(tweet))))))) } // `--search `: that search's results, opened from the home timeline. if let index = arguments.firstIndex(of: "--search"), index + 1 < arguments.count { store.send(.home(.root(.feeds(.delegate(.open(.search(arguments[index + 1]))))))) } // `--settings`: Settings, over your profile. if arguments.contains("--settings") { store.selectedTab = .profile store.send(.profile(.root(.profile(.settingsButtonTapped)))) } // `--compose`: the New Tweet sheet. if arguments.contains("--compose") { store.send(.home(.composeButtonTapped)) } // `--compose-reply`: a reply to the first mock post. if arguments.contains("--compose-reply"), let tweet = Tweet.mocks.first { store.send(.home(.root(.feeds(.delegate(.compose(Draft(replyingTo: tweet))))))) } // `--open-reply`: a reply's thread, with the conversation it replies to above it. if arguments.contains("--open-reply") { store.send(.home(.root(.feeds(.delegate(.open(.thread(.mockReply))))))) } if arguments.contains("--open-web"), let url = URL(string: "https://www.pointfree.co") { // Once the stack knows whether it has room for a web panel, rather than Safari's view. Task { @MainActor in try? await Task.sleep(for: .seconds(1)) store.send(.home(.root(.feeds(.delegate(.open(.web(url))))))) } } // `--cycle-columns`: opens a thread column, closes it and opens it again, a few seconds apart, to watch // the strip go from one column to two without touching anything. if arguments.contains("--cycle-columns"), let tweet = Tweet.mocks.first { Task { @MainActor in for _ in 0..<2 { try? await Task.sleep(for: .seconds(2)) store.send(.home(.root(.feeds(.delegate(.open(.thread(tweet))))))) try? await Task.sleep(for: .seconds(2)) // `--pop`: close as a pulled-down or thrown-away column does, straight out of the path. if arguments.contains("--pop"), let id = store.home.path.ids.first { store.send(.home(.path(.popFrom(id: id)))) } else { store.send(.home(.tabReselected)) } } } } if arguments.contains("--open-window-thread"), let tweet = Tweet.mocks.first { openWindow(value: Route.thread(tweet).window) } // `--open-profile` (after `--open-thread`) opens a third column from the thread. if arguments.contains("--open-profile"), let id = store.home.path.ids.last { store.send(.home(.path(.element(id: id, action: .thread(.delegate(.open(.profile(.mock)))))))) } // `--open-followers`, `--open-following`, `--open-reposts`, `--open-likes` and `--open-quotes` open the mock // profile's or first mock post's list as a column from the home timeline, or after `--open-thread`'s // thread. let lists: [(argument: String, route: Route)] = [ ("--open-followers", .userList(.followers(userID: User.mock.id))), ("--open-following", .userList(.following(userID: User.mock.id))), ("--open-likes", .userList(.likers(tweetID: Tweet.mocks[0].id))), ("--open-lists", .lists(userID: User.mock.id)), ("--open-quotes", .quotes(tweetID: Tweet.mocks[0].id)), ("--open-reposts", .userList(.reposters(tweetID: Tweet.mocks[0].id))) ] for list in lists where arguments.contains(list.argument) { if let id = store.home.path.ids.last, case .thread? = store.home.path[id: id] { store.send(.home(.path(.element(id: id, action: .thread(.delegate(.open(list.route))))))) } else { store.send(.home(.root(.feeds(.delegate(.open(list.route)))))) } } } } #endif