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 providers20
/// - Dispatches actions to notifiers21
/// - Passes data and callbacks to RecordingWidgetView22
///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
}