Фикс: race condition свайп-минимизации call bar + асимметричный transition оверлея

This commit is contained in:
2026-03-30 20:18:43 +05:00
parent f24f7ee555
commit dcefce7cd5
10 changed files with 139 additions and 34 deletions

View File

@@ -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 */

View File

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

View File

@@ -39,6 +39,7 @@ final class CallManager: NSObject, ObservableObject {
var durationTask: Task<Void, Never>?
var ringTimeoutTask: Task<Void, Never>?
var pendingMinimizeTask: Task<Void, Never>?
var liveActivity: Activity<CallActivityAttributes>?
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

View File

@@ -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 сообщений. Клавиатура синхронизирована с контентом.
"""
)
]

View File

@@ -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
}

View File

@@ -1,18 +1,46 @@
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
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 {
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { timeline in
@@ -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)
}
}

View File

@@ -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)) {

View File

@@ -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,

View File

@@ -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).

View File

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