diff --git a/assets/icons/arrow_flip.svg b/assets/icons/arrow_flip.svg new file mode 100644 index 0000000..1bb3d75 --- /dev/null +++ b/assets/icons/arrow_flip.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce78242..b255453 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "state" : { - "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", - "version" : "3.6.0" + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "179438b8e0c9a357ab0caf453577f766ffddbcb3", - "version" : "3.59.3" + "revision" : "8497f6ea218b14c348a353f2bafbb26069e9da2c", + "version" : "3.64.1" } }, { diff --git a/lib/src/core/design_system/components/atoms/icons.dart b/lib/src/core/design_system/components/atoms/icons.dart index 7518076..f1815af 100644 --- a/lib/src/core/design_system/components/atoms/icons.dart +++ b/lib/src/core/design_system/components/atoms/icons.dart @@ -43,6 +43,15 @@ class AppIcons { : null, package: 'assets', ); + static Widget arrowFlip({double size = 24, Color? color}) => SvgPicture.asset( + '$_path/arrow_flip.svg', + width: size, + height: size, + colorFilter: color != null + ? ColorFilter.mode(color, BlendMode.srcIn) + : null, + package: 'assets', + ); static Widget at({double size = 24, Color? color}) => SvgPicture.asset( '$_path/at.svg', width: size, diff --git a/lib/src/core/design_system/templates/recording_page_template.dart b/lib/src/core/design_system/templates/recording_page_template.dart index 10c1c8c..e3e8172 100644 --- a/lib/src/core/design_system/templates/recording_page_template.dart +++ b/lib/src/core/design_system/templates/recording_page_template.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/components/molecules/recording_button.dart'; import 'package:spark/src/core/design_system/components/molecules/recording_timer.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; @@ -102,6 +103,11 @@ class RecordingPageTemplate extends StatelessWidget { child: Center(child: cameraPreview), ), ), + Positioned.fill( + child: _CameraFlipGesture( + onFlipCamera: canFlipCamera ? onFlipCamera : null, + ), + ), // Top controls aligned within rounded view _TopOverlay( onBack: onBack, @@ -118,8 +124,6 @@ class RecordingPageTemplate extends StatelessWidget { ), // Bottom overlay sits inside rounded view _BottomOverlay( - onFlipCamera: canFlipCamera ? onFlipCamera : null, - onOpenLibrary: onOpenLibrary, soundLabel: soundLabel, onSelectSound: onSelectSound, onClearSound: onClearSound, @@ -142,9 +146,10 @@ class RecordingPageTemplate extends StatelessWidget { }, ), ), - const SizedBox( + _FooterBar( height: footerHeight, - child: ColoredBox(color: Colors.black), + onOpenLibrary: onOpenLibrary, + onFlipCamera: canFlipCamera ? onFlipCamera : null, ), ], ), @@ -153,6 +158,64 @@ class RecordingPageTemplate extends StatelessWidget { } } +class _FooterBar extends StatelessWidget { + const _FooterBar({ + required this.height, + required this.onOpenLibrary, + required this.onFlipCamera, + }); + + final double height; + final VoidCallback? onOpenLibrary; + final VoidCallback? onFlipCamera; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: ColoredBox( + color: Colors.black, + child: Padding( + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 6), + child: Align( + alignment: Alignment.bottomCenter, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + onOpenLibrary == null + ? const SizedBox(width: 56, height: 56) + : _LibraryButton(onPressed: onOpenLibrary!), + onFlipCamera == null + ? const SizedBox(width: 56, height: 56) + : _FlipCameraFooterButton(onPressed: onFlipCamera!), + ], + ), + ), + ), + ), + ); + } +} + +class _CameraFlipGesture extends StatelessWidget { + const _CameraFlipGesture({required this.onFlipCamera}); + + final VoidCallback? onFlipCamera; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTap: onFlipCamera == null + ? null + : () { + HapticFeedback.lightImpact(); + onFlipCamera!(); + }, + ); + } +} + class _ProcessingOverlay extends StatelessWidget { const _ProcessingOverlay({required this.label}); @@ -321,8 +384,6 @@ class _CloseButton extends StatelessWidget { class _BottomOverlay extends StatelessWidget { const _BottomOverlay({ - required this.onFlipCamera, - required this.onOpenLibrary, required this.soundLabel, required this.onSelectSound, required this.onClearSound, @@ -330,8 +391,6 @@ class _BottomOverlay extends StatelessWidget { required this.bottomPadding, }); - final VoidCallback? onFlipCamera; - final VoidCallback? onOpenLibrary; final String? soundLabel; final VoidCallback? onSelectSound; final VoidCallback? onClearSound; @@ -370,15 +429,9 @@ class _BottomOverlay extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - if (onFlipCamera != null) - _FlipCameraButton(onPressed: onFlipCamera!) - else - const SizedBox(width: 80), + const SizedBox(width: 80), recordingButton, - if (onOpenLibrary != null) - _LibraryButton(onPressed: onOpenLibrary!) - else - const SizedBox(width: 80), + const SizedBox(width: 80), ], ), ], @@ -485,9 +538,8 @@ class _SoundButton extends StatelessWidget { } } -/// iOS-style flip camera button with blur background. -class _FlipCameraButton extends StatelessWidget { - const _FlipCameraButton({required this.onPressed}); +class _LibraryButton extends StatelessWidget { + const _LibraryButton({required this.onPressed}); final VoidCallback onPressed; @@ -499,8 +551,8 @@ class _FlipCameraButton extends StatelessWidget { onPressed(); }, child: SizedBox( - width: 80, - height: 80, + width: 56, + height: 56, child: Center( child: ClipOval( child: BackdropFilter( @@ -512,10 +564,8 @@ class _FlipCameraButton extends StatelessWidget { shape: BoxShape.circle, color: Colors.black.withAlpha(90), ), - child: const Icon( - Icons.flip_camera_ios_rounded, - color: Colors.white, - size: 26, + child: Center( + child: AppIcons.gallery(size: 24, color: Colors.white), ), ), ), @@ -526,8 +576,8 @@ class _FlipCameraButton extends StatelessWidget { } } -class _LibraryButton extends StatelessWidget { - const _LibraryButton({required this.onPressed}); +class _FlipCameraFooterButton extends StatelessWidget { + const _FlipCameraFooterButton({required this.onPressed}); final VoidCallback onPressed; @@ -539,23 +589,21 @@ class _LibraryButton extends StatelessWidget { onPressed(); }, child: SizedBox( - width: 80, - height: 80, + width: 56, + height: 56, child: Center( child: ClipOval( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - width: 50, - height: 50, + width: 40, + height: 40, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black.withAlpha(90), ), - child: const Icon( - Icons.photo_library_outlined, - color: Colors.white, - size: 24, + child: Center( + child: AppIcons.arrowFlip(size: 24, color: Colors.white), ), ), ), diff --git a/lib/src/core/media/create_media_actions.dart b/lib/src/core/media/create_media_actions.dart index a28c42c..9843efe 100644 --- a/lib/src/core/media/create_media_actions.dart +++ b/lib/src/core/media/create_media_actions.dart @@ -29,12 +29,23 @@ class CreateMediaActions { }) { return () async { if (!context.mounted) return; - await context.router.push( - RecordingRoute( - storyMode: storyMode, - captureMode: storyMode ? CaptureMode.hybrid : CaptureMode.videoOnly, - ), + final mediaPlaybackContainer = ProviderScope.containerOf( + context, + listen: false, + ); + final mediaPlaybackSuspension = suspendMediaPlayback( + mediaPlaybackContainer, ); + try { + await context.router.push( + RecordingRoute( + storyMode: storyMode, + captureMode: storyMode ? CaptureMode.hybrid : CaptureMode.videoOnly, + ), + ); + } finally { + mediaPlaybackSuspension.release(); + } }; } diff --git a/lib/src/features/posting/providers/camera_provider.dart b/lib/src/features/posting/providers/camera_provider.dart index 726a233..1dbe38f 100644 --- a/lib/src/features/posting/providers/camera_provider.dart +++ b/lib/src/features/posting/providers/camera_provider.dart @@ -14,6 +14,7 @@ part 'camera_provider.g.dart'; class Camera extends _$Camera { late final SparkLogger _logger; AppLifecycleListener? _lifecycleListener; + bool _isFlippingCamera = false; // Track if camera was disposed due to app lifecycle (not user navigation) bool _wasDisposedByLifecycle = false; @@ -116,8 +117,6 @@ class Camera extends _$Camera { await controller.initialize(); if (controller.value.isInitialized) { - // Pre-initialize audio session on iOS to eliminate recording start lag - await controller.prepareForVideoRecording(); _logger.i('Camera controller successfully initialized'); return controller; } else { @@ -149,6 +148,11 @@ class Camera extends _$Camera { } Future flipCamera() async { + if (_isFlippingCamera) { + _logger.d('Ignoring camera flip - already flipping'); + return; + } + final currentState = state.value; if (currentState == null) { _logger.w('Cannot flip camera - no current state'); @@ -195,56 +199,56 @@ class Camera extends _$Camera { _logger.d('Flipping camera'); final newCamera = currentState.cameras[newIndex]; - final oldController = currentState.controller; - - // Detach preview first, then dispose old controller. + final controller = currentState.controller; + _isFlippingCamera = true; state = AsyncValue.data( - currentState.copyWith( - controller: null, - isInitialized: false, - isFlipping: true, - error: null, - ), + currentState.copyWith(isFlipping: true, error: null), ); - await _waitForPreviewDetach(); - if (!ref.mounted) return; - try { _logger.d('Switching to camera: ${newCamera.name}'); - await oldController?.dispose(); - if (!ref.mounted) return; + if (controller == null) { + final newController = await _createCameraController(newCamera); + if (!ref.mounted) { + await newController.dispose(); + return; + } - final newController = await _createCameraController(newCamera); - if (!ref.mounted) { - await newController.dispose(); - return; - } + state = AsyncValue.data( + currentState.copyWith( + controller: newController, + selectedCameraIndex: newIndex, + isInitialized: true, + isFlipping: false, + error: null, + ), + ); + } else { + await controller.setDescription(newCamera); + if (!ref.mounted) return; - state = AsyncValue.data( - currentState.copyWith( - controller: newController, - selectedCameraIndex: newIndex, - isInitialized: true, - isFlipping: false, - error: null, - ), - ); + state = AsyncValue.data( + currentState.copyWith( + controller: controller, + selectedCameraIndex: newIndex, + isInitialized: true, + isFlipping: false, + error: null, + ), + ); + } _logger.i('Camera flipped successfully to ${newCamera.name}'); } catch (e, stackTrace) { _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); if (ref.mounted) { state = AsyncValue.data( - currentState.copyWith( - controller: null, - isInitialized: false, - isFlipping: false, - error: e.toString(), - ), + currentState.copyWith(isFlipping: false, error: e.toString()), ); } + } finally { + _isFlippingCamera = false; } } @@ -295,7 +299,10 @@ class Camera extends _$Camera { state = AsyncValue.data(currentState.copyWith(isRecording: true)); try { - await currentState.controller!.startVideoRecording(); + await currentState.controller!.prepareForVideoRecording(); + await currentState.controller!.startVideoRecording( + enablePersistentRecording: true, + ); _logger.i('Video recording started successfully'); return true; } catch (e, stackTrace) { diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index 1be5e7c..bec1b92 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -965,8 +965,6 @@ class _RecordingPageState extends ConsumerState { availableLensDirections.contains(CameraLensDirection.front) && availableLensDirections.contains(CameraLensDirection.back) && !_isStartingRecording && - !recordingState.isRecording && - !recordingState.hasSegments && !cameraState.isFlipping; final aspectRatio = cameraState.controller!.value.aspectRatio; final canFinalizeSession = @@ -986,57 +984,42 @@ class _RecordingPageState extends ConsumerState { ? null : _handleTap; - return Stack( - children: [ - RecordingPageTemplate( - cameraPreview: RepaintBoundary( - child: CameraPreview(cameraState.controller!), - ), - aspectRatio: aspectRatio, - isRecording: recordingState.isRecording, - elapsedDuration: recordingState.elapsedDuration, - maxDuration: recordingState.maxDuration, - onBack: () { - if (_isStartingRecording || recordingState.isRecording) { - return; - } - context.router.pop(); - }, - onFlipCamera: canFlipCamera ? _handleFlipCamera : null, - canFlipCamera: canFlipCamera, - captureMode: widget.captureMode, - isProcessing: _isFinalizingRecordingSession, - processingLabel: AppLocalizations.of( - context, - ).messageProcessingVideo, - doneLabel: AppLocalizations.of(context).buttonDone, - onDone: canFinalizeSession ? _finalizeRecordingSession : null, - onTap: onTap, - onRecordStart: _isProcessing ? null : _handleRecordStart, - onRecordStop: _isProcessing ? null : _handleRecordStop, - onOpenLibrary: - _isProcessing || - _isStartingRecording || - recordingState.isRecording || - recordingState.hasSegments - ? null - : _openMediaLibraryPicker, - soundLabel: recordingState.selectedSound?.title, - onSelectSound: canChangeSound ? _showSoundPicker : null, - onClearSound: canChangeSound && recordingState.hasSelectedSound - ? _clearSelectedSound - : null, - ), - if (cameraState.isFlipping) - const Positioned.fill( - child: ColoredBox( - color: Colors.black, - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), - ], + return RecordingPageTemplate( + cameraPreview: RepaintBoundary( + child: CameraPreview(cameraState.controller!), + ), + aspectRatio: aspectRatio, + isRecording: recordingState.isRecording, + elapsedDuration: recordingState.elapsedDuration, + maxDuration: recordingState.maxDuration, + onBack: () { + if (_isStartingRecording || recordingState.isRecording) { + return; + } + context.router.pop(); + }, + onFlipCamera: canFlipCamera ? _handleFlipCamera : null, + canFlipCamera: canFlipCamera, + captureMode: widget.captureMode, + isProcessing: _isFinalizingRecordingSession, + processingLabel: AppLocalizations.of(context).messageProcessingVideo, + doneLabel: AppLocalizations.of(context).buttonDone, + onDone: canFinalizeSession ? _finalizeRecordingSession : null, + onTap: onTap, + onRecordStart: _isProcessing ? null : _handleRecordStart, + onRecordStop: _isProcessing ? null : _handleRecordStop, + onOpenLibrary: + _isProcessing || + _isStartingRecording || + recordingState.isRecording || + recordingState.hasSegments + ? null + : _openMediaLibraryPicker, + soundLabel: recordingState.selectedSound?.title, + onSelectSound: canChangeSound ? _showSoundPicker : null, + onClearSound: canChangeSound && recordingState.hasSelectedSound + ? _clearSelectedSound + : null, ); }, loading: () => const Scaffold( diff --git a/widgetbook/lib/atoms/icons_showcase.dart b/widgetbook/lib/atoms/icons_showcase.dart index 650954b..112c8d9 100644 --- a/widgetbook/lib/atoms/icons_showcase.dart +++ b/widgetbook/lib/atoms/icons_showcase.dart @@ -24,6 +24,7 @@ Widget buildAppIconsGridUseCase(BuildContext context) { AppIcons.bookmarkFilled(size: size, color: color), AppIcons.camera(size: size, color: color), AppIcons.arrowRight(size: size, color: color), + AppIcons.arrowFlip(size: size, color: color), AppIcons.pin(size: size, color: color), AppIcons.music(size: size, color: color), AppIcons.folderMini(size: size, color: color),