Home, Messages and Calls are lists that lead to a conversation, which is what a second column is for. Everything else is a single screen, and splitting those went badly: Files and the wallet pushed their unlock screen into a column a third of the width, and the other two thirds said "Pick a conversation" on a tab with no conversations in it. Account did the same. Found in the 13-inch screenshot set. Those tabs now take the whole width, the way a settings screen does on a tablet. Whether a row opens beside the list or on top of it follows whether the right-hand column is actually showing, so a row on a full-width tab cannot open into a column that is not there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.6 KiB
Swift
191 lines
7.6 KiB
Swift
import SwiftUI
|
|
|
|
/// Signed-in container.
|
|
struct MainTabView: View {
|
|
@EnvironmentObject private var environment: AppEnvironment
|
|
@State private var unreadCount: Int = 0
|
|
|
|
@State private var selection = 0
|
|
|
|
/// Two columns when there is room for two columns.
|
|
///
|
|
/// The size class rather than the device: an iPad in Slide Over is as
|
|
/// narrow as a phone and wants the phone's layout, and asking
|
|
/// `UIDevice.current.userInterfaceIdiom` would give it the wrong one and
|
|
/// keep giving it the wrong one while somebody dragged the divider.
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
@StateObject private var pane = SplitPane()
|
|
|
|
private var isWide: Bool { sizeClass == .regular }
|
|
|
|
/// Home, Messages and Calls: the tabs that are lists leading to a
|
|
/// conversation, which is what a second column is for.
|
|
///
|
|
/// Everything else is a single screen. Split, Files and the wallet pushed
|
|
/// their unlock screen into a column a third of the width, and the other
|
|
/// two thirds said "Pick a conversation" on a tab with no conversations in
|
|
/// it. Those take the whole width instead, the way a settings screen does
|
|
/// on a tablet.
|
|
private static let listTabs: Set<Int> = [0, 1, 2]
|
|
|
|
/// Two columns only when there is room for two AND this tab is a list.
|
|
private var usesSplit: Bool { isWide && Self.listTabs.contains(selection) }
|
|
|
|
/// When the Calls tab was last opened, so the missed-call badge counts only
|
|
/// what arrived since.
|
|
@AppStorage("fnmsg.callsSeenAt") private var callsSeenAt: Double = 0
|
|
|
|
private var missedCallCount: Int {
|
|
environment.e2eInbox?.callLog
|
|
.filter {
|
|
!$0.connected && $0.incoming
|
|
&& $0.at.timeIntervalSince1970 > callsSeenAt
|
|
}
|
|
.count ?? 0
|
|
}
|
|
|
|
var body: some View {
|
|
layout
|
|
.environmentObject(pane)
|
|
// One place decides, and everything downstream reads it. See
|
|
// SplitPane.
|
|
.onChange(of: usesSplit, initial: true) { _, split in
|
|
// Whether a row opens beside the list or on top of it depends
|
|
// on whether the right-hand column is actually showing.
|
|
pane.isWide = split
|
|
}
|
|
.onChange(of: isWide, initial: true) { _, wide in
|
|
// Coming back to one column, the conversation on the right has
|
|
// nowhere to be. Dropping it returns to the list rather than
|
|
// leaving a chat open that nothing on screen points at.
|
|
if !wide { pane.buddy = nil }
|
|
}
|
|
}
|
|
|
|
/// The lists on the left, the conversation on the right.
|
|
@ViewBuilder private var wideLayout: some View {
|
|
NavigationSplitView {
|
|
tabs
|
|
.navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 460)
|
|
} detail: {
|
|
// Keyed on the selection so switching conversations rebuilds the
|
|
// screen rather than handing the next one the last one's state.
|
|
switch pane.conversation {
|
|
case .buddy(let buddy):
|
|
ChatScreen(buddy: buddy).id(pane.conversation)
|
|
case .remote(let address):
|
|
if let card = ContactCard.decode(address), let inbox = environment.e2eInbox {
|
|
RemoteChatScreen(card: card, inbox: inbox).id(pane.conversation)
|
|
} else {
|
|
NoConversationSelected()
|
|
}
|
|
case .group(let group):
|
|
GroupChatScreen(group: group).id(pane.conversation)
|
|
case nil:
|
|
NoConversationSelected()
|
|
}
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
}
|
|
|
|
@ViewBuilder private var layout: some View {
|
|
if usesSplit { wideLayout } else { tabs }
|
|
}
|
|
|
|
@ViewBuilder private var tabs: some View {
|
|
// Not a `TabView`.
|
|
PillTabBar(
|
|
items: [
|
|
.init(id: 0, title: "Home", symbol: "house.fill"),
|
|
// The kit's 07 Messages-Listing as its own tab: search, the
|
|
// Recent circles, and the conversation list.
|
|
.init(
|
|
id: 1, title: "Messages", symbol: "bubble.left.fill",
|
|
badge: environment.homeBadgeCount),
|
|
// A tab, matching Android.
|
|
.init(
|
|
id: 2, title: "Calls", symbol: "phone",
|
|
badge: missedCallCount),
|
|
// Supervision removes the surface rather than emptying it.
|
|
ParentalControls.storiesHidden
|
|
? nil : .init(id: 3, title: "Stories", symbol: "circle.dashed"),
|
|
// Files and Wallet as separate tabs.
|
|
.init(id: 5, title: "Files", symbol: "folder.fill"),
|
|
.init(id: 6, title: "Wallet", symbol: "wallet.bifold.fill"),
|
|
.init(
|
|
id: 4, title: "Account", symbol: "person.crop.circle.fill",
|
|
badge: unreadCount),
|
|
].compactMap { $0 },
|
|
selection: $selection
|
|
) { index in
|
|
switch index {
|
|
case 0: BuddyListView()
|
|
case 1: MessagesListView()
|
|
case 2:
|
|
if let inbox = environment.e2eInbox {
|
|
CallsView(inbox: inbox, buddies: environment.knownBuddies)
|
|
} else {
|
|
Text("Calls need the encrypted transport, which is not up yet.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(Theme.Kit.margin)
|
|
}
|
|
case 3:
|
|
if let inbox = environment.e2eInbox {
|
|
StoriesFeedView(inbox: inbox)
|
|
} else {
|
|
// Stories ride the E2E transport (spec §6.6), so the feed
|
|
// only exists once the inbox is up.
|
|
Text("Stories need the encrypted transport, which is not up yet.")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(Theme.Kit.margin)
|
|
}
|
|
case 5: FilesView()
|
|
case 6: WalletScreen()
|
|
default: ProfileView()
|
|
}
|
|
}
|
|
.tint(Theme.primary)
|
|
// Calls interrupt every tab (spec §6.8), the host presents the full-
|
|
// screen call UI whenever the manager leaves .idle.
|
|
.background {
|
|
if let manager = environment.callManager {
|
|
CallOverlayHost(manager: manager, inbox: environment.e2eInbox)
|
|
}
|
|
}
|
|
.task(id: environment.auth?.currentUser?.userID) {
|
|
// Restart the loop whenever the signed-in user changes.
|
|
await refreshLoop()
|
|
}
|
|
// Opening the Calls tab is what "seeing" the missed calls means, the
|
|
// badge clears here and stays cleared.
|
|
.onChange(of: selection) { _, tab in
|
|
if tab == 2 { callsSeenAt = Date().timeIntervalSince1970 }
|
|
}
|
|
// An address arrived from another tab's scanner.
|
|
.onChange(of: environment.pendingAddress) { _, pending in
|
|
if pending != nil { selection = 0 }
|
|
}
|
|
|
|
}
|
|
|
|
private func refreshLoop() async {
|
|
while !Task.isCancelled {
|
|
await refreshOnce()
|
|
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 s
|
|
}
|
|
}
|
|
|
|
private func refreshOnce() async {
|
|
guard let api = environment.api else { return }
|
|
if let page = try? await api.fetchNotifications() {
|
|
unreadCount = page.unread
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
MainTabView().environmentObject(AppEnvironment())
|
|
}
|