From 86a400b543ea2cb53413588e6abf81050b0d7cdb Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sat, 11 Apr 2026 21:45:19 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20UI,=20Settings=20=D0=BD=D0=B0=20UIKit,=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Layout/MessageCellLayout.swift | 35 +- Rosetta/Core/Services/AudioRecorder.swift | 39 +- Rosetta/Core/Services/SessionManager.swift | 10 +- .../Core/Services/VoiceMessagePlayer.swift | 141 ++++ .../Components/RosettaTabBar.swift | 6 +- .../Components/WaveformView.swift | 151 ++++ .../Chats/ChatDetail/ChatDetailView.swift | 30 +- .../Chats/ChatDetail/ComposerView.swift | 520 +++++++++++-- .../Chats/ChatDetail/MessageVoiceView.swift | 237 ++++++ .../Chats/ChatDetail/NativeMessageCell.swift | 60 ++ .../Chats/ChatDetail/NativeMessageList.swift | 91 ++- .../Chats/ChatDetail/PendingAttachment.swift | 9 +- .../Chats/ChatDetail/RecordingLockView.swift | 228 ++++++ .../Chats/ChatDetail/RecordingMicButton.swift | 92 ++- .../ChatDetail/RecordingPreviewPanel.swift | 406 +++++++++++ .../ChatDetail/VoiceRecordingFlowTypes.swift | 23 + .../ChatDetail/VoiceRecordingOverlay.swift | 198 ++++- .../ChatDetail/VoiceRecordingPanel.swift | 152 +++- Rosetta/Features/MainTabView.swift | 4 +- .../Settings/SettingsProfileHeader.swift | 2 +- Rosetta/Features/Settings/SettingsView.swift | 108 +-- .../Settings/SettingsViewController.swift | 681 ++++++++++++++++++ 22 files changed, 3021 insertions(+), 202 deletions(-) create mode 100644 Rosetta/Core/Services/VoiceMessagePlayer.swift create mode 100644 Rosetta/DesignSystem/Components/WaveformView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift create mode 100644 Rosetta/Features/Settings/SettingsViewController.swift diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 3ceb1a5..83679fd 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -121,6 +121,8 @@ extension MessageCellLayout { let fileCount: Int let avatarCount: Int let callCount: Int + let voiceCount: Int + let voiceDuration: TimeInterval let isForward: Bool let forwardImageCount: Int let forwardFileCount: Int @@ -175,7 +177,7 @@ extension MessageCellLayout { // Pre-check for emojiOnly to choose font size (40pt vs 17pt). let isEmojiOnlyPrecheck = !config.text.isEmpty && config.imageCount == 0 && config.fileCount == 0 - && config.avatarCount == 0 && config.callCount == 0 + && config.avatarCount == 0 && config.callCount == 0 && config.voiceCount == 0 && !config.isForward && !config.hasReplyQuote && EmojiParser.isEmojiOnly(config.text) // Telegram: messageEmojiFont = Font.regular(53.0) (ChatPresentationData.swift line 58) @@ -197,7 +199,7 @@ extension MessageCellLayout { messageType = .photoWithCaption } else if config.imageCount > 0 { messageType = .photo - } else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 { + } else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 || config.voiceCount > 0 { messageType = .file } else if config.groupInviteCount > 0 { messageType = .groupInvite @@ -337,6 +339,7 @@ extension MessageCellLayout { var fileH: CGFloat = CGFloat(config.fileCount) * 52 + CGFloat(config.callCount) * 42 + CGFloat(config.avatarCount) * 52 + + CGFloat(config.voiceCount) * 38 // Tiny floor just to prevent zero-width collapse. // Telegram does NOT force a large minW β€” short messages get tight bubbles. @@ -471,7 +474,16 @@ extension MessageCellLayout { } else if fileH > 0 { // Telegram: call width = title + button(54) + insets β‰ˆ 200pt // Telegram: file width = icon(55) + filename + insets β‰ˆ 220pt - let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220 + let fileMinW: CGFloat + if config.voiceCount > 0 { + // Telegram: voice width scales with duration (2-30s range, 120-maxW) + let minVoiceW: CGFloat = 120 + let maxVoiceW = effectiveMaxBubbleWidth - 36 + let clampedDur = max(2, min(30, config.voiceDuration)) + fileMinW = minVoiceW + (maxVoiceW - minVoiceW) * CGFloat(clampedDur - 2) / CGFloat(30 - 2) + } else { + fileMinW = config.callCount > 0 ? 200 : 220 + } bubbleW = min(fileMinW, effectiveMaxBubbleWidth) bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad) // Symmetric centering: content + gap + timestamp block centered in bubble. @@ -479,7 +491,7 @@ extension MessageCellLayout { // To achieve visual symmetry, fileH spans the ENTIRE bubble // and metadataBottomInset = (fileH - contentH) / 2 (same as content topY). let tsGap: CGFloat = 6 - let contentH: CGFloat = config.callCount > 0 ? 36 : 44 + let contentH: CGFloat = config.callCount > 0 ? 36 : (config.voiceCount > 0 ? 38 : 44) let tsPad = ceil((fileH + tsGap - contentH) / 2) fileOnlyTsPad = tsPad bubbleH += tsGap + tsSize.height + tsPad @@ -706,7 +718,7 @@ extension MessageCellLayout { hasPhoto: config.imageCount > 0, photoFrame: photoFrame, photoCollageHeight: photoH, - hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0, + hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 || config.voiceCount > 0, fileFrame: fileFrame, hasGroupInvite: config.groupInviteCount > 0, groupInviteTitle: config.groupInviteTitle, @@ -857,7 +869,7 @@ extension MessageCellLayout { if hasImage { return .media } - let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar || $0.type == .call } + let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar || $0.type == .call || $0.type == .voice } if hasFileLike { return .file } @@ -1017,6 +1029,15 @@ extension MessageCellLayout { let files = message.attachments.filter { $0.type == .file } let avatars = message.attachments.filter { $0.type == .avatar } let calls = message.attachments.filter { $0.type == .call } + let voices = message.attachments.filter { $0.type == .voice } + let voiceDuration: TimeInterval = { + guard let preview = voices.first?.preview else { return 0 } + let parts = preview.components(separatedBy: "::") + if parts.count >= 3, let dur = Int(parts[1]) { return TimeInterval(dur) } + if parts.count >= 2, let dur = Int(parts[0]) { return TimeInterval(dur) } + if let dur = Int(parts[0]) { return TimeInterval(dur) } + return 0 + }() let hasReply = message.attachments.contains { $0.type == .messages } let isForward = hasReply && displayText.isEmpty @@ -1069,6 +1090,8 @@ extension MessageCellLayout { fileCount: files.count, avatarCount: avatars.count, callCount: calls.count, + voiceCount: voices.count, + voiceDuration: voiceDuration, isForward: isForward, forwardImageCount: forwardInnerImageCount, forwardFileCount: forwardInnerFileCount, diff --git a/Rosetta/Core/Services/AudioRecorder.swift b/Rosetta/Core/Services/AudioRecorder.swift index 25e67d4..4b63fc1 100644 --- a/Rosetta/Core/Services/AudioRecorder.swift +++ b/Rosetta/Core/Services/AudioRecorder.swift @@ -8,6 +8,7 @@ import os enum AudioRecordingState: Sendable { case idle case recording(duration: TimeInterval, micLevel: Float) + case paused(url: URL, duration: TimeInterval, waveform: [Float]) case finished(url: URL, duration: TimeInterval, waveform: [Float]) } @@ -86,7 +87,7 @@ final class AudioRecorder: NSObject { } func stopRecording() { - guard let rec = recorder, rec.isRecording else { return } + guard let rec = recorder else { return } let duration = rec.currentTime rec.stop() stopDisplayLink() @@ -97,6 +98,42 @@ final class AudioRecorder: NSObject { recorder = nil } + /// Pauses recording without losing the current file/waveform. + /// Used by preview flow (`lock -> stop -> preview -> record more`). + @discardableResult + func pauseRecordingForPreview() -> (url: URL, duration: TimeInterval, waveform: [Float])? { + guard let rec = recorder, rec.isRecording else { return nil } + rec.pause() + stopDisplayLink() + let snapshot = (url: rec.url, duration: rec.currentTime, waveform: waveformSamples) + state = .paused(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform) + return snapshot + } + + @discardableResult + func resumeRecording() -> Bool { + guard let rec = recorder else { return false } + guard case .paused = state else { return false } + guard rec.record() else { return false } + state = .recording(duration: rec.currentTime, micLevel: micLevel) + startDisplayLink() + return true + } + + func currentRecordingSnapshot() -> (url: URL, duration: TimeInterval, waveform: [Float])? { + if let rec = recorder { + return (url: rec.url, duration: rec.currentTime, waveform: waveformSamples) + } + switch state { + case .paused(let url, let duration, let waveform): + return (url: url, duration: duration, waveform: waveform) + case .finished(let url, let duration, let waveform): + return (url: url, duration: duration, waveform: waveform) + default: + return nil + } + } + func cancelRecording() { guard let rec = recorder else { reset(); return } let url = rec.url diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 607090d..c0bc664 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -570,7 +570,7 @@ final class SessionManager { toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "" - ) async throws { + ) async throws -> String { guard let privKey = privateKeyHex, let hash = privateKeyHash else { Self.logger.error("πŸ“€ Cannot send β€” missing keys") throw CryptoError.decryptionFailed @@ -677,6 +677,9 @@ final class SessionManager { previewSuffix = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" case .file: previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")" + case .voice: + // Voice preview: "duration::waveform_base64" + previewSuffix = attachment.voicePreview ?? "" default: previewSuffix = "" } @@ -694,7 +697,7 @@ final class SessionManager { for item in encryptedAttachments { if item.original.type == .image, let image = UIImage(data: item.original.data) { AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id) - } else if item.original.type == .file { + } else if item.original.type == .file || item.original.type == .voice { AttachmentCache.shared.saveFile( item.original.data, forAttachmentId: item.original.id, @@ -763,7 +766,7 @@ final class SessionManager { } MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) - return + return messageId } // ── Phase 2: Upload in background, then send packet ── @@ -819,6 +822,7 @@ final class SessionManager { } MessageRepository.shared.persistNow() Self.logger.info("πŸ“€ Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…") + return messageId } catch { // CDN upload or packet send failed β€” mark as .error to show failure to user. // Note: retryWaitingOutgoingMessagesAfterReconnect() may still pick up .error diff --git a/Rosetta/Core/Services/VoiceMessagePlayer.swift b/Rosetta/Core/Services/VoiceMessagePlayer.swift new file mode 100644 index 0000000..7b8b23a --- /dev/null +++ b/Rosetta/Core/Services/VoiceMessagePlayer.swift @@ -0,0 +1,141 @@ +import AVFAudio +import Combine +import QuartzCore +import os + +// MARK: - VoiceMessagePlayer + +/// Singleton audio player for voice messages in the message list. +/// Only one voice message plays at a time β€” tapping another stops the current. +/// Uses AVAudioPlayer for local file playback with display link for progress. +@MainActor +final class VoiceMessagePlayer: ObservableObject { + + static let shared = VoiceMessagePlayer() + + private let logger = Logger(subsystem: "com.rosetta.messenger", category: "VoicePlayer") + + // MARK: - Published State + + @Published private(set) var currentMessageId: String? + @Published private(set) var isPlaying = false + @Published private(set) var progress: Double = 0 + @Published private(set) var currentTime: TimeInterval = 0 + @Published private(set) var duration: TimeInterval = 0 + + // MARK: - Private + + private var audioPlayer: AVAudioPlayer? + private var displayLink: CADisplayLink? + private var displayLinkTarget: DisplayLinkProxy? + + private init() {} + + // MARK: - Public API + + /// Play a voice message. Stops any currently playing message first. + func play(messageId: String, fileURL: URL) { + // If same message is playing, toggle pause + if currentMessageId == messageId, isPlaying { + pause() + return + } + + // Stop previous playback + stop() + + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + + let player = try AVAudioPlayer(contentsOf: fileURL) + player.prepareToPlay() + player.play() + + audioPlayer = player + currentMessageId = messageId + isPlaying = true + duration = player.duration + startDisplayLink() + + logger.info("[VoicePlayer] Playing \(messageId.prefix(8))") + } catch { + logger.error("[VoicePlayer] Failed: \(error.localizedDescription)") + stop() + } + } + + func pause() { + audioPlayer?.pause() + isPlaying = false + stopDisplayLink() + } + + func resume() { + guard audioPlayer != nil else { return } + audioPlayer?.play() + isPlaying = true + startDisplayLink() + } + + func togglePlayPause() { + if isPlaying { pause() } else { resume() } + } + + func stop() { + audioPlayer?.stop() + audioPlayer = nil + currentMessageId = nil + isPlaying = false + progress = 0 + currentTime = 0 + duration = 0 + stopDisplayLink() + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + /// Seek to a fraction of the duration (0..1). + func seek(to fraction: Double) { + guard let player = audioPlayer else { return } + let target = fraction * player.duration + player.currentTime = target + updateProgress() + } + + // MARK: - Display Link + + private func startDisplayLink() { + guard displayLink == nil else { return } + let proxy = DisplayLinkProxy { [weak self] in self?.updateProgress() } + let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick)) + link.add(to: .main, forMode: .common) + displayLink = link + displayLinkTarget = proxy + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + displayLinkTarget = nil + } + + private func updateProgress() { + guard let player = audioPlayer else { return } + if !player.isPlaying && isPlaying { + // Playback ended + stop() + return + } + currentTime = player.currentTime + progress = player.duration > 0 ? player.currentTime / player.duration : 0 + } +} + +// MARK: - DisplayLinkProxy + +private final class DisplayLinkProxy: NSObject { + let callback: () -> Void + init(_ callback: @escaping () -> Void) { self.callback = callback } + @objc func tick() { callback() } +} diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index aa2cc9f..1bbc953 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -11,7 +11,11 @@ private enum TabBarUIColors { static let selectedText = UIColor(RosettaColors.primaryBlue) static let badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1) static let badgeText = UIColor.white - static let selectionFill = UIColor.white.withAlphaComponent(0.07) + static let selectionFill = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor.white.withAlphaComponent(0.07) + : UIColor.black.withAlphaComponent(0.06) + } } // MARK: - Gesture (Telegram TabSelectionRecognizer) diff --git a/Rosetta/DesignSystem/Components/WaveformView.swift b/Rosetta/DesignSystem/Components/WaveformView.swift new file mode 100644 index 0000000..a8c7c8c --- /dev/null +++ b/Rosetta/DesignSystem/Components/WaveformView.swift @@ -0,0 +1,151 @@ +import QuartzCore +import UIKit + +// MARK: - WaveformView + +/// Renders audio waveform as vertical bars with rounded ellipse caps. +/// Telegram parity from AudioWaveformNode.swift: +/// - Bar width: 2pt, gap: 1pt, peak height: 12pt +/// - Each bar = rect body + top ellipse cap + bottom ellipse cap +/// - Gravity: .center (bars grow from center) or .bottom +final class WaveformView: UIView { + + enum Gravity { case center, bottom } + + // MARK: - Configuration (Telegram exact: AudioWaveformNode lines 96-98) + + private let sampleWidth: CGFloat = 2.0 + private let halfSampleWidth: CGFloat = 1.0 + private let distance: CGFloat = 1.0 + + var peakHeight: CGFloat = 12.0 + var gravity: Gravity = .center + var backgroundColor_: UIColor = UIColor.white.withAlphaComponent(0.3) + var foregroundColor_: UIColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + + // MARK: - State + + private var samples: [Float] = [] + var progress: CGFloat = 0 { + didSet { setNeedsDisplay() } + } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + } + + convenience init( + foregroundColor: UIColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1), + backgroundColor: UIColor = UIColor.white.withAlphaComponent(0.3) + ) { + self.init(frame: .zero) + self.foregroundColor_ = foregroundColor + self.backgroundColor_ = backgroundColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Public + + func setSamples(_ newSamples: [Float]) { + samples = newSamples + setNeedsDisplay() + } + + // MARK: - Drawing (Telegram exact: AudioWaveformNode lines 86-232) + + override func draw(_ rect: CGRect) { + guard !samples.isEmpty else { return } + guard let ctx = UIGraphicsGetCurrentContext() else { return } + + let size = rect.size + let numSamples = Int(floor(size.width / (sampleWidth + distance))) + guard numSamples > 0 else { return } + + let resampled = resample(samples, toCount: numSamples) + + // Telegram: diff = sampleWidth * 1.5 = 3.0 (subtracted from bar height) + let diff: CGFloat = sampleWidth * 1.5 + + let gravityMultiplierY: CGFloat = gravity == .bottom ? 1.0 : 0.5 + + // Draw background bars, then foreground bars on top + for pass in 0..<2 { + let color = pass == 0 ? backgroundColor_ : foregroundColor_ + ctx.setFillColor(color.cgColor) + + for i in 0.. peakHeight { sampleHeight = peakHeight } + + let adjustedSampleHeight = sampleHeight - diff + + if adjustedSampleHeight <= sampleWidth { + // Tiny bar: single dot + small rect (Telegram lines 212-214) + ctx.fillEllipse(in: CGRect( + x: offset, + y: (size.height - sampleWidth) * gravityMultiplierY, + width: sampleWidth, + height: sampleWidth + )) + ctx.fill(CGRect( + x: offset, + y: (size.height - halfSampleWidth) * gravityMultiplierY, + width: sampleWidth, + height: halfSampleWidth + )) + } else { + // Normal bar: rect + top cap + bottom cap (Telegram lines 216-224) + let barRect = CGRect( + x: offset, + y: (size.height - adjustedSampleHeight) * gravityMultiplierY, + width: sampleWidth, + height: adjustedSampleHeight + ) + ctx.fill(barRect) + ctx.fillEllipse(in: CGRect( + x: barRect.minX, + y: barRect.minY - halfSampleWidth, + width: sampleWidth, + height: sampleWidth + )) + ctx.fillEllipse(in: CGRect( + x: barRect.minX, + y: barRect.maxY - halfSampleWidth, + width: sampleWidth, + height: sampleWidth + )) + } + } + } + } + + // MARK: - Resampling (Telegram: max extraction per bin) + + private func resample(_ input: [Float], toCount count: Int) -> [Float] { + guard !input.isEmpty, count > 0 else { return Array(repeating: 0, count: count) } + var result = [Float](repeating: 0, count: count) + let step = Float(input.count) / Float(count) + for i in 0.. some View { - let useComposer: Bool = { - if #available(iOS 26, *) { return false } - return !route.isSystemAccount - }() + let useComposer = !route.isSystemAccount // Reply info for ComposerView let replySender: String? = replyingToMessage.map { senderDisplayName(for: $0.fromPublicKey) } @@ -1502,6 +1485,7 @@ private extension ChatDetailView { case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" + case .voice: return "Voice message" @unknown default: return "Attachment" } } @@ -1745,7 +1729,7 @@ private extension ChatDetailView { do { if !attachments.isEmpty { // Send message with attachments - try await SessionManager.shared.sendMessageWithAttachments( + _ = try await SessionManager.shared.sendMessageWithAttachments( text: message, attachments: attachments, toPublicKey: route.publicKey, diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index efb8d55..7486727 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -1,3 +1,5 @@ +import AVFAudio +@preconcurrency import AVFoundation import UIKit // MARK: - ComposerViewDelegate @@ -103,7 +105,22 @@ final class ComposerView: UIView, UITextViewDelegate { private let audioRecorder = AudioRecorder() private var recordingOverlay: VoiceRecordingOverlay? private var recordingPanel: VoiceRecordingPanel? + private var recordingLockView: RecordingLockView? + private var recordingPreviewPanel: RecordingPreviewPanel? + private var recordingStartTask: Task? + private var recordingSendAccessibilityButton: UIButton? private(set) var isRecording = false + private(set) var isRecordingLocked = false + private(set) var recordingFlowState: VoiceRecordingFlowState = .idle + + // Voice recording result (populated on stopRecording, read by delegate) + private(set) var lastRecordedURL: URL? + private(set) var lastRecordedDuration: TimeInterval = 0 + private(set) var lastRecordedWaveform: [Float] = [] + private(set) var lastVoiceSendTransitionSource: VoiceSendTransitionSource? + + private let minVoiceDuration: TimeInterval = 0.5 + private let minFreeDiskBytes: Int64 = 8 * 1024 * 1024 // MARK: - Init @@ -244,6 +261,9 @@ final class ComposerView: UIView, UITextViewDelegate { micIconLayer = micIcon micButton.tag = 4 micButton.recordingDelegate = self + micButton.isAccessibilityElement = true + micButton.accessibilityLabel = "Voice message" + micButton.accessibilityHint = "Hold to record voice message. Slide left to cancel or up to lock." addSubview(micButton) updateThemeColors() @@ -315,6 +335,15 @@ final class ComposerView: UIView, UITextViewDelegate { } } + func consumeVoiceSendTransitionSource() -> VoiceSendTransitionSource? { + defer { lastVoiceSendTransitionSource = nil } + return lastVoiceSendTransitionSource + } + + private func setRecordingFlowState(_ state: VoiceRecordingFlowState) { + recordingFlowState = state + } + // MARK: - Layout override func layoutSubviews() { @@ -398,6 +427,10 @@ final class ComposerView: UIView, UITextViewDelegate { sendCapsule.layer.cornerRadius = sendButtonHeight / 2 centerIconLayer(in: sendButton, iconSize: CGSize(width: 22, height: 19)) + if recordingSendAccessibilityButton != nil { + updateRecordingSendAccessibilityArea(isEnabled: true) + } + // Report height if abs(totalH - currentHeight) > 0.5 { currentHeight = totalH @@ -605,17 +638,168 @@ final class ComposerView: UIView, UITextViewDelegate { extension ComposerView: RecordingMicButtonDelegate { - func micButtonRecordingBegan(_ button: RecordingMicButton) { - guard audioRecorder.startRecording() else { return } - isRecording = true - guard let window else { return } + func micButtonRecordingArmed(_ button: RecordingMicButton) { + setRecordingFlowState(.armed) + } + + func micButtonRecordingArmingCancelled(_ button: RecordingMicButton) { + if recordingFlowState == .armed { + setRecordingFlowState(.idle) + } + } + + func micButtonRecordingBegan(_ button: RecordingMicButton) { + recordingStartTask?.cancel() + recordingStartTask = Task { @MainActor [weak self] in + guard let self else { return } + guard CallManager.shared.uiState.phase == .idle else { + self.failRecordingStart(for: button) + return + } + guard self.hasSufficientDiskSpaceForRecording() else { + self.failRecordingStart(for: button) + return + } + + let granted = await AudioRecorder.requestMicrophonePermission() + guard !Task.isCancelled else { return } + guard granted else { + self.failRecordingStart(for: button) + return + } + guard button.recordingState == .recording else { return } + guard self.audioRecorder.startRecording() else { + self.failRecordingStart(for: button) + return + } + + self.isRecording = true + self.isRecordingLocked = false + self.setRecordingFlowState(.recordingUnlocked) + self.presentRecordingChrome(locked: false, animatePanel: true) + self.configureRecorderLevelUpdates() + self.delegate?.composerDidStartRecording(self) + } + } + + func micButtonRecordingFinished(_ button: RecordingMicButton) { + guard recordingFlowState == .recordingUnlocked else { + button.resetState() + return + } + finishRecordingAndSend(sourceView: micButton) + button.resetState() + } + + func micButtonRecordingCancelled(_ button: RecordingMicButton) { + cancelRecordingWithDismissAnimation() + button.resetState() + delegate?.composerDidCancelRecording(self) + } + + func micButtonRecordingLocked(_ button: RecordingMicButton) { + guard recordingFlowState == .recordingUnlocked else { return } + isRecordingLocked = true + setRecordingFlowState(.recordingLocked) + + recordingPanel?.showCancelButton() + recordingLockView?.showStopButton { [weak self] in + self?.showRecordingPreview() + } + recordingOverlay?.transitionToLocked(onTapStop: { [weak self] in + self?.showRecordingPreview() + self?.micButton.resetState() + }) + updateRecordingSendAccessibilityArea(isEnabled: true) + + delegate?.composerDidLockRecording(self) + } + + func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat) { + recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY) + recordingPanel?.updateCancelTranslation(distanceX) + let lockness = min(1, max(0, abs(distanceY) / 105)) + recordingLockView?.updateLockness(lockness) + } + + func showRecordingPreview() { + guard recordingFlowState == .recordingLocked || recordingFlowState == .recordingUnlocked else { return } + setRecordingFlowState(.waitingForPreview) + + audioRecorder.onLevelUpdate = nil + let paused = audioRecorder.pauseRecordingForPreview() ?? audioRecorder.currentRecordingSnapshot() + guard let snapshot = paused else { + dismissOverlayAndRestore() + return + } + lastRecordedURL = snapshot.url + lastRecordedDuration = snapshot.duration + lastRecordedWaveform = snapshot.waveform + + if snapshot.duration < minVoiceDuration { + dismissOverlayAndRestore() + return + } + + recordingOverlay?.dismiss() + recordingOverlay = nil + recordingLockView?.dismiss() + recordingLockView = nil + recordingPanel?.animateOut { [weak self] in + self?.recordingPanel = nil + } + updateRecordingSendAccessibilityArea(isEnabled: false) + + guard let url = lastRecordedURL else { return } + let panelX = horizontalPadding + let panelW = micButton.frame.minX - innerSpacing - horizontalPadding + let preview = RecordingPreviewPanel( + frame: CGRect( + x: panelX, + y: inputContainer.frame.origin.y, + width: panelW, + height: inputContainer.frame.height + ), + fileURL: url, + duration: lastRecordedDuration, + waveform: lastRecordedWaveform + ) + preview.delegate = self + addSubview(preview) + preview.animateIn() + recordingPreviewPanel = preview + isRecording = false + isRecordingLocked = false + setRecordingFlowState(.draftPreview) + } + + private func finishRecordingAndSend(sourceView: UIView?) { + audioRecorder.onFinished = { [weak self] url, duration, waveform in + self?.lastRecordedURL = url + self?.lastRecordedDuration = duration + self?.lastRecordedWaveform = waveform + } + audioRecorder.onLevelUpdate = nil + audioRecorder.stopRecording() + + guard lastRecordedDuration >= minVoiceDuration else { + dismissOverlayAndRestore(skipAudioCleanup: true) + return + } + + lastVoiceSendTransitionSource = captureVoiceSendTransition(from: sourceView) + dismissOverlayAndRestore(skipAudioCleanup: true) + delegate?.composerDidFinishRecording(self, sendImmediately: true) + } + + private func presentRecordingChrome(locked: Bool, animatePanel: Bool) { + guard let window else { return } + hideComposerChrome() - // 1. Overlay circles on mic button let overlay = VoiceRecordingOverlay() overlay.present(anchorView: micButton, in: window) recordingOverlay = overlay - // 2. Recording panel (spans full width: attach area to mic button) let panelX = horizontalPadding let panelW = micButton.frame.minX - innerSpacing - horizontalPadding let panel = VoiceRecordingPanel(frame: CGRect( @@ -626,16 +810,32 @@ extension ComposerView: RecordingMicButtonDelegate { )) panel.delegate = self addSubview(panel) - panel.animateIn(panelWidth: panelW) + if animatePanel { + panel.animateIn(panelWidth: panelW) + } + if locked { + panel.showCancelButton() + overlay.transitionToLocked(onTapStop: { [weak self] in + self?.showRecordingPreview() + self?.micButton.resetState() + }) + } else { + let lockView = RecordingLockView(frame: .zero) + let micCenter = convert(micButton.center, to: window) + lockView.present(anchorCenter: micCenter, in: window) + recordingLockView = lockView + } recordingPanel = panel + } - // 3. Feed audio level β†’ overlay + timer + private func configureRecorderLevelUpdates() { audioRecorder.onLevelUpdate = { [weak self] duration, level in self?.recordingOverlay?.addMicLevel(CGFloat(level)) self?.recordingPanel?.updateDuration(duration) } + } - // 4. Hide composer content (Telegram: textInput alphaβ†’0, accessories alphaβ†’0) + private func hideComposerChrome() { UIView.animate(withDuration: 0.15) { self.inputContainer.alpha = 0 self.attachButton.alpha = 0 @@ -644,44 +844,7 @@ extension ComposerView: RecordingMicButtonDelegate { } } - func micButtonRecordingFinished(_ button: RecordingMicButton) { - dismissOverlayAndRestore() - button.resetState() - } - - func micButtonRecordingCancelled(_ button: RecordingMicButton) { - dismissOverlayAndRestore() - button.resetState() - } - - func micButtonRecordingLocked(_ button: RecordingMicButton) { - dismissOverlayAndRestore() - button.resetState() - } - - func micButtonCancelTranslationChanged(_ button: RecordingMicButton, translation: CGFloat) { - let progress = min(1, abs(translation) / 150) - recordingOverlay?.dismissFactor = 1.0 - progress * 0.5 - recordingPanel?.updateCancelTranslation(translation) - } - - func micButtonLockProgressChanged(_ button: RecordingMicButton, progress: CGFloat) { - // Future: lock indicator - } - - private func dismissOverlayAndRestore() { - isRecording = false - audioRecorder.onLevelUpdate = nil - audioRecorder.cancelRecording() - - recordingOverlay?.dismiss() - recordingOverlay = nil - - recordingPanel?.animateOut { [weak self] in - self?.recordingPanel = nil - } - - // Restore composer content + private func restoreComposerChrome() { UIView.animate(withDuration: 0.15) { self.inputContainer.alpha = 1 self.attachButton.alpha = 1 @@ -690,13 +853,274 @@ extension ComposerView: RecordingMicButtonDelegate { } updateSendMicVisibility(animated: false) } + + private func failRecordingStart(for button: RecordingMicButton) { + let feedback = UINotificationFeedbackGenerator() + feedback.notificationOccurred(.warning) + setRecordingFlowState(.idle) + button.resetState() + } + + private func hasSufficientDiskSpaceForRecording() -> Bool { + let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + let keys: Set = [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ] + guard let values = try? home.resourceValues(forKeys: keys) else { return true } + let important = values.volumeAvailableCapacityForImportantUsage ?? Int64.max + let generic = Int64(values.volumeAvailableCapacity ?? Int.max) + let available = min(important, generic) + return available >= minFreeDiskBytes + } + + private func updateRecordingSendAccessibilityArea(isEnabled: Bool) { + if !isEnabled { + recordingSendAccessibilityButton?.removeFromSuperview() + recordingSendAccessibilityButton = nil + return + } + guard let window else { return } + let button: UIButton + if let existing = recordingSendAccessibilityButton { + button = existing + } else { + button = UIButton(type: .custom) + button.backgroundColor = .clear + button.isAccessibilityElement = true + button.accessibilityLabel = "Stop recording" + button.accessibilityHint = "Stops recording and opens voice preview." + button.addTarget(self, action: #selector(accessibilityStopRecordingTapped), for: .touchUpInside) + recordingSendAccessibilityButton = button + window.addSubview(button) + } + let micCenter = convert(micButton.center, to: window) + button.frame = CGRect(x: micCenter.x - 60, y: micCenter.y - 60, width: 120, height: 120) + } + + @objc private func accessibilityStopRecordingTapped() { + showRecordingPreview() + } + + private func cancelRecordingWithDismissAnimation() { + isRecording = false + isRecordingLocked = false + setRecordingFlowState(.idle) + audioRecorder.onLevelUpdate = nil + audioRecorder.cancelRecording() + + recordingOverlay?.dismissCancel() + recordingOverlay = nil + + recordingLockView?.dismiss() + recordingLockView = nil + + recordingPanel?.animateOutCancel { [weak self] in + self?.recordingPanel = nil + } + + recordingPreviewPanel?.animateOut { [weak self] in + self?.recordingPreviewPanel = nil + } + + updateRecordingSendAccessibilityArea(isEnabled: false) + restoreComposerChrome() + } + + private func dismissOverlayAndRestore(skipAudioCleanup: Bool = false) { + isRecording = false + isRecordingLocked = false + setRecordingFlowState(.idle) + recordingStartTask?.cancel() + recordingStartTask = nil + audioRecorder.onLevelUpdate = nil + if !skipAudioCleanup { + audioRecorder.cancelRecording() + } + + recordingOverlay?.dismiss() + recordingOverlay = nil + + recordingLockView?.dismiss() + recordingLockView = nil + + recordingPanel?.animateOut { [weak self] in + self?.recordingPanel = nil + } + + recordingPreviewPanel?.animateOut { [weak self] in + self?.recordingPreviewPanel = nil + } + updateRecordingSendAccessibilityArea(isEnabled: false) + restoreComposerChrome() + } + + private func captureVoiceSendTransition(from sourceView: UIView?) -> VoiceSendTransitionSource? { + guard let sourceView, let window else { return nil } + guard let snapshot = sourceView.snapshotView(afterScreenUpdates: true) else { return nil } + let frame = sourceView.convert(sourceView.bounds, to: window) + snapshot.frame = frame + snapshot.layer.cornerRadius = sourceView.layer.cornerRadius + snapshot.layer.cornerCurve = .continuous + snapshot.clipsToBounds = true + return VoiceSendTransitionSource( + snapshotView: snapshot, + sourceFrameInWindow: frame, + cornerRadius: sourceView.layer.cornerRadius + ) + } + + private func resumeRecordingFromPreview() { + guard audioRecorder.resumeRecording() else { + dismissOverlayAndRestore() + return + } + recordingPreviewPanel?.animateOut { [weak self] in + self?.recordingPreviewPanel = nil + } + isRecording = true + isRecordingLocked = true + setRecordingFlowState(.recordingLocked) + presentRecordingChrome(locked: true, animatePanel: false) + configureRecorderLevelUpdates() + updateRecordingSendAccessibilityArea(isEnabled: true) + } + + private func clampTrimRange(_ trimRange: ClosedRange, duration: TimeInterval) -> ClosedRange { + let lower = max(0, min(trimRange.lowerBound, duration)) + let upper = max(lower, min(trimRange.upperBound, duration)) + return lower...upper + } + + private func trimWaveform( + _ waveform: [Float], + totalDuration: TimeInterval, + trimRange: ClosedRange + ) -> [Float] { + guard !waveform.isEmpty, totalDuration > 0 else { return waveform } + let startIndex = max(0, Int(floor((trimRange.lowerBound / totalDuration) * Double(waveform.count)))) + let endIndex = min(waveform.count, Int(ceil((trimRange.upperBound / totalDuration) * Double(waveform.count)))) + guard startIndex < endIndex else { return waveform } + return Array(waveform[startIndex..) async -> URL? { + let asset = AVURLAsset(url: url) + guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { + return nil + } + final class ExportSessionBox: @unchecked Sendable { + let session: AVAssetExportSession + init(_ session: AVAssetExportSession) { + self.session = session + } + } + let box = ExportSessionBox(export) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("rosetta_voice_trim_\(UUID().uuidString).m4a") + try? FileManager.default.removeItem(at: outputURL) + + box.session.outputURL = outputURL + box.session.outputFileType = .m4a + box.session.timeRange = CMTimeRange( + start: CMTime(seconds: trimRange.lowerBound, preferredTimescale: 600), + end: CMTime(seconds: trimRange.upperBound, preferredTimescale: 600) + ) + + return await withCheckedContinuation { continuation in + box.session.exportAsynchronously { + if box.session.status == .completed { + continuation.resume(returning: outputURL) + } else { + continuation.resume(returning: nil) + } + } + } + } + + private func finalizePreviewSend(trimRange: ClosedRange, sourceView: UIView?) { + Task { @MainActor [weak self] in + guard let self else { return } + + audioRecorder.onFinished = { [weak self] url, duration, waveform in + self?.lastRecordedURL = url + self?.lastRecordedDuration = duration + self?.lastRecordedWaveform = waveform + } + audioRecorder.stopRecording() + + guard let url = lastRecordedURL else { + dismissOverlayAndRestore(skipAudioCleanup: true) + return + } + + var finalURL = url + var finalDuration = lastRecordedDuration + var finalWaveform = lastRecordedWaveform + + let normalizedTrim = clampTrimRange(trimRange, duration: lastRecordedDuration) + let shouldTrim = + normalizedTrim.lowerBound > 0.01 || + normalizedTrim.upperBound < lastRecordedDuration - 0.01 + if shouldTrim, + let trimmedURL = await exportTrimmedAudio(url: url, trimRange: normalizedTrim) { + finalURL = trimmedURL + finalDuration = normalizedTrim.upperBound - normalizedTrim.lowerBound + finalWaveform = trimWaveform( + lastRecordedWaveform, + totalDuration: lastRecordedDuration, + trimRange: normalizedTrim + ) + try? FileManager.default.removeItem(at: url) + } + + guard finalDuration >= minVoiceDuration else { + try? FileManager.default.removeItem(at: finalURL) + dismissOverlayAndRestore(skipAudioCleanup: true) + return + } + + lastRecordedURL = finalURL + lastRecordedDuration = finalDuration + lastRecordedWaveform = finalWaveform + lastVoiceSendTransitionSource = captureVoiceSendTransition(from: sourceView) + dismissOverlayAndRestore(skipAudioCleanup: true) + delegate?.composerDidFinishRecording(self, sendImmediately: true) + } + } } // MARK: - VoiceRecordingPanelDelegate extension ComposerView: VoiceRecordingPanelDelegate { func recordingPanelDidTapCancel(_ panel: VoiceRecordingPanel) { - dismissOverlayAndRestore() + cancelRecordingWithDismissAnimation() micButton.resetState() + delegate?.composerDidCancelRecording(self) + } +} + +// MARK: - RecordingPreviewPanelDelegate + +extension ComposerView: RecordingPreviewPanelDelegate { + func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange) { + finalizePreviewSend(trimRange: trimRange, sourceView: panel) + } + + func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) { + audioRecorder.cancelRecording() + if let url = lastRecordedURL { + try? FileManager.default.removeItem(at: url) + } + lastRecordedURL = nil + lastRecordedDuration = 0 + lastRecordedWaveform = [] + + dismissOverlayAndRestore(skipAudioCleanup: true) + delegate?.composerDidCancelRecording(self) + } + + func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel) { + resumeRecordingFromPreview() } } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift new file mode 100644 index 0000000..829a9d9 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift @@ -0,0 +1,237 @@ +import UIKit + +// MARK: - MessageVoiceView + +/// Voice message bubble content: play/pause button + waveform bars + duration. +/// Telegram parity from ChatMessageInteractiveFileNode.swift audit. +final class MessageVoiceView: UIView { + + // MARK: - Subviews + + private let playButton = UIButton(type: .system) + private let waveformView = WaveformView() + private let durationLabel = UILabel() + + // MARK: - State + + private var messageId: String = "" + private var attachmentId: String = "" + private var isOutgoing = false + + // MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode) + + private let playButtonSize: CGFloat = 44 + private let playButtonLeading: CGFloat = 3 + private let waveformX: CGFloat = 57 // Telegram: x=57 + private let waveformY: CGFloat = 1 // Telegram: y=1 + private let waveformHeight: CGFloat = 18 // Telegram: peakHeight=18 in component + private let durationX: CGFloat = 56 // Telegram: x=56 + private let durationY: CGFloat = 22 // Telegram: y=22 + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupSubviews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // Playback + var onPlayTapped: (() -> Void)? + + // MARK: - Setup + + private func setupSubviews() { + let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .bold) + playButton.setImage(UIImage(systemName: "play.fill", withConfiguration: config), for: .normal) + playButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + playButton.tintColor = .white + playButton.layer.cornerRadius = playButtonSize / 2 + playButton.clipsToBounds = true + playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside) + addSubview(playButton) + + waveformView.peakHeight = 18 // Telegram AudioWaveformComponent peak + waveformView.gravity = .center + addSubview(waveformView) + + durationLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + durationLabel.textColor = .white.withAlphaComponent(0.6) + addSubview(durationLabel) + } + + // MARK: - Layout (Telegram exact: play at x=3,y=1; waveform at x=57,y=1; duration at x=56,y=22) + + override func layoutSubviews() { + super.layoutSubviews() + let h = bounds.height + + // Play button: 44Γ—44, Telegram x=3, y=centered in cell + playButton.frame = CGRect( + x: playButtonLeading, + y: max(0, (h - playButtonSize) / 2), + width: playButtonSize, + height: playButtonSize + ) + + // Waveform: from x=57 to near right edge, height=18, y=1 + let waveW = bounds.width - waveformX - 4 + waveformView.frame = CGRect( + x: waveformX, + y: waveformY, + width: max(0, waveW), + height: waveformHeight + ) + + // Duration: at x=56, y=22 + durationLabel.frame = CGRect( + x: durationX, + y: durationY, + width: bounds.width - durationX - 4, + height: 14 + ) + } + + // MARK: - Configure + + func configure(messageId: String, attachmentId: String, preview: String, + duration: TimeInterval, isOutgoing: Bool) { + self.messageId = messageId + self.attachmentId = attachmentId + self.isOutgoing = isOutgoing + + // Decode waveform from preview + let samples = Self.decodeWaveform(from: preview) + waveformView.setSamples(samples) + waveformView.progress = 0 + + // Duration label + let totalSeconds = Int(duration) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + durationLabel.text = String(format: "%d:%02d", minutes, seconds) + + // Style based on incoming/outgoing + if isOutgoing { + playButton.backgroundColor = .white + playButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + durationLabel.textColor = .white.withAlphaComponent(0.6) + } else { + playButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + playButton.tintColor = .white + durationLabel.textColor = UIColor.white.withAlphaComponent(0.5) + } + } + + // MARK: - Play Action + + @objc private func playTapped() { + onPlayTapped?() + } + + /// Update play button icon and waveform progress from VoiceMessagePlayer state. + func updatePlaybackState(isPlaying: Bool, progress: CGFloat) { + let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .bold) + let name = isPlaying ? "pause.fill" : "play.fill" + playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal) + waveformView.progress = progress + } + + // MARK: - Waveform Decoding + + /// Decode waveform from preview string. + /// Format: comma-separated float values or base64-encoded 5-bit bitstream. + static func decodeWaveform(from preview: String) -> [Float] { + guard !preview.isEmpty else { return [] } + + // Try comma-separated floats first + if preview.contains(",") || preview.contains(".") { + let parts = preview.split(separator: ",") + let values = parts.compactMap { Float($0.trimmingCharacters(in: .whitespaces)) } + if !values.isEmpty { return values } + } + + // Try base64-encoded 5-bit bitstream (Telegram format) + guard let data = Data(base64Encoded: preview), !data.isEmpty else { + return [] + } + return decode5BitWaveform(data) + } + + /// Decode 5-bit packed waveform data (Telegram AudioWaveform format). + /// Each sample is 5 bits (0-31), normalized to 0.0-1.0. + private static func decode5BitWaveform(_ data: Data) -> [Float] { + let bitCount = data.count * 8 + let sampleCount = bitCount / 5 + guard sampleCount > 0 else { return [] } + + var samples = [Float](repeating: 0, count: sampleCount) + let bytes = [UInt8](data) + + for i in 0..> (8 - bitIndex - 5)) & 0x1F + } else { + let bitsFromFirst = 8 - bitIndex + let bitsFromSecond = 5 - bitsFromFirst + value = (bytes[byteIndex] & ((1 << bitsFromFirst) - 1)) << bitsFromSecond + if byteIndex + 1 < bytes.count { + value |= bytes[byteIndex + 1] >> (8 - bitsFromSecond) + } + } + + samples[i] = Float(value) / 31.0 + } + + return samples + } + + // MARK: - Waveform Encoding + + /// Encode waveform samples to 5-bit packed base64 string (for sending). + static func encodeWaveform(_ samples: [Float]) -> String { + guard !samples.isEmpty else { return "" } + + // Resample to ~63 bars (Telegram standard) + let targetCount = min(63, samples.count) + let step = Float(samples.count) / Float(targetCount) + var resampled = [Float](repeating: 0, count: targetCount) + for i in 0..> (5 - bitsInFirst) + if byteIndex + 1 < bytes.count { + bytes[byteIndex + 1] |= value << (8 - (5 - bitsInFirst)) + } + } + } + + return Data(bytes).base64EncodedString() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 8745309..5be5429 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -190,6 +190,9 @@ final class NativeMessageCell: UICollectionViewCell { private let callArrowView = UIImageView() private let callBackButton = UIButton(type: .custom) + // Voice message + private let voiceView = MessageVoiceView() + // Avatar-specific private let avatarImageView = UIImageView() @@ -482,6 +485,9 @@ final class NativeMessageCell: UICollectionViewCell { avatarImageView.isHidden = true fileContainer.addSubview(avatarImageView) + voiceView.isHidden = true + fileContainer.addSubview(voiceView) + bubbleView.addSubview(fileContainer) // Group Invite Card @@ -855,6 +861,34 @@ final class NativeMessageCell: UICollectionViewCell { } else { callPhoneView?.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) } + } else if let voiceAtt = message.attachments.first(where: { $0.type == .voice }) { + // Voice message: play button + waveform + duration + // Preview format: "tag::duration::waveform_base64" or "duration::waveform_base64" + let previewParts = Self.parseVoicePreview(voiceAtt.preview) + voiceView.isHidden = false + voiceView.frame = CGRect(x: 0, y: 0, width: fileContainer.bounds.width, height: 38) + voiceView.configure( + messageId: message.id, + attachmentId: voiceAtt.id, + preview: previewParts.waveform, + duration: previewParts.duration, + isOutgoing: layout.isOutgoing + ) + let voiceId = voiceAtt.id + let voiceFileName = voiceAtt.preview.components(separatedBy: "::").last ?? "" + voiceView.onPlayTapped = { [weak self] in + guard let self else { return } + let fileName = "voice_\(Int(previewParts.duration))s.m4a" + if let url = AttachmentCache.shared.fileURL(forAttachmentId: voiceId, fileName: fileName) { + VoiceMessagePlayer.shared.play(messageId: message.id, fileURL: url) + } + } + fileIconView.isHidden = true + fileNameLabel.isHidden = true + fileSizeLabel.isHidden = true + callArrowView.isHidden = true + callBackButton.isHidden = true + avatarImageView.isHidden = true } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) { let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview) let isFileOutgoing = layout.isOutgoing @@ -1456,6 +1490,20 @@ final class NativeMessageCell: UICollectionViewCell { } /// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift). + /// Parse voice preview: "tag::duration::waveform" or "duration::waveform" + private static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) { + let parts = preview.components(separatedBy: "::") + // Format: "tag::duration::waveform" or "duration::waveform" + if parts.count >= 3, let dur = Int(parts[1]) { + return (TimeInterval(dur), parts[2]) + } else if parts.count >= 2, let dur = Int(parts[0]) { + return (TimeInterval(dur), parts[1]) + } else if let dur = Int(parts[0]) { + return (TimeInterval(dur), "") + } + return (0, preview) + } + private static func fileIcon(for fileName: String) -> String { let ext = (fileName as NSString).pathExtension.lowercased() switch ext { @@ -2688,6 +2736,7 @@ final class NativeMessageCell: UICollectionViewCell { replyMessageId = nil highlightOverlay.alpha = 0 fileContainer.isHidden = true + voiceView.isHidden = true callArrowView.isHidden = true callBackButton.isHidden = true groupInviteContainer.isHidden = true @@ -2698,6 +2747,8 @@ final class NativeMessageCell: UICollectionViewCell { avatarImageView.image = nil avatarImageView.isHidden = true fileIconView.isHidden = false + fileNameLabel.isHidden = false + fileSizeLabel.isHidden = false forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true @@ -2804,6 +2855,15 @@ final class NativeMessageCell: UICollectionViewCell { selectionCheckContainer.layer.add(anim, forKey: "checkBounce") } } + + func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? { + guard !voiceView.isHidden else { return nil } + return voiceView.convert(voiceView.bounds, to: window) + } + + func bubbleFrameInWindow(_ window: UIWindow) -> CGRect { + bubbleView.convert(bubbleView.bounds, to: window) + } } // MARK: - UIGestureRecognizerDelegate diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 1b5a222..452ef79 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1433,7 +1433,30 @@ extension NativeMessageListController: ComposerViewDelegate { } func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) { - // Recording finished β€” will be wired to send pipeline later + guard sendImmediately, + let url = composer.lastRecordedURL, + let data = try? Data(contentsOf: url) else { return } + let transitionSource = composer.consumeVoiceSendTransitionSource() + let pending = PendingAttachment.fromVoice( + data: data, + duration: composer.lastRecordedDuration, + waveform: composer.lastRecordedWaveform + ) + let pubKey = config.opponentPublicKey + let title = config.opponentTitle + let username = config.opponentUsername + Task { @MainActor in + let messageId = try? await SessionManager.shared.sendMessageWithAttachments( + text: "", + attachments: [pending], + toPublicKey: pubKey, + opponentTitle: title, + opponentUsername: username + ) + if let source = transitionSource, let messageId { + animateVoiceSendTransition(source: source, messageId: messageId) + } + } } func composerDidCancelRecording(_ composer: ComposerView) { @@ -1443,6 +1466,70 @@ extension NativeMessageListController: ComposerViewDelegate { func composerDidLockRecording(_ composer: ComposerView) { // Recording locked β€” UI handled by ComposerView } + + private func animateVoiceSendTransition(source: VoiceSendTransitionSource, messageId: String) { + guard let window = view.window else { return } + let snapshot = source.snapshotView + snapshot.frame = source.sourceFrameInWindow + snapshot.layer.cornerRadius = source.cornerRadius + snapshot.layer.cornerCurve = .continuous + snapshot.clipsToBounds = true + window.addSubview(snapshot) + + resolveVoiceTargetFrame(messageId: messageId, attempt: 0, snapshot: snapshot) + } + + private func resolveVoiceTargetFrame(messageId: String, attempt: Int, snapshot: UIView) { + guard let window = view.window else { + snapshot.removeFromSuperview() + return + } + let maxAttempts = 12 + guard attempt <= maxAttempts else { + UIView.animate(withDuration: 0.16, animations: { + snapshot.alpha = 0 + snapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + }, completion: { _ in + snapshot.removeFromSuperview() + }) + return + } + + let targetFrame = targetFrameForVoiceMessage(messageId: messageId, in: window) + guard let targetFrame else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in + self?.resolveVoiceTargetFrame(messageId: messageId, attempt: attempt + 1, snapshot: snapshot) + } + return + } + + UIView.animate(withDuration: 0.34, delay: 0, options: [.curveEaseInOut]) { + snapshot.frame = targetFrame + snapshot.layer.cornerRadius = 12 + snapshot.alpha = 0.84 + } completion: { _ in + UIView.animate(withDuration: 0.12, animations: { + snapshot.alpha = 0 + }, completion: { _ in + snapshot.removeFromSuperview() + }) + } + } + + private func targetFrameForVoiceMessage(messageId: String, in window: UIWindow) -> CGRect? { + let snapshot = dataSource.snapshot() + guard let itemIndex = snapshot.indexOfItem(messageId) else { return nil } + let indexPath = IndexPath(item: itemIndex, section: 0) + + if collectionView.cellForItem(at: indexPath) == nil { + collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false) + collectionView.layoutIfNeeded() + } + guard let cell = collectionView.cellForItem(at: indexPath) as? NativeMessageCell else { + return nil + } + return cell.voiceTransitionTargetFrame(in: window) ?? cell.bubbleFrameInWindow(window) + } } // MARK: - PreSizedCell @@ -1504,7 +1591,7 @@ struct NativeMessageListView: UIViewControllerRepresentable { let actions: MessageCellActions let hasMoreMessages: Bool let firstUnreadMessageId: String? - /// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay). + /// true = create UIKit ComposerView bridge. false = no composer (system chats). let useUIKitComposer: Bool /// Empty chat state data (iOS < 26). nil = no empty state management. var emptyChatInfo: EmptyChatInfo? diff --git a/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift b/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift index 8d0d246..e1f3850 100644 --- a/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift +++ b/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift @@ -27,6 +27,9 @@ struct PendingAttachment: Identifiable, Sendable { /// File size in bytes (files only). `nil` for images. let fileSize: Int? + /// Voice preview: "duration::waveform_base64" (voice only). + var voicePreview: String? = nil + // MARK: - Factory /// Creates a PendingAttachment from a UIImage (compressed to JPEG). @@ -62,13 +65,17 @@ struct PendingAttachment: Identifiable, Sendable { /// Creates a PendingAttachment from a voice recording. /// Duration in seconds, waveform is normalized [Float] array (0..1). static func fromVoice(data: Data, duration: TimeInterval, waveform: [Float]) -> PendingAttachment { + // Encode waveform as 5-bit base64 for preview + let waveformBase64 = MessageVoiceView.encodeWaveform(waveform) return PendingAttachment( id: generateRandomId(), type: .voice, data: data, thumbnail: nil, + // Encode duration + waveform in fileName for preview extraction fileName: "voice_\(Int(duration))s.m4a", - fileSize: data.count + fileSize: data.count, + voicePreview: "\(Int(duration))::\(waveformBase64)" ) } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift new file mode 100644 index 0000000..89df767 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift @@ -0,0 +1,228 @@ +import QuartzCore +import UIKit + +// MARK: - RecordingLockView + +/// Lock indicator shown above mic button during voice recording. +/// Telegram parity from TGModernConversationInputMicButton.m: +/// - Frame: 40Γ—72pt, positioned 122pt above mic center +/// - Padlock icon (CAShapeLayer) + upward arrow +/// - Spring entry: damping 0.55, duration 0.5s +/// - Lockness progress: arrow fades, panel shrinks +final class RecordingLockView: UIView { + + // MARK: - Layout Constants (Telegram exact) + + private let panelWidth: CGFloat = 40 + private let panelFullHeight: CGFloat = 72 + private let panelLockedHeight: CGFloat = 40 // 72 - 32 + private let verticalOffset: CGFloat = 122 // above mic center + private let cornerRadius: CGFloat = 20 + + // MARK: - Subviews + + private let backgroundView = UIView() + private let lockIcon = CAShapeLayer() + private let arrowLayer = CAShapeLayer() + private let stopButton = UIButton(type: .system) + private var onStopTap: (() -> Void)? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + setupBackground() + setupLockIcon() + setupArrow() + setupStopButton() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupBackground() { + backgroundView.backgroundColor = UIColor(white: 0.15, alpha: 0.9) + backgroundView.layer.cornerRadius = cornerRadius + backgroundView.layer.cornerCurve = .continuous + backgroundView.layer.borderWidth = 1.0 / UIScreen.main.scale + backgroundView.layer.borderColor = UIColor(white: 0.3, alpha: 0.5).cgColor + addSubview(backgroundView) + } + + private func setupLockIcon() { + // Simple padlock: body (rounded rect) + shackle (arc) + let path = UIBezierPath() + + // Shackle (arc above body) + let shackleW: CGFloat = 10 + let shackleH: CGFloat = 8 + let bodyTop: CGFloat = 10 + let centerX: CGFloat = panelWidth / 2 + path.move(to: CGPoint(x: centerX - shackleW / 2, y: bodyTop)) + path.addLine(to: CGPoint(x: centerX - shackleW / 2, y: bodyTop - shackleH + 3)) + path.addCurve( + to: CGPoint(x: centerX + shackleW / 2, y: bodyTop - shackleH + 3), + controlPoint1: CGPoint(x: centerX - shackleW / 2, y: bodyTop - shackleH - 2), + controlPoint2: CGPoint(x: centerX + shackleW / 2, y: bodyTop - shackleH - 2) + ) + path.addLine(to: CGPoint(x: centerX + shackleW / 2, y: bodyTop)) + + lockIcon.path = path.cgPath + lockIcon.strokeColor = UIColor.white.cgColor + lockIcon.fillColor = UIColor.clear.cgColor + lockIcon.lineWidth = 1.5 + lockIcon.lineCap = .round + + // Body (rounded rect below shackle) + let bodyW: CGFloat = 14 + let bodyH: CGFloat = 10 + let bodyPath = UIBezierPath( + roundedRect: CGRect( + x: centerX - bodyW / 2, + y: bodyTop, + width: bodyW, + height: bodyH + ), + cornerRadius: 2 + ) + let bodyLayer = CAShapeLayer() + bodyLayer.path = bodyPath.cgPath + bodyLayer.fillColor = UIColor.white.cgColor + layer.addSublayer(bodyLayer) + layer.addSublayer(lockIcon) + } + + private func setupArrow() { + // Upward chevron arrow below the lock + let arrowPath = UIBezierPath() + let centerX = panelWidth / 2 + let arrowY: CGFloat = 30 + arrowPath.move(to: CGPoint(x: centerX - 5, y: arrowY + 5)) + arrowPath.addLine(to: CGPoint(x: centerX, y: arrowY)) + arrowPath.addLine(to: CGPoint(x: centerX + 5, y: arrowY + 5)) + + arrowLayer.path = arrowPath.cgPath + arrowLayer.strokeColor = UIColor.white.withAlphaComponent(0.6).cgColor + arrowLayer.fillColor = UIColor.clear.cgColor + arrowLayer.lineWidth = 1.5 + arrowLayer.lineCap = .round + arrowLayer.lineJoin = .round + layer.addSublayer(arrowLayer) + } + + private func setupStopButton() { + stopButton.isHidden = true + stopButton.alpha = 0 + stopButton.backgroundColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1) + stopButton.tintColor = .white + stopButton.layer.cornerRadius = 14 + stopButton.clipsToBounds = true + let iconConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .bold) + stopButton.setImage(UIImage(systemName: "stop.fill", withConfiguration: iconConfig), for: .normal) + stopButton.addTarget(self, action: #selector(stopTapped), for: .touchUpInside) + stopButton.isAccessibilityElement = true + stopButton.accessibilityLabel = "Stop recording" + stopButton.accessibilityHint = "Stops voice recording and opens preview." + addSubview(stopButton) + } + + // MARK: - Present + + /// Position above anchor (mic button) and animate in with spring. + func present(anchorCenter: CGPoint, in parent: UIView) { + frame = CGRect( + x: floor(anchorCenter.x - panelWidth / 2), + y: floor(anchorCenter.y - verticalOffset - panelFullHeight / 2), + width: panelWidth, + height: panelFullHeight + ) + backgroundView.frame = bounds + stopButton.frame = CGRect(x: floor((panelWidth - 28) / 2), y: panelFullHeight - 34, width: 28, height: 28) + + parent.addSubview(self) + + // Start offscreen below + transform = CGAffineTransform(translationX: 0, y: 100) + alpha = 0 + + UIView.animate( + withDuration: 0.5, delay: 0, + usingSpringWithDamping: 0.55, + initialSpringVelocity: 0, options: [] + ) { + self.transform = .identity + self.alpha = 1 + } + } + + // MARK: - Lockness Update + + /// Update lock progress (0 = idle, 1 = locked). + /// Telegram: arrow alpha = max(0, 1 - lockness * 1.6) + func updateLockness(_ lockness: CGFloat) { + CATransaction.begin() + CATransaction.setDisableActions(true) + arrowLayer.opacity = Float(max(0, 1 - lockness * 1.6)) + CATransaction.commit() + + // Lock icon shifts up slightly + let yOffset = -16 * lockness + CATransaction.begin() + CATransaction.setDisableActions(true) + lockIcon.transform = CATransform3DMakeTranslation(0, yOffset, 0) + CATransaction.commit() + } + + // MARK: - Animate Lock Complete + + /// Shrink and dismiss the lock panel after lock is committed. + /// Telegram: panel height 72β†’40, then slides down off-screen. + func animateLockComplete() { + UIView.animate(withDuration: 0.2) { + self.arrowLayer.opacity = 0 + self.lockIcon.transform = CATransform3DMakeTranslation(0, -16, 0) + } + + // Slide down and fade after 0.45s + UIView.animate(withDuration: 0.2, delay: 0.45, options: []) { + self.transform = CGAffineTransform(translationX: 0, y: 120) + } completion: { _ in + self.alpha = 0 + self.removeFromSuperview() + } + } + + func showStopButton(onTap: @escaping () -> Void) { + onStopTap = onTap + stopButton.isHidden = false + stopButton.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + + UIView.animate(withDuration: 0.2) { + self.arrowLayer.opacity = 0 + self.lockIcon.transform = CATransform3DMakeTranslation(0, -16, 0) + } + + UIView.animate(withDuration: 0.2, delay: 0.02, options: [.curveEaseOut]) { + self.stopButton.alpha = 1 + self.stopButton.transform = .identity + } + } + + // MARK: - Dismiss + + func dismiss() { + UIView.animate(withDuration: 0.18) { + self.alpha = 0 + self.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + } completion: { _ in + self.removeFromSuperview() + } + } + + @objc private func stopTapped() { + onStopTap?() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift index e0c38d6..7f7f88c 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift @@ -5,7 +5,7 @@ import UIKit enum VoiceRecordingState { case idle - case waiting // finger down, waiting for threshold (0.15s) + case waiting // finger down, waiting for threshold (0.19s) case recording // actively recording, finger held case locked // slid up past lock threshold, finger released case cancelled // slid left past cancel threshold @@ -16,7 +16,13 @@ enum VoiceRecordingState { @MainActor protocol RecordingMicButtonDelegate: AnyObject { - /// Recording threshold reached (0.15s hold). Start actual recording. + /// Finger down, hold timer armed. + func micButtonRecordingArmed(_ button: RecordingMicButton) + + /// Hold was cancelled before threshold (tap / move / system cancel). + func micButtonRecordingArmingCancelled(_ button: RecordingMicButton) + + /// Recording threshold reached (0.19s hold). Start actual recording. func micButtonRecordingBegan(_ button: RecordingMicButton) /// Finger released normally β†’ send the recording. @@ -28,12 +34,9 @@ protocol RecordingMicButtonDelegate: AnyObject { /// Slid up past lock threshold β†’ lock into hands-free recording. func micButtonRecordingLocked(_ button: RecordingMicButton) - /// Horizontal slide translation update for cancel indicator. - /// Value is negative (slide left), range roughly -150..0. - func micButtonCancelTranslationChanged(_ button: RecordingMicButton, translation: CGFloat) - - /// Vertical lock progress update (0..1). - func micButtonLockProgressChanged(_ button: RecordingMicButton, progress: CGFloat) + /// Raw drag distances for overlay transforms (Telegram: continueTrackingWithTouch). + /// distanceX: negative = left (cancel), distanceY: negative = up (lock) + func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat) } // MARK: - RecordingMicButton @@ -42,9 +45,10 @@ protocol RecordingMicButtonDelegate: AnyObject { /// Ported from Telegram's `TGModernConversationInputMicButton`. /// /// Gesture mechanics: -/// - Long press (0.15s) β†’ begin recording +/// - Long press (0.19s) β†’ begin recording /// - Slide left β†’ cancel (threshold: -150px, haptic at -100px) /// - Slide up β†’ lock (threshold: -110px, haptic at -60px) +/// - Release velocity gate: <-400 px/s on X/Y commits cancel/lock /// - Release β†’ finish (send) final class RecordingMicButton: UIControl { @@ -54,15 +58,21 @@ final class RecordingMicButton: UIControl { // MARK: - Gesture Thresholds (Telegram parity) - private let holdThreshold: TimeInterval = 0.15 + private let holdThreshold: TimeInterval = 0.19 private let cancelDistanceThreshold: CGFloat = -150 private let cancelHapticThreshold: CGFloat = -100 private let lockDistanceThreshold: CGFloat = -110 private let lockHapticThreshold: CGFloat = -60 + private let velocityGate: CGFloat = -400 + private let preHoldCancelDistance: CGFloat = 10 // MARK: - Tracking State private var touchStartLocation: CGPoint = .zero + private var lastTouchLocation: CGPoint = .zero + private var lastTouchTimestamp: TimeInterval = 0 + private var velocityX: CGFloat = 0 + private var velocityY: CGFloat = 0 private var holdTimer: Timer? private var displayLink: CADisplayLink? @@ -96,6 +106,10 @@ final class RecordingMicButton: UIControl { guard recordingState == .idle else { return false } touchStartLocation = touch.location(in: window) + lastTouchLocation = touchStartLocation + lastTouchTimestamp = touch.timestamp + velocityX = 0 + velocityY = 0 recordingState = .waiting targetCancelTranslation = 0 targetLockTranslation = 0 @@ -105,12 +119,11 @@ final class RecordingMicButton: UIControl { didLockHaptic = false impactFeedback.prepare() + recordingDelegate?.micButtonRecordingArmed(self) - // Start hold timer β€” after 0.15s we begin recording + // Start hold timer β€” after 0.19s we begin recording holdTimer = Timer.scheduledTimer(withTimeInterval: holdThreshold, repeats: false) { [weak self] _ in - Task { @MainActor in - self?.beginRecording() - } + self?.beginRecording() } return true @@ -122,14 +135,16 @@ final class RecordingMicButton: UIControl { let location = touch.location(in: window) let distanceX = min(0, location.x - touchStartLocation.x) let distanceY = min(0, location.y - touchStartLocation.y) + updateVelocity(with: touch, at: location) // Check if we moved enough to cancel the hold timer (before recording started) if recordingState == .waiting { let totalDistance = sqrt(distanceX * distanceX + distanceY * distanceY) - if totalDistance > 10 { + if totalDistance > preHoldCancelDistance { // Movement before threshold β€” cancel the timer, don't start recording cancelHoldTimer() recordingState = .idle + recordingDelegate?.micButtonRecordingArmingCancelled(self) return false } return true @@ -171,21 +186,31 @@ final class RecordingMicButton: UIControl { // Released before hold threshold β€” just a tap cancelHoldTimer() recordingState = .idle + recordingDelegate?.micButtonRecordingArmingCancelled(self) return } if recordingState == .recording { - // Check velocity for quick flick gestures + // Telegram velocity gate: fast flick left/up commits immediately. + if velocityX < velocityGate { + commitCancel() + return + } + if velocityY < velocityGate { + commitLock() + return + } + + // Fallback to distance thresholds on release. if let touch { let location = touch.location(in: window) let distanceX = location.x - touchStartLocation.x let distanceY = location.y - touchStartLocation.y - - if distanceX < cancelDistanceThreshold / 2 { + if distanceX < cancelDistanceThreshold { commitCancel() return } - if distanceY < lockDistanceThreshold / 2 { + if distanceY < lockDistanceThreshold { commitLock() return } @@ -203,10 +228,15 @@ final class RecordingMicButton: UIControl { } else { cancelHoldTimer() recordingState = .idle + recordingDelegate?.micButtonRecordingArmingCancelled(self) } stopDisplayLink() } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -10, dy: 0).contains(point) + } + // MARK: - State Transitions private func beginRecording() { @@ -248,6 +278,8 @@ final class RecordingMicButton: UIControl { cancelHoldTimer() stopDisplayLink() recordingState = .idle + velocityX = 0 + velocityY = 0 targetCancelTranslation = 0 targetLockTranslation = 0 currentCancelTranslation = 0 @@ -269,16 +301,12 @@ final class RecordingMicButton: UIControl { } @objc private func displayLinkUpdate() { - // Smooth interpolation (Telegram: 0.7/0.3 blend) - currentCancelTranslation = currentCancelTranslation * 0.7 + targetCancelTranslation * 0.3 - currentLockTranslation = currentLockTranslation * 0.7 + targetLockTranslation * 0.3 + // Telegram exact: 0.7/0.3 blend (TGModernConversationInputMicButton.m line 918-919) + currentCancelTranslation = min(0, currentCancelTranslation * 0.7 + targetCancelTranslation * 0.3) + currentLockTranslation = min(0, currentLockTranslation * 0.7 + targetLockTranslation * 0.3) - // Report cancel translation - recordingDelegate?.micButtonCancelTranslationChanged(self, translation: currentCancelTranslation) - - // Report lock progress (0..1) - let lockProgress = min(1.0, abs(currentLockTranslation) / abs(lockDistanceThreshold)) - recordingDelegate?.micButtonLockProgressChanged(self, progress: lockProgress) + // Report raw smoothed distances for overlay transforms + recordingDelegate?.micButtonDragUpdate(self, distanceX: currentCancelTranslation, distanceY: currentLockTranslation) } // MARK: - Helpers @@ -287,4 +315,12 @@ final class RecordingMicButton: UIControl { holdTimer?.invalidate() holdTimer = nil } + + private func updateVelocity(with touch: UITouch, at location: CGPoint) { + let dt = max(0.001, touch.timestamp - lastTouchTimestamp) + velocityX = (location.x - lastTouchLocation.x) / dt + velocityY = (location.y - lastTouchLocation.y) / dt + lastTouchLocation = location + lastTouchTimestamp = touch.timestamp + } } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift new file mode 100644 index 0000000..47298d6 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift @@ -0,0 +1,406 @@ +import AVFAudio +import QuartzCore +import UIKit + +// MARK: - RecordingPreviewPanelDelegate + +@MainActor +protocol RecordingPreviewPanelDelegate: AnyObject { + func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange) + func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) + func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel) +} + +// MARK: - RecordingPreviewPanel + +/// Preview panel shown after `lock -> stop`, before sending voice message. +/// Includes waveform scrubbing + trim handles + send/delete/record-more controls. +final class RecordingPreviewPanel: UIView { + + private enum PanMode { + case scrub + case trimLeft + case trimRight + } + + weak var delegate: RecordingPreviewPanelDelegate? + + // MARK: - Subviews + + private let glassBackground = TelegramGlassUIView(frame: .zero) + private let deleteButton = UIButton(type: .system) + private let playButton = UIButton(type: .system) + private let waveformContainer = UIView() + private let waveformView = WaveformView() + private let leftTrimMask = UIView() + private let rightTrimMask = UIView() + private let leftTrimHandle = UIView() + private let rightTrimHandle = UIView() + private let durationLabel = UILabel() + private let recordMoreButton = UIButton(type: .system) + private let sendButton = UIButton(type: .system) + + // MARK: - Audio Playback + + private var audioPlayer: AVAudioPlayer? + private var displayLink: CADisplayLink? + private var isPlaying = false + private let fileURL: URL + private let duration: TimeInterval + private let waveformSamples: [Float] + + // MARK: - Trim / Scrub + + private var trimStart: TimeInterval = 0 + private var trimEnd: TimeInterval = 0 + private var minTrimDuration: TimeInterval = 1 + private var activePanMode: PanMode? + + var selectedTrimRange: ClosedRange { + trimStart...trimEnd + } + + // MARK: - Init + + init(frame: CGRect, fileURL: URL, duration: TimeInterval, waveform: [Float]) { + self.fileURL = fileURL + self.duration = max(0, duration) + self.waveformSamples = waveform + super.init(frame: frame) + self.trimEnd = self.duration + clipsToBounds = true + layer.cornerRadius = 21 + layer.cornerCurve = .continuous + setupSubviews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupSubviews() { + glassBackground.fixedCornerRadius = 21 + glassBackground.isUserInteractionEnabled = false + addSubview(glassBackground) + + let trashConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + deleteButton.setImage(UIImage(systemName: "trash", withConfiguration: trashConfig), for: .normal) + deleteButton.tintColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + deleteButton.isAccessibilityElement = true + deleteButton.accessibilityLabel = "Delete recording" + deleteButton.accessibilityHint = "Deletes the current voice draft." + addSubview(deleteButton) + + configurePlayButton(playing: false) + playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside) + playButton.isAccessibilityElement = true + playButton.accessibilityLabel = "Play recording" + playButton.accessibilityHint = "Plays or pauses voice preview." + addSubview(playButton) + + waveformContainer.clipsToBounds = true + waveformContainer.layer.cornerRadius = 6 + addSubview(waveformContainer) + + waveformView.setSamples(waveformSamples) + waveformView.progress = 0 + waveformContainer.addSubview(waveformView) + + leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25) + rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25) + waveformContainer.addSubview(leftTrimMask) + waveformContainer.addSubview(rightTrimMask) + + leftTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + leftTrimHandle.layer.cornerRadius = 2 + waveformContainer.addSubview(leftTrimHandle) + + rightTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + rightTrimHandle.layer.cornerRadius = 2 + waveformContainer.addSubview(rightTrimHandle) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:))) + waveformContainer.addGestureRecognizer(pan) + waveformContainer.isAccessibilityElement = true + waveformContainer.accessibilityLabel = "Waveform trim area" + waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim." + + durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) + durationLabel.textColor = .white.withAlphaComponent(0.72) + durationLabel.textAlignment = .right + addSubview(durationLabel) + + let recordMoreConfig = UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold) + recordMoreButton.setImage(UIImage(systemName: "plus.circle", withConfiguration: recordMoreConfig), for: .normal) + recordMoreButton.tintColor = .white.withAlphaComponent(0.85) + recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside) + recordMoreButton.isAccessibilityElement = true + recordMoreButton.accessibilityLabel = "Record more" + recordMoreButton.accessibilityHint = "Resume recording and append more audio." + addSubview(recordMoreButton) + + let sendConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) + sendButton.setImage(UIImage(systemName: "arrow.up.circle.fill", withConfiguration: sendConfig), for: .normal) + sendButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1) + sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) + sendButton.isAccessibilityElement = true + sendButton.accessibilityLabel = "Send recording" + sendButton.accessibilityHint = "Sends current trimmed voice message." + addSubview(sendButton) + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let h = bounds.height + let w = bounds.width + + glassBackground.frame = bounds + glassBackground.applyCornerRadius() + + deleteButton.frame = CGRect(x: 4, y: (h - 40) / 2, width: 40, height: 40) + playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30) + + sendButton.frame = CGRect(x: w - 40, y: (h - 36) / 2, width: 36, height: 36) + recordMoreButton.frame = CGRect(x: sendButton.frame.minX - 34, y: (h - 30) / 2, width: 30, height: 30) + + let durationW: CGFloat = 44 + durationLabel.frame = CGRect( + x: recordMoreButton.frame.minX - durationW - 6, + y: (h - 20) / 2, + width: durationW, + height: 20 + ) + + let waveX = playButton.frame.maxX + 8 + let waveW = durationLabel.frame.minX - 8 - waveX + waveformContainer.frame = CGRect(x: waveX, y: 4, width: max(0, waveW), height: h - 8) + waveformView.frame = waveformContainer.bounds + + minTrimDuration = max(1.0, 56.0 * duration / max(waveformContainer.bounds.width, 1)) + trimEnd = max(trimEnd, min(duration, trimStart + minTrimDuration)) + updateTrimVisuals() + updateDurationLabel(isPlaying ? remainingFromPlayer() : (trimEnd - trimStart)) + } + + // MARK: - Play/Pause + + @objc private func playTapped() { + if isPlaying { + pausePlayback() + } else { + startPlayback() + } + } + + private func startPlayback() { + if audioPlayer == nil { + audioPlayer = try? AVAudioPlayer(contentsOf: fileURL) + audioPlayer?.prepareToPlay() + } + guard let player = audioPlayer else { return } + if player.currentTime < trimStart || player.currentTime > trimEnd { + player.currentTime = trimStart + } + player.play() + isPlaying = true + configurePlayButton(playing: true) + startDisplayLink() + } + + private func pausePlayback() { + audioPlayer?.pause() + isPlaying = false + configurePlayButton(playing: false) + stopDisplayLink() + } + + private func stopPlayback(resetToTrimStart: Bool = true) { + audioPlayer?.stop() + if resetToTrimStart { + audioPlayer?.currentTime = trimStart + waveformView.progress = CGFloat((duration > 0 ? trimStart / duration : 0)) + } else { + waveformView.progress = 0 + } + isPlaying = false + configurePlayButton(playing: false) + updateDurationLabel(trimEnd - trimStart) + stopDisplayLink() + } + + private func configurePlayButton(playing: Bool) { + let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold) + let name = playing ? "pause.fill" : "play.fill" + playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal) + playButton.tintColor = .white + } + + // MARK: - Display Link + + private func startDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(displayLinkTick)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func displayLinkTick() { + guard let player = audioPlayer else { return } + if !player.isPlaying && isPlaying { + stopPlayback() + return + } + if player.currentTime >= trimEnd { + stopPlayback() + return + } + let progress = duration > 0 ? player.currentTime / duration : 0 + waveformView.progress = CGFloat(progress) + updateDurationLabel(remainingFromPlayer()) + } + + // MARK: - Trim / Scrub + + @objc private func handleWaveformPan(_ gesture: UIPanGestureRecognizer) { + guard duration > 0, waveformContainer.bounds.width > 1 else { return } + let location = gesture.location(in: waveformContainer) + let normalized = min(1, max(0, location.x / waveformContainer.bounds.width)) + let targetTime = TimeInterval(normalized) * duration + + switch gesture.state { + case .began: + let leftX = xForTime(trimStart) + let rightX = xForTime(trimEnd) + if abs(location.x - leftX) <= 14 { + activePanMode = .trimLeft + } else if abs(location.x - rightX) <= 14 { + activePanMode = .trimRight + } else { + activePanMode = .scrub + } + if activePanMode != .scrub { + pausePlayback() + } + case .changed: + switch activePanMode { + case .trimLeft: + trimStart = min(max(0, targetTime), trimEnd - minTrimDuration) + if let player = audioPlayer, player.currentTime < trimStart { + player.currentTime = trimStart + } + case .trimRight: + trimEnd = max(min(duration, targetTime), trimStart + minTrimDuration) + if let player = audioPlayer, player.currentTime > trimEnd { + player.currentTime = trimEnd + } + case .scrub: + let clamped = min(trimEnd, max(trimStart, targetTime)) + if audioPlayer == nil { + audioPlayer = try? AVAudioPlayer(contentsOf: fileURL) + audioPlayer?.prepareToPlay() + } + audioPlayer?.currentTime = clamped + waveformView.progress = CGFloat(clamped / duration) + case .none: + break + } + updateTrimVisuals() + if activePanMode == .scrub { + updateDurationLabel(max(0, trimEnd - (audioPlayer?.currentTime ?? trimStart))) + } else { + updateDurationLabel(trimEnd - trimStart) + } + default: + activePanMode = nil + } + } + + private func updateTrimVisuals() { + let h = waveformContainer.bounds.height + let w = waveformContainer.bounds.width + guard w > 0 else { return } + + let startX = xForTime(trimStart) + let endX = xForTime(trimEnd) + leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: h) + rightTrimMask.frame = CGRect(x: min(w, endX), y: 0, width: max(0, w - endX), height: h) + + let handleW: CGFloat = 4 + leftTrimHandle.frame = CGRect(x: max(0, startX - handleW / 2), y: 0, width: handleW, height: h) + rightTrimHandle.frame = CGRect(x: min(w - handleW, endX - handleW / 2), y: 0, width: handleW, height: h) + } + + private func xForTime(_ time: TimeInterval) -> CGFloat { + guard duration > 0 else { return 0 } + let normalized = min(1, max(0, time / duration)) + return CGFloat(normalized) * waveformContainer.bounds.width + } + + private func remainingFromPlayer() -> TimeInterval { + guard let player = audioPlayer else { return trimEnd - trimStart } + return max(0, trimEnd - player.currentTime) + } + + // MARK: - Duration Formatting + + private func updateDurationLabel(_ time: TimeInterval) { + let totalSeconds = Int(max(0, time)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + durationLabel.text = String(format: "%d:%02d", minutes, seconds) + } + + // MARK: - Actions + + @objc private func deleteTapped() { + stopPlayback() + delegate?.previewPanelDidTapDelete(self) + } + + @objc private func recordMoreTapped() { + stopPlayback(resetToTrimStart: false) + delegate?.previewPanelDidTapRecordMore(self) + } + + @objc private func sendTapped() { + stopPlayback(resetToTrimStart: false) + delegate?.previewPanelDidTapSend(self, trimRange: selectedTrimRange) + } + + // MARK: - Animate In + + func animateIn() { + alpha = 0 + transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) { + self.alpha = 1 + self.transform = .identity + } + } + + // MARK: - Animate Out + + func animateOut(completion: (() -> Void)? = nil) { + stopPlayback() + UIView.animate(withDuration: 0.15, animations: { + self.alpha = 0 + self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + }, completion: { _ in + self.removeFromSuperview() + completion?() + }) + } + + deinit { + stopDisplayLink() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift new file mode 100644 index 0000000..5558a74 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift @@ -0,0 +1,23 @@ +import CoreGraphics +import UIKit + +enum VoiceRecordingFlowState: Equatable { + case idle + case armed + case recordingUnlocked + case recordingLocked + case waitingForPreview + case draftPreview +} + +final class VoiceSendTransitionSource { + let snapshotView: UIView + let sourceFrameInWindow: CGRect + let cornerRadius: CGFloat + + init(snapshotView: UIView, sourceFrameInWindow: CGRect, cornerRadius: CGFloat) { + self.snapshotView = snapshotView + self.sourceFrameInWindow = sourceFrameInWindow + self.cornerRadius = cornerRadius + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift index 7b143ed..4d63838 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift @@ -1,3 +1,4 @@ +import ObjectiveC import QuartzCore import UIKit @@ -36,12 +37,8 @@ final class VoiceRecordingOverlay { private var currentLevel: CGFloat = 0 private var inputLevel: CGFloat = 0 - var dismissFactor: CGFloat = 1.0 { - didSet { - let s = max(0.3, min(dismissFactor, 1.0)) - containerView.transform = CGAffineTransform(scaleX: s, y: s) - } - } + private var isLocked = false + private var onTapStop: (() -> Void)? // MARK: - Init @@ -67,6 +64,11 @@ final class VoiceRecordingOverlay { containerView.layer.addSublayer(micIconLayer) } + deinit { + displayLink?.invalidate() + containerView.removeFromSuperview() + } + private func configureMicIcon() { let viewBox = CGSize(width: 17.168, height: 23.555) let targetSize = CGSize(width: 25, height: 34) @@ -138,11 +140,107 @@ final class VoiceRecordingOverlay { startDisplayLink() } + // MARK: - Lock Transition (mic β†’ stop icon, tappable) + + /// Transition to locked state: mic icon β†’ stop icon, overlay becomes tappable. + /// Telegram: TGModernConversationInputMicButton.m line 616-693 + func transitionToLocked(onTapStop: @escaping () -> Void) { + isLocked = true + self.onTapStop = onTapStop + containerView.isUserInteractionEnabled = true + + // Add tap gesture via helper target + let tapTarget = TapTarget { onTapStop() } + let tap = UITapGestureRecognizer(target: tapTarget, action: #selector(TapTarget.tapped)) + containerView.addGestureRecognizer(tap) + // Retain the target via associated object + objc_setAssociatedObject(containerView, "tapTarget", tapTarget, .OBJC_ASSOCIATION_RETAIN) + + // Reset drag transforms: scale back to 1.0, position to center + UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { + self.innerCircle.transform = .identity + self.outerCircle.transform = CGAffineTransform( + scaleX: self.outerMinScale, y: self.outerMinScale + ) + } + + // Transition icon: mic β†’ stop (two vertical bars) + transitionToStopIcon() + } + + /// Animate mic icon β†’ stop icon (Telegram: snapshot + cross-fade, 0.3s) + private func transitionToStopIcon() { + // Create stop icon path (two parallel vertical bars, Telegram exact) + let stopPath = UIBezierPath() + let barW: CGFloat = 4 + let barH: CGFloat = 16 + let gap: CGFloat = 6 + let totalW = barW * 2 + gap + let originX = -totalW / 2 + let originY = -barH / 2 + // Left bar + stopPath.append(UIBezierPath( + roundedRect: CGRect(x: originX, y: originY, width: barW, height: barH), + cornerRadius: 1 + )) + // Right bar + stopPath.append(UIBezierPath( + roundedRect: CGRect(x: originX + barW + gap, y: originY, width: barW, height: barH), + cornerRadius: 1 + )) + + // Animate: old icon scales down, new icon scales up + let newIconLayer = CAShapeLayer() + newIconLayer.path = stopPath.cgPath + newIconLayer.fillColor = UIColor.white.cgColor + let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) + newIconLayer.position = mid + newIconLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1) + newIconLayer.opacity = 0 + containerView.layer.addSublayer(newIconLayer) + + // Old mic icon scales to 0 + let shrink = CABasicAnimation(keyPath: "transform.scale") + shrink.toValue = 0.001 + shrink.duration = 0.3 + shrink.fillMode = .forwards + shrink.isRemovedOnCompletion = false + micIconLayer.add(shrink, forKey: "shrink") + + let fadeOut = CABasicAnimation(keyPath: "opacity") + fadeOut.toValue = 0 + fadeOut.duration = 0.2 + fadeOut.fillMode = .forwards + fadeOut.isRemovedOnCompletion = false + micIconLayer.add(fadeOut, forKey: "fadeOutMic") + + // New stop icon grows in + let grow = CABasicAnimation(keyPath: "transform.scale") + grow.fromValue = 0.3 + grow.toValue = 1.0 + grow.duration = 0.3 + grow.fillMode = .forwards + grow.isRemovedOnCompletion = false + newIconLayer.add(grow, forKey: "grow") + + let fadeIn = CABasicAnimation(keyPath: "opacity") + fadeIn.fromValue = 0 + fadeIn.toValue = 1 + fadeIn.duration = 0.25 + fadeIn.fillMode = .forwards + fadeIn.isRemovedOnCompletion = false + newIconLayer.add(fadeIn, forKey: "fadeInStop") + } + // MARK: - Dismiss (Telegram exact: 0.18s, scaleβ†’0.2, alphaβ†’0) func dismiss() { stopDisplayLink() + // Capture containerView strongly β€” overlay may be deallocated before the + // delayed cleanup fires (ComposerView sets recordingOverlay = nil immediately). + let container = containerView + UIView.animate(withDuration: 0.18, animations: { self.innerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) self.innerCircle.alpha = 0 @@ -158,12 +256,34 @@ final class VoiceRecordingOverlay { iconFade.isRemovedOnCompletion = false micIconLayer.add(iconFade, forKey: "fadeOut") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.containerView.removeFromSuperview() - self?.micIconLayer.removeAllAnimations() - self?.micIconLayer.opacity = 1 - self?.currentLevel = 0 - self?.inputLevel = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + container.removeFromSuperview() + } + } + + /// Cancel-specific dismiss: leftward translation matching cancel drag direction. + func dismissCancel() { + stopDisplayLink() + + let container = containerView + + UIView.animate(withDuration: 0.18, animations: { + self.innerCircle.transform = CGAffineTransform(translationX: -80, y: 0) + .scaledBy(x: 0.2, y: 0.2) + self.innerCircle.alpha = 0 + self.outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) + self.outerCircle.alpha = 0 + }) + + let iconFade = CABasicAnimation(keyPath: "opacity") + iconFade.toValue = 0.0 + iconFade.duration = 0.18 + iconFade.fillMode = .forwards + iconFade.isRemovedOnCompletion = false + micIconLayer.add(iconFade, forKey: "fadeOut") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + container.removeFromSuperview() } } @@ -173,6 +293,48 @@ final class VoiceRecordingOverlay { inputLevel = level } + // MARK: - Drag Transforms (Telegram: displayLinkUpdate lines 921-936) + + /// Apply drag-based transforms to overlay circles. + /// distanceX: negative = sliding left (cancel), distanceY: negative = sliding up (lock) + /// Telegram exact from TGModernConversationInputMicButton.m + func applyDragTransform(distanceX: CGFloat, distanceY: CGFloat) { + guard CACurrentMediaTime() > animationStartTime else { return } + + // Telegram cancel-transform threshold: 8pt + guard abs(distanceX) > 8 || abs(distanceY) > 8 else { return } + + // Telegram line 763: normalize to 0..1 over 300pt range + let valueX = max(0, min(1, abs(distanceX) / 300)) + + // Telegram line 768: inner scale squeezes from 1.0 β†’ 0.4 + let innerScale = max(0.4, min(1.0, 1.0 - valueX)) + + // Vertical translation (follows finger) + let translation = CGAffineTransform(translationX: 0, y: distanceY) + + // Telegram line 922-924: outer circle = translation + audio scale + let outerScale = outerMinScale + currentLevel * (1.0 - outerMinScale) + outerCircle.transform = translation.scaledBy(x: outerScale, y: outerScale) + + // Telegram line 931-932: inner circle = translation + cancel scale + horizontal offset + let innerTransform = translation + .scaledBy(x: innerScale, y: innerScale) + .translatedBy(x: distanceX, y: 0) + innerCircle.transform = innerTransform + + // Icon follows inner circle + CATransaction.begin() + CATransaction.setDisableActions(true) + let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2) + micIconLayer.position = CGPoint( + x: mid.x + distanceX * innerScale, + y: mid.y + distanceY + ) + micIconLayer.transform = CATransform3DMakeScale(innerScale, innerScale, 1) + CATransaction.commit() + } + // MARK: - Display Link (Telegram: displayLinkEvent, 0.8/0.2 smoothing) private func startDisplayLink() { @@ -194,8 +356,8 @@ final class VoiceRecordingOverlay { // Telegram: wait 0.5s for spring to settle before reacting to audio guard CACurrentMediaTime() > animationStartTime + 0.5 else { return } - // Telegram exact smoothing (ChatTextInputAudioRecordingOverlay line 162) - currentLevel = currentLevel * 0.8 + inputLevel * 0.2 + // Telegram exact: TGModernConversationInputMicButton.m line 916 (0.9/0.1) + currentLevel = currentLevel * 0.9 + inputLevel * 0.1 // Telegram exact: outerCircleMinScale + currentLevel * (1.0 - outerCircleMinScale) let scale = outerMinScale + currentLevel * (1.0 - outerMinScale) @@ -210,3 +372,11 @@ private final class DisplayLinkTarget: NSObject { init(_ callback: @escaping () -> Void) { self.callback = callback } @objc func tick() { callback() } } + +// MARK: - TapTarget + +private final class TapTarget: NSObject { + let callback: () -> Void + init(_ callback: @escaping () -> Void) { self.callback = callback } + @objc func tapped() { callback() } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift index cff9900..a69d520 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift @@ -22,7 +22,7 @@ final class VoiceRecordingPanel: UIView { // MARK: - Subviews - // Glass background + // Glass background (matches input container style) private let glassBackground = TelegramGlassUIView(frame: .zero) // Red dot (10Γ—10, #FF2D55) @@ -42,11 +42,12 @@ final class VoiceRecordingPanel: UIView { // MARK: - State private(set) var isDisplayingCancel = false + private var isEntryAnimationComplete = false // MARK: - Telegram-exact layout constants - private let dotX: CGFloat = 16 - private let timerX: CGFloat = 34 + private let dotX: CGFloat = 5 // Telegram: indicator X=5 + private let timerX: CGFloat = 40 // Telegram: timer X=40 private let dotSize: CGFloat = 10 private let arrowLabelGap: CGFloat = 6 @@ -76,23 +77,29 @@ final class VoiceRecordingPanel: UIView { redDot.layer.cornerRadius = dotSize / 2 addSubview(redDot) - // Timer: 15pt monospaced - timerLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) + // Timer: 15pt monospaced rounded (Telegram: Font.with(size: 15, design: .camera)) + if let descriptor = UIFont.systemFont(ofSize: 15, weight: .regular) + .fontDescriptor.withDesign(.rounded) { + timerLabel.font = UIFont(descriptor: descriptor, size: 15) + } else { + timerLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) + } timerLabel.textColor = .white timerLabel.text = "0:00" addSubview(timerLabel) - // Arrow icon (template, white 30% alpha like panelControlColor on dark) - let arrowConfig = UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold) - arrowIcon.image = UIImage(systemName: "chevron.left", withConfiguration: arrowConfig) - arrowIcon.tintColor = UIColor.white.withAlphaComponent(0.4) + // Arrow: exact Telegram SVG "AudioRecordingCancelArrow" (arrowleft.svg, 9Γ—18pt) + arrowIcon.image = Self.makeCancelArrowImage() + arrowIcon.contentMode = .center cancelContainer.addSubview(arrowIcon) - // "Slide to cancel" label: 14pt regular + // "Slide to cancel": 14pt regular, panelControlColor = #FFFFFF (dark theme) slideLabel.font = .systemFont(ofSize: 14, weight: .regular) - slideLabel.textColor = UIColor.white.withAlphaComponent(0.4) + slideLabel.textColor = .white slideLabel.text = "Slide to cancel" cancelContainer.addSubview(slideLabel) + cancelContainer.isAccessibilityElement = true + cancelContainer.accessibilityLabel = "Slide left to cancel recording" addSubview(cancelContainer) // Cancel button (for locked state): 17pt @@ -100,6 +107,9 @@ final class VoiceRecordingPanel: UIView { cancelButton.setTitleColor(.white, for: .normal) cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .regular) cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + cancelButton.isAccessibilityElement = true + cancelButton.accessibilityLabel = "Cancel recording" + cancelButton.accessibilityHint = "Discards the current recording." cancelButton.alpha = 0 addSubview(cancelButton) } @@ -129,16 +139,19 @@ final class VoiceRecordingPanel: UIView { // Timer: at X=34 timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerSize.width + 4, height: timerSize.height) - // Cancel indicator: centered in available width + // Cancel indicator: centered in full panel width + // Telegram: frame.width = arrowSize.width + 12.0 + labelLayout.size.width let labelSize = slideLabel.sizeThatFits(CGSize(width: 200, height: h)) - let arrowW: CGFloat = 12 - let totalCancelW = arrowW + arrowLabelGap + labelSize.width + let arrowW: CGFloat = 9 // Telegram SVG: 9pt wide + let arrowH: CGFloat = 18 // Telegram SVG: 18pt tall + let totalCancelW = arrowW + 12 + labelSize.width // Telegram: arrowWidth + 12 + labelWidth let cancelX = floor((w - totalCancelW) / 2) cancelContainer.frame = CGRect(x: cancelX, y: 0, width: totalCancelW, height: h) - arrowIcon.frame = CGRect(x: 0, y: floor((h - 12) / 2), width: arrowW, height: 12) + arrowIcon.frame = CGRect(x: 0, y: floor((h - arrowH) / 2), width: arrowW, height: arrowH) + // Telegram: label X = arrowSize.width + 6.0 slideLabel.frame = CGRect( - x: arrowW + arrowLabelGap, + x: arrowW + 6, y: 1 + floor((h - labelSize.height) / 2), width: labelSize.width, height: labelSize.height @@ -163,16 +176,18 @@ final class VoiceRecordingPanel: UIView { /// Updates cancel indicator position based on horizontal drag. /// translation is negative (finger sliding left). func updateCancelTranslation(_ translation: CGFloat) { - guard !isDisplayingCancel else { return } + guard !isDisplayingCancel, isEntryAnimationComplete else { return } - // Telegram: indicatorTranslation = max(0, cancelTranslation - 8) - let offset = max(0, abs(translation) - 8) + // Only apply transform when actually dragging (threshold 8pt) + let drag = abs(translation) + guard drag > 8 else { return } + + let offset = drag - 8 cancelContainer.transform = CGAffineTransform(translationX: -offset * 0.5, y: 0) - // Telegram: alpha = max(0, min(1, (frameMinX - 100) / 10)) - let minX = cancelContainer.frame.minX - offset * 0.5 - let alpha = max(0, min(1, (minX - 100) / 10)) - cancelContainer.alpha = alpha + // Fade: starts at 60% of cancel threshold (90pt drag), fully hidden at threshold + let fadeProgress = max(0, min(1, (drag - 90) / 60)) + cancelContainer.alpha = 1 - fadeProgress } /// Animate panel in. Called when recording begins. @@ -195,7 +210,7 @@ final class VoiceRecordingPanel: UIView { timerLabel.alpha = 0 let timerStartX = timerLabel.frame.origin.x - 30 timerLabel.transform = CGAffineTransform(translationX: -30, y: 0) - UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: 0, options: []) { + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: []) { self.timerLabel.alpha = 1 self.timerLabel.transform = .identity } @@ -203,17 +218,18 @@ final class VoiceRecordingPanel: UIView { // Cancel indicator: slide in from right, spring 0.4s (Telegram exact) cancelContainer.alpha = 1 cancelContainer.transform = CGAffineTransform(translationX: panelWidth * 0.3, y: 0) - UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: 0, options: []) { + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: []) { self.cancelContainer.transform = .identity } - // Start jiggle after cancel slides in (Telegram: 6pt, 1.0s, easeInOut, infinite) + // Mark entry animation complete + start jiggle after spring settles DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.isEntryAnimationComplete = true self?.startCancelJiggle() } } - /// Animate panel out. Called when recording ends. + /// Animate panel out. Called when recording ends normally (send). func animateOut(completion: (() -> Void)? = nil) { stopDotPulsing() stopCancelJiggle() @@ -231,6 +247,43 @@ final class VoiceRecordingPanel: UIView { }) } + /// Cancel-specific dismiss: red dot "trash" animation + timer slide left. + /// Telegram: MediaRecordingPanelComponent animateOut(dismissRecording:) + func animateOutCancel(completion: (() -> Void)? = nil) { + stopDotPulsing() + stopCancelJiggle() + + // Red dot: scale pulse 1β†’1.3β†’0, color redβ†’gray + UIView.animate(withDuration: 0.15, animations: { + self.redDot.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) + self.redDot.backgroundColor = .gray + }, completion: { _ in + UIView.animate(withDuration: 0.15, animations: { + self.redDot.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + self.redDot.alpha = 0 + }) + }) + + // Timer: scale to 0, slide left + UIView.animate(withDuration: 0.25) { + self.timerLabel.transform = CGAffineTransform(translationX: -30, y: 0) + .scaledBy(x: 0.001, y: 0.001) + self.timerLabel.alpha = 0 + } + + // Cancel indicator: fade out + UIView.animate(withDuration: 0.25) { + self.cancelContainer.alpha = 0 + self.cancelButton.alpha = 0 + } + + // Remove after animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.removeFromSuperview() + completion?() + } + } + /// Transition cancel indicator to "Cancel" button (locked state). /// Telegram: arrow+label shrink up (-22pt, scale 0.25), button grows down. func showCancelButton() { @@ -291,4 +344,49 @@ final class VoiceRecordingPanel: UIView { @objc private func cancelTapped() { delegate?.recordingPanelDidTapCancel(self) } + + // MARK: - Telegram Cancel Arrow (exact SVG from arrowleft.svg, 9Γ—18pt) + + private static func makeCancelArrowImage() -> UIImage { + let size = CGSize(width: 9, height: 18) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let path = UIBezierPath() + // Exact path from Telegram's arrowleft.svg + path.move(to: CGPoint(x: 8.438, y: 0.500)) + path.addCurve( + to: CGPoint(x: 8.500, y: 1.438), + controlPoint1: CGPoint(x: 8.714, y: 0.741), + controlPoint2: CGPoint(x: 8.742, y: 1.161) + ) + path.addLine(to: CGPoint(x: 1.884, y: 9.000)) + path.addLine(to: CGPoint(x: 8.500, y: 16.562)) + path.addCurve( + to: CGPoint(x: 8.438, y: 17.500), + controlPoint1: CGPoint(x: 8.742, y: 16.839), + controlPoint2: CGPoint(x: 8.714, y: 17.259) + ) + path.addCurve( + to: CGPoint(x: 7.500, y: 17.438), + controlPoint1: CGPoint(x: 8.161, y: 17.742), + controlPoint2: CGPoint(x: 7.741, y: 17.714) + ) + path.addLine(to: CGPoint(x: 0.499, y: 9.438)) + path.addCurve( + to: CGPoint(x: 0.499, y: 8.562), + controlPoint1: CGPoint(x: 0.280, y: 9.187), + controlPoint2: CGPoint(x: 0.280, y: 8.813) + ) + path.addLine(to: CGPoint(x: 7.500, y: 0.562)) + path.addCurve( + to: CGPoint(x: 8.438, y: 0.500), + controlPoint1: CGPoint(x: 7.741, y: 0.286), + controlPoint2: CGPoint(x: 8.161, y: 0.258) + ) + path.close() + + UIColor.white.setFill() + path.fill() + } + } } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 4b68bc2..7d754d4 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -130,7 +130,7 @@ struct MainTabView: View { .tag(RosettaTab.chats) .badge(cachedUnreadCount) - SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) + SettingsContainerView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) .tabItem { Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) } @@ -216,7 +216,7 @@ struct MainTabView: View { CallsView() .callBarSafeAreaInset(callBarTopPadding) case .settings: - SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) + SettingsContainerView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) .callBarSafeAreaInset(callBarTopPadding) } } else { diff --git a/Rosetta/Features/Settings/SettingsProfileHeader.swift b/Rosetta/Features/Settings/SettingsProfileHeader.swift index 199227e..a9d379c 100644 --- a/Rosetta/Features/Settings/SettingsProfileHeader.swift +++ b/Rosetta/Features/Settings/SettingsProfileHeader.swift @@ -35,7 +35,7 @@ struct SettingsProfileHeader: View { ) .background(RosettaColors.Adaptive.background) .opacity(1 - scrollProgress) - .blur(radius: scrollProgress * 10, opaque: true) + .blur(radius: scrollProgress * 10) .clipShape(Circle()) .anchorPreference(key: AnchorKey.self, value: .bounds) { ["HEADER": $0] diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 7bac26f..74bd6b5 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -148,10 +148,70 @@ struct SettingsView: View { } } - // Toolbar OUTSIDE NavigationStack β€” above hidden nav bar + // Toolbar OUTSIDE NavigationStack β€” sits in safe area naturally if !isDetailPresented { - settingsToolbarOverlay(safeArea: viewSafeArea) - .ignoresSafeArea(.all, edges: .top) + HStack { + if isEditingProfile { + Button { + pendingAvatarPhoto = nil + withAnimation(.easeInOut(duration: 0.2)) { + isEditingProfile = false + } + } label: { + Text("Cancel") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + .disabled(isSaving) + } else { + DarkModeButton() + .glassCircle() + } + + Spacer() + + if isEditingProfile { + Button { + saveProfile() + } label: { + Text("Done") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle( + hasProfileChanges + ? RosettaColors.Adaptive.text + : RosettaColors.Adaptive.text.opacity(0.4) + ) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + } else { + Button { + editDisplayName = viewModel.displayName + editUsername = viewModel.username + displayNameError = nil + usernameError = nil + pendingAvatarPhoto = nil + withAnimation(.easeInOut(duration: 0.2)) { + isEditingProfile = true + } + } label: { + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + } + } + .padding(.horizontal, 15) } } } @@ -180,48 +240,6 @@ struct SettingsView: View { .transition(.opacity) } } - .backgroundPreferenceValue(AnchorKey.self) { pref in - GeometryReader { proxy in - if let anchor = pref["HEADER"], isHavingNotch { - let frameRect = proxy[anchor] - let isHavingDynamicIsland = safeArea.top > 51 - let capsuleHeight: CGFloat = isHavingDynamicIsland ? 37 : (safeArea.top - 15) - - Canvas { out, canvasSize in - out.addFilter(.alphaThreshold(min: 0.5)) - out.addFilter(.blur(radius: 12)) - out.drawLayer { ctx in - if let headerView = out.resolveSymbol(id: 0) { - ctx.draw(headerView, in: frameRect) - } - if let dynamicIsland = out.resolveSymbol(id: 1) { - let rect = CGRect( - x: (canvasSize.width - 120) / 2, - y: isHavingDynamicIsland ? 11 : 0, - width: 120, - height: capsuleHeight - ) - ctx.draw(dynamicIsland, in: rect) - } - } - } symbols: { - Circle() - .fill(.black) - .frame(width: frameRect.width, height: frameRect.height) - .tag(0).id(0) - Capsule() - .fill(.black) - .frame(width: 120, height: capsuleHeight) - .tag(1).id(1) - } - } - } - .overlay(alignment: .top) { - Rectangle() - .fill(RosettaColors.Adaptive.background) - .frame(height: 15) - } - } .coordinateSpace(name: "SETTINGS_SCROLL") } diff --git a/Rosetta/Features/Settings/SettingsViewController.swift b/Rosetta/Features/Settings/SettingsViewController.swift new file mode 100644 index 0000000..4b02858 --- /dev/null +++ b/Rosetta/Features/Settings/SettingsViewController.swift @@ -0,0 +1,681 @@ +import SwiftUI +import UIKit + +// MARK: - SettingsViewController + +/// Pure UIKit Settings screen β€” replaces SwiftUI SettingsView. +/// Frame-based layout (Telegram pattern), no Auto Layout. +/// Sub-screens (Appearance, Updates, Safety, Backup) stay SwiftUI via UIHostingController. +final class SettingsViewController: UIViewController, UIScrollViewDelegate { + + // MARK: - Callbacks + + var onLogout: (() -> Void)? + var onAddAccount: ((AuthScreen) -> Void)? + /// Reports editing/detail state to parent so tab bar hides when appropriate. + var onEditingStateChanged: ((Bool) -> Void)? + var onDetailStateChanged: ((Bool) -> Void)? + + // MARK: - State + + private let viewModel = SettingsViewModel() + private var avatarImage: UIImage? + private var isBiometricEnabled = false + + // MARK: - Views + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let toolbarView = UIView() + + // Profile header + private let avatarContainer = UIView() + private var avatarHosting: UIHostingController? + private let nameLabel = UILabel() + private let usernameLabel = UILabel() + private var publicKeyView: CopyableLabel? + + // Cards + private var accountCard: UIView? + private var appearanceCard: UIView! + private var updatesCard: UIView! + private var biometricCard: UIView? + private let biometricSwitch = UISwitch() + private var safetyCard: UIView! + private var footerView: UIView! + + // Toolbar buttons (DarkModeButton is SwiftUI β€” uses circular reveal animation + AppStorage theme) + private var darkModeHosting: UIHostingController? + private var editHosting: UIHostingController? + + // MARK: - Layout Constants + + private let hPad: CGFloat = 16 + private let cardCornerRadius: CGFloat = 26 + private let rowHeight: CGFloat = 52 + private let iconSize: CGFloat = 26 + private let iconCornerRadius: CGFloat = 6 + private let cardFill = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 28/255, green: 28/255, blue: 30/255, alpha: 1) + : UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1) + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + navigationController?.setNavigationBarHidden(true, animated: false) + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + + setupScrollView() + setupToolbar() + setupProfileHeader() + setupCards() + setupFooter() + + refresh() + + NotificationCenter.default.addObserver( + self, selector: #selector(profileDidUpdate), + name: .profileDidUpdate, object: nil + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + onDetailStateChanged?(false) + refresh() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutAll() + } + + @objc private func profileDidUpdate() { + refresh() + } + + // MARK: - Refresh + + private func refresh() { + viewModel.refresh() + avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey) + refreshBiometricState() + updateProfileHeader() + } + + // MARK: - Setup Scroll View + + private func setupScrollView() { + scrollView.delegate = self + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = true + scrollView.backgroundColor = .clear + view.addSubview(scrollView) + + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + } + + // MARK: - Setup Toolbar + + private func setupToolbar() { + toolbarView.backgroundColor = .clear + view.addSubview(toolbarView) + + // Dark mode button β€” SwiftUI DarkModeButton (circular reveal animation + theme system) + let darkModeView = AnyView( + DarkModeButton() + .glassCircle() + ) + let darkHosting = UIHostingController(rootView: darkModeView) + darkHosting.view.backgroundColor = .clear + addChild(darkHosting) + toolbarView.addSubview(darkHosting.view) + darkHosting.didMove(toParent: self) + darkModeHosting = darkHosting + + // Edit button β€” SwiftUI for glass consistency + let editView = AnyView( + SettingsEditButton { [weak self] in + self?.editTapped() + } + ) + let editHost = UIHostingController(rootView: editView) + editHost.view.backgroundColor = UIColor.clear + addChild(editHost) + toolbarView.addSubview(editHost.view) + editHost.didMove(toParent: self) + editHosting = editHost + } + + // MARK: - Setup Profile Header + + private func setupProfileHeader() { + contentView.addSubview(avatarContainer) + + nameLabel.font = .systemFont(ofSize: 22, weight: .bold) + nameLabel.textColor = UIColor(RosettaColors.Adaptive.text) + nameLabel.textAlignment = .center + contentView.addSubview(nameLabel) + + usernameLabel.font = .systemFont(ofSize: 15, weight: .regular) + usernameLabel.textColor = UIColor(RosettaColors.secondaryText) + usernameLabel.textAlignment = .center + contentView.addSubview(usernameLabel) + + let pkView = CopyableLabel() + pkView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + pkView.textColor = UIColor(RosettaColors.tertiaryText) + pkView.textAlignment = .center + contentView.addSubview(pkView) + publicKeyView = pkView + } + + // MARK: - Setup Cards + + private func setupCards() { + appearanceCard = makeSettingsCard( + icon: "paintbrush.fill", title: "Appearance", iconColor: .systemBlue, + subtitle: "Customize theme, wallpaper and chat appearance.", + action: #selector(appearanceTapped) + ) + contentView.addSubview(appearanceCard) + + updatesCard = makeSettingsCard( + icon: "arrow.triangle.2.circlepath", title: "Updates", iconColor: .systemGreen, + subtitle: "You can check for new versions of the app here. Updates may include security improvements and new features.", + action: #selector(updatesTapped) + ) + contentView.addSubview(updatesCard) + + if BiometricAuthManager.shared.isBiometricAvailable { + let card = makeBiometricCard() + contentView.addSubview(card) + biometricCard = card + } + + safetyCard = makeSettingsCard( + icon: "shield.lefthalf.filled", title: "Safety", iconColor: .systemPurple, + subtitle: "You can learn more about your safety on the safety page, please make sure you are viewing the screen alone before proceeding to the safety page.", + action: #selector(safetyTapped) + ) + contentView.addSubview(safetyCard) + } + + // MARK: - Setup Footer + + private func setupFooter() { + let footer = UIView() + let label = UILabel() + label.text = "rosetta – powering freedom" + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(RosettaColors.tertiaryText) + label.textAlignment = .center + footer.addSubview(label) + label.tag = 100 + contentView.addSubview(footer) + footerView = footer + } + + // MARK: - Layout + + private func layoutAll() { + let w = view.bounds.width + let safeTop = view.safeAreaInsets.top + let safeBottom = view.safeAreaInsets.bottom + + // Scroll view: full screen + scrollView.frame = view.bounds + + // Toolbar: pinned at top in safe area + let toolbarH: CGFloat = 44 + toolbarView.frame = CGRect(x: 0, y: safeTop, width: w, height: toolbarH) + + darkModeHosting?.view.frame = CGRect(x: hPad, y: 0, width: 44, height: 44) + editHosting?.view.frame = CGRect(x: w - hPad - 80, y: 0, width: 80, height: 44) + + // Content layout + var y: CGFloat = safeTop + toolbarH + 15 + + // Avatar (100Γ—100, centered) + let avatarSize: CGFloat = 100 + let avatarX = floor((w - avatarSize) / 2) + avatarContainer.frame = CGRect(x: avatarX, y: y, width: avatarSize, height: avatarSize) + layoutAvatarHosting() + y += avatarSize + 12 + + // Name + nameLabel.sizeToFit() + nameLabel.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: 28) + y += 28 + + // Username + if !usernameLabel.isHidden { + usernameLabel.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: 20) + y += 24 + } + + // Public key + if let pkView = publicKeyView { + pkView.frame = CGRect(x: hPad, y: y, width: w - hPad * 2, height: 16) + y += 28 + } + + // Cards + let cardW = w - hPad * 2 + + y += 16 + layoutCard(appearanceCard, x: hPad, y: &y, width: cardW) + y += 16 + layoutCard(updatesCard, x: hPad, y: &y, width: cardW) + + if let bioCard = biometricCard { + y += 16 + layoutCard(bioCard, x: hPad, y: &y, width: cardW) + } + + y += 16 + layoutCard(safetyCard, x: hPad, y: &y, width: cardW) + + // Footer + y += 32 + footerView.frame = CGRect(x: hPad, y: y, width: cardW, height: 20) + if let label = footerView.viewWithTag(100) as? UILabel { + label.frame = CGRect(x: 0, y: 0, width: cardW, height: 20) + } + y += 20 + + // Content size + let contentH = y + safeBottom + 100 + contentView.frame = CGRect(x: 0, y: 0, width: w, height: contentH) + scrollView.contentSize = CGSize(width: w, height: contentH) + } + + private func layoutCard(_ card: UIView, x: CGFloat, y: inout CGFloat, width: CGFloat) { + // Card has: row (52pt) + subtitle label + let rowView = card.viewWithTag(200) + let subtitleLabel = card.viewWithTag(201) as? UILabel + let bgView = card.viewWithTag(199) + + let subtitleH: CGFloat + if let sub = subtitleLabel, let text = sub.text, !text.isEmpty { + let maxW = width - 32 + let size = sub.sizeThatFits(CGSize(width: maxW, height: .greatestFiniteMagnitude)) + subtitleH = size.height + 16 + sub.frame = CGRect(x: 16, y: rowHeight + 8, width: maxW, height: size.height) + } else { + subtitleH = 0 + } + + let totalH = rowHeight + subtitleH + card.frame = CGRect(x: x, y: y, width: width, height: totalH) + bgView?.frame = CGRect(x: 0, y: 0, width: width, height: rowHeight) + rowView?.frame = CGRect(x: 0, y: 0, width: width, height: rowHeight) + + y += totalH + } + + // MARK: - Avatar + + private func layoutAvatarHosting() { + let size = avatarContainer.bounds.size + guard size.width > 0 else { return } + + if let hosting = avatarHosting { + hosting.view.frame = CGRect(origin: .zero, size: size) + } + } + + private func updateAvatarHosting() { + let avatarView = AvatarView( + initials: viewModel.initials, + colorIndex: viewModel.avatarColorIndex, + size: 100, + isSavedMessages: false, + image: avatarImage + ) + + if let hosting = avatarHosting { + hosting.rootView = avatarView + } else { + let hosting = UIHostingController(rootView: avatarView) + hosting.view.backgroundColor = .clear + addChild(hosting) + avatarContainer.addSubview(hosting.view) + hosting.didMove(toParent: self) + avatarHosting = hosting + } + layoutAvatarHosting() + } + + // MARK: - Update Profile Header + + private func updateProfileHeader() { + nameLabel.text = viewModel.headerName + usernameLabel.text = viewModel.username.isEmpty ? nil : "@\(viewModel.username)" + usernameLabel.isHidden = viewModel.username.isEmpty + + let pk = viewModel.publicKey + let displayPK = pk.count > 16 + ? String(pk.prefix(8)) + "..." + String(pk.suffix(6)) + : pk + publicKeyView?.text = displayPK + publicKeyView?.textToCopy = pk + + updateAvatarHosting() + biometricSwitch.isOn = isBiometricEnabled + view.setNeedsLayout() + } + + // MARK: - Card Factory + + private func makeSettingsCard( + icon: String, title: String, iconColor: UIColor, + subtitle: String, action: Selector + ) -> UIView { + let container = UIView() + + // Background + let bg = UIView() + bg.backgroundColor = cardFill + bg.layer.cornerRadius = cardCornerRadius + bg.layer.cornerCurve = .continuous + bg.tag = 199 + container.addSubview(bg) + + // Row (tappable) + let row = UIControl() + row.tag = 200 + row.addTarget(self, action: action, for: .touchUpInside) + container.addSubview(row) + + // Icon + let iconBg = UIView() + iconBg.backgroundColor = iconColor + iconBg.layer.cornerRadius = iconCornerRadius + iconBg.frame = CGRect(x: 16, y: (rowHeight - iconSize) / 2, width: iconSize, height: iconSize) + let iconImg = UIImageView(image: UIImage( + systemName: icon, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium) + )) + iconImg.tintColor = .white + iconImg.contentMode = .center + iconImg.frame = iconBg.bounds + iconBg.addSubview(iconImg) + row.addSubview(iconBg) + + // Title + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.frame = CGRect(x: 16 + iconSize + 16, y: 0, width: 200, height: rowHeight) + row.addSubview(titleLabel) + + // Chevron + let chevron = UIImageView(image: UIImage( + systemName: "chevron.right", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + )) + chevron.tintColor = .tertiaryLabel + chevron.contentMode = .center + chevron.frame = CGRect(x: 0, y: 0, width: 20, height: rowHeight) + chevron.autoresizingMask = [.flexibleLeftMargin] + row.addSubview(chevron) + chevron.tag = 202 + + // Subtitle + let sub = UILabel() + sub.text = subtitle + sub.font = .systemFont(ofSize: 13) + sub.textColor = .secondaryLabel + sub.numberOfLines = 0 + sub.tag = 201 + container.addSubview(sub) + + return container + } + + private func makeBiometricCard() -> UIView { + let biometric = BiometricAuthManager.shared + let container = UIView() + + // Background + let bg = UIView() + bg.backgroundColor = cardFill + bg.layer.cornerRadius = cardCornerRadius + bg.layer.cornerCurve = .continuous + bg.tag = 199 + container.addSubview(bg) + + // Row + let row = UIView() + row.tag = 200 + container.addSubview(row) + + // Icon + let iconBg = UIView() + iconBg.backgroundColor = .systemBlue + iconBg.layer.cornerRadius = iconCornerRadius + iconBg.frame = CGRect(x: 16, y: (rowHeight - iconSize) / 2, width: iconSize, height: iconSize) + let iconImg = UIImageView(image: UIImage( + systemName: biometric.biometricIconName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium) + )) + iconImg.tintColor = .white + iconImg.contentMode = .center + iconImg.frame = iconBg.bounds + iconBg.addSubview(iconImg) + row.addSubview(iconBg) + + // Title + let titleLabel = UILabel() + titleLabel.text = biometric.biometricName + titleLabel.font = .systemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.frame = CGRect(x: 16 + iconSize + 16, y: 0, width: 200, height: rowHeight) + row.addSubview(titleLabel) + + // Switch + biometricSwitch.isOn = isBiometricEnabled + biometricSwitch.onTintColor = UIColor(RosettaColors.primaryBlue) + biometricSwitch.addTarget(self, action: #selector(biometricToggled), for: .valueChanged) + row.addSubview(biometricSwitch) + + // Subtitle + let sub = UILabel() + sub.text = "Use \(biometric.biometricName) to unlock Rosetta instead of entering your password." + sub.font = .systemFont(ofSize: 13) + sub.textColor = .secondaryLabel + sub.numberOfLines = 0 + sub.tag = 201 + container.addSubview(sub) + + return container + } + + // MARK: - Layout Card Fixups + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + // Position chevrons at right edge + for card in [appearanceCard, updatesCard, safetyCard].compactMap({ $0 }) { + if let row = card.viewWithTag(200), let chevron = row.viewWithTag(202) as? UIImageView { + chevron.frame = CGRect(x: row.bounds.width - 36, y: 0, width: 20, height: rowHeight) + } + } + // Position biometric switch + if let bioCard = biometricCard, let row = bioCard.viewWithTag(200) { + let switchW: CGFloat = 51 + biometricSwitch.frame = CGRect( + x: row.bounds.width - switchW - 16, + y: (rowHeight - 31) / 2, + width: switchW, height: 31 + ) + } + } + + // MARK: - Actions + + private func editTapped() { + let editView = ProfileEditView( + onAddAccount: onAddAccount, + displayName: .constant(viewModel.displayName), + username: .constant(viewModel.username), + publicKey: viewModel.publicKey, + displayNameError: .constant(nil), + usernameError: .constant(nil), + pendingPhoto: .constant(nil) + ) + let hosting = UIHostingController(rootView: editView) + hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + onEditingStateChanged?(true) + navigationController?.pushViewController(hosting, animated: true) + } + + @objc private func appearanceTapped() { + let hosting = UIHostingController(rootView: AppearanceView()) + hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + onDetailStateChanged?(true) + navigationController?.pushViewController(hosting, animated: true) + } + + @objc private func updatesTapped() { + let hosting = UIHostingController(rootView: UpdatesView()) + hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + onDetailStateChanged?(true) + navigationController?.pushViewController(hosting, animated: true) + } + + @objc private func safetyTapped() { + let hosting = UIHostingController(rootView: SafetyView(onLogout: onLogout)) + hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + onDetailStateChanged?(true) + navigationController?.pushViewController(hosting, animated: true) + } + + @objc private func biometricToggled() { + let newValue = biometricSwitch.isOn + if newValue { + showBiometricPasswordPrompt() + } else { + disableBiometric() + } + } + + // MARK: - Biometric + + private func refreshBiometricState() { + let pk = viewModel.publicKey + guard !pk.isEmpty else { return } + isBiometricEnabled = BiometricAuthManager.shared.isBiometricEnabled(forAccount: pk) + biometricSwitch.isOn = isBiometricEnabled + } + + private func showBiometricPasswordPrompt() { + let biometric = BiometricAuthManager.shared + let alert = UIAlertController( + title: "Enable \(biometric.biometricName)", + message: "Enter your password to securely save it for \(biometric.biometricName) unlock.", + preferredStyle: .alert + ) + alert.addTextField { field in + field.placeholder = "Password" + field.isSecureTextEntry = true + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in + self?.biometricSwitch.setOn(false, animated: true) + self?.isBiometricEnabled = false + }) + alert.addAction(UIAlertAction(title: "Enable", style: .default) { [weak self] _ in + let password = alert.textFields?.first?.text ?? "" + self?.enableBiometric(password: password) + }) + present(alert, animated: true) + } + + private func enableBiometric(password: String) { + guard !password.isEmpty else { + biometricSwitch.setOn(false, animated: true) + isBiometricEnabled = false + return + } + + let pk = viewModel.publicKey + Task { + do { + _ = try await AccountManager.shared.unlock(password: password) + let biometric = BiometricAuthManager.shared + try biometric.savePassword(password, forAccount: pk) + biometric.setBiometricEnabled(true, forAccount: pk) + isBiometricEnabled = true + biometricSwitch.setOn(true, animated: true) + } catch { + isBiometricEnabled = false + biometricSwitch.setOn(false, animated: true) + let alert = UIAlertController(title: "Error", message: "Wrong password", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + } + } + + private func disableBiometric() { + let pk = viewModel.publicKey + let biometric = BiometricAuthManager.shared + biometric.deletePassword(forAccount: pk) + biometric.setBiometricEnabled(false, forAccount: pk) + isBiometricEnabled = false + } +} + +// MARK: - SwiftUI Edit Button (needed for @MainActor closure compatibility) + +private struct SettingsEditButton: View { + let action: @MainActor () -> Void + + var body: some View { + Button(action: action) { + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(height: 44) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .glassCapsule() + } +} + +// MARK: - SwiftUI Bridge + +struct SettingsContainerView: UIViewControllerRepresentable { + var onLogout: (() -> Void)? + var onAddAccount: ((AuthScreen) -> Void)? + @Binding var isEditingProfile: Bool + @Binding var isDetailPresented: Bool + + func makeUIViewController(context: Context) -> UINavigationController { + let vc = SettingsViewController() + vc.onLogout = onLogout + vc.onAddAccount = onAddAccount + vc.onEditingStateChanged = { editing in + DispatchQueue.main.async { isEditingProfile = editing } + } + vc.onDetailStateChanged = { detail in + DispatchQueue.main.async { isDetailPresented = detail } + } + let nav = UINavigationController(rootViewController: vc) + nav.setNavigationBarHidden(true, animated: false) + nav.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + return nav + } + + func updateUIViewController(_ nav: UINavigationController, context: Context) { + // State flows from UIKit β†’ SwiftUI via callbacks, not the other way + } +}