diff --git a/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift b/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift index 83ca80bd3..bf6b61f25 100644 --- a/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift +++ b/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift @@ -42,3 +42,9 @@ struct ViewFrameReader: View { } } } + +extension View { + func readFrame(_ frame: Binding, in coordinateSpace: CoordinateSpace = .local) -> some View { + background(ViewFrameReader(frame: frame, coordinateSpace: coordinateSpace)) + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 570211206..813dd579c 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -27,15 +27,6 @@ struct ComposerToolbar: View { @ScaledMetric private var sendButtonIconSize = 16 @ScaledMetric(relativeTo: .title) private var spinnerSize = 44 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 - - @State private var voiceMessageRecordingStartTime: Date? - @State private var showVoiceMessageRecordingTooltip = false - @ScaledMetric private var voiceMessageTooltipPointerHeight = 6 - @State private var voiceMessageRecordingButtonFrame: CGRect = .zero - - private let voiceMessageMinimumRecordingDuration = 1.0 - private let voiceMessageTooltipDuration = 1.0 - @State private var frame: CGRect = .zero @Environment(\.verticalSizeClass) private var verticalSizeClass @@ -64,12 +55,6 @@ struct ComposerToolbar: View { .offset(y: -frame.height) } } - .overlay(alignment: .bottomTrailing) { - if showVoiceMessageRecordingTooltip { - voiceMessageRecordingButtonTooltipView - .offset(y: -frame.height - voiceMessageTooltipPointerHeight) - } - } .alert(item: $context.alertInfo) } @@ -84,7 +69,7 @@ struct ComposerToolbar: View { private var topBar: some View { topBarLayout { mainTopBarContent - + if !context.composerActionsEnabled { if context.viewState.isUploading { ProgressView() @@ -95,9 +80,6 @@ struct ComposerToolbar: View { .padding(.leading, 3) } else { voiceMessageRecordingButton - .background { - ViewFrameReader(frame: $voiceMessageRecordingButtonFrame, coordinateSpace: .global) - } .padding(.leading, 4) } } @@ -121,7 +103,7 @@ struct ComposerToolbar: View { private var topBarLayout: some Layout { HStackLayout(alignment: .bottom, spacing: 5) } - + @ViewBuilder private var mainTopBarContent: some View { ZStack(alignment: .bottom) { @@ -139,13 +121,13 @@ struct ComposerToolbar: View { .padding(.trailing, context.composerActionsEnabled ? 4 : 0) } .opacity(context.viewState.isVoiceMessageModeActivated ? 0 : 1) - + if context.viewState.isVoiceMessageModeActivated { voiceMessageContent } } } - + private var closeRTEButton: some View { Button { context.composerActionsEnabled = false @@ -249,7 +231,7 @@ struct ComposerToolbar: View { } // MARK: - Voice message - + @ViewBuilder private var voiceMessageContent: some View { // Display the voice message composer above to keep the focus and keep the keyboard open if it's already open. @@ -270,22 +252,17 @@ struct ComposerToolbar: View { private var voiceMessageRecordingButton: some View { VoiceMessageRecordingButton { - showVoiceMessageRecordingTooltip = false - voiceMessageRecordingStartTime = Date.now context.send(viewAction: .startVoiceMessageRecording) - } stopRecording: { - if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration { - context.send(viewAction: .cancelVoiceMessageRecording) - withElementAnimation { - showVoiceMessageRecordingTooltip = true - } - } else { + } stopRecording: { minimumRecordTimeReached in + if minimumRecordTimeReached { context.send(viewAction: .stopVoiceMessageRecording) + } else { + context.send(viewAction: .cancelVoiceMessageRecording) } } .padding(4) } - + private var voiceMessageTrashButton: some View { Button(role: .destructive) { context.send(viewAction: .deleteVoiceMessageRecording) @@ -297,22 +274,6 @@ struct ComposerToolbar: View { .accessibilityLabel(L10n.a11yDelete) } - private var voiceMessageRecordingButtonTooltipView: some View { - VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, - pointerHeight: voiceMessageTooltipPointerHeight, - pointerLocation: voiceMessageRecordingButtonFrame.midX, - pointerLocationCoordinateSpace: .global) - .allowsHitTesting(false) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) { - withElementAnimation { - showVoiceMessageRecordingTooltip = false - } - } - } - .padding(.horizontal, 8) - } - private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View { VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) { context.send(viewAction: .startVoiceMessagePlayback) @@ -376,7 +337,7 @@ extension ComposerToolbar { wysiwygViewModel: wysiwygViewModel, keyCommandHandler: { _ in false }) } - + static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() var composerViewModel: ComposerToolbarViewModel { diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift index 4fac4a8f3..85b40f855 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift @@ -18,33 +18,72 @@ import Compound import SwiftUI struct VoiceMessageRecordingButton: View { - @State private var buttonPressed = false - var startRecording: (() -> Void)? - var stopRecording: (() -> Void)? + var stopRecording: ((_ minimumRecordTimeReached: Bool) -> Void)? + + @ScaledMetric private var tooltipPointerHeight = 6 + + @State private var buttonPressed = false + @State private var recordingStartTime: Date? + @State private var showTooltip = false + @State private var frame: CGRect = .zero + + private let minimumRecordingDuration = 1.0 + private let tooltipDuration = 1.0 + private let impactFeedbackGenerator = UIImpactFeedbackGenerator() + private let notificationFeedbackGenerator = UINotificationFeedbackGenerator() var body: some View { Button { } label: { - voiceMessageButtonImage + CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline) + .foregroundColor(.compound.iconSecondary) + .padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) } + .readFrame($frame, in: .global) + .accessibilityLabel(L10n.a11yVoiceMessageRecord) .onLongPressGesture { } onPressingChanged: { isPressing in buttonPressed = isPressing + if isPressing { - // Start recording + showTooltip = false + recordingStartTime = Date.now + impactFeedbackGenerator.impactOccurred() startRecording?() } else { - // Stop recording - stopRecording?() + if let recordingStartTime, Date.now.timeIntervalSince(recordingStartTime) < minimumRecordingDuration { + withElementAnimation { + showTooltip = true + } + notificationFeedbackGenerator.notificationOccurred(.error) + stopRecording?(false) + } else { + impactFeedbackGenerator.impactOccurred() + stopRecording?(true) + } + } + } + .overlay(alignment: .bottomTrailing) { + if showTooltip { + tooltipView + .offset(y: -frame.height - tooltipPointerHeight) } } } - @ViewBuilder - private var voiceMessageButtonImage: some View { - CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline) - .foregroundColor(.compound.iconSecondary) - .padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) - .accessibilityLabel(L10n.a11yVoiceMessageRecord) + private var tooltipView: some View { + VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, + pointerHeight: tooltipPointerHeight, + pointerLocation: frame.midX, + pointerLocationCoordinateSpace: .global) + .allowsHitTesting(false) + .fixedSize() + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + tooltipDuration) { + withElementAnimation { + showTooltip = false + } + } + } } } diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift index 800492862..c46661878 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift @@ -141,8 +141,10 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] do { - try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(true) + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a") let audioRecorder = try AVAudioRecorder(url: url, settings: settings) audioRecorder.delegate = self