diff --git a/lib/src/core/design_system/components/molecules/recording_button.dart b/lib/src/core/design_system/components/molecules/recording_button.dart index 1639410f..80390735 100644 --- a/lib/src/core/design_system/components/molecules/recording_button.dart +++ b/lib/src/core/design_system/components/molecules/recording_button.dart @@ -1,17 +1,41 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; -import 'package:spark/src/core/design_system/tokens/colors.dart'; +/// Camera capture mode. +enum CaptureMode { + /// Video only - tap to start/stop recording. + videoOnly, + + /// Hybrid - tap for photo, hold for video. + hybrid, +} + +/// iOS-style camera recording button. +/// +/// In [CaptureMode.videoOnly]: tap toggles recording. +/// In [CaptureMode.hybrid]: tap takes photo, hold records video. class RecordingButton extends StatefulWidget { const RecordingButton({ required this.isRecording, - required this.onPressed, + required this.mode, + this.onTap, + this.onRecordStart, + this.onRecordStop, super.key, }); final bool isRecording; - final VoidCallback? onPressed; + final CaptureMode mode; + + /// Called on tap. In videoOnly mode, toggles recording. + /// In hybrid mode, takes photo. + final VoidCallback? onTap; + + /// Called when hold starts (hybrid mode only). + final VoidCallback? onRecordStart; + + /// Called when hold ends (hybrid mode only). + final VoidCallback? onRecordStop; @override State createState() => _RecordingButtonState(); @@ -19,101 +43,130 @@ class RecordingButton extends StatefulWidget { class _RecordingButtonState extends State with SingleTickerProviderStateMixin { - late AnimationController _pulseController; + late AnimationController _controller; + late Animation _sizeAnimation; + late Animation _borderRadiusAnimation; + late Animation _colorAnimation; + + static const _outerSize = 80.0; + static const _ringWidth = 4.0; + static const _idleInnerSize = 66.0; + static const _recordingInnerSize = 32.0; + static const _idleColor = Colors.white; + static const _recordingColor = Color(0xFFFF3B30); // iOS red @override void initState() { super.initState(); - _pulseController = AnimationController( + _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1000), - )..repeat(reverse: true); + duration: const Duration(milliseconds: 200), + ); + + _sizeAnimation = Tween( + begin: _idleInnerSize, + end: _recordingInnerSize, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + _borderRadiusAnimation = Tween( + begin: _idleInnerSize / 2, + end: 8, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + _colorAnimation = ColorTween( + begin: _idleColor, + end: _recordingColor, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + if (widget.isRecording) { + _controller.value = 1.0; + } } @override void didUpdateWidget(RecordingButton oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.isRecording && !oldWidget.isRecording) { - _pulseController.repeat(reverse: true); - } else if (!widget.isRecording && oldWidget.isRecording) { - _pulseController - ..stop() - ..reset(); + if (widget.isRecording != oldWidget.isRecording) { + if (widget.isRecording) { + _controller.forward(); + } else { + _controller.reverse(); + } } } @override void dispose() { - _pulseController.dispose(); + _controller.dispose(); super.dispose(); } void _handleTap() { - if (widget.onPressed == null) return; + if (widget.onTap == null) return; + HapticFeedback.mediumImpact(); + widget.onTap!(); + } + void _handleLongPressStart(LongPressStartDetails details) { + if (widget.mode != CaptureMode.hybrid) return; + if (widget.onRecordStart == null) return; HapticFeedback.mediumImpact(); - widget.onPressed!(); + widget.onRecordStart!(); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + if (widget.mode != CaptureMode.hybrid) return; + if (widget.onRecordStop == null) return; + HapticFeedback.lightImpact(); + widget.onRecordStop!(); } @override Widget build(BuildContext context) { - const size = 72.0; - - return InteractivePressable( + return GestureDetector( onTap: _handleTap, - pressedScale: 0.9, - borderRadius: BorderRadius.circular(size), - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withAlpha(100), - width: 3, - ), - ), - child: AnimatedBuilder( - animation: _pulseController, - builder: (context, child) { - return Container( - margin: EdgeInsets.all(widget.isRecording ? 8 : 4), + onLongPressStart: widget.mode == CaptureMode.hybrid + ? _handleLongPressStart + : null, + onLongPressEnd: widget.mode == CaptureMode.hybrid + ? _handleLongPressEnd + : null, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: _outerSize, + height: _outerSize, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer white ring + Container( + width: _outerSize, + height: _outerSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: widget.isRecording - ? AppColors.red500 - : AppColors.greyWhite, - boxShadow: widget.isRecording - ? [ - BoxShadow( - color: AppColors.red500.withAlpha( - (128 * (0.5 + 0.5 * _pulseController.value)) - .toInt(), - ), - blurRadius: 20, - spreadRadius: 4, - ), - ] - : null, - ), - child: Center( - child: widget.isRecording - ? Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: AppColors.greyWhite, - borderRadius: BorderRadius.circular(2), - ), - ) - : const Icon( - Icons.circle, - color: AppColors.red500, - size: 24, - ), + border: Border.all( + color: Colors.white, + width: _ringWidth, + ), ), - ); - }, + ), + // Animated inner shape (white circle -> red square) + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + width: _sizeAnimation.value, + height: _sizeAnimation.value, + decoration: BoxDecoration( + color: _colorAnimation.value, + borderRadius: BorderRadius.circular( + _borderRadiusAnimation.value, + ), + ), + ); + }, + ), + ], ), ), ); 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 02cc147f..3162bfe2 100644 --- a/lib/src/core/design_system/templates/recording_page_template.dart +++ b/lib/src/core/design_system/templates/recording_page_template.dart @@ -1,8 +1,12 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; -import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; +import 'package:flutter/services.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/design_system/tokens/colors.dart'; + +export 'package:spark/src/core/design_system/components/molecules/recording_button.dart' + show CaptureMode; class RecordingPageTemplate extends StatelessWidget { const RecordingPageTemplate({ @@ -13,8 +17,11 @@ class RecordingPageTemplate extends StatelessWidget { required this.maxDuration, required this.onBack, required this.onFlipCamera, - required this.onRecordPressed, required this.canFlipCamera, + required this.captureMode, + this.onTap, + this.onRecordStart, + this.onRecordStop, super.key, }); @@ -25,12 +32,23 @@ class RecordingPageTemplate extends StatelessWidget { final Duration maxDuration; final VoidCallback onBack; final VoidCallback? onFlipCamera; - final VoidCallback? onRecordPressed; final bool canFlipCamera; + final CaptureMode captureMode; + + /// Called on tap. In videoOnly: toggle recording. In hybrid: take photo. + final VoidCallback? onTap; + + /// Called when hold starts (hybrid mode only). + final VoidCallback? onRecordStart; + + /// Called when hold ends (hybrid mode only). + final VoidCallback? onRecordStop; @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + final footerHeight = kBottomNavigationBarHeight + 12; + const borderRadius = BorderRadius.all(Radius.circular(20)); // Calculate scale based on camera aspect ratio and screen aspect ratio var scale = size.aspectRatio * aspectRatio; @@ -40,39 +58,54 @@ class RecordingPageTemplate extends StatelessWidget { return Scaffold( backgroundColor: Colors.black, - body: Stack( - fit: StackFit.expand, - children: [ - // Camera preview fills entire screen (including safe areas) - // For camera package v0.7.0+, AspectRatio is handled by the package - Positioned.fill( - child: Transform.scale( - scale: scale, - child: Center( - child: cameraPreview, - ), - ), - ), - // Top controls respect safe area - SafeArea( - bottom: false, - child: _TopOverlay( - onBack: onBack, - timer: RecordingTimer( - duration: elapsedDuration, - maxDuration: maxDuration, + body: SafeArea( + child: Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: borderRadius, + child: Stack( + fit: StackFit.expand, + children: [ + // Camera preview fills rounded view area + Positioned.fill( + child: Transform.scale( + scale: scale, + child: Center( + child: cameraPreview, + ), + ), + ), + // Top controls aligned within rounded view + _TopOverlay( + onBack: onBack, + timer: RecordingTimer( + duration: elapsedDuration, + maxDuration: maxDuration, + ), + ), + // Bottom overlay sits inside rounded view + _BottomOverlay( + onFlipCamera: canFlipCamera ? onFlipCamera : null, + recordingButton: RecordingButton( + isRecording: isRecording, + mode: captureMode, + onTap: onTap, + onRecordStart: onRecordStart, + onRecordStop: onRecordStop, + ), + bottomPadding: 24, + ), + ], + ), ), ), - ), - // Bottom overlay extends to bottom edge (no safe area) - _BottomOverlay( - onFlipCamera: canFlipCamera ? onFlipCamera : null, - recordingButton: RecordingButton( - isRecording: isRecording, - onPressed: onRecordPressed, + SizedBox( + height: footerHeight, + child: const ColoredBox(color: Colors.black), ), - ), - ], + ], + ), ), ); } @@ -96,19 +129,9 @@ class _TopOverlay extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container( - decoration: BoxDecoration( - color: Colors.black.withAlpha(128), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Colors.white.withAlpha(50), - width: 1.5, - ), - ), - child: const AppLeadingButton(color: AppColors.greyWhite), - ), + _CloseButton(onPressed: onBack), timer, - const SizedBox(width: 48), + const SizedBox(width: 40), ], ), ), @@ -116,14 +139,51 @@ class _TopOverlay extends StatelessWidget { } } +/// iOS-style close button with blur background. +class _CloseButton extends StatelessWidget { + const _CloseButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withAlpha(90), + ), + child: const Icon( + Icons.close_rounded, + color: Colors.white, + size: 22, + ), + ), + ), + ), + ); + } +} + class _BottomOverlay extends StatelessWidget { const _BottomOverlay({ required this.onFlipCamera, required this.recordingButton, + required this.bottomPadding, }); final VoidCallback? onFlipCamera; final Widget recordingButton; + final double bottomPadding; @override Widget build(BuildContext context) { @@ -140,16 +200,21 @@ class _BottomOverlay extends StatelessWidget { ], ), ), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 40, + bottom: bottomPadding, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (onFlipCamera != null) _FlipCameraButton(onPressed: onFlipCamera!) else - const SizedBox(width: 72), + const SizedBox(width: 80), recordingButton, - const SizedBox(width: 72), + const SizedBox(width: 80), ], ), ), @@ -157,6 +222,7 @@ class _BottomOverlay extends StatelessWidget { } } +/// iOS-style flip camera button with blur background. class _FlipCameraButton extends StatelessWidget { const _FlipCameraButton({required this.onPressed}); @@ -165,27 +231,31 @@ class _FlipCameraButton extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: onPressed, + onTap: () { + HapticFeedback.lightImpact(); + onPressed(); + }, child: SizedBox( - width: 72, - height: 72, + width: 80, + height: 80, child: Center( - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.black.withAlpha(128), - border: Border.all( - color: Colors.white.withAlpha(100), - width: 2, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withAlpha(90), + ), + child: const Icon( + Icons.flip_camera_ios_rounded, + color: Colors.white, + size: 26, + ), ), ), - child: const Icon( - Icons.flip_camera_ios, - color: AppColors.greyWhite, - size: 28, - ), ), ), ), diff --git a/lib/src/core/media/create_media_actions.dart b/lib/src/core/media/create_media_actions.dart index 637621bf..5258a733 100644 --- a/lib/src/core/media/create_media_actions.dart +++ b/lib/src/core/media/create_media_actions.dart @@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:pro_video_editor/pro_video_editor.dart'; import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; /// Centralized factories for the create media actions used across the app. /// @@ -16,17 +17,28 @@ class CreateMediaActions { const CreateMediaActions._(); /// Record flow: camera capture -> editor -> review. + /// + /// For stories, uses hybrid mode (tap for photo, hold for video). + /// For posts, uses video-only mode (tap toggles recording). static VoidCallback onRecord( BuildContext context, { required bool storyMode, }) { return () async { if (!context.mounted) return; - await context.router.push(RecordingRoute(storyMode: storyMode)); + await context.router.push( + RecordingRoute( + storyMode: storyMode, + captureMode: storyMode ? CaptureMode.hybrid : CaptureMode.videoOnly, + ), + ); }; } - /// Upload video flow: pick from gallery -> open editor -> review (story/post mode decided here). + /// Upload video flow: pick from gallery -> open editor -> direct post/review. + /// + /// For stories, posts directly after editing (with story-specific tools). + /// For posts, goes to review page (with full editing tools). static VoidCallback onUploadVideo( BuildContext context, { required bool storyMode, @@ -38,35 +50,67 @@ class CreateMediaActions { ); if (pickedVideo != null && context.mounted) { final editorVideo = EditorVideo.file(File(pickedVideo.path)); - final result = await GetIt.I() - .openVideoEditor(context, editorVideo); + final repository = GetIt.I(); + final result = storyMode + ? await repository.openStoryVideoEditor(context, editorVideo) + : await repository.openVideoEditor(context, editorVideo); if (result != null && context.mounted) { - await context.router.push( - VideoReviewRoute( - videoPath: result.video.path, - storyMode: storyMode, - soundRef: result.soundRef, - ), - ); + if (storyMode) { + // For stories, post directly + await context.router.push( + StoryPostRoute(videoPath: result.video.path), + ); + } else { + // For posts, go to review + await context.router.push( + VideoReviewRoute( + videoPath: result.video.path, + storyMode: storyMode, + soundRef: result.soundRef, + ), + ); + } } } }; } - /// Upload images flow: multi-pick -> image review (story/post mode decided here). + /// Upload images flow: multi-pick -> story editor/image review. + /// + /// For stories, opens story editor then posts directly. + /// For posts, goes to image review page. static VoidCallback onUploadImages( BuildContext context, { required bool storyMode, }) { return () async { - final pickedImages = await ImagePicker().pickMultiImage(limit: 12); - if (context.mounted && pickedImages.isNotEmpty) { - await context.router.push( - ImageReviewRoute( - imageFiles: pickedImages, - storyMode: storyMode, - ), + if (storyMode) { + // For stories, pick single image and open story editor + final pickedImage = await ImagePicker().pickImage( + source: ImageSource.gallery, ); + if (pickedImage != null && context.mounted) { + // Open story editor + final editedImage = await GetIt.I() + .openStoryImageEditor(context, pickedImage); + if (editedImage != null && context.mounted) { + // Post directly + await context.router.push( + StoryPostRoute(imageFile: editedImage), + ); + } + } + } else { + // For posts, multi-pick and go to review + final pickedImages = await ImagePicker().pickMultiImage(limit: 12); + if (context.mounted && pickedImages.isNotEmpty) { + await context.router.push( + ImageReviewRoute( + imageFiles: pickedImages, + storyMode: storyMode, + ), + ); + } } }; } diff --git a/lib/src/core/pro_image_editor/story_image_editor_configs.dart b/lib/src/core/pro_image_editor/story_image_editor_configs.dart new file mode 100644 index 00000000..382a7a77 --- /dev/null +++ b/lib/src/core/pro_image_editor/story_image_editor_configs.dart @@ -0,0 +1,294 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:pro_image_editor/designs/grounded/grounded_design.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/theme/color_scheme.dart'; +import 'package:spark/src/core/design_system/theme/text_theme.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart'; +import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_header.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/blur/blur_editor_bar.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/filter/filter_editor_bar.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/paint/paint_editor_bar.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/text/text_editor_bar.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/text/text_editor_color_picker.dart'; + +/// Border radius for the story editor preview area (top and bottom). +const _storyEditorBorderRadius = BorderRadius.vertical( + top: Radius.circular(20), + bottom: Radius.circular(20), +); + +/// Configuration builder for the Story Image Editor. +/// +/// Creates a fixed 9:16 aspect ratio editor optimized for stories. +class StoryImageEditorConfigs { + const StoryImageEditorConfigs._(); + + /// Fixed story canvas size (1080x1920 = 9:16 aspect ratio). + static const Size storySize = Size(1080, 1920); + + /// Builds the ProImageEditor configuration for story editing. + /// + /// This configuration: + /// - Uses a fixed 9:16 aspect ratio canvas + /// - Excludes crop/rotate tools to maintain aspect ratio + /// - Provides text, paint, stickers, emoji, filter, and blur tools + static ProImageEditorConfigs build({ + required bool useMaterialDesign, + required Widget Function() imagePreviewBuilder, + }) { + return ProImageEditorConfigs( + designMode: platformDesignMode, + theme: ThemeData( + useMaterial3: true, + colorScheme: AppColorScheme.dark, + textTheme: AppTextTheme.dark, + ), + // Force output to story dimensions + imageGeneration: const ImageGenerationConfigs( + outputFormat: OutputFormat.png, + maxOutputSize: storySize, + ), + mainEditor: MainEditorConfigs( + // Story-appropriate tools only - NO crop/rotate + tools: const [ + SubEditorMode.paint, + SubEditorMode.text, + SubEditorMode.filter, + SubEditorMode.blur, + SubEditorMode.emoji, + SubEditorMode.sticker, + ], + widgets: MainEditorWidgets( + removeLayerArea: + ( + removeAreaKey, + editor, + rebuildStream, + isLayerBeingTransformed, + ) => VideoEditorRemoveArea( + removeAreaKey: removeAreaKey, + editor: editor, + rebuildStream: rebuildStream, + isLayerBeingTransformed: isLayerBeingTransformed, + ), + appBar: (editor, rebuildStream) => null, + bottomBar: (editor, rebuildStream, key) => ReactiveWidget( + key: key, + builder: (context) { + return StoryEditorBottomSection(editor: editor); + }, + stream: rebuildStream, + ), + wrapBody: (editor, rebuildStream, content) { + // Fill behind content so no letterboxing shows as dark lines on sides + return ClipRRect( + borderRadius: _storyEditorBorderRadius, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black, + child: content, + ), + ); + }, + bodyItems: (editor, rebuildStream) => [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: StoryEditorHeader( + onBack: editor.closeEditor, + onDone: editor.doneEditing, + canUndo: editor.canUndo, + canRedo: editor.canRedo, + onUndo: editor.undoAction, + onRedo: editor.redoAction, + ), + ), + ), + ), + ], + ), + style: const MainEditorStyle( + background: Colors.black, + bottomBarBackground: AppColors.grey800, + ), + ), + paintEditor: PaintEditorConfigs( + style: const PaintEditorStyle( + background: AppColors.greyBlack, + bottomBarBackground: AppColors.grey800, + initialStrokeWidth: 5, + ), + widgets: PaintEditorWidgets( + appBar: (paintEditor, rebuildStream) => null, + colorPicker: (paintEditor, rebuildStream, currentColor, setColor) => + null, + bottomBar: (editorState, rebuildStream) { + return ReactiveWidget( + builder: (context) { + return PaintEditorBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + i18nColor: 'Color', + showColorPicker: (currentColor) {}, + ); + }, + stream: rebuildStream, + ); + }, + ), + ), + textEditor: TextEditorConfigs( + customTextStyles: [ + GoogleFonts.roboto(), + GoogleFonts.averiaLibre(), + GoogleFonts.lato(), + GoogleFonts.comicNeue(), + GoogleFonts.actor(), + GoogleFonts.odorMeanChey(), + GoogleFonts.nabla(), + ], + style: TextEditorStyle( + textFieldMargin: const EdgeInsets.only(top: kToolbarHeight), + bottomBarBackground: AppColors.grey800, + bottomBarMainAxisAlignment: !useMaterialDesign + ? MainAxisAlignment.spaceEvenly + : MainAxisAlignment.start, + ), + widgets: TextEditorWidgets( + appBar: (textEditor, rebuildStream) => null, + colorPicker: (textEditor, rebuildStream, currentColor, setColor) { + return ReactiveWidget( + stream: rebuildStream, + builder: (_) => TextEditorColorPicker( + configs: textEditor.configs, + primaryColor: currentColor, + onUpdateColor: setColor, + rebuildStream: rebuildStream, + ), + ); + }, + bottomBar: (editorState, rebuildStream) { + return ReactiveWidget( + builder: (context) { + return TextEditorBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + i18nColor: 'Color', + showColorPicker: (currentColor) {}, + ); + }, + stream: rebuildStream, + ); + }, + bodyItems: (editorState, rebuildStream) => [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: GroundedTextSizeSlider(textEditor: editorState), + ), + ), + ], + ), + ), + filterEditor: FilterEditorConfigs( + style: const FilterEditorStyle( + filterListSpacing: 7, + filterListMargin: EdgeInsets.fromLTRB(8, 0, 8, 8), + background: AppColors.greyBlack, + ), + widgets: FilterEditorWidgets( + slider: (editorState, rebuildStream, value, onChanged, onChangeEnd) => + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Slider( + onChanged: onChanged, + onChangeEnd: onChangeEnd, + value: value, + activeColor: AppColors.primary400, + ), + ), + appBar: (editorState, rebuildStream) => null, + bottomBar: (editorState, rebuildStream) { + return ReactiveWidget( + builder: (context) { + return FilterEditorBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + image: imagePreviewBuilder(), + ); + }, + stream: rebuildStream, + ); + }, + ), + ), + blurEditor: BlurEditorConfigs( + maxBlur: 25, + style: const BlurEditorStyle( + background: AppColors.greyBlack, + ), + widgets: BlurEditorWidgets( + appBar: (blurEditor, rebuildStream) => null, + bottomBar: (editorState, rebuildStream) { + return ReactiveWidget( + builder: (context) { + return BlurEditorBar( + configs: editorState.configs, + callbacks: editorState.callbacks, + editor: editorState, + ); + }, + stream: rebuildStream, + ); + }, + ), + ), + emojiEditor: EmojiEditorConfigs( + checkPlatformCompatibility: !kIsWeb, + style: EmojiEditorStyle( + backgroundColor: Colors.transparent, + textStyle: DefaultEmojiTextStyle.copyWith( + fontFamily: !kIsWeb + ? null + : GoogleFonts.notoColorEmoji().fontFamily, + fontSize: useMaterialDesign ? 48 : 30, + ), + bottomActionBarConfig: const BottomActionBarConfig(enabled: false), + ), + ), + stickerEditor: StickerEditorConfigs( + builder: (setLayer, scrollController) => DemoBuildStickers( + setLayer: setLayer, + scrollController: scrollController, + ), + style: const StickerEditorStyle( + showDragHandle: false, + ), + ), + i18n: const I18n( + paintEditor: I18nPaintEditor( + changeOpacity: 'Opacity', + lineWidth: 'Thickness', + ), + textEditor: I18nTextEditor( + backgroundMode: 'Mode', + textAlign: 'Align', + ), + ), + ); + } +} diff --git a/lib/src/core/pro_image_editor/ui/story_image_editor_page.dart b/lib/src/core/pro_image_editor/ui/story_image_editor_page.dart new file mode 100644 index 00000000..8c87573d --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/story_image_editor_page.dart @@ -0,0 +1,394 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_image_editor/story_image_editor_configs.dart'; +import 'package:spark/src/core/pro_image_editor/utils/story_image_cropper.dart'; + +/// A story-specific image editor page. +/// +/// This editor uses a fixed 9:16 aspect ratio canvas (1080x1920) optimized +/// for Instagram Stories-style content. +/// +/// Features: +/// - Auto-crops input images to 9:16 aspect ratio +/// - Rounded preview area matching the camera view style +/// - Text, paint, stickers, emoji, filter, and blur tools +/// - NO crop/rotate tools (to maintain aspect ratio) +/// - Custom UI matching the app's design language +class StoryImageEditorPage extends StatefulWidget { + const StoryImageEditorPage({ + required this.imageFile, + super.key, + }); + + /// The source image file to edit. + final File imageFile; + + /// Opens the story image editor and returns the edited image. + /// + /// Returns `null` if the user cancels without completing the edit. + static Future open(BuildContext context, File imageFile) async { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StoryImageEditorPage(imageFile: imageFile), + ), + ); + } + + @override + State createState() => _StoryImageEditorPageState(); +} + +class _StoryImageEditorPageState extends State { + final _editorKey = GlobalKey(); + final bool _useMaterialDesign = + platformDesignMode == ImageEditorDesignMode.material; + + late ProImageEditorConfigs _configs; + File? _croppedImageFile; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _prepareImage(); + } + + @override + void dispose() { + // Clean up cropped temp file if it exists and is different from original + if (_croppedImageFile != null && + _croppedImageFile!.path != widget.imageFile.path) { + _croppedImageFile!.delete().catchError((_) => _croppedImageFile!); + } + super.dispose(); + } + + Future _prepareImage() async { + try { + // Check if image needs cropping to 9:16 + final needsCrop = await StoryImageCropper.needsCropping(widget.imageFile); + + if (needsCrop) { + // Crop to 9:16 aspect ratio + _croppedImageFile = await StoryImageCropper.cropToStoryAspectRatio( + widget.imageFile, + ); + } else { + _croppedImageFile = widget.imageFile; + } + + // Initialize editor config + _configs = StoryImageEditorConfigs.build( + useMaterialDesign: _useMaterialDesign, + imagePreviewBuilder: () => Image.file( + _croppedImageFile!, + fit: BoxFit.cover, + ), + ); + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + } + + Future _onImageEditingComplete(Uint8List bytes) async { + final directory = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filename = 'spark_story_$timestamp.png'; + final file = File('${directory.path}/$filename'); + await file.writeAsBytes(bytes, flush: true); + + if (mounted) { + Navigator.of(context).pop( + XFile( + file.path, + mimeType: 'image/png', + name: filename, + ), + ); + } + } + + void _onCloseEditor(EditorMode editorMode) { + if (editorMode == EditorMode.main) { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 16), + Text( + 'Preparing image...', + style: TextStyle(color: Colors.white70), + ), + ], + ), + ), + ); + } + + if (_error != null) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + const Text( + 'Failed to load image', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Go Back'), + ), + ], + ), + ), + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: ProImageEditor.file( + _croppedImageFile!, + key: _editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: _onImageEditingComplete, + onCloseEditor: _onCloseEditor, + stickerEditorCallbacks: StickerEditorCallbacks( + onSearchChanged: (value) { + debugPrint('Sticker search: $value'); + }, + ), + ), + configs: _configs, + ), + ); + } +} + +/// A blank canvas story editor that allows adding images as movable layers. +/// +/// This approach gives more flexibility - the user's image becomes a movable +/// layer on a fixed 9:16 canvas, allowing them to position it anywhere. +class StoryBlankCanvasEditorPage extends StatefulWidget { + const StoryBlankCanvasEditorPage({ + this.backgroundImage, + this.backgroundColor = Colors.black, + super.key, + }); + + /// Optional background image to add as a movable layer. + final File? backgroundImage; + + /// Background color for the canvas. + final Color backgroundColor; + + /// Opens the blank canvas story editor and returns the edited image. + static Future open( + BuildContext context, { + File? backgroundImage, + Color backgroundColor = Colors.black, + }) async { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StoryBlankCanvasEditorPage( + backgroundImage: backgroundImage, + backgroundColor: backgroundColor, + ), + ), + ); + } + + @override + State createState() => + _StoryBlankCanvasEditorPageState(); +} + +class _StoryBlankCanvasEditorPageState + extends State { + final _editorKey = GlobalKey(); + final bool _useMaterialDesign = + platformDesignMode == ImageEditorDesignMode.material; + + late ProImageEditorConfigs _configs; + bool _isInitialized = false; + Size? _canvasSize; + Size? _imageSize; + bool _hasAddedBackgroundLayer = false; + + @override + void initState() { + super.initState(); + _loadBackgroundImageSize(); + _initializeEditor(); + } + + Future _loadBackgroundImageSize() async { + if (widget.backgroundImage == null) return; + try { + final bytes = await widget.backgroundImage!.readAsBytes(); + final completer = Completer(); + ui.decodeImageFromList(bytes, completer.complete); + final image = await completer.future; + final size = Size(image.width.toDouble(), image.height.toDouble()); + image.dispose(); + if (mounted) { + setState(() { + _imageSize = size; + }); + _addBackgroundImageLayer(); + } + } catch (e) { + debugPrint('Failed to load image size: $e'); + } + } + + void _initializeEditor() { + _configs = StoryImageEditorConfigs.build( + useMaterialDesign: _useMaterialDesign, + imagePreviewBuilder: () => widget.backgroundImage != null + ? Image.file(widget.backgroundImage!, fit: BoxFit.cover) + : const SizedBox.shrink(), + ); + + setState(() { + _isInitialized = true; + }); + } + + Future _onImageEditingComplete(Uint8List bytes) async { + final directory = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filename = 'spark_story_$timestamp.png'; + final file = File('${directory.path}/$filename'); + await file.writeAsBytes(bytes, flush: true); + + if (mounted) { + Navigator.of(context).pop( + XFile( + file.path, + mimeType: 'image/png', + name: filename, + ), + ); + } + } + + void _onCloseEditor(EditorMode editorMode) { + if (editorMode == EditorMode.main) { + Navigator.of(context).pop(); + } + } + + void _addBackgroundImageLayer() { + if (widget.backgroundImage == null) return; + if (_hasAddedBackgroundLayer) return; + if (_imageSize == null) return; + + final editorState = _editorKey.currentState; + if (editorState == null) return; + + final size = _canvasSize ?? StoryImageEditorConfigs.storySize; + final initWidth = _configs.stickerEditor.initWidth; + final imageSize = _imageSize!; + final widthForCover = size.height * (imageSize.width / imageSize.height); + final targetWidth = widthForCover > size.width ? widthForCover : size.width; + final scale = initWidth > 0 ? targetWidth / initWidth : 1.0; + // Use original image aspect (no forced 9:16 crop). + // Scale fills the canvas (cover), while preserving aspect ratio. + editorState.addLayer( + WidgetLayer( + widget: Image.file( + widget.backgroundImage!, + fit: BoxFit.contain, + ), + scale: scale, + ), + ); + _hasAddedBackgroundLayer = true; + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: LayoutBuilder( + builder: (context, constraints) { + final viewWidth = constraints.maxWidth; + final aspect = + StoryImageEditorConfigs.storySize.height / + StoryImageEditorConfigs.storySize.width; + final canvasSize = Size(viewWidth, viewWidth * aspect); + _canvasSize = canvasSize; + + return ProImageEditor.blank( + canvasSize, + key: _editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingComplete: _onImageEditingComplete, + onCloseEditor: _onCloseEditor, + mainEditorCallbacks: MainEditorCallbacks( + onAfterViewInit: _addBackgroundImageLayer, + ), + stickerEditorCallbacks: StickerEditorCallbacks( + onSearchChanged: (value) { + debugPrint('Sticker search: $value'); + }, + ), + ), + configs: _configs.copyWith( + mainEditor: _configs.mainEditor.copyWith( + style: MainEditorStyle( + background: widget.backgroundColor, + bottomBarBackground: + _configs.mainEditor.style.bottomBarBackground, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart b/lib/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart new file mode 100644 index 00000000..d35b2ba2 --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart'; + +/// Bottom section for the Story Image Editor. +/// +/// Contains the toolbar with editing tools. +class StoryEditorBottomSection extends StatelessWidget { + const StoryEditorBottomSection({ + required this.editor, + super.key, + }); + + final ProImageEditorState editor; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: AppColors.greyBlack, + child: SafeArea( + top: false, + child: StoryEditorToolbar( + onPaint: editor.openPaintEditor, + onText: editor.openTextEditor, + onFilter: editor.openFilterEditor, + onBlur: editor.openBlurEditor, + onEmoji: editor.openEmojiEditor, + onStickers: editor.openStickerEditor, + ), + ), + ); + } +} diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_editor_header.dart b/lib/src/core/pro_image_editor/ui/widgets/story_editor_header.dart new file mode 100644 index 00000000..5759295a --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/widgets/story_editor_header.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/circle_icon_button.dart'; +import 'package:spark/src/core/design_system/components/atoms/icons.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; + +/// Header widget for the Story Image Editor. +/// +/// Shows back button, undo/redo controls, and done button. +class StoryEditorHeader extends StatelessWidget { + const StoryEditorHeader({ + required this.onBack, + required this.onDone, + required this.canUndo, + required this.canRedo, + required this.onUndo, + required this.onRedo, + super.key, + }); + + final VoidCallback onBack; + final VoidCallback onDone; + final bool canUndo; + final bool canRedo; + final VoidCallback onUndo; + final VoidCallback onRedo; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Back button + CircleIconButton( + onPressed: onBack, + backgroundColor: AppColors.grey600.withAlpha(180), + icon: AppIcons.chevronleft(), + semanticLabel: 'Back', + ), + const Spacer(), + // Undo/Redo controls + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _UndoRedoButton( + icon: Icons.undo_rounded, + onPressed: canUndo ? onUndo : null, + semanticLabel: 'Undo', + ), + const SizedBox(width: 8), + _UndoRedoButton( + icon: Icons.redo_rounded, + onPressed: canRedo ? onRedo : null, + semanticLabel: 'Redo', + ), + ], + ), + const Spacer(), + // Done button + CircleIconButton( + onPressed: onDone, + backgroundColor: AppColors.primary500, + icon: const Icon(Icons.arrow_forward, size: 22), + iconColor: AppColors.greyWhite, + semanticLabel: 'Done', + ), + ], + ), + ); + } +} + +class _UndoRedoButton extends StatelessWidget { + const _UndoRedoButton({ + required this.icon, + required this.onPressed, + required this.semanticLabel, + }); + + final IconData icon; + final VoidCallback? onPressed; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + final isEnabled = onPressed != null; + + return Semantics( + label: semanticLabel, + button: true, + enabled: isEnabled, + child: GestureDetector( + onTap: onPressed, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.grey600.withAlpha(isEnabled ? 180 : 90), + ), + child: Icon( + icon, + size: 20, + color: isEnabled + ? AppColors.greyWhite + : AppColors.greyWhite.withAlpha(100), + ), + ), + ), + ); + } +} diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart b/lib/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart new file mode 100644 index 00000000..85e99232 --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; + +/// Toolbar widget for the Story Image Editor. +/// +/// Displays horizontal list of editing tools optimized for stories. +class StoryEditorToolbar extends StatelessWidget { + const StoryEditorToolbar({ + required this.onPaint, + required this.onText, + required this.onFilter, + required this.onBlur, + required this.onEmoji, + required this.onStickers, + super.key, + }); + + final VoidCallback onPaint; + final VoidCallback onText; + final VoidCallback onFilter; + final VoidCallback onBlur; + final VoidCallback onEmoji; + final VoidCallback onStickers; + + @override + Widget build(BuildContext context) { + return Container( + height: 80, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ToolbarItem( + icon: Icons.brush_rounded, + label: 'Draw', + onTap: onPaint, + ), + _ToolbarItem( + icon: Icons.text_fields_rounded, + label: 'Text', + onTap: onText, + ), + _ToolbarItem( + icon: Icons.auto_awesome_rounded, + label: 'Filter', + onTap: onFilter, + ), + _ToolbarItem( + icon: Icons.blur_on_rounded, + label: 'Blur', + onTap: onBlur, + ), + _ToolbarItem( + icon: Icons.emoji_emotions_rounded, + label: 'Emoji', + onTap: onEmoji, + ), + _ToolbarItem( + icon: Icons.sticky_note_2_rounded, + label: 'Stickers', + onTap: onStickers, + ), + ], + ), + ); + } +} + +class _ToolbarItem extends StatelessWidget { + const _ToolbarItem({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 56, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: AppColors.greyWhite, + size: 26, + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + color: AppColors.grey300, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/core/pro_image_editor/utils/story_image_cropper.dart b/lib/src/core/pro_image_editor/utils/story_image_cropper.dart new file mode 100644 index 00000000..9f429285 --- /dev/null +++ b/lib/src/core/pro_image_editor/utils/story_image_cropper.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Utility to crop images to story aspect ratio (9:16 / 1080x1920). +class StoryImageCropper { + StoryImageCropper._(); + + /// Target story dimensions. + static const double targetWidth = 1080; + static const double targetHeight = 1920; + static const double targetAspectRatio = targetWidth / targetHeight; // 0.5625 + + /// Crops the given image file to 9:16 aspect ratio (center crop). + /// + /// Returns the path to the cropped image file. + static Future cropToStoryAspectRatio(File imageFile) async { + // Decode the image + final bytes = await imageFile.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + + final originalWidth = image.width.toDouble(); + final originalHeight = image.height.toDouble(); + final originalAspectRatio = originalWidth / originalHeight; + + // Calculate crop area (center crop to 9:16) + double cropWidth; + double cropHeight; + double cropX; + double cropY; + + if (originalAspectRatio > targetAspectRatio) { + // Image is wider than 9:16 - crop horizontally + cropHeight = originalHeight; + cropWidth = originalHeight * targetAspectRatio; + cropX = (originalWidth - cropWidth) / 2; + cropY = 0; + } else { + // Image is taller than 9:16 - crop vertically + cropWidth = originalWidth; + cropHeight = originalWidth / targetAspectRatio; + cropX = 0; + cropY = (originalHeight - cropHeight) / 2; + } + + // Create a picture recorder to draw the cropped image + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // Draw the cropped portion scaled to target size + final srcRect = Rect.fromLTWH(cropX, cropY, cropWidth, cropHeight); + final dstRect = Rect.fromLTWH(0, 0, targetWidth, targetHeight); + + canvas.drawImageRect(image, srcRect, dstRect, Paint()); + + // Convert to image + final picture = recorder.endRecording(); + final croppedImage = await picture.toImage( + targetWidth.toInt(), + targetHeight.toInt(), + ); + + // Encode to PNG + final byteData = await croppedImage.toByteData( + format: ui.ImageByteFormat.png, + ); + final pngBytes = byteData!.buffer.asUint8List(); + + // Save to temp file + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final croppedFile = File('${tempDir.path}/story_cropped_$timestamp.png'); + await croppedFile.writeAsBytes(pngBytes); + + // Clean up + image.dispose(); + croppedImage.dispose(); + + return croppedFile; + } + + /// Checks if an image needs to be cropped to 9:16 aspect ratio. + static Future needsCropping(File imageFile) async { + final bytes = await imageFile.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + + final aspectRatio = image.width / image.height; + image.dispose(); + + // Allow small tolerance for floating point comparison + return (aspectRatio - targetAspectRatio).abs() > 0.01; + } +} diff --git a/lib/src/core/pro_video_editor/pro_video_editor_repository.dart b/lib/src/core/pro_video_editor/pro_video_editor_repository.dart index cf9d21f5..037e4cb5 100644 --- a/lib/src/core/pro_video_editor/pro_video_editor_repository.dart +++ b/lib/src/core/pro_video_editor/pro_video_editor_repository.dart @@ -45,4 +45,35 @@ abstract class ProVideoEditorRepository { BuildContext context, EditorVideo video, ); + + /// Opens the Story Image Editor with a fixed 9:16 aspect ratio canvas. + /// + /// The [source] image is displayed in the editor with story-appropriate + /// tools (text, paint, stickers, emoji, filter, blur - NO crop/rotate). + /// + /// Returns `null` if the user cancels without completing the edit. + Future openStoryImageEditor(BuildContext context, XFile source); + + /// Opens a blank canvas Story Image Editor (1080x1920). + /// + /// Optionally adds [backgroundImage] as a movable layer on the canvas. + /// This gives more flexibility for positioning the image. + /// + /// Returns `null` if the user cancels without completing the edit. + Future openStoryBlankCanvasEditor( + BuildContext context, { + XFile? backgroundImage, + Color backgroundColor = const Color(0xFF000000), + }); + + /// Opens the Story Video Editor with story-appropriate tools. + /// + /// Uses the same limited toolset as the story image editor + /// (paint, text, filter, blur, emoji, stickers - NO crop/rotate/tune). + /// + /// Returns `null` if the user cancels without completing the edit. + Future openStoryVideoEditor( + BuildContext context, + EditorVideo video, + ); } diff --git a/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart b/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart index 92e2adb1..c4b10c10 100644 --- a/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart +++ b/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart @@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart'; +import 'package:spark/src/core/pro_image_editor/ui/story_image_editor_page.dart'; import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; import 'package:spark/src/core/pro_video_editor/ui/video_editor_grounded_page.dart'; @@ -85,4 +86,38 @@ class ProVideoEditorRepositoryImpl implements ProVideoEditorRepository { ), ); } + + @override + Future openStoryImageEditor(BuildContext context, XFile source) { + return StoryBlankCanvasEditorPage.open( + context, + backgroundImage: File(source.path), + ); + } + + @override + Future openStoryBlankCanvasEditor( + BuildContext context, { + XFile? backgroundImage, + Color backgroundColor = const Color(0xFF000000), + }) { + return StoryBlankCanvasEditorPage.open( + context, + backgroundImage: + backgroundImage != null ? File(backgroundImage.path) : null, + backgroundColor: backgroundColor, + ); + } + + @override + Future openStoryVideoEditor( + BuildContext context, + EditorVideo video, + ) async { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoEditorGroundedPage(video: video, storyMode: true), + ), + ); + } } diff --git a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart index a1bf1fe8..38de120c 100644 --- a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart +++ b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart @@ -27,12 +27,16 @@ import 'package:video_player/video_player.dart'; class VideoEditorGroundedPage extends StatefulWidget { const VideoEditorGroundedPage({ required this.video, + this.storyMode = false, super.key, }); /// Input video to be edited. final EditorVideo video; + /// When true, uses story-specific tools (no crop/rotate/tune). + final bool storyMode; + @override State createState() => _VideoEditorGroundedPageState(); @@ -192,6 +196,7 @@ class _VideoEditorGroundedPageState extends State { video: widget.video, taskId: _taskId, useMaterialDesign: _useMaterialDesign, + storyMode: widget.storyMode, videoPlayerBuilder: () => VideoPlayerWidget( controller: _videoController, isLoadingListenable: _updateClipsNotifier, @@ -227,23 +232,12 @@ class _VideoEditorGroundedPageState extends State { ]); if (!mounted) return; - // Adjust resolution based on rotation metadata - final rotation = _videoMetadata.rotation; - final convertedRotation = rotation % 360; - final is90DegRotated = convertedRotation == 90 || convertedRotation == 270; - final adjustedResolution = is90DegRotated - ? Size( - _videoMetadata.resolution.height, - _videoMetadata.resolution.width, - ) - : _videoMetadata.resolution; - _proVideoController = ProVideoController( videoPlayer: VideoPlayerWidget( controller: _videoController, isLoadingListenable: _updateClipsNotifier, ), - initialResolution: adjustedResolution, + initialResolution: _videoMetadata.resolution, videoDuration: _videoMetadata.duration, fileSize: _videoMetadata.fileSize, thumbnails: _thumbnails, diff --git a/lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart b/lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart index 6919f79f..ba9d38a1 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart @@ -25,6 +25,30 @@ import 'package:spark/src/core/pro_video_editor/ui/widgets/tune/tune_editor_bar. class VideoEditorConfigsBuilder { const VideoEditorConfigsBuilder._(); + /// Tools available in story mode (matches story image editor). + static const _storyModeTools = [ + SubEditorMode.audio, + SubEditorMode.paint, + SubEditorMode.text, + SubEditorMode.filter, + SubEditorMode.blur, + SubEditorMode.emoji, + SubEditorMode.sticker, + ]; + + /// Full set of tools for regular video editing. + static const _fullTools = [ + SubEditorMode.audio, + SubEditorMode.paint, + SubEditorMode.text, + SubEditorMode.cropRotate, + SubEditorMode.tune, + SubEditorMode.filter, + SubEditorMode.blur, + SubEditorMode.emoji, + SubEditorMode.sticker, + ]; + static ProImageEditorConfigs build({ required EditorVideo video, required String taskId, @@ -36,6 +60,7 @@ class VideoEditorConfigsBuilder { required VoidCallback onToggleMute, required VoidCallback onAddSound, required VoidCallback onToggleFullscreen, + bool storyMode = false, List audioTracks = const [], VideoEditorConfigs videoEditorConfigs = const VideoEditorConfigs( initialMuted: true, @@ -46,6 +71,8 @@ class VideoEditorConfigsBuilder { ), ), }) { + final tools = storyMode ? _storyModeTools : _fullTools; + return ProImageEditorConfigs( designMode: platformDesignMode, dialogConfigs: DialogConfigs( @@ -60,18 +87,7 @@ class VideoEditorConfigsBuilder { textTheme: AppTextTheme.dark, ), mainEditor: MainEditorConfigs( - tools: const [ - //SubEditorMode.videoClips, - SubEditorMode.audio, - SubEditorMode.paint, - SubEditorMode.text, - SubEditorMode.cropRotate, - SubEditorMode.tune, - SubEditorMode.filter, - SubEditorMode.blur, - SubEditorMode.emoji, - SubEditorMode.sticker, - ], + tools: tools, widgets: MainEditorWidgets( removeLayerArea: ( diff --git a/lib/src/core/pro_video_editor/ui/widgets/player/preview_video.dart b/lib/src/core/pro_video_editor/ui/widgets/player/preview_video.dart index 4518dd26..aaa56e5d 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/player/preview_video.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/player/preview_video.dart @@ -96,16 +96,12 @@ class _PreviewVideoState extends State { } final aspectRatio = snapshot.data?.resolution.aspectRatio ?? 1; - final rotation = snapshot.data?.rotation ?? 0; - final convertedRotation = rotation % 360; - final is90DegRotated = - convertedRotation == 90 || convertedRotation == 270; final maxWidth = constraints.maxWidth; final maxHeight = constraints.maxHeight; var width = maxWidth; - var height = is90DegRotated ? width * aspectRatio : width / aspectRatio; + var height = width / aspectRatio; if (height > maxHeight) { height = maxHeight; diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index 8191c6db..0247e610 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -132,6 +132,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), AutoRoute(page: ImageReviewRoute.page, path: '/image-review'), + AutoRoute(page: StoryPostRoute.page, path: '/story-post'), AutoRoute( page: VideoEditorGroundedRoute.page, path: '/video-editor-grounded', diff --git a/lib/src/core/routing/pages.dart b/lib/src/core/routing/pages.dart index d69efcf6..f2a1bce4 100644 --- a/lib/src/core/routing/pages.dart +++ b/lib/src/core/routing/pages.dart @@ -16,6 +16,7 @@ export 'package:spark/src/features/messages/ui/pages/new_chat_search_page.dart'; export 'package:spark/src/features/notifications/ui/pages/notifications_page.dart'; export 'package:spark/src/features/posting/ui/pages/image_review_page.dart'; export 'package:spark/src/features/posting/ui/pages/recording_page.dart'; +export 'package:spark/src/features/posting/ui/pages/story_post_page.dart'; export 'package:spark/src/features/posting/ui/pages/video_review_page.dart'; export 'package:spark/src/features/profile/ui/pages/blocks_page.dart'; export 'package:spark/src/features/profile/ui/pages/edit_profile_page.dart'; diff --git a/lib/src/features/posting/providers/camera_provider.dart b/lib/src/features/posting/providers/camera_provider.dart index faf70ac1..db09f626 100644 --- a/lib/src/features/posting/providers/camera_provider.dart +++ b/lib/src/features/posting/providers/camera_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:camera/camera.dart'; +import 'package:flutter/scheduler.dart'; import 'package:get_it/get_it.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; @@ -20,7 +21,10 @@ class Camera extends _$Camera { _logger.i('Initializing camera provider'); - // Initialize camera on build + // Yield so the loading UI can paint before blocking on camera init + await Future.delayed(Duration.zero); + if (!ref.mounted) return const CameraState(error: 'Disposed during init'); + try { return await _initializeCamera(); } catch (e, stackTrace) { @@ -76,6 +80,8 @@ 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 { @@ -117,33 +123,47 @@ class Camera extends _$Camera { _logger.d('Flipping camera'); - try { - final newIndex = - (currentState.selectedCameraIndex + 1) % currentState.cameras.length; - final newCamera = currentState.cameras[newIndex]; - - _logger.d('Switching to camera: ${newCamera.name}'); + final newIndex = + (currentState.selectedCameraIndex + 1) % currentState.cameras.length; + final newCamera = currentState.cameras[newIndex]; - // Dispose current controller - await currentState.controller?.dispose(); - - // Create new controller - final newController = await _createCameraController(newCamera); - - state = AsyncValue.data( - currentState.copyWith( - controller: newController, - selectedCameraIndex: newIndex, - isInitialized: true, - error: null, - ), - ); + // Show flipping state immediately so the UI can paint a loading overlay + state = AsyncValue.data( + currentState.copyWith(isFlipping: true, error: null), + ); - _logger.i('Camera flipped successfully to ${newCamera.name}'); - } catch (e, stackTrace) { - _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); - state = AsyncValue.data(currentState.copyWith(error: e.toString())); - } + // Defer heavy work to after the next frame so the current frame paints + SchedulerBinding.instance.addPostFrameCallback((_) async { + if (!ref.mounted) return; + try { + _logger.d('Switching to camera: ${newCamera.name}'); + + await currentState.controller?.dispose(); + if (!ref.mounted) return; + + final newController = await _createCameraController(newCamera); + if (!ref.mounted) return; + + state = AsyncValue.data( + currentState.copyWith( + controller: newController, + 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(isFlipping: false, error: e.toString()), + ); + } + } + }); } Future takePhoto() async { @@ -189,9 +209,11 @@ class Camera extends _$Camera { _logger.d('Starting video recording'); + // Update state optimistically BEFORE native call so UI responds immediately + state = AsyncValue.data(currentState.copyWith(isRecording: true)); + try { await currentState.controller!.startVideoRecording(); - state = AsyncValue.data(currentState.copyWith(isRecording: true)); _logger.i('Video recording started successfully'); return true; } catch (e, stackTrace) { @@ -200,7 +222,10 @@ class Camera extends _$Camera { error: e, stackTrace: stackTrace, ); - state = AsyncValue.data(currentState.copyWith(error: e.toString())); + // Revert optimistic update on failure + state = AsyncValue.data( + currentState.copyWith(isRecording: false, error: e.toString()), + ); return false; } } @@ -221,9 +246,11 @@ class Camera extends _$Camera { _logger.d('Stopping video recording'); + // Update state optimistically BEFORE the native call so UI responds immediately + state = AsyncValue.data(currentState.copyWith(isRecording: false)); + try { final file = await currentState.controller!.stopVideoRecording(); - state = AsyncValue.data(currentState.copyWith(isRecording: false)); _logger.i('Video recording stopped successfully: ${file.path}'); return file; } catch (e, stackTrace) { @@ -232,9 +259,7 @@ class Camera extends _$Camera { error: e, stackTrace: stackTrace, ); - state = AsyncValue.data( - currentState.copyWith(isRecording: false, error: e.toString()), - ); + state = AsyncValue.data(currentState.copyWith(error: e.toString())); return null; } } diff --git a/lib/src/features/posting/providers/camera_state.dart b/lib/src/features/posting/providers/camera_state.dart index 553bb09b..cb8b1872 100644 --- a/lib/src/features/posting/providers/camera_state.dart +++ b/lib/src/features/posting/providers/camera_state.dart @@ -11,6 +11,7 @@ abstract class CameraState with _$CameraState { @Default(0) int selectedCameraIndex, @Default(false) bool isInitialized, @Default(false) bool isRecording, + @Default(false) bool isFlipping, @Default(0) int initAttempts, String? error, }) = _CameraState; diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index ad26112f..0aba31ee 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -12,16 +12,26 @@ import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/posting/providers/camera_provider.dart'; import 'package:spark/src/features/posting/providers/recording_provider.dart'; +import 'package:spark/src/features/posting/utils/story_direct_post.dart'; + +export 'package:spark/src/core/design_system/templates/recording_page_template.dart' + show CaptureMode; @RoutePage() class RecordingPage extends ConsumerStatefulWidget { const RecordingPage({ required this.storyMode, + this.captureMode = CaptureMode.videoOnly, super.key, }); final bool storyMode; + /// Camera capture mode: + /// - [CaptureMode.videoOnly]: tap to start/stop recording (default) + /// - [CaptureMode.hybrid]: tap for photo, hold for video + final CaptureMode captureMode; + @override ConsumerState createState() => _RecordingPageState(); } @@ -29,48 +39,184 @@ class RecordingPage extends ConsumerStatefulWidget { class _RecordingPageState extends ConsumerState { late final SparkLogger _logger; bool _isProcessing = false; + bool _isExiting = false; + + // Store notifier reference for safe disposal + Recording? _recordingNotifier; @override void initState() { super.initState(); _logger = GetIt.instance().getLogger('RecordingPage'); + // Save reference to notifier for use in dispose + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _recordingNotifier = ref.read(recordingProvider.notifier); + } + }); } - Future _handleRecordPressed() async { + bool _isCameraReady() { final cameraAsync = ref.read(cameraProvider); - final recordingState = ref.read(recordingProvider); + if (cameraAsync.hasError) return false; + final cameraState = cameraAsync.value; + return cameraState != null && + cameraState.isInitialized && + cameraState.controller != null; + } - if (cameraAsync.hasError) { - return; + /// Handle tap on record button. + /// In videoOnly mode: toggle recording. + /// In hybrid mode: take photo. + void _handleTap() { + if (!_isCameraReady()) return; + + if (widget.captureMode == CaptureMode.videoOnly) { + final recordingState = ref.read(recordingProvider); + if (recordingState.isRecording) { + _stopRecording(); + } else { + _startRecording(); + } + } else { + // Hybrid mode - tap takes photo + _takePhoto(); } + } - final cameraState = cameraAsync.value; - if (cameraState == null || - !cameraState.isInitialized || - cameraState.controller == null) { + /// Handle hold start (hybrid mode only). + void _handleRecordStart() { + if (!_isCameraReady()) return; + if (widget.captureMode != CaptureMode.hybrid) return; + _startRecording(); + } + + /// Handle hold end (hybrid mode only). + void _handleRecordStop() { + if (!_isCameraReady()) return; + if (widget.captureMode != CaptureMode.hybrid) return; + final recordingState = ref.read(recordingProvider); + if (recordingState.isRecording) { + _stopRecording(); + } + } + + Future _takePhoto() async { + if (_isProcessing) return; + + final cameraNotifier = ref.read(cameraProvider.notifier); + _logger.d('Taking photo'); + + setState(() { + _isProcessing = true; + }); + + final photoFile = await cameraNotifier.takePhoto(); + + if (!mounted) return; + if (photoFile == null) { + setState(() { + _isProcessing = false; + }); return; } - if (recordingState.isRecording) { - await _stopRecording(); - } else { - await _startRecording(); + await _processPhoto(photoFile); + } + + Future _processPhoto(XFile photoFile) async { + if (!mounted) return; + + try { + _logger.d('Processing photo: ${photoFile.path}'); + + // Open the story image editor + final editedImage = await GetIt.I() + .openStoryImageEditor(context, photoFile); + + if (!mounted) return; + + if (editedImage != null) { + if (widget.storyMode) { + // For stories, post directly without review + // Show exiting state to prevent camera rendering issues + setState(() { + _isExiting = true; + }); + + try { + final result = await StoryDirectPost.postPhotoStory( + context, + ref, + editedImage, + ); + if (result != null && mounted) { + _logger.i('Story posted successfully'); + // Exit the recording flow completely + if (mounted) context.router.maybePop(); + return; + } + } catch (e, stackTrace) { + _logger.e('Error posting story', error: e, stackTrace: stackTrace); + if (mounted) { + setState(() { + _isExiting = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to post story: $e')), + ); + } + } + } else { + // For posts, go to review page + await context.router.push( + ImageReviewRoute( + imageFiles: [editedImage], + storyMode: widget.storyMode, + ), + ); + } + } + + // Reset processing state and reinitialize camera + if (mounted) { + setState(() { + _isProcessing = false; + }); + // Reinitialize camera after returning from editor + ref.read(cameraProvider.notifier).reinitializeCamera(); + } + } catch (e, stackTrace) { + _logger.e('Error processing photo', error: e, stackTrace: stackTrace); + if (mounted) { + setState(() { + _isProcessing = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } } } - Future _startRecording() async { + void _startRecording() { final cameraNotifier = ref.read(cameraProvider.notifier); final recordingNotifier = ref.read(recordingProvider.notifier); _logger.d('Starting video recording'); - final success = await cameraNotifier.startVideoRecording(); - if (success) { - recordingNotifier.startRecording(); - } + // Start timer optimistically so UI responds immediately + recordingNotifier.startRecording(); + + // Start native recording; revert timer if it fails + cameraNotifier.startVideoRecording().then((success) { + if (!success && mounted) { + recordingNotifier.stopRecording(); + } + }); } - Future _stopRecording() async { + void _stopRecording() { if (_isProcessing) return; setState(() { @@ -83,16 +229,21 @@ class _RecordingPageState extends ConsumerState { _logger.d('Stopping video recording'); recordingNotifier.stopRecording(); - final videoFile = await cameraNotifier.stopVideoRecording(); - if (videoFile == null) { - setState(() { - _isProcessing = false; - }); - return; - } + // Defer heavy stop so the "processing" frame paints before blocking + WidgetsBinding.instance.addPostFrameCallback((_) async { + final videoFile = await cameraNotifier.stopVideoRecording(); + + if (!mounted) return; + if (videoFile == null) { + setState(() { + _isProcessing = false; + }); + return; + } - await _processVideo(videoFile); + await _processVideo(videoFile); + }); } Future _processVideo(XFile videoFile) async { @@ -107,10 +258,10 @@ class _RecordingPageState extends ConsumerState { if (!mounted) return; final editorVideo = EditorVideo.file(File(videoFile.path)); - final result = await GetIt.I().openVideoEditor( - context, - editorVideo, - ); + final repository = GetIt.I(); + final result = widget.storyMode + ? await repository.openStoryVideoEditor(context, editorVideo) + : await repository.openVideoEditor(context, editorVideo); if (!mounted) return; @@ -123,16 +274,58 @@ class _RecordingPageState extends ConsumerState { return; } - await context.router.push( - VideoReviewRoute( - videoPath: result.video.path, - storyMode: widget.storyMode, - soundRef: result.soundRef, - ), - ); + if (widget.storyMode) { + // For stories, post directly without review + // Show exiting state to prevent camera rendering issues + setState(() { + _isExiting = true; + }); - if (mounted) { - context.router.pop(); + try { + final postResult = await StoryDirectPost.postVideoStory( + context, + ref, + result.video.path, + soundRef: result.soundRef, + ); + if (postResult != null && mounted) { + _logger.i('Video story posted successfully'); + // Exit the recording flow completely + if (mounted) context.router.maybePop(); + return; + } + } catch (e, stackTrace) { + _logger.e( + 'Error posting video story', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to post story: $e')), + ); + } + } + // If posting failed or was cancelled, reset state + if (mounted) { + setState(() { + _isExiting = false; + _isProcessing = false; + }); + } + } else { + // For posts, go to review page + await context.router.push( + VideoReviewRoute( + videoPath: result.video.path, + storyMode: widget.storyMode, + soundRef: result.soundRef, + ), + ); + + if (mounted) { + context.router.pop(); + } } } catch (e, stackTrace) { _logger.e('Error processing video', error: e, stackTrace: stackTrace); @@ -140,6 +333,9 @@ class _RecordingPageState extends ConsumerState { setState(() { _isProcessing = false; }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); } } } @@ -211,25 +407,55 @@ class _RecordingPageState extends ConsumerState { ); } + // Show loading when exiting to prevent camera rendering issues + if (_isExiting) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ); + } + final canFlipCamera = - cameraState.cameras.length > 1 && !recordingState.isRecording; + cameraState.cameras.length > 1 && + !recordingState.isRecording && + !cameraState.isFlipping; final aspectRatio = cameraState.controller!.value.aspectRatio; - return RecordingPageTemplate( - cameraPreview: CameraPreview(cameraState.controller!), - aspectRatio: aspectRatio, - isRecording: recordingState.isRecording, - elapsedDuration: recordingState.elapsedDuration, - maxDuration: recordingState.maxDuration, - onBack: () { - if (recordingState.isRecording) { - return; - } - context.router.pop(); - }, - onFlipCamera: canFlipCamera ? _handleFlipCamera : null, - onRecordPressed: _isProcessing ? null : _handleRecordPressed, - canFlipCamera: canFlipCamera, + return Stack( + children: [ + RecordingPageTemplate( + cameraPreview: RepaintBoundary( + child: CameraPreview(cameraState.controller!), + ), + aspectRatio: aspectRatio, + isRecording: recordingState.isRecording, + elapsedDuration: recordingState.elapsedDuration, + maxDuration: recordingState.maxDuration, + onBack: () { + if (recordingState.isRecording) { + return; + } + context.router.pop(); + }, + onFlipCamera: canFlipCamera ? _handleFlipCamera : null, + canFlipCamera: canFlipCamera, + captureMode: widget.captureMode, + onTap: _isProcessing ? null : _handleTap, + onRecordStart: _isProcessing ? null : _handleRecordStart, + onRecordStop: _isProcessing ? null : _handleRecordStop, + ), + if (cameraState.isFlipping) + const Positioned.fill( + child: ColoredBox( + color: Colors.black, + child: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], ); }, loading: () => const Scaffold( @@ -274,7 +500,11 @@ class _RecordingPageState extends ConsumerState { @override void dispose() { - ref.read(recordingProvider.notifier).reset(); + // Defer provider modification to avoid modifying during widget tree finalization + final notifier = _recordingNotifier; + if (notifier != null) { + Future(notifier.reset); + } super.dispose(); } } diff --git a/lib/src/features/posting/ui/pages/story_post_page.dart b/lib/src/features/posting/ui/pages/story_post_page.dart new file mode 100644 index 00000000..11a76755 --- /dev/null +++ b/lib/src/features/posting/ui/pages/story_post_page.dart @@ -0,0 +1,175 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/network/atproto/data/models/models.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/features/posting/providers/post_story.dart'; +import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; + +/// Page that handles posting a story directly without a review UI. +/// +/// This is used when uploading media from gallery for stories. +/// Shows a loading indicator while uploading and posting. +@RoutePage() +class StoryPostPage extends ConsumerStatefulWidget { + const StoryPostPage({ + this.imageFile, + this.videoPath, + super.key, + }) : assert( + imageFile != null || videoPath != null, + 'Either imageFile or videoPath must be provided', + ); + + final XFile? imageFile; + final String? videoPath; + + @override + ConsumerState createState() => _StoryPostPageState(); +} + +class _StoryPostPageState extends ConsumerState { + bool _isPosting = false; + String _statusMessage = 'Preparing...'; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _postStory(); + }); + } + + Future _postStory() async { + if (_isPosting) return; + + setState(() { + _isPosting = true; + _error = null; + }); + + try { + if (widget.imageFile != null) { + await _postImageStory(); + } else if (widget.videoPath != null) { + await _postVideoStory(); + } + + if (mounted) { + // Success - pop back + context.router.pop(true); + } + } catch (e) { + if (mounted) { + setState(() { + _isPosting = false; + _error = e.toString(); + }); + } + } + } + + Future _postImageStory() async { + setState(() { + _statusMessage = 'Uploading image...'; + }); + + final feedRepository = GetIt.I().feed; + final uploadedImages = await feedRepository.uploadImages( + imageFiles: [widget.imageFile!], + altTexts: {widget.imageFile!.path: ''}, + ); + + if (uploadedImages.isEmpty) { + throw Exception('Failed to upload image'); + } + + setState(() { + _statusMessage = 'Posting story...'; + }); + + final uploadedImage = uploadedImages.first; + final result = await ref.read( + postStoryProvider( + Media.image(image: uploadedImage.image, alt: uploadedImage.alt), + ).future, + ); + + if (result == null) { + throw Exception('Failed to post story'); + } + } + + Future _postVideoStory() async { + setState(() { + _statusMessage = 'Processing video...'; + }); + + final result = await ref.read( + processAndPostVideoProvider( + videoPath: widget.videoPath!, + storyMode: true, + ).future, + ); + + if (result == null) { + throw Exception('Failed to post video story'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_error != null) ...[ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + const Text( + 'Failed to post story', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: Colors.white70, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () => context.router.pop(false), + child: const Text('Cancel'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _postStory, + child: const Text('Retry'), + ), + ], + ), + ] else ...[ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 24), + Text( + _statusMessage, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/posting/utils/story_direct_post.dart b/lib/src/features/posting/utils/story_direct_post.dart new file mode 100644 index 00000000..04838249 --- /dev/null +++ b/lib/src/features/posting/utils/story_direct_post.dart @@ -0,0 +1,162 @@ +import 'package:atproto/com_atproto_repo_strongref.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/network/atproto/data/models/models.dart'; +import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; +import 'package:spark/src/features/posting/providers/post_story.dart'; +import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; + +/// Utility for posting stories directly without a review page. +class StoryDirectPost { + StoryDirectPost._(); + + /// Posts a photo story directly. + /// + /// Shows a loading indicator while posting. + /// Returns the post reference if successful, null if cancelled/failed. + static Future postPhotoStory( + BuildContext context, + WidgetRef ref, + XFile imageFile, + ) async { + debugPrint('[StoryDirectPost] Starting photo story post'); + debugPrint('[StoryDirectPost] Image path: ${imageFile.path}'); + + // Show loading overlay + final navigator = Navigator.of(context); + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _PostingOverlay(message: 'Posting story...'), + ); + + try { + final feedRepository = GetIt.I().feed; + + debugPrint('[StoryDirectPost] Uploading image...'); + // Upload the image + final uploadedImages = await feedRepository.uploadImages( + imageFiles: [imageFile], + altTexts: {imageFile.path: ''}, + ); + + debugPrint('[StoryDirectPost] Upload result: ${uploadedImages.length} images'); + + if (uploadedImages.isEmpty) { + throw Exception('Failed to upload image'); + } + + final uploadedImage = uploadedImages.first; + debugPrint('[StoryDirectPost] Image uploaded, posting story...'); + + // Post the story + final result = await ref.read( + postStoryProvider( + Media.image(image: uploadedImage.image, alt: uploadedImage.alt), + ).future, + ); + + if (result == null) { + throw Exception('Failed to post story'); + } + + debugPrint('[StoryDirectPost] Story posted: ${result.uri}'); + + // Dismiss loading + if (navigator.mounted) { + navigator.pop(); + } + + return result; + } catch (e, stackTrace) { + debugPrint('[StoryDirectPost] Error: $e'); + debugPrint('[StoryDirectPost] StackTrace: $stackTrace'); + // Dismiss loading + if (navigator.mounted) { + navigator.pop(); + } + rethrow; + } + } + + /// Posts a video story directly. + /// + /// Shows a loading indicator while processing and posting. + /// Returns the post reference if successful, null if cancelled/failed. + static Future postVideoStory( + BuildContext context, + WidgetRef ref, + String videoPath, { + RepoStrongRef? soundRef, + }) async { + // Show loading overlay + final navigator = Navigator.of(context); + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _PostingOverlay(message: 'Processing video...'), + ); + + try { + // Use existing video upload provider which handles story posting + final result = await ref.read( + processAndPostVideoProvider( + videoPath: videoPath, + storyMode: true, + soundRef: soundRef, + ).future, + ); + + // Dismiss loading + if (navigator.mounted) { + navigator.pop(); + } + + return result; + } catch (e) { + // Dismiss loading + if (navigator.mounted) { + navigator.pop(); + } + rethrow; + } + } +} + +class _PostingOverlay extends StatelessWidget { + const _PostingOverlay({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/profile/ui/pages/profile_page.dart b/lib/src/features/profile/ui/pages/profile_page.dart index 5d8d1cfe..104b6502 100644 --- a/lib/src/features/profile/ui/pages/profile_page.dart +++ b/lib/src/features/profile/ui/pages/profile_page.dart @@ -14,6 +14,7 @@ import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/network/atproto/data/models/actor_models.dart' as actor_models; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; import 'package:spark/src/core/ui/widgets/options_panel.dart'; import 'package:spark/src/core/ui/widgets/report_dialog.dart'; import 'package:spark/src/core/utils/blocking_utils.dart'; @@ -169,13 +170,10 @@ class _ProfilePageState extends ConsumerState { } Future _handleAddStory(BuildContext context) async { - showCreateMediaSheet( - context, - onRecord: CreateMediaActions.onRecord(context, storyMode: true), - onUploadVideo: CreateMediaActions.onUploadVideo(context, storyMode: true), - onUploadImages: CreateMediaActions.onUploadImages( - context, + context.router.push( + RecordingRoute( storyMode: true, + captureMode: CaptureMode.hybrid, ), ); } diff --git a/lib/src/features/stories/ui/pages/story_page.dart b/lib/src/features/stories/ui/pages/story_page.dart index 222adc35..a316bb97 100644 --- a/lib/src/features/stories/ui/pages/story_page.dart +++ b/lib/src/features/stories/ui/pages/story_page.dart @@ -105,6 +105,9 @@ class _StoryPageState extends ConsumerState @override Widget build(BuildContext context) { + final footerHeight = kBottomNavigationBarHeight + 12; + const borderRadius = BorderRadius.all(Radius.circular(20)); + // Determine the main media widget (video or image) first. late final Widget mediaContent; @@ -116,6 +119,7 @@ class _StoryPageState extends ConsumerState color: Colors.black, alignment: Alignment.center, child: FittedBox( + fit: BoxFit.cover, child: SizedBox( width: (size.width > 0 ? size.width : 1280), height: (size.height > 0 ? size.height : 720), @@ -169,52 +173,65 @@ class _StoryPageState extends ConsumerState } // Wrap the media in a Stack to overlay gradient shadows for readability. - return Stack( - fit: StackFit.expand, + return Column( children: [ - mediaContent, - // Top shadow overlay - Positioned( - left: 0, - right: 0, - top: 0, - child: IgnorePointer( - child: Container( - height: 120, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black87.withAlpha(100), - Colors.transparent, - ], + Expanded( + child: ClipRRect( + borderRadius: borderRadius, + child: Stack( + fit: StackFit.expand, + children: [ + mediaContent, + // Top shadow overlay + Positioned( + left: 0, + right: 0, + top: 0, + child: IgnorePointer( + child: Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black87.withAlpha(100), + Colors.transparent, + ], + ), + ), + ), + ), ), - ), - ), - ), - ), - // Bottom shadow overlay - Positioned( - left: 0, - right: 0, - bottom: 0, - child: IgnorePointer( - child: Container( - height: 120, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black87.withAlpha(100), - Colors.transparent, - ], + // Bottom shadow overlay + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black87.withAlpha(100), + Colors.transparent, + ], + ), + ), + ), + ), ), - ), + ], ), ), ), + SizedBox( + height: footerHeight, + child: const ColoredBox(color: Colors.black), + ), ], ); } diff --git a/lib/src/features/stories/ui/widgets/stories_list.dart b/lib/src/features/stories/ui/widgets/stories_list.dart index 0d6f6044..272a9539 100644 --- a/lib/src/features/stories/ui/widgets/stories_list.dart +++ b/lib/src/features/stories/ui/widgets/stories_list.dart @@ -1,11 +1,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spark/src/core/design_system/components/molecules/create_media_sheet.dart'; import 'package:spark/src/core/design_system/components/molecules/story_circle.dart'; -import 'package:spark/src/core/media/create_media_actions.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; +import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; import 'package:spark/src/features/profile/providers/profile_provider.dart'; import 'package:spark/src/features/stories/providers/stories_by_author.dart'; @@ -19,14 +18,11 @@ class StoriesList extends ConsumerStatefulWidget { class _StoriesListState extends ConsumerState { String? _cursor; - void _showCreateMenu(BuildContext context) { - showCreateMediaSheet( - context, - onRecord: CreateMediaActions.onRecord(context, storyMode: true), - onUploadVideo: CreateMediaActions.onUploadVideo(context, storyMode: true), - onUploadImages: CreateMediaActions.onUploadImages( - context, + void _openStoryRecorder(BuildContext context) { + context.router.push( + RecordingRoute( storyMode: true, + captureMode: CaptureMode.hybrid, ), ); } @@ -93,7 +89,7 @@ class _StoriesListState extends ConsumerState { ); return GestureDetector( - onTap: () => _showCreateMenu(context), + onTap: () => _openStoryRecorder(context), child: Padding( padding: const EdgeInsets.only(right: 12), child: StoryCircle.create(