mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
New voice message recording mode (#2051)
* Lock voice message recording * Use the VoiceMessageCache to store the recording file * Rework on some composer toolbar buttons * Update accessibility labels for voice message recording button * PreviewTests
This commit is contained in:
@@ -24,9 +24,13 @@ struct ComposerToolbar: View {
|
||||
let keyCommandHandler: KeyCommandHandler
|
||||
|
||||
@FocusState private var composerFocused: Bool
|
||||
@ScaledMetric private var sendButtonIconSize = 16
|
||||
@ScaledMetric(relativeTo: .title) private var sendButtonIconSize = 16
|
||||
@ScaledMetric(relativeTo: .title) private var sendButtonIconPadding = 10
|
||||
@ScaledMetric(relativeTo: .title) private var sendButtonIconOffsetX = 1
|
||||
|
||||
@ScaledMetric(relativeTo: .title) private var spinnerSize = 44
|
||||
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
|
||||
@ScaledMetric(relativeTo: .title) private var deleteRecordingButtonSize = 30
|
||||
@State private var frame: CGRect = .zero
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@@ -77,8 +81,8 @@ struct ComposerToolbar: View {
|
||||
sendButton
|
||||
.padding(.leading, 3)
|
||||
} else {
|
||||
voiceMessageRecordingButton
|
||||
.padding(.leading, 4)
|
||||
voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
|
||||
.padding(.leading, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +126,7 @@ struct ComposerToolbar: View {
|
||||
|
||||
if context.viewState.isVoiceMessageModeActivated {
|
||||
voiceMessageContent
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,8 +219,9 @@ struct ComposerToolbar: View {
|
||||
.accessibilityHidden(!context.viewState.composerMode.isEdit)
|
||||
Image(asset: Asset.Images.sendMessage)
|
||||
.resizable()
|
||||
.offset(x: sendButtonIconOffsetX)
|
||||
.frame(width: sendButtonIconSize, height: sendButtonIconSize)
|
||||
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 9))
|
||||
.padding(sendButtonIconPadding)
|
||||
.opacity(context.viewState.composerMode.isEdit ? 0 : 1)
|
||||
.accessibilityLabel(L10n.actionSend)
|
||||
.accessibilityHidden(context.viewState.composerMode.isEdit)
|
||||
@@ -235,8 +241,10 @@ struct ComposerToolbar: View {
|
||||
// Display the voice message composer above to keep the focus and keep the keyboard open if it's already open.
|
||||
switch context.viewState.composerMode {
|
||||
case .recordVoiceMessage(let state):
|
||||
VoiceMessageRecordingComposer(recorderState: state)
|
||||
.padding(.leading, 12)
|
||||
topBarLayout {
|
||||
voiceMessageTrashButton
|
||||
VoiceMessageRecordingComposer(recorderState: state)
|
||||
}
|
||||
case .previewVoiceMessage(let state, let waveform, let isUploading):
|
||||
topBarLayout {
|
||||
voiceMessageTrashButton
|
||||
@@ -248,17 +256,12 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceMessageRecordingButton: some View {
|
||||
VoiceMessageRecordingButton {
|
||||
private func voiceMessageRecordingButton(mode: VoiceMessageRecordingButtonMode) -> some View {
|
||||
VoiceMessageRecordingButton(mode: mode) {
|
||||
context.send(viewAction: .voiceMessage(.startRecording))
|
||||
} stopRecording: { minimumRecordTimeReached in
|
||||
if minimumRecordTimeReached {
|
||||
context.send(viewAction: .voiceMessage(.stopRecording))
|
||||
} else {
|
||||
context.send(viewAction: .voiceMessage(.cancelRecording))
|
||||
}
|
||||
} stopRecording: {
|
||||
context.send(viewAction: .voiceMessage(.stopRecording))
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
private var voiceMessageTrashButton: some View {
|
||||
@@ -266,7 +269,9 @@ struct ComposerToolbar: View {
|
||||
context.send(viewAction: .voiceMessage(.deleteRecording))
|
||||
} label: {
|
||||
CompoundIcon(\.delete)
|
||||
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11))
|
||||
.scaledToFit()
|
||||
.frame(width: deleteRecordingButtonSize, height: deleteRecordingButtonSize)
|
||||
.padding(7)
|
||||
}
|
||||
.buttonStyle(.compound(.plain))
|
||||
.accessibilityLabel(L10n.a11yDelete)
|
||||
|
||||
@@ -72,8 +72,6 @@ struct VoiceMessagePreviewComposer: View {
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 42)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -17,79 +17,71 @@
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
enum VoiceMessageRecordingButtonMode {
|
||||
case idle
|
||||
case recording
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButton: View {
|
||||
let mode: VoiceMessageRecordingButtonMode
|
||||
var startRecording: (() -> Void)?
|
||||
var stopRecording: ((_ minimumRecordTimeReached: Bool) -> Void)?
|
||||
var stopRecording: (() -> 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()
|
||||
@ScaledMetric(relativeTo: .title) private var idleImageSize = 16
|
||||
@ScaledMetric(relativeTo: .title) private var idleImagePadding = 10
|
||||
|
||||
@ScaledMetric(relativeTo: .title) private var recordingImageSize = 24
|
||||
@ScaledMetric(relativeTo: .title) private var recordingImagePadding = 6
|
||||
|
||||
var body: some View {
|
||||
Button { } label: {
|
||||
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 {
|
||||
showTooltip = false
|
||||
recordingStartTime = Date.now
|
||||
impactFeedbackGenerator.impactOccurred()
|
||||
Button {
|
||||
impactFeedbackGenerator.impactOccurred()
|
||||
switch mode {
|
||||
case .idle:
|
||||
startRecording?()
|
||||
} else {
|
||||
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)
|
||||
case .recording:
|
||||
stopRecording?()
|
||||
}
|
||||
} label: {
|
||||
switch mode {
|
||||
case .idle:
|
||||
CompoundIcon(\.micOnOutline, size: .medium, relativeTo: .title)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.frame(width: idleImageSize, height: idleImageSize)
|
||||
.padding(idleImagePadding)
|
||||
.padding(4)
|
||||
case .recording:
|
||||
Asset.Images.stopRecording.swiftUIImage
|
||||
.resizable()
|
||||
.foregroundColor(.compound.iconOnSolidPrimary)
|
||||
.frame(width: recordingImageSize, height: recordingImageSize)
|
||||
.padding(recordingImagePadding)
|
||||
.background(
|
||||
Circle()
|
||||
.foregroundColor(.compound.bgActionPrimaryRest)
|
||||
)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.buttonStyle(VoiceMessageRecordingButtonStyle())
|
||||
.accessibilityLabel(mode == .idle ? L10n.a11yVoiceMessageRecord : L10n.a11yVoiceMessageStopRecording)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VoiceMessageRecordingButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.opacity(configuration.isPressed ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VoiceMessageRecordingButton()
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
HStack {
|
||||
VoiceMessageRecordingButton(mode: .idle)
|
||||
|
||||
VoiceMessageRecordingButton(mode: .recording)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRecordingButtonTooltipView: View {
|
||||
var text: String
|
||||
var radius: CGFloat = 4
|
||||
var corners: UIRectCorner = .allCorners
|
||||
@ScaledMetric var pointerHeight: CGFloat = 6
|
||||
@ScaledMetric var pointerWidth: CGFloat = 10
|
||||
var pointerLocation: CGFloat = 10
|
||||
var pointerLocationCoordinateSpace: CoordinateSpace = .local
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
.padding(6)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
TooltipShape(radius: radius,
|
||||
corners: corners,
|
||||
pointerHeight: pointerHeight,
|
||||
pointerWidth: pointerWidth,
|
||||
pointerLocation: localPointerLocation(using: geometry))
|
||||
.fill(.compound.bgActionPrimaryRest)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func localPointerLocation(using geometry: GeometryProxy) -> CGFloat {
|
||||
let frame = geometry.frame(in: pointerLocationCoordinateSpace)
|
||||
let minX = radius + pointerWidth / 2
|
||||
let maxX = geometry.size.width - radius - pointerWidth / 2
|
||||
return min(max(minX, pointerLocation - frame.minX), maxX)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TooltipShape: Shape {
|
||||
var radius: CGFloat
|
||||
var corners: UIRectCorner
|
||||
var pointerHeight: CGFloat
|
||||
var pointerWidth: CGFloat
|
||||
var pointerLocation: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
|
||||
var topLeft: CGFloat = corners.contains(.topLeft) ? radius : 0.0
|
||||
var topRight: CGFloat = corners.contains(.topRight) ? radius : 0.0
|
||||
var bottomLeft: CGFloat = corners.contains(.bottomLeft) ? radius : 0.0
|
||||
var bottomRight: CGFloat = corners.contains(.bottomRight) ? radius : 0.0
|
||||
|
||||
// Make sure we do not exceed the size of the rectangle
|
||||
topRight = min(min(topRight, height / 2), width / 2)
|
||||
topLeft = min(min(topLeft, height / 2), width / 2)
|
||||
bottomLeft = min(min(bottomLeft, height / 2), width / 2)
|
||||
bottomRight = min(min(bottomRight, height / 2), width / 2)
|
||||
|
||||
path.move(to: CGPoint(x: width / 2.0, y: 0))
|
||||
path.addLine(to: CGPoint(x: width - topRight, y: 0))
|
||||
path.addArc(center: CGPoint(x: width - topRight, y: topRight), radius: topRight,
|
||||
startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
|
||||
|
||||
path.addLine(to: CGPoint(x: width, y: height - bottomRight))
|
||||
path.addArc(center: CGPoint(x: width - bottomRight, y: height - bottomRight), radius: bottomRight,
|
||||
startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
|
||||
|
||||
path.addLine(to: CGPoint(x: pointerLocation + (pointerWidth / 2.0), y: height))
|
||||
path.addLine(to: CGPoint(x: pointerLocation, y: height + pointerHeight))
|
||||
path.addLine(to: CGPoint(x: pointerLocation - (pointerWidth / 2.0), y: height))
|
||||
|
||||
path.addLine(to: CGPoint(x: bottomLeft, y: height))
|
||||
path.addArc(center: CGPoint(x: bottomLeft, y: height - bottomLeft), radius: bottomLeft,
|
||||
startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
|
||||
|
||||
path.addLine(to: CGPoint(x: 0, y: topLeft))
|
||||
path.addArc(center: CGPoint(x: topLeft, y: topLeft), radius: topLeft,
|
||||
startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false)
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButtonTooltipView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VoiceMessageRecordingButtonTooltipView(text: "Hold to record")
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,6 @@ struct VoiceMessageRecordingComposer: View {
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 42)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() { }
|
||||
|
||||
@@ -390,7 +390,12 @@ class RoomScreenInteractionHandler {
|
||||
}
|
||||
|
||||
func deleteCurrentVoiceMessage() async {
|
||||
await voiceMessageRecorder.deleteRecording()
|
||||
if voiceMessageRecorder.isRecording {
|
||||
await voiceMessageRecorder.cancelRecording()
|
||||
} else {
|
||||
await voiceMessageRecorder.deleteRecording()
|
||||
}
|
||||
|
||||
voiceMessageRecorderObserver = nil
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
@@ -473,7 +478,12 @@ class RoomScreenInteractionHandler {
|
||||
MXLog.error("Cannot play a voice message without an audio player")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Stop any recording in progress
|
||||
if voiceMessageRecorder.isRecording {
|
||||
await voiceMessageRecorder.stopRecording()
|
||||
}
|
||||
|
||||
let audioPlayerState = audioPlayerState(for: itemID)
|
||||
|
||||
// Ensure this one is attached
|
||||
|
||||
Reference in New Issue
Block a user