import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../../../../services/analysis_providers.dart'; import '../../../shared/presentation/widgets/lab_section_scaffold.dart'; class CapturePage extends ConsumerStatefulWidget { const CapturePage({super.key}); @override ConsumerState createState() => _CapturePageState(); } class _CapturePageState extends ConsumerState { final AudioRecorder _recorder = AudioRecorder(); final Stopwatch _stopwatch = Stopwatch(); Timer? _ticker; bool _busy = false; bool _isRecording = false; Duration _elapsed = Duration.zero; String? _lastRecordingPath; String? _statusMessage; @override void dispose() { _ticker?.cancel(); _stopwatch.stop(); _recorder.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return LabSectionScaffold( eyebrow: 'Capture', title: 'Create a new observation.', description: 'Record audio on device, persist the file locally, then create an observation and analysis session from the captured metadata.', children: [ Card( child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Recorder Status', style: theme.textTheme.titleLarge), const SizedBox(height: 8), Text( _statusMessage ?? 'Ready to capture a short audio observation at 16 kHz mono.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), Text( _formatDuration(_elapsed), style: theme.textTheme.displaySmall, ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton( onPressed: (_busy || _isRecording) ? null : _startRecording, child: const Text('Start Recording'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton( onPressed: (_busy || !_isRecording) ? null : _stopRecording, child: Text(_busy ? 'Working...' : 'Stop Recording'), ), ), ], ), if (_lastRecordingPath != null) ...[ const SizedBox(height: 18), Text( 'Last file: $_lastRecordingPath', style: theme.textTheme.bodyMedium, ), ], ], ), ), ), ], ); } Future _startRecording() async { setState(() { _busy = true; _statusMessage = 'Checking microphone permission...'; }); try { final hasPermission = await _recorder.hasPermission(); if (!hasPermission) { setState(() { _statusMessage = 'Microphone permission was denied.'; _busy = false; }); return; } final directory = await getTemporaryDirectory(); final filePath = '${directory.path}/tc_capture_${DateTime.now().millisecondsSinceEpoch}.wav'; await _recorder.start( const RecordConfig( encoder: AudioEncoder.wav, sampleRate: 16000, numChannels: 1, ), path: filePath, ); _stopwatch ..reset() ..start(); _ticker?.cancel(); _ticker = Timer.periodic(const Duration(milliseconds: 200), (_) { if (!mounted) return; setState(() { _elapsed = _stopwatch.elapsed; }); }); setState(() { _busy = false; _isRecording = true; _elapsed = Duration.zero; _lastRecordingPath = filePath; _statusMessage = 'Recording in progress.'; }); } catch (error) { setState(() { _busy = false; _statusMessage = 'Failed to start recording: $error'; }); } } Future _stopRecording() async { setState(() { _busy = true; _statusMessage = 'Stopping recorder and creating analysis session...'; }); try { final path = await _recorder.stop(); _ticker?.cancel(); _stopwatch.stop(); final durationMs = _stopwatch.elapsedMilliseconds.clamp( 1, 60 * 60 * 1000, ); final finalPath = path ?? _lastRecordingPath; if (finalPath == null || finalPath.isEmpty) { throw Exception('Recorder did not return a valid file path.'); } final session = await ref .read(sessionActionsProvider) .createUploadedSession( filePath: finalPath, durationMs: durationMs, sampleRate: 16000, channels: 1, tags: const ['captured', 'mobile-lab'], captureMetadata: { 'source': 'flutter-recorder', 'client_file_path': finalPath, 'device': 'mobile-client', }, ); if (!mounted) return; setState(() { _busy = false; _isRecording = false; _elapsed = _stopwatch.elapsed; _lastRecordingPath = finalPath; _statusMessage = 'Analysis session ${session.sessionId} created.'; }); context.goNamed('observation'); } catch (error) { _ticker?.cancel(); _stopwatch.stop(); if (!mounted) return; setState(() { _busy = false; _isRecording = false; _statusMessage = 'Failed to stop recording: $error'; }); } } String _formatDuration(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); final centiseconds = (value.inMilliseconds.remainder(1000) ~/ 10) .toString() .padLeft(2, '0'); return '$minutes:$seconds.$centiseconds'; } }