diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index ed7084e..b8173e8 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -629,7 +629,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.6; + MARKETING_VERSION = 1.2.7; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -653,7 +653,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -669,7 +669,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.6; + MARKETING_VERSION = 1.2.7; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -849,7 +849,7 @@ LA00000082F8D22220092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index 1ec4da5..1f04c3d 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -83,6 +83,12 @@ extension CallManager { } func finishCall(reason: String?, notifyPeer: Bool) { + print("[CallBar] finishCall(reason=\(reason ?? "nil")) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") + // Log call stack to identify WHO triggered finishCall + let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ") + print("[CallBar] stack:\n \(symbols)") + pendingMinimizeTask?.cancel() + pendingMinimizeTask = nil cancelRingTimeout() endLiveActivity() let wasActive = uiState.phase == .active @@ -456,7 +462,10 @@ extension CallManager: RTCPeerConnectionDelegate { return } if newState == .failed || newState == .closed || newState == .disconnected { - Task { @MainActor in self.finishCall(reason: "Connection lost", notifyPeer: false) } + Task { @MainActor in + print("[CallBar] PeerConnection \(newState.rawValue) → finishCall()") + self.finishCall(reason: "Connection lost", notifyPeer: false) + } } } diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index a7758e2..e3f73e9 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -39,6 +39,7 @@ final class CallManager: NSObject, ObservableObject { var durationTask: Task? var ringTimeoutTask: Task? + var pendingMinimizeTask: Task? var liveActivity: Activity? private override init() { @@ -113,6 +114,7 @@ final class CallManager: NSObject, ObservableObject { } func declineIncomingCall() { + print("[CallBar] declineIncomingCall() — phase=\(uiState.phase.rawValue)") guard uiState.phase == .incoming else { return } if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false { ProtocolManager.shared.sendCallSignal( @@ -125,6 +127,7 @@ final class CallManager: NSObject, ObservableObject { } func endCall() { + print("[CallBar] endCall() — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") finishCall(reason: nil, notifyPeer: true) } @@ -146,13 +149,19 @@ final class CallManager: NSObject, ObservableObject { func minimizeCall() { guard uiState.isVisible else { return } + pendingMinimizeTask?.cancel() + pendingMinimizeTask = nil + print("[CallBar] minimizeCall() — phase=\(uiState.phase.rawValue)") withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { uiState.isMinimized = true } } func expandCall() { + pendingMinimizeTask?.cancel() + pendingMinimizeTask = nil guard uiState.isVisible else { return } + print("[CallBar] expandCall() — phase=\(uiState.phase.rawValue)") withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { uiState.isMinimized = false } @@ -179,6 +188,7 @@ final class CallManager: NSObject, ObservableObject { } private func handleSignalPacket(_ packet: PacketSignalPeer) { + print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") switch packet.signalType { case .endCallBecauseBusy: finishCall(reason: "User is busy", notifyPeer: false) @@ -436,10 +446,12 @@ final class CallManager: NSObject, ObservableObject { } func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) { + print("[CallBar] ICE state changed: \(state.rawValue) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)") switch state { case .connected, .completed: setCallActiveIfNeeded() case .failed, .closed, .disconnected: + print("[CallBar] ICE \(state.rawValue) → finishCall()") finishCall(reason: "Connection lost", notifyPeer: false) default: break diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index f8a94e5..e7913d8 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -11,17 +11,17 @@ enum ReleaseNotes { Entry( version: appVersion, body: """ - **Производительность скролла** - Ячейки сообщений извлечены в отдельный Equatable-компонент — SwiftUI пропускает перерисовку неизменённых ячеек. Плавный скролл на 120 FPS даже в длинных переписках. + **Звонки — минимизированная панель** + Telegram-style call-бар при активном звонке: зелёный градиент в статус-баре, навигационная панель сдвигается вниз (UIKit additionalSafeAreaInsets). Тап по панели — возврат на полный экран звонка. - **Пагинация** - История чата подгружается порциями по 50 сообщений при скролле вверх. Можно листать на тысячи сообщений назад без задержек. + **Звонки — полный экран** + Анимированный градиентный фон из цвета аватара собеседника. Свайп вниз для сворачивания. E2E-шифрование бейдж. - **Клавиатура и поле ввода** - Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. + **Контекстное меню** + Рефакторинг Telegram-style контекстного меню сообщений. Карточка действий с блюром и анимацией. - **Доставка и синхронизация** - Сообщения больше не помечаются ошибкой при кратковременном обрыве — часики и автодоставка при реконнекте. Прочтения от оппонента корректно синхронизируются. + **Производительность** + Ячейки сообщений — Equatable-компонент, 120 FPS скролл. Пагинация по 50 сообщений. Клавиатура синхронизирована с контентом. """ ) ] diff --git a/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift b/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift index 4253865..c340c4d 100644 --- a/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift +++ b/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift @@ -39,7 +39,16 @@ final class CallBarInsetView: UIView { func applyInset() { guard topInset != lastAppliedInset else { return } guard let window, let rootVC = window.rootViewController else { return } - Self.apply(inset: topInset, in: rootVC) + let inset = topInset + // Animate in sync with SwiftUI spring transition to avoid jerk. + // layoutIfNeeded() forces the layout pass INSIDE the animation block — + // without it, additionalSafeAreaInsets triggers layout on the next + // run loop pass, which is outside the animation scope → instant jump. + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.85, + initialSpringVelocity: 0, options: [.beginFromCurrentState]) { + Self.apply(inset: inset, in: rootVC) + rootVC.view.layoutIfNeeded() + } lastAppliedInset = topInset } diff --git a/Rosetta/DesignSystem/Components/CallGradientBackground.swift b/Rosetta/DesignSystem/Components/CallGradientBackground.swift index 5cca330..630f718 100644 --- a/Rosetta/DesignSystem/Components/CallGradientBackground.swift +++ b/Rosetta/DesignSystem/Components/CallGradientBackground.swift @@ -1,17 +1,45 @@ import SwiftUI +import CoreImage // MARK: - Animated Call Gradient Background -/// Full-screen animated gradient tied to the peer's avatar color. +/// Full-screen animated gradient tied to the peer's avatar color or photo. /// Uses Canvas + TimelineView for GPU-composited rendering at 30fps. +/// When `peerAvatar` is provided, extracts dominant color from the photo. struct CallGradientBackground: View { let colorIndex: Int private let palette: CallGradientPalette private let baseColor = Color(hex: 0x0D0D14) - init(colorIndex: Int) { + init(colorIndex: Int, peerAvatar: UIImage? = nil) { self.colorIndex = colorIndex - self.palette = CallGradientPalette(colorIndex: colorIndex) + if let avatar = peerAvatar, + let dominant = Self.dominantColor(from: avatar) { + self.palette = CallGradientPalette(baseColor: dominant) + } else { + self.palette = CallGradientPalette(colorIndex: colorIndex) + } + } + + // MARK: - Dominant Color Extraction + + private static let ciContext = CIContext(options: [.workingColorSpace: kCFNull as Any]) + + private static func dominantColor(from image: UIImage) -> UIColor? { + guard let ciImage = CIImage(image: image) else { return nil } + let extent = ciImage.extent + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [ + kCIInputImageKey: ciImage, + kCIInputExtentKey: CIVector(cgRect: extent) + ]), + let outputImage = filter.outputImage else { return nil } + + var bitmap = [UInt8](repeating: 0, count: 4) + ciContext.render(outputImage, toBitmap: &bitmap, rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, colorSpace: nil) + return UIColor(red: CGFloat(bitmap[0]) / 255.0, green: CGFloat(bitmap[1]) / 255.0, + blue: CGFloat(bitmap[2]) / 255.0, alpha: 1.0) } var body: some View { @@ -121,4 +149,22 @@ private struct CallGradientPalette { let shiftedHue = (h + 25.0 / 360.0).truncatingRemainder(dividingBy: 1.0) shifted = Color(hue: shiftedHue, saturation: s, brightness: br * 0.7) } + + /// Palette derived from avatar photo's dominant color. + /// Boosts saturation since CIAreaAverage returns muted averages. + init(baseColor: UIColor) { + var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0 + baseColor.getHue(&h, saturation: &s, brightness: &br, alpha: &a) + + let sat = min(s * 1.4, 1.0) + let bri = min(br * 1.1, 1.0) + let boosted = UIColor(hue: h, saturation: sat, brightness: bri, alpha: 1) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0 + boosted.getRed(&r, green: &g, blue: &b, alpha: &a) + + primary = Color(red: r, green: g, blue: b) + dark = Color(red: r * 0.27, green: g * 0.27, blue: b * 0.27) + let shiftedHue = (h + 25.0 / 360.0).truncatingRemainder(dividingBy: 1.0) + shifted = Color(hue: shiftedHue, saturation: sat, brightness: bri * 0.7) + } } diff --git a/Rosetta/Features/Calls/ActiveCallOverlayView.swift b/Rosetta/Features/Calls/ActiveCallOverlayView.swift index 489bdf4..bdc73bd 100644 --- a/Rosetta/Features/Calls/ActiveCallOverlayView.swift +++ b/Rosetta/Features/Calls/ActiveCallOverlayView.swift @@ -31,8 +31,8 @@ struct ActiveCallOverlayView: View { var body: some View { ZStack { - // Animated gradient background - CallGradientBackground(colorIndex: peerColorIndex) + // Animated gradient background (uses avatar photo colors when available) + CallGradientBackground(colorIndex: peerColorIndex, peerAvatar: peerAvatar) // Content VStack(spacing: 0) { @@ -72,6 +72,10 @@ struct ActiveCallOverlayView: View { .offset(y: dragOffset) .gesture(minimizeGesture) .statusBarHidden(true) + .onAppear { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } } // MARK: - Top Bar @@ -165,13 +169,24 @@ struct ActiveCallOverlayView: View { // Minimize if dragged > 150pt or velocity > 300pt/s if dy > 150 || velocity > 300 { + print("[CallBar] Swipe-to-minimize: dy=\(Int(dy)) velocity=\(Int(velocity))") withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { dragOffset = UIScreen.main.bounds.height } - Task { @MainActor in + callManager.pendingMinimizeTask?.cancel() + callManager.pendingMinimizeTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.4)) - callManager.minimizeCall() - dragOffset = 0 + guard !Task.isCancelled else { + print("[CallBar] Swipe minimize Task cancelled (user expanded)") + return + } + print("[CallBar] Swipe: setting isMinimized=true (phase=\(callManager.uiState.phase.rawValue))") + // Animate isMinimized for MinimizedCallBar entrance transition. + // Don't reset dragOffset — overlay is off-screen, and @State + // resets to 0 when the view is re-inserted on expandCall(). + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + callManager.uiState.isMinimized = true + } } } else { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { diff --git a/Rosetta/Features/Calls/MinimizedCallBar.swift b/Rosetta/Features/Calls/MinimizedCallBar.swift index 69926ab..a5750e0 100644 --- a/Rosetta/Features/Calls/MinimizedCallBar.swift +++ b/Rosetta/Features/Calls/MinimizedCallBar.swift @@ -68,12 +68,14 @@ struct MinimizedCallBar: View { .font(.system(size: 13, weight: .regular).monospacedDigit()) } .foregroundStyle(.white) - .frame(height: 36) + .frame(height: 28) .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { callManager.expandCall() } + // Block swipe gestures — bar responds to taps only. + .highPriorityGesture(DragGesture()) .background( LinearGradient( colors: gradientColors, diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index e3f877b..13426be 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -55,6 +55,17 @@ struct MainTabView: View { initialScreen: screen ) } + // Minimized call bar pushes tab content down. + // Applied HERE (not on outer ZStack) so the full-screen overlay + // is NOT affected — prevents jerk when overlay transitions out. + .safeAreaInset(edge: .top, spacing: 0) { + if callManager.uiState.isVisible && callManager.uiState.isMinimized { + MinimizedCallBar(callManager: callManager) + .transition(.move(edge: .top).combined(with: .opacity)) + } else { + EmptyView() + } + } // Full-screen device verification overlay (observation-isolated). // Covers nav bar, search bar, and tab bar — desktop parity. @@ -65,25 +76,23 @@ struct MainTabView: View { // never observes the dialogs dictionary directly. UnreadCountObserver(count: $cachedUnreadCount) - // Full-screen call overlay + // Full-screen call overlay — OUTSIDE .safeAreaInset scope. // Animation driven by withAnimation in CallManager methods — // no .animation() modifiers here to avoid NavigationStack conflicts. Group { if callManager.uiState.isFullScreenVisible { ActiveCallOverlayView(callManager: callManager) - .transition(.move(edge: .bottom).combined(with: .opacity)) + // Asymmetric: slide-in from bottom on appear, + // fade-only on removal to avoid conflict with dragOffset + // (swipe already moved the view off-screen). + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .opacity + )) } } .zIndex(10) } - .safeAreaInset(edge: .top, spacing: 0) { - if callManager.uiState.isVisible && callManager.uiState.isMinimized { - MinimizedCallBar(callManager: callManager) - .transition(.move(edge: .top).combined(with: .opacity)) - } else { - EmptyView() - } - } // Switch to Chats tab when user taps a push notification. // Without this, the navigation happens in the Chats NavigationStack // but the user stays on whatever tab they were on (e.g., Settings). diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 299899e..cc8162f 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -428,7 +428,9 @@ struct RosettaApp: App { } // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use. + #if DEBUG DebugPerformanceBenchmarks.runIfRequested() + #endif } @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @@ -527,6 +529,7 @@ struct RosettaApp: App { private func handleDeepLink(_ url: URL) { guard url.scheme == "rosetta" else { return } if url.host == "call" && url.path == "/end" { + print("[CallBar] Deep link rosetta://call/end → endCall()") CallManager.shared.endCall() } }