Фикс: race condition свайп-минимизации call bar + асимметричный transition оверлея
This commit is contained in:
@@ -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 */
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 сообщений. Клавиатура синхронизирована с контентом.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user