Добавлен encryptWithPasswordDesktopCompat (SHA1+zlibDeflate) для кросс-платформенных данных (aesChachaKey, аватар) 3 вызова в SessionManager переведены на desktop-compatible путь Добавлен Notification.Name.profileDidUpdate для мгновенного обновления имени в Settings Удалены debug-логи из CryptoManager и SessionManager
279 lines
9.8 KiB
Swift
279 lines
9.8 KiB
Swift
import SwiftUI
|
|
|
|
/// Main container view with tab-based navigation.
|
|
struct MainTabView: View {
|
|
var onLogout: (() -> Void)?
|
|
/// Always start on Chats tab after login / account switch.
|
|
/// Using @State (not @SceneStorage) ensures the tab resets to .chats
|
|
/// when MainTabView is recreated after an account switch or unlock.
|
|
@State private var selectedTab: RosettaTab = .chats
|
|
@State private var isChatSearchActive = false
|
|
@State private var isChatListDetailPresented = false
|
|
@State private var isSettingsEditPresented = false
|
|
@State private var isSettingsDetailPresented = false
|
|
|
|
// Add Account — presented as fullScreenCover so Settings stays alive.
|
|
// Using optional AuthScreen as the item ensures the correct screen is
|
|
// passed directly to the content closure (no stale capture).
|
|
@State private var addAccountScreen: AuthScreen?
|
|
/// All tabs are pre-activated so that switching only changes the offset,
|
|
/// not the view structure. Creating a NavigationStack mid-animation causes
|
|
/// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze.
|
|
@State private var activatedTabs: Set<RosettaTab> = Set(RosettaTab.interactionOrder)
|
|
/// When non-nil, the tab bar is being dragged and the pager follows interactively.
|
|
@State private var dragFractionalIndex: CGFloat?
|
|
|
|
/// Local handler for Add Account — triggers fullScreenCover instead of app-level navigation.
|
|
private var handleAddAccount: (AuthScreen) -> Void {
|
|
{ screen in
|
|
addAccountScreen = screen
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if #available(iOS 26.0, *) {
|
|
systemTabView
|
|
} else {
|
|
legacyTabView
|
|
}
|
|
}
|
|
.fullScreenCover(item: $addAccountScreen) { screen in
|
|
AuthCoordinator(
|
|
onAuthComplete: {
|
|
addAccountScreen = nil
|
|
// New account created — end session and go to unlock for the new account
|
|
SessionManager.shared.endSession()
|
|
onLogout?()
|
|
},
|
|
onBackToUnlock: {
|
|
addAccountScreen = nil
|
|
},
|
|
initialScreen: screen
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)
|
|
|
|
@available(iOS 26.0, *)
|
|
private var systemTabView: some View {
|
|
TabView(selection: $selectedTab) {
|
|
CallsView()
|
|
.tabItem {
|
|
Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon)
|
|
}
|
|
.tag(RosettaTab.calls)
|
|
|
|
ChatListView(
|
|
isSearchActive: $isChatSearchActive,
|
|
isDetailPresented: $isChatListDetailPresented
|
|
)
|
|
.tabItem {
|
|
Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon)
|
|
}
|
|
.tag(RosettaTab.chats)
|
|
.badge(chatUnreadCount)
|
|
|
|
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
|
.tabItem {
|
|
Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon)
|
|
}
|
|
.tag(RosettaTab.settings)
|
|
}
|
|
.tint(RosettaColors.primaryBlue)
|
|
}
|
|
|
|
// MARK: - iOS < 26 (custom RosettaTabBar with pager)
|
|
|
|
private var legacyTabView: some View {
|
|
ZStack(alignment: .bottom) {
|
|
RosettaColors.Adaptive.background
|
|
.ignoresSafeArea()
|
|
|
|
GeometryReader { geometry in
|
|
tabPager(availableSize: geometry.size)
|
|
}
|
|
.ignoresSafeArea()
|
|
|
|
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented {
|
|
RosettaTabBar(
|
|
selectedTab: selectedTab,
|
|
onTabSelected: { tab in
|
|
activatedTabs.insert(tab)
|
|
for t in RosettaTab.interactionOrder { activatedTabs.insert(t) }
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
selectedTab = tab
|
|
}
|
|
},
|
|
onSwipeStateChanged: { state in
|
|
if let state {
|
|
for tab in RosettaTab.interactionOrder {
|
|
activatedTabs.insert(tab)
|
|
}
|
|
dragFractionalIndex = state.fractionalIndex
|
|
} else {
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
dragFractionalIndex = nil
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.ignoresSafeArea(.keyboard)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.ignoresSafeArea(.keyboard)
|
|
.onChange(of: isChatSearchActive) { _, isActive in
|
|
if isActive {
|
|
dragFractionalIndex = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentPageIndex: CGFloat {
|
|
CGFloat(selectedTab.interactionIndex)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabPager(availableSize: CGSize) -> some View {
|
|
let width = max(1, availableSize.width)
|
|
|
|
ZStack {
|
|
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
|
|
tabView(for: tab)
|
|
.frame(width: width, height: availableSize.height)
|
|
.opacity(tabOpacity(for: tab))
|
|
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil)
|
|
}
|
|
}
|
|
.clipped()
|
|
}
|
|
|
|
private func tabOpacity(for tab: RosettaTab) -> Double {
|
|
if let frac = dragFractionalIndex {
|
|
// During drag: crossfade between adjacent tabs
|
|
let tabIndex = CGFloat(tab.interactionIndex)
|
|
let distance = abs(frac - tabIndex)
|
|
if distance >= 1 { return 0 }
|
|
return Double(1 - distance)
|
|
} else {
|
|
return tab == selectedTab ? 1 : 0
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabView(for tab: RosettaTab) -> some View {
|
|
if activatedTabs.contains(tab) {
|
|
switch tab {
|
|
case .chats:
|
|
ChatListView(
|
|
isSearchActive: $isChatSearchActive,
|
|
isDetailPresented: $isChatListDetailPresented
|
|
)
|
|
case .calls:
|
|
CallsView()
|
|
case .settings:
|
|
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
|
}
|
|
} else {
|
|
RosettaColors.Adaptive.background
|
|
}
|
|
}
|
|
|
|
private var isAnyChatDetailPresented: Bool {
|
|
isChatListDetailPresented
|
|
}
|
|
|
|
private var tabBadges: [TabBadge] {
|
|
guard let chatUnreadBadge else {
|
|
return []
|
|
}
|
|
return [TabBadge(tab: .chats, text: chatUnreadBadge)]
|
|
}
|
|
|
|
/// Int badge for iOS 26+ TabView — `.badge(0)` shows nothing,
|
|
/// and being non-conditional preserves ChatListView's structural identity.
|
|
private var chatUnreadCount: Int {
|
|
DialogRepository.shared.sortedDialogs
|
|
.filter { !$0.isMuted }
|
|
.reduce(0) { $0 + $1.unreadCount }
|
|
}
|
|
|
|
private var chatUnreadBadge: String? {
|
|
let unread = chatUnreadCount
|
|
if unread <= 0 {
|
|
return nil
|
|
}
|
|
return unread > 999 ? "\(unread / 1000)K" : "\(unread)"
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
/// Non-conditional badge — preserves structural identity.
|
|
/// A @ViewBuilder `if let / else` creates two branches; switching
|
|
/// between them changes the child's structural identity, destroying
|
|
/// any @StateObject (including ChatListNavigationState.path).
|
|
func badgeIfNeeded(_ value: String?) -> some View {
|
|
badge(Text(value ?? ""))
|
|
}
|
|
}
|
|
|
|
// MARK: - Pager Offset Modifier
|
|
|
|
/// Isolates the offset/animation from child view identity so that
|
|
/// changing `effectiveIndex` only redraws the transform, not the child views.
|
|
private struct PagerOffsetModifier: ViewModifier {
|
|
let effectiveIndex: CGFloat
|
|
let pageWidth: CGFloat
|
|
let isDragging: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.offset(x: -effectiveIndex * pageWidth)
|
|
.animation(
|
|
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
|
value: effectiveIndex
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Placeholder
|
|
|
|
struct PlaceholderTabView: View {
|
|
let title: String
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
RosettaColors.Adaptive.background
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 16) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 52))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
|
|
|
Text(title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
|
|
Text("Coming soon")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
Text(title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
|
}
|
|
}
|
|
.applyGlassNavBar()
|
|
}
|
|
}
|
|
}
|