From 51477e3f514164d761248b6f73f2309c2fa74ea7 Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 29 Apr 2026 17:12:22 +0200 Subject: [PATCH] Improved: Lock to record hands-free --- CHANGELOG.md | 1 + .../camera_bottom_controls.dart | 110 +++++++++++++++++- .../camera_preview_controller_view.dart | 37 +++++- 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32120721..ef6918cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Improved: Show ⌛ instead of the flame icon when it is about to expire - Improved: FAQ is now in the app rather than opening in the browser - Improved: Videos can now be paused +- Improved: Lock to record hands-free - Fix: Many smaller issues ## 0.1.8 diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart index 412a2f56..a8475c34 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_bottom_controls.dart @@ -9,23 +9,29 @@ class CameraBottomControls extends StatelessWidget { const CameraBottomControls({ required this.mainController, required this.isVideoRecording, + required this.videoRecordingLocked, required this.isFront, required this.keyTriggerButton, + required this.keyLockButton, required this.onTakePicture, required this.onPressSideButtonLeft, required this.onPressSideButtonRight, required this.updateScaleFactor, + required this.onStopVideoRecording, super.key, }); final MainCameraController mainController; final bool isVideoRecording; + final bool videoRecordingLocked; final bool isFront; final GlobalKey keyTriggerButton; + final GlobalKey keyLockButton; final VoidCallback onTakePicture; final VoidCallback onPressSideButtonLeft; final VoidCallback onPressSideButtonRight; final Future Function(double) updateScaleFactor; + final VoidCallback onStopVideoRecording; MainCameraController get mc => mainController; @@ -57,13 +63,18 @@ class CameraBottomControls extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (!isVideoRecording) _buildSideButtonLeft(), + if (!isVideoRecording) + _buildSideButtonLeft() + else + _buildLockOrStopButton(), _buildShutterButton(), if (!isVideoRecording) if (isFront) _buildSideButtonRight() else - const SizedBox(width: 80), + const SizedBox(width: 80) + else + const SizedBox(width: 80), ], ), ], @@ -72,6 +83,34 @@ class CameraBottomControls extends StatelessWidget { ); } + Widget _buildLockOrStopButton() { + if (videoRecordingLocked) { + // Show stop button + return GestureDetector( + onTap: onStopVideoRecording, + child: Container( + key: keyLockButton, + height: 50, + width: 80, + padding: const EdgeInsets.all(2), + child: Center( + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ), + ); + } else { + // Show animated lock icon (slide here to lock) + return _AnimatedLockButton(keyLockButton: keyLockButton); + } + } + Widget _buildSideButtonLeft() { return GestureDetector( onTap: onPressSideButtonLeft, @@ -144,3 +183,70 @@ class CameraBottomControls extends StatelessWidget { ); } } + +class _AnimatedLockButton extends StatefulWidget { + const _AnimatedLockButton({required this.keyLockButton}); + + final GlobalKey keyLockButton; + + @override + State<_AnimatedLockButton> createState() => _AnimatedLockButtonState(); +} + +class _AnimatedLockButtonState extends State<_AnimatedLockButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnim; + late Animation _opacityAnim; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + _scaleAnim = Tween(begin: 0.85, end: 1.15).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _opacityAnim = Tween(begin: 0.5, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _opacityAnim.value, + child: Transform.scale( + scale: _scaleAnim.value, + child: child, + ), + ); + }, + child: Container( + key: widget.keyLockButton, + height: 50, + width: 80, + padding: const EdgeInsets.all(2), + child: const Center( + child: FaIcon( + FontAwesomeIcons.lock, + color: Colors.white, + size: 25, + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index b4e2ff94..6b7a8a1c 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -115,9 +115,11 @@ class _CameraPreviewViewState extends State { bool _hasAudioPermission = true; DateTime? _videoRecordingStarted; Timer? _videoRecordingTimer; + bool _videoRecordingLocked = false; DateTime _currentTime = clock.now(); final GlobalKey keyTriggerButton = GlobalKey(); + final GlobalKey keyLockButton = GlobalKey(); final GlobalKey navigatorKey = GlobalKey(); MainCameraController get mc => widget.mainCameraController; @@ -399,6 +401,32 @@ class _CameraPreviewViewState extends State { return; } + // Check if the finger moved over the lock button during video recording + if (mc.isVideoRecording && !_videoRecordingLocked) { + final lockContext = keyLockButton.currentContext; + if (lockContext != null) { + final lockRenderBox = lockContext.findRenderObject() as RenderBox?; + if (lockRenderBox != null) { + final lockLocalPosition = lockRenderBox.globalToLocal( + // ignore: avoid_dynamic_calls + details.globalPosition as Offset, + ); + final lockRect = Rect.fromLTWH( + 0, + 0, + lockRenderBox.size.width, + lockRenderBox.size.height, + ); + if (lockRect.contains(lockLocalPosition)) { + setState(() { + _videoRecordingLocked = true; + }); + await HapticFeedback.heavyImpact(); + } + } + } + } + setState(() { mc.selectedCameraDetails.scaleFactor = (_baseScaleFactor + @@ -537,7 +565,10 @@ class _CameraPreviewViewState extends State { } } - Future stopVideoRecording() async { + Future stopVideoRecording({bool force = false}) async { + // If recording is locked, only stop when explicitly forced (e.g. stop button tap) + if (_videoRecordingLocked && !force) return; + if (_videoRecordingTimer != null) { _videoRecordingTimer?.cancel(); _videoRecordingTimer = null; @@ -548,6 +579,7 @@ class _CameraPreviewViewState extends State { setState(() { _videoRecordingStarted = null; mc.isVideoRecording = false; + _videoRecordingLocked = false; }); if (mc.cameraController == null || @@ -699,12 +731,15 @@ class _CameraPreviewViewState extends State { CameraBottomControls( mainController: mc, isVideoRecording: mc.isVideoRecording, + videoRecordingLocked: _videoRecordingLocked, isFront: isFront, keyTriggerButton: keyTriggerButton, + keyLockButton: keyLockButton, onTakePicture: takePicture, onPressSideButtonLeft: pressSideButtonLeft, onPressSideButtonRight: pressSideButtonRight, updateScaleFactor: updateScaleFactor, + onStopVideoRecording: () => stopVideoRecording(force: true), ), VideoRecordingTimer( videoRecordingStarted: _videoRecordingStarted,