Files
mobile-ios/Rosetta/Features/MainTabView.swift
senseiGai dd4642f251 encryptWithPassword возвращён к SHA256+rawDeflate (iOS-only данные)
Добавлен encryptWithPasswordDesktopCompat (SHA1+zlibDeflate) для кросс-платформенных данных (aesChachaKey, аватар)
3 вызова в SessionManager переведены на desktop-compatible путь
Добавлен Notification.Name.profileDidUpdate для мгновенного обновления имени в Settings
Удалены debug-логи из CryptoManager и SessionManager
2026-03-15 03:50:56 +05:00

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