Files
mobile-ios/Rosetta/Features/Calls/ActiveCallOverlayView.swift

189 lines
5.9 KiB
Swift

import SwiftUI
struct ActiveCallOverlayView: View {
@ObservedObject var callManager: CallManager
private var state: CallUiState {
callManager.uiState
}
private var durationText: String {
let duration = max(state.durationSec, 0)
let minutes = duration / 60
let seconds = duration % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var peerInitials: String {
let name = state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle
guard !name.isEmpty else { return "?" }
let parts = name.split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
}
return String(name.prefix(2)).uppercased()
}
private var peerColorIndex: Int {
guard !state.peerPublicKey.isEmpty else { return 0 }
return abs(state.peerPublicKey.hashValue) % 7
}
var body: some View {
ZStack {
Color.black.opacity(0.7)
.ignoresSafeArea()
VStack(spacing: 20) {
avatarSection
Text(state.displayName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
if state.phase == .active {
Text(durationText)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Color.white.opacity(0.85))
} else {
Text(statusText(for: state.phase))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Color.white.opacity(0.85))
}
controls
}
.padding(28)
.frame(maxWidth: 360)
.background(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(Color.black.opacity(0.62))
)
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 1)
)
.padding(.horizontal, 24)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
@ViewBuilder
private var avatarSection: some View {
ZStack {
if state.phase != .active {
PulsingRings()
}
AvatarView(
initials: peerInitials,
colorIndex: peerColorIndex,
size: 90
)
}
.frame(width: 130, height: 130)
}
@ViewBuilder
private var controls: some View {
if state.phase == .incoming {
HStack(spacing: 16) {
callActionButton(
title: "Decline",
icon: "phone.down.fill",
color: RosettaColors.error
) {
callManager.declineIncomingCall()
}
callActionButton(
title: "Accept",
icon: "phone.fill",
color: RosettaColors.success
) {
_ = callManager.acceptIncomingCall()
}
}
} else {
HStack(spacing: 16) {
callActionButton(
title: state.isMuted ? "Unmute" : "Mute",
icon: state.isMuted ? "mic.slash.fill" : "mic.fill",
color: Color.white.opacity(0.18)
) {
callManager.toggleMute()
}
callActionButton(
title: state.isSpeakerOn ? "Earpiece" : "Speaker",
icon: state.isSpeakerOn ? "speaker.slash.fill" : "speaker.wave.2.fill",
color: Color.white.opacity(0.18)
) {
callManager.toggleSpeaker()
}
callActionButton(
title: "End",
icon: "phone.down.fill",
color: RosettaColors.error
) {
callManager.endCall()
}
}
}
}
@ViewBuilder
private func callActionButton(
title: String,
icon: String,
color: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 52, height: 52)
.background(Circle().fill(color))
Text(title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.92))
}
}
.buttonStyle(.plain)
}
private func statusText(for phase: CallPhase) -> String {
switch phase {
case .incoming: return "Incoming call"
case .outgoing: return "Calling..."
case .keyExchange: return "Exchanging keys..."
case .webRtcExchange: return "Connecting..."
case .active: return "Active"
case .ended: return "Ended"
case .idle: return ""
}
}
}
private struct PulsingRings: View {
@State private var animate = false
var body: some View {
ZStack {
ForEach(0..<3, id: \.self) { index in
Circle()
.stroke(Color.white.opacity(0.08 - Double(index) * 0.02), lineWidth: 1.5)
.scaleEffect(animate ? 1.0 + CGFloat(index + 1) * 0.12 : 1.0)
.opacity(animate ? 0.0 : 0.6)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}