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),