Files
flamenet-messenger-ios/Sources/Views/MainTabView.swift
T
Paul HittandClaude Opus 5 12eb447feb iPad: only the list tabs split into two columns
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>
2026-09-11 12:22:03 -04:00

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())
}