| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- 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<CapturePage> createState() => _CapturePageState();
- }
- class _CapturePageState extends ConsumerState<CapturePage> {
- 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<void> _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<void> _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';
- }
- }
|