Фикс: 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_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -629,7 +629,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.6; MARKETING_VERSION = 1.2.7;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -653,7 +653,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -669,7 +669,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.6; MARKETING_VERSION = 1.2.7;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -849,7 +849,7 @@
LA00000082F8D22220092AD05 /* Release */, LA00000082F8D22220092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */

View File

@@ -83,6 +83,12 @@ extension CallManager {
} }
func finishCall(reason: String?, notifyPeer: Bool) { 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() cancelRingTimeout()
endLiveActivity() endLiveActivity()
let wasActive = uiState.phase == .active let wasActive = uiState.phase == .active
@@ -456,7 +462,10 @@ extension CallManager: RTCPeerConnectionDelegate {
return return
} }
if newState == .failed || newState == .closed || newState == .disconnected { 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 durationTask: Task<Void, Never>?
var ringTimeoutTask: Task<Void, Never>? var ringTimeoutTask: Task<Void, Never>?
var pendingMinimizeTask: Task<Void, Never>?
var liveActivity: Activity<CallActivityAttributes>? var liveActivity: Activity<CallActivityAttributes>?
private override init() { private override init() {
@@ -113,6 +114,7 @@ final class CallManager: NSObject, ObservableObject {
} }
func declineIncomingCall() { func declineIncomingCall() {
print("[CallBar] declineIncomingCall() — phase=\(uiState.phase.rawValue)")
guard uiState.phase == .incoming else { return } guard uiState.phase == .incoming else { return }
if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false { if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false {
ProtocolManager.shared.sendCallSignal( ProtocolManager.shared.sendCallSignal(
@@ -125,6 +127,7 @@ final class CallManager: NSObject, ObservableObject {
} }
func endCall() { func endCall() {
print("[CallBar] endCall() — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
finishCall(reason: nil, notifyPeer: true) finishCall(reason: nil, notifyPeer: true)
} }
@@ -146,13 +149,19 @@ final class CallManager: NSObject, ObservableObject {
func minimizeCall() { func minimizeCall() {
guard uiState.isVisible else { return } guard uiState.isVisible else { return }
pendingMinimizeTask?.cancel()
pendingMinimizeTask = nil
print("[CallBar] minimizeCall() — phase=\(uiState.phase.rawValue)")
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
uiState.isMinimized = true uiState.isMinimized = true
} }
} }
func expandCall() { func expandCall() {
pendingMinimizeTask?.cancel()
pendingMinimizeTask = nil
guard uiState.isVisible else { return } guard uiState.isVisible else { return }
print("[CallBar] expandCall() — phase=\(uiState.phase.rawValue)")
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
uiState.isMinimized = false uiState.isMinimized = false
} }
@@ -179,6 +188,7 @@ final class CallManager: NSObject, ObservableObject {
} }
private func handleSignalPacket(_ packet: PacketSignalPeer) { private func handleSignalPacket(_ packet: PacketSignalPeer) {
print("[CallBar] handleSignalPacket: type=\(packet.signalType) phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
switch packet.signalType { switch packet.signalType {
case .endCallBecauseBusy: case .endCallBecauseBusy:
finishCall(reason: "User is busy", notifyPeer: false) finishCall(reason: "User is busy", notifyPeer: false)
@@ -436,10 +446,12 @@ final class CallManager: NSObject, ObservableObject {
} }
func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) { func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) {
print("[CallBar] ICE state changed: \(state.rawValue) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
switch state { switch state {
case .connected, .completed: case .connected, .completed:
setCallActiveIfNeeded() setCallActiveIfNeeded()
case .failed, .closed, .disconnected: case .failed, .closed, .disconnected:
print("[CallBar] ICE \(state.rawValue) → finishCall()")
finishCall(reason: "Connection lost", notifyPeer: false) finishCall(reason: "Connection lost", notifyPeer: false)
default: default:
break break

View File

@@ -11,17 +11,17 @@ enum ReleaseNotes {
Entry( Entry(
version: appVersion, version: appVersion,
body: """ 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() { func applyInset() {
guard topInset != lastAppliedInset else { return } guard topInset != lastAppliedInset else { return }
guard let window, let rootVC = window.rootViewController 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 lastAppliedInset = topInset
} }

View File

@@ -1,17 +1,45 @@
import SwiftUI import SwiftUI
import CoreImage
// MARK: - Animated Call Gradient Background // 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. /// Uses Canvas + TimelineView for GPU-composited rendering at 30fps.
/// When `peerAvatar` is provided, extracts dominant color from the photo.
struct CallGradientBackground: View { struct CallGradientBackground: View {
let colorIndex: Int let colorIndex: Int
private let palette: CallGradientPalette private let palette: CallGradientPalette
private let baseColor = Color(hex: 0x0D0D14) private let baseColor = Color(hex: 0x0D0D14)
init(colorIndex: Int) { init(colorIndex: Int, peerAvatar: UIImage? = nil) {
self.colorIndex = colorIndex 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 { var body: some View {
@@ -121,4 +149,22 @@ private struct CallGradientPalette {
let shiftedHue = (h + 25.0 / 360.0).truncatingRemainder(dividingBy: 1.0) let shiftedHue = (h + 25.0 / 360.0).truncatingRemainder(dividingBy: 1.0)
shifted = Color(hue: shiftedHue, saturation: s, brightness: br * 0.7) 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 { var body: some View {
ZStack { ZStack {
// Animated gradient background // Animated gradient background (uses avatar photo colors when available)
CallGradientBackground(colorIndex: peerColorIndex) CallGradientBackground(colorIndex: peerColorIndex, peerAvatar: peerAvatar)
// Content // Content
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -72,6 +72,10 @@ struct ActiveCallOverlayView: View {
.offset(y: dragOffset) .offset(y: dragOffset)
.gesture(minimizeGesture) .gesture(minimizeGesture)
.statusBarHidden(true) .statusBarHidden(true)
.onAppear {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
} }
// MARK: - Top Bar // MARK: - Top Bar
@@ -165,13 +169,24 @@ struct ActiveCallOverlayView: View {
// Minimize if dragged > 150pt or velocity > 300pt/s // Minimize if dragged > 150pt or velocity > 300pt/s
if dy > 150 || velocity > 300 { if dy > 150 || velocity > 300 {
print("[CallBar] Swipe-to-minimize: dy=\(Int(dy)) velocity=\(Int(velocity))")
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
dragOffset = UIScreen.main.bounds.height dragOffset = UIScreen.main.bounds.height
} }
Task { @MainActor in callManager.pendingMinimizeTask?.cancel()
callManager.pendingMinimizeTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(0.4)) try? await Task.sleep(for: .seconds(0.4))
callManager.minimizeCall() guard !Task.isCancelled else {
dragOffset = 0 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 { } else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {

View File

@@ -68,12 +68,14 @@ struct MinimizedCallBar: View {
.font(.system(size: 13, weight: .regular).monospacedDigit()) .font(.system(size: 13, weight: .regular).monospacedDigit())
} }
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(height: 36) .frame(height: 28)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
callManager.expandCall() callManager.expandCall()
} }
// Block swipe gestures bar responds to taps only.
.highPriorityGesture(DragGesture())
.background( .background(
LinearGradient( LinearGradient(
colors: gradientColors, colors: gradientColors,

View File

@@ -55,6 +55,17 @@ struct MainTabView: View {
initialScreen: screen 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). // Full-screen device verification overlay (observation-isolated).
// Covers nav bar, search bar, and tab bar desktop parity. // Covers nav bar, search bar, and tab bar desktop parity.
@@ -65,25 +76,23 @@ struct MainTabView: View {
// never observes the dialogs dictionary directly. // never observes the dialogs dictionary directly.
UnreadCountObserver(count: $cachedUnreadCount) UnreadCountObserver(count: $cachedUnreadCount)
// Full-screen call overlay // Full-screen call overlay OUTSIDE .safeAreaInset scope.
// Animation driven by withAnimation in CallManager methods // Animation driven by withAnimation in CallManager methods
// no .animation() modifiers here to avoid NavigationStack conflicts. // no .animation() modifiers here to avoid NavigationStack conflicts.
Group { Group {
if callManager.uiState.isFullScreenVisible { if callManager.uiState.isFullScreenVisible {
ActiveCallOverlayView(callManager: callManager) 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) .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. // Switch to Chats tab when user taps a push notification.
// Without this, the navigation happens in the Chats NavigationStack // Without this, the navigation happens in the Chats NavigationStack
// but the user stays on whatever tab they were on (e.g., Settings). // 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. // Avoid heavy startup work on MainActor; Lottie assets load lazily on first use.
#if DEBUG
DebugPerformanceBenchmarks.runIfRequested() DebugPerformanceBenchmarks.runIfRequested()
#endif
} }
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@@ -527,6 +529,7 @@ struct RosettaApp: App {
private func handleDeepLink(_ url: URL) { private func handleDeepLink(_ url: URL) {
guard url.scheme == "rosetta" else { return } guard url.scheme == "rosetta" else { return }
if url.host == "call" && url.path == "/end" { if url.host == "call" && url.path == "/end" {
print("[CallBar] Deep link rosetta://call/end → endCall()")
CallManager.shared.endCall() CallManager.shared.endCall()
} }
} }