Files
Luma/Sources/Shared/UI/CallView.swift
T
wt cbb820a86e
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
migrate persistence from JSON to SwiftData
Models:
- Convert Conversation and ChatMessage to SwiftData @Model classes with
  a
  one-to-many relationship and cascade delete.
- Rename ChatMessage.id to clientID and drop Conversation.id in favor of
  jid
  (the string id would clash with PersistentIdentifier).
- Keep MessageReaction as a Codable value stored in an array attribute.

Persistence:
- Add ArchiveStore, a per-account ModelContainer/ModelContext that
  replaces
  the JSON ChatArchive with load/save/erase.
- Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor,
  locally
  deleted message IDs and roster contact JIDs.
- Add LegacyArchiveImporter to import the old JSON snapshot once and
  delete
  the file afterwards, preserving existing history.

AppModel:
- Replace ChatArchive with ArchiveStore and persist via context.save();
  insert new models and delete the replaced object on upsert merges so
  SwiftData never keeps an orphaned duplicate.

Tests:
- Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and
  legacy import) and drop the now-obsolete Codable round-trip
  assertions.
2026-08-18 22:58:18 +07:00

244 lines
8.4 KiB
Swift

import Foundation
import SwiftUI
import WebRTC
@MainActor
struct CallView: View {
@ObservedObject var model: AppModel
let call: CallSnapshot
private var conversation: Conversation {
model.conversations.first(where: { $0.jid == call.peerJID.lowercased() })
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
}
private var remoteTrack: RTCVideoTrack? {
model.remoteCallVideoTrack(for: call.id)
}
private var localTrack: RTCVideoTrack? {
model.localCallVideoTrack(for: call.id)
}
var body: some View {
ZStack {
background
VStack(spacing: 24) {
callHeader
.padding(.top, 22)
Spacer(minLength: 12)
if remoteTrack == nil {
contactIdentity
}
Spacer(minLength: 20)
controls
.padding(.bottom, 28)
}
.padding(.horizontal, 24)
if call.isVideoCall, let localTrack, call.isCameraEnabled {
RTCVideoRendererView(track: localTrack, mirrored: true)
.frame(width: 118, height: 168)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(.white.opacity(0.24), lineWidth: 1)
}
.shadow(color: .black.opacity(0.38), radius: 16, y: 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding(.top, 92)
.padding(.trailing, 18)
}
}
.foregroundStyle(.white)
.accessibilityElement(children: .contain)
}
@ViewBuilder
private var background: some View {
if call.isVideoCall, let remoteTrack {
RTCVideoRendererView(track: remoteTrack)
.ignoresSafeArea()
.overlay {
LinearGradient(
colors: [
.black.opacity(0.48),
.clear,
.black.opacity(0.62)
],
startPoint: .top,
endPoint: .bottom
)
}
} else {
LinearGradient(
colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Circle()
.fill(Color.accentColor.opacity(0.24))
.frame(width: 440, height: 440)
.blur(radius: 90)
.offset(x: 170, y: -260)
}
.ignoresSafeArea()
}
}
private var callHeader: some View {
VStack(spacing: 5) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(statusText(at: context.date))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.white.opacity(0.72))
}
}
.frame(maxWidth: .infinity)
}
private var contactIdentity: some View {
VStack(spacing: 18) {
AvatarView(
conversation: conversation,
imageData: model.avatarData(for: call.peerJID),
size: 132
)
.overlay {
Circle().stroke(.white.opacity(0.14), lineWidth: 1)
}
.shadow(color: .black.opacity(0.32), radius: 28, y: 14)
Text(conversation.displayName)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.lineLimit(2)
Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.66))
}
}
@ViewBuilder
private var controls: some View {
if call.isIncomingRinging {
HStack(spacing: 52) {
CallControlButton(
title: "Отклонить",
systemImage: "phone.down.fill",
color: .red,
action: model.rejectCall
)
CallControlButton(
title: "Ответить",
systemImage: call.isVideoCall ? "video.fill" : "phone.fill",
color: .green
) {
Task { await model.answerCall() }
}
}
} else {
VStack(spacing: 22) {
HStack(spacing: call.isVideoCall ? 10 : 24) {
CallControlButton(
title: call.isMuted ? "Включить" : "Микрофон",
systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill",
color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallMuted(!call.isMuted)
}
#if os(iOS)
CallControlButton(
title: "Динамик",
systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill",
color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallSpeakerEnabled(!call.isSpeakerEnabled)
}
#endif
if call.isVideoCall {
CallControlButton(
title: call.isCameraEnabled ? "Камера" : "Включить",
systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill",
color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34)
) {
model.setCallCameraEnabled(!call.isCameraEnabled)
}
CallControlButton(
title: "Повернуть",
systemImage: "camera.rotate.fill",
color: .white.opacity(0.18),
action: model.switchCallCamera
)
}
}
CallControlButton(
title: "Завершить",
systemImage: "phone.down.fill",
color: .red,
action: model.endCall
)
}
}
}
private func statusText(at date: Date) -> String {
switch call.phase {
case .ringing:
return call.direction == .incoming ? "Входящий звонок" : "Вызов…"
case .connecting:
return "Соединение…"
case .connected:
guard let connectedAt = call.connectedAt else { return "Соединено" }
let total = max(0, Int(date.timeIntervalSince(connectedAt)))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
}
private struct CallControlButton: View {
let title: String
let systemImage: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 58, height: 58)
.overlay {
Image(systemName: systemImage)
.font(.system(size: 24, weight: .semibold))
}
Text(title)
.font(.caption)
.lineLimit(1)
}
.frame(minWidth: 58)
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.accessibilityLabel(title)
}
}