BioFlow Requirements
lib\presentation\widgets\recording_widget.dart
Source file coverage
Path:
lib/presentation/widgets/recording_widget.dart
Lines:
310
Non-empty lines:
273
Non-empty lines covered with requirements:
273 / 273 (100.0%)
Functions:
0
Functions covered by requirements:
0 / 0 (0.0%)
1
import 'package:bioflow_pro/application/impedance/providers/impedance_providers.dart';
2
import 'package:bioflow_pro/application/playback/providers/playback_providers.dart';
3
import 'package:bioflow_pro/application/providers/cloud_providers.dart';
4
import 'package:bioflow_pro/application/providers/signal_engine_provider.dart';
5
import 'package:bioflow_pro/application/providers/state_providers.dart';
6
import 'package:bioflow_pro/application/providers/ui/overlay_state_providers.dart';
7
import 'package:bioflow_pro/application/providers/ui/ui_providers.dart';
8
import 'package:bioflow_pro/domain/cloud/entities/connection_status.dart' as cloud;
9
import 'package:bioflow_pro/infrastructure/logging/dart_logging_adapter.dart';
10
import 'package:bioflow_pro/presentation/widgets/recording_widget_view.dart';
11
import 'package:flutter/material.dart';
12
import 'package:flutter/services.dart';
13
import 'package:flutter_riverpod/flutter_riverpod.dart';
14
 
15
// @relation(ARCH-008, scope=file)
16
/// Container widget that connects RecordingWidgetView to Riverpod providers.
17
///
18
/// This is a thin wrapper that:
19
/// - Reads state from providers
20
/// - Dispatches actions to notifiers
21
/// - Passes data and callbacks to RecordingWidgetView
22
///
23
/// The actual UI rendering (including auto-close timer) is done by RecordingWidgetView.
24
class RecordingWidget extends ConsumerStatefulWidget {
25
  const RecordingWidget({super.key});
26
 
27
  @override
28
  ConsumerState<RecordingWidget> createState() => _RecordingWidgetState();
29
}
30
 
31
class _RecordingWidgetState extends ConsumerState<RecordingWidget> {
32
  final _logger = DartLoggingAdapter('Widget.Recording');
33
  bool _isEngineOperationInProgress = false;
34
 
35
  void _toggleRecording() {
36
    // Close any open overlay panel (patients, montages, settings, activities)
37
    if (ref.read(navigationProvider).selectedIndex != null) {
38
      ref.read(navigationProvider.notifier).clearSelection();
39
    }
40
 
41
    final recordingState = ref.read(recordingProvider);
42
    final notifier = ref.read(recordingProvider.notifier);
43
    final selectedPatient = ref.read(selectedPatientEntityProvider);
44
 
45
    if (recordingState.isIdle) {
46
      // Cannot start recording without a patient
47
      if (selectedPatient == null) {
48
        return;
49
      }
50
 
51
      // Cannot start recording when signal engine is not streaming
52
      final engineState = ref.read(signalEngineProvider);
53
      if (!engineState.isStreaming) {
54
        _logger.warning(
55
          'Cannot start recording: signal engine is not streaming',
56
        );
57
        return;
58
      }
59
 
60
      notifier.startRecordingWithPatient(selectedPatient);
61
    } else if (recordingState.isStopped) {
62
      // In stopped state, start a new recording with the same patient
63
      if (selectedPatient == null) {
64
        _logger.warning('Cannot start recording: no patient selected');
65
        return;
66
      }
67
 
68
      final engineState = ref.read(signalEngineProvider);
69
      if (!engineState.isStreaming) {
70
        _logger.warning(
71
          'Cannot start recording: signal engine is not streaming',
72
        );
73
        return;
74
      }
75
 
76
      notifier.startRecordingWithPatient(selectedPatient);
77
    } else if (recordingState.isRecording) {
78
      notifier.pauseRecording();
79
    } else if (recordingState.isPaused) {
80
      notifier.resumeRecording();
81
    }
82
  }
83
 
84
  void _stopRecording() {
85
    final recordingState = ref.read(recordingProvider);
86
 
87
    // Only stop if actually recording or paused
88
    if (recordingState.isActive) {
89
      ref.read(recordingProvider.notifier).stopRecording();
90
      // Patient clearing removed for both local and remote patients
91
      // Now done via reset button for consistent UX
92
    }
93
  }
94
 
95
  void _resetRecording() {
96
    final recordingState = ref.read(recordingProvider);
97
 
98
    if (recordingState.isStopped || recordingState.isSuccessful) {
99
      // Reset recording state to idle
100
      ref.read(recordingProvider.notifier).resetRecording();
101
 
102
      // Unload patient
103
      ref.read(selectedPatientEntityProvider.notifier).setPatient(null);
104
    }
105
  }
106
 
107
  void _unloadPatient() {
108
    ref.read(selectedPatientEntityProvider.notifier).setPatient(null);
109
  }
110
 
111
  void _toggleDrawer() {
112
    final recordingNotifier = ref.read(recordingProvider.notifier);
113
    if (!recordingNotifier.isDrawerPinned) {
114
      recordingNotifier.toggleDrawer();
115
    }
116
  }
117
 
118
  void _togglePin() {
119
    ref.read(recordingProvider.notifier).togglePin();
120
  }
121
 
122
  Future<void> _handleEngineToggle(bool value) async {
123
    if (_isEngineOperationInProgress) return;
124
 
125
    setState(() {
126
      _isEngineOperationInProgress = true;
127
    });
128
 
129
    try {
130
      if (value) {
131
        _logger.info('Toggle ON: Starting engine with graphics reinit...');
132
        // Reinitialize graphics + start streaming for clean state
133
        await ref.read(signalEngineProvider.notifier).startEngine(
134
              width: 1280,
135
              height: 720,
136
            );
137
      } else {
138
        _logger.info('Toggle OFF: Stopping streaming...');
139
        // Keep graphics ready - just stop UDP streaming
140
        await ref.read(signalEngineProvider.notifier).stopStreaming();
141
 
142
        // Also stop playback if it was active (sync playback button state)
143
        await ref.read(signalPlaybackProvider.notifier).stop();
144
      }
145
    } catch (error) {
146
      _logger.error('Error toggling streaming', error);
147
      // No need to revert - provider state is the source of truth
148
    } finally {
149
      if (mounted) {
150
        setState(() {
151
          _isEngineOperationInProgress = false;
152
        });
153
      }
154
    }
155
  }
156
 
157
  void _onImpedanceButtonPressed() {
158
    final engineState = ref.read(signalEngineProvider);
159
    if (!engineState.isStreaming) return;
160
    ref.read(impedanceWindowVisibleProvider.notifier).toggle();
161
  }
162
 
163
  /// Handle left-click on impedance preset button.
164
  /// Requires active UDP data stream before opening.
165
  Future<void> _onImpedancePresetPressed(int presetIndex) async {
166
    final engineState = ref.read(signalEngineProvider);
167
    if (!engineState.isStreaming) return;
168
    await ref.read(impedanceSettingsProvider).loadPreset(presetIndex);
169
  }
170
 
171
  /// Handle right-click on impedance preset button
172
  Future<void> _onImpedancePresetSave(int presetIndex) async {
173
    final saved = await ref.read(impedanceSettingsProvider).savePreset(presetIndex);
174
 
175
    if (!mounted) return;
176
 
177
    if (saved) {
178
      // Show success feedback
179
      ScaffoldMessenger.of(context).showSnackBar(
180
        SnackBar(
181
          content: Center(child: Text('Position saved to Ω$presetIndex')),
182
          duration: const Duration(seconds: 2),
183
          backgroundColor: Colors.green,
184
        ),
185
      );
186
    } else {
187
      // Window was closed - show message explaining how to save
188
      ScaffoldMessenger.of(context).showSnackBar(
189
        SnackBar(
190
          content: const Center(
191
            child: Text('Window closed • Left-click to open • Right-click to save'),
192
          ),
193
          duration: const Duration(seconds: 1),
194
          backgroundColor: Colors.orange,
195
        ),
196
      );
197
    }
198
  }
199
 
200
  void _onCenterTapped() {
201
    final patient = ref.read(selectedPatientEntityProvider);
202
 
203
    ref.read(bottomBarProvider.notifier).setMenuMode();
204
 
205
    if (patient != null) {
206
      // Patient loaded — ensure patient overlay is open (don't toggle-close)
207
      final navState = ref.read(navigationProvider);
208
      if (navState.selectedIndex != 0) {
209
        ref.read(navigationProvider.notifier).selectItem(0);
210
      }
211
      // Show the recording patient's detail card
212
      ref.read(patientSelectionProvider.notifier).selectPatient(patient);
213
    } else {
214
      // No patient — open patient list (existing behavior)
215
      ref.read(navigationProvider.notifier).selectItem(0);
216
    }
217
  }
218
 
219
  // ========== Upload Callbacks ==========
220
 
221
  void _dismissSuccess() {
222
    ref.read(recordingProvider.notifier).dismissUploadSuccess();
223
  }
224
 
225
  void _copyRecordingDetails() {
226
    final recordingState = ref.read(recordingProvider);
227
    final details = '''
228
Uploaded to: ${recordingState.uploadedToUrl ?? 'N/A'}
229
Local Recording: ${recordingState.localRecordingPath ?? recordingState.filePath ?? 'N/A'}
230
Recording ID for Cloud: ${recordingState.uploadedEegId ?? 'N/A'}
231
Recording ID for local: ${recordingState.recordingId ?? 'N/A'}
232
''';
233
    Clipboard.setData(ClipboardData(text: details.trim()));
234
    if (mounted) {
235
      ScaffoldMessenger.of(context).showSnackBar(
236
        const SnackBar(
237
          content: Text('Recording details copied to clipboard'),
238
          duration: Duration(seconds: 2),
239
          behavior: SnackBarBehavior.floating,
240
          width: 280,
241
        ),
242
      );
243
    }
244
  }
245
 
246
  @override
247
  Widget build(BuildContext context) {
248
    final recordingState = ref.watch(recordingProvider);
249
    final selectedPatient = ref.watch(selectedPatientEntityProvider);
250
    final connectionStatus = ref.watch(connectionStatusProvider);
251
    final isCloudConnected = connectionStatus.state == cloud.ConnectionState.connected;
252
    final overlayState = ref.watch(overlayProvider);
253
    final isImpedanceWindowVisible = ref.watch(impedanceWindowVisibleProvider);
254
    // Watch signal engine state - single source of truth for toggle
255
    final engineState = ref.watch(signalEngineProvider);
256
    // Toggle shows ON when streaming is active (or discovering/waiting for first packet)
257
    final isEngineOn = engineState.isStreaming || engineState.isDiscovering;
258
    final isStreaming = engineState.isStreaming;
259
 
260
    // Listen for no-stream timeout and show snackbar
261
    ref.listen<SignalEngineState>(signalEngineProvider, (previous, next) {
262
      if (next.noStreamTimeoutOccurred && !(previous?.noStreamTimeoutOccurred ?? false)) {
263
        ScaffoldMessenger.of(context).showSnackBar(
264
          const SnackBar(
265
            content: Text('No EEG data stream detected'),
266
            duration: Duration(seconds: 2),
267
            behavior: SnackBarBehavior.floating,
268
            width: 280,
269
          ),
270
        );
271
        ref.read(signalEngineProvider.notifier).clearNoStreamTimeoutFlag();
272
      }
273
    });
274
 
275
    // Watch impedance preset states
276
    final activePreset = ref.watch(activeImpedancePresetProvider);
277
    final preset1 = ref.watch(impedancePreset1Provider);
278
    final preset2 = ref.watch(impedancePreset2Provider);
279
 
280
    return RecordingWidgetView(
281
      recordingState: recordingState,
282
      selectedPatient: selectedPatient,
283
      isEngineToggleOn: isEngineOn,
284
      isStreaming: isStreaming,
285
      isEngineOperationInProgress: _isEngineOperationInProgress,
286
      isCloudConnected: isCloudConnected,
287
      isOverlayVisible: overlayState.isVisible,
288
      isImpedanceWindowVisible: isImpedanceWindowVisible,
289
      onToggleRecording: _toggleRecording,
290
      onStopRecording: _stopRecording,
291
      onReset: _resetRecording,
292
      onUnloadPatient: _unloadPatient,
293
      onToggleDrawer: _toggleDrawer,
294
      onTogglePin: _togglePin,
295
      onEngineToggle: _handleEngineToggle,
296
      onImpedanceButtonPressed: _onImpedanceButtonPressed,
297
      onImpedancePresetPressed: _onImpedancePresetPressed,
298
      onImpedancePresetSave: _onImpedancePresetSave,
299
      activeImpedancePreset: activePreset,
300
      impedancePresetsSaved: [
301
        preset1.hasSettings,
302
        preset2.hasSettings,
303
      ],
304
      onCenterTapped: _onCenterTapped,
305
      onDismissSuccess: _dismissSuccess,
306
      onCopyRecordingDetails: _copyRecordingDetails,
307
      onDismissOverlay: () => ref.read(navigationProvider.notifier).clearSelection(),
308
    );
309
  }
310
}