import SwiftUI /// The record surface in the composer: a mic button that, once tapped (or /// auto-started), shows the elapsed time and a live level meter with Stop / /// Discard. When recording ends it hands the finished `(url, duration)` up via /// `onFinish`; the composer owns everything after that. struct AudioRecordingControl: View { @Environment(AudioRecorderService.self) private var recorder /// Begin recording as soon as the view appears (the toolbar "record a note" /// entry point), after the permission prompt resolves. var autostart = false let onFinish: (URL, TimeInterval) -> Void @State private var permissionDenied = false @State private var startError: String? @State private var didAutostart = false var body: some View { VStack(spacing: 12) { if permissionDenied { deniedState } else if recorder.isRecording { recordingState } else { idleState } if let startError { Text(startError) .font(.footnote) .foregroundStyle(.red) } } .frame(maxWidth: .infinity) .task { recorder.onInterrupted = { recording in onFinish(recording.url, recording.duration) } if autostart, !didAutostart { didAutostart = true await beginRecording() } } .onDisappear { recorder.onInterrupted = nil if recorder.isRecording { recorder.discard() } } } private var idleState: some View { Button { Task { await beginRecording() } } label: { VStack(spacing: 6) { Image(systemName: "mic.circle.fill") .font(.system(size: 52)) .foregroundStyle(.tint) Text("Record") .font(.callout) } } .buttonStyle(.plain) } private var recordingState: some View { VStack(spacing: 14) { LevelMeter(level: recorder.level) Text(AudioPlayerView.timeString(recorder.elapsed)) .font(.title2.monospacedDigit()) .foregroundStyle(.primary) HStack(spacing: 24) { Button(role: .destructive) { recorder.discard() } label: { Label("Discard", systemImage: "trash") } .buttonStyle(.bordered) Button { if let finished = recorder.stopRecording() { onFinish(finished.url, finished.duration) } } label: { Label("Stop", systemImage: "stop.circle.fill") } .buttonStyle(.borderedProminent) } } } private var deniedState: some View { VStack(spacing: 8) { Image(systemName: "mic.slash.circle.fill") .font(.system(size: 44)) .foregroundStyle(.secondary) Text("Microphone access is off. Enable it for Contextful in Settings to record voice notes.") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } private func beginRecording() async { startError = nil guard await recorder.requestPermission() else { permissionDenied = true return } permissionDenied = false do { try recorder.startRecording() } catch { startError = "Couldn't start recording: \(error.localizedDescription)" } } } /// A small row of bars whose height rides the current input level — enough to /// show the mic is live without pretending to be a real waveform. private struct LevelMeter: View { let level: Float private let barCount = 9 /// Fixed per-bar weights so the meter has a stable, mic-like shape. private let weights: [CGFloat] = [0.35, 0.55, 0.75, 0.9, 1.0, 0.9, 0.75, 0.55, 0.35] var body: some View { HStack(alignment: .center, spacing: 4) { ForEach(0.. CGFloat { let base: CGFloat = 4 let peak: CGFloat = 36 return base + peak * CGFloat(level) * weights[index] } }