capture_page.dart 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:path_provider/path_provider.dart';
  6. import 'package:record/record.dart';
  7. import '../../../../services/analysis_providers.dart';
  8. import '../../../shared/presentation/widgets/lab_section_scaffold.dart';
  9. class CapturePage extends ConsumerStatefulWidget {
  10. const CapturePage({super.key});
  11. @override
  12. ConsumerState<CapturePage> createState() => _CapturePageState();
  13. }
  14. class _CapturePageState extends ConsumerState<CapturePage> {
  15. final AudioRecorder _recorder = AudioRecorder();
  16. final Stopwatch _stopwatch = Stopwatch();
  17. Timer? _ticker;
  18. bool _busy = false;
  19. bool _isRecording = false;
  20. Duration _elapsed = Duration.zero;
  21. String? _lastRecordingPath;
  22. String? _statusMessage;
  23. @override
  24. void dispose() {
  25. _ticker?.cancel();
  26. _stopwatch.stop();
  27. _recorder.dispose();
  28. super.dispose();
  29. }
  30. @override
  31. Widget build(BuildContext context) {
  32. final theme = Theme.of(context);
  33. return LabSectionScaffold(
  34. eyebrow: 'Capture',
  35. title: 'Create a new observation.',
  36. description:
  37. 'Record audio on device, persist the file locally, then create an observation and analysis session from the captured metadata.',
  38. children: [
  39. Card(
  40. child: Padding(
  41. padding: const EdgeInsets.all(20),
  42. child: Column(
  43. crossAxisAlignment: CrossAxisAlignment.start,
  44. children: [
  45. Text('Recorder Status', style: theme.textTheme.titleLarge),
  46. const SizedBox(height: 8),
  47. Text(
  48. _statusMessage ??
  49. 'Ready to capture a short audio observation at 16 kHz mono.',
  50. style: theme.textTheme.bodyMedium,
  51. ),
  52. const SizedBox(height: 16),
  53. Text(
  54. _formatDuration(_elapsed),
  55. style: theme.textTheme.displaySmall,
  56. ),
  57. const SizedBox(height: 16),
  58. Row(
  59. children: [
  60. Expanded(
  61. child: FilledButton(
  62. onPressed: (_busy || _isRecording)
  63. ? null
  64. : _startRecording,
  65. child: const Text('Start Recording'),
  66. ),
  67. ),
  68. const SizedBox(width: 12),
  69. Expanded(
  70. child: OutlinedButton(
  71. onPressed: (_busy || !_isRecording)
  72. ? null
  73. : _stopRecording,
  74. child: Text(_busy ? 'Working...' : 'Stop Recording'),
  75. ),
  76. ),
  77. ],
  78. ),
  79. if (_lastRecordingPath != null) ...[
  80. const SizedBox(height: 18),
  81. Text(
  82. 'Last file: $_lastRecordingPath',
  83. style: theme.textTheme.bodyMedium,
  84. ),
  85. ],
  86. ],
  87. ),
  88. ),
  89. ),
  90. ],
  91. );
  92. }
  93. Future<void> _startRecording() async {
  94. setState(() {
  95. _busy = true;
  96. _statusMessage = 'Checking microphone permission...';
  97. });
  98. try {
  99. final hasPermission = await _recorder.hasPermission();
  100. if (!hasPermission) {
  101. setState(() {
  102. _statusMessage = 'Microphone permission was denied.';
  103. _busy = false;
  104. });
  105. return;
  106. }
  107. final directory = await getTemporaryDirectory();
  108. final filePath =
  109. '${directory.path}/tc_capture_${DateTime.now().millisecondsSinceEpoch}.wav';
  110. await _recorder.start(
  111. const RecordConfig(
  112. encoder: AudioEncoder.wav,
  113. sampleRate: 16000,
  114. numChannels: 1,
  115. ),
  116. path: filePath,
  117. );
  118. _stopwatch
  119. ..reset()
  120. ..start();
  121. _ticker?.cancel();
  122. _ticker = Timer.periodic(const Duration(milliseconds: 200), (_) {
  123. if (!mounted) return;
  124. setState(() {
  125. _elapsed = _stopwatch.elapsed;
  126. });
  127. });
  128. setState(() {
  129. _busy = false;
  130. _isRecording = true;
  131. _elapsed = Duration.zero;
  132. _lastRecordingPath = filePath;
  133. _statusMessage = 'Recording in progress.';
  134. });
  135. } catch (error) {
  136. setState(() {
  137. _busy = false;
  138. _statusMessage = 'Failed to start recording: $error';
  139. });
  140. }
  141. }
  142. Future<void> _stopRecording() async {
  143. setState(() {
  144. _busy = true;
  145. _statusMessage = 'Stopping recorder and creating analysis session...';
  146. });
  147. try {
  148. final path = await _recorder.stop();
  149. _ticker?.cancel();
  150. _stopwatch.stop();
  151. final durationMs = _stopwatch.elapsedMilliseconds.clamp(
  152. 1,
  153. 60 * 60 * 1000,
  154. );
  155. final finalPath = path ?? _lastRecordingPath;
  156. if (finalPath == null || finalPath.isEmpty) {
  157. throw Exception('Recorder did not return a valid file path.');
  158. }
  159. final session = await ref
  160. .read(sessionActionsProvider)
  161. .createUploadedSession(
  162. filePath: finalPath,
  163. durationMs: durationMs,
  164. sampleRate: 16000,
  165. channels: 1,
  166. tags: const ['captured', 'mobile-lab'],
  167. captureMetadata: {
  168. 'source': 'flutter-recorder',
  169. 'client_file_path': finalPath,
  170. 'device': 'mobile-client',
  171. },
  172. );
  173. if (!mounted) return;
  174. setState(() {
  175. _busy = false;
  176. _isRecording = false;
  177. _elapsed = _stopwatch.elapsed;
  178. _lastRecordingPath = finalPath;
  179. _statusMessage = 'Analysis session ${session.sessionId} created.';
  180. });
  181. context.goNamed('observation');
  182. } catch (error) {
  183. _ticker?.cancel();
  184. _stopwatch.stop();
  185. if (!mounted) return;
  186. setState(() {
  187. _busy = false;
  188. _isRecording = false;
  189. _statusMessage = 'Failed to stop recording: $error';
  190. });
  191. }
  192. }
  193. String _formatDuration(Duration value) {
  194. final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
  195. final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
  196. final centiseconds = (value.inMilliseconds.remainder(1000) ~/ 10)
  197. .toString()
  198. .padLeft(2, '0');
  199. return '$minutes:$seconds.$centiseconds';
  200. }
  201. }