[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: dedicated story editor

+2214 -325
+123 -70
lib/src/core/design_system/components/molecules/recording_button.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter/services.dart'; 3 - import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; 4 - import 'package:spark/src/core/design_system/tokens/colors.dart'; 3 + 4 + /// Camera capture mode. 5 + enum CaptureMode { 6 + /// Video only - tap to start/stop recording. 7 + videoOnly, 8 + 9 + /// Hybrid - tap for photo, hold for video. 10 + hybrid, 11 + } 5 12 13 + /// iOS-style camera recording button. 14 + /// 15 + /// In [CaptureMode.videoOnly]: tap toggles recording. 16 + /// In [CaptureMode.hybrid]: tap takes photo, hold records video. 6 17 class RecordingButton extends StatefulWidget { 7 18 const RecordingButton({ 8 19 required this.isRecording, 9 - required this.onPressed, 20 + required this.mode, 21 + this.onTap, 22 + this.onRecordStart, 23 + this.onRecordStop, 10 24 super.key, 11 25 }); 12 26 13 27 final bool isRecording; 14 - final VoidCallback? onPressed; 28 + final CaptureMode mode; 29 + 30 + /// Called on tap. In videoOnly mode, toggles recording. 31 + /// In hybrid mode, takes photo. 32 + final VoidCallback? onTap; 33 + 34 + /// Called when hold starts (hybrid mode only). 35 + final VoidCallback? onRecordStart; 36 + 37 + /// Called when hold ends (hybrid mode only). 38 + final VoidCallback? onRecordStop; 15 39 16 40 @override 17 41 State<RecordingButton> createState() => _RecordingButtonState(); ··· 19 43 20 44 class _RecordingButtonState extends State<RecordingButton> 21 45 with SingleTickerProviderStateMixin { 22 - late AnimationController _pulseController; 46 + late AnimationController _controller; 47 + late Animation<double> _sizeAnimation; 48 + late Animation<double> _borderRadiusAnimation; 49 + late Animation<Color?> _colorAnimation; 50 + 51 + static const _outerSize = 80.0; 52 + static const _ringWidth = 4.0; 53 + static const _idleInnerSize = 66.0; 54 + static const _recordingInnerSize = 32.0; 55 + static const _idleColor = Colors.white; 56 + static const _recordingColor = Color(0xFFFF3B30); // iOS red 23 57 24 58 @override 25 59 void initState() { 26 60 super.initState(); 27 - _pulseController = AnimationController( 61 + _controller = AnimationController( 28 62 vsync: this, 29 - duration: const Duration(milliseconds: 1000), 30 - )..repeat(reverse: true); 63 + duration: const Duration(milliseconds: 200), 64 + ); 65 + 66 + _sizeAnimation = Tween<double>( 67 + begin: _idleInnerSize, 68 + end: _recordingInnerSize, 69 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); 70 + 71 + _borderRadiusAnimation = Tween<double>( 72 + begin: _idleInnerSize / 2, 73 + end: 8, 74 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); 75 + 76 + _colorAnimation = ColorTween( 77 + begin: _idleColor, 78 + end: _recordingColor, 79 + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); 80 + 81 + if (widget.isRecording) { 82 + _controller.value = 1.0; 83 + } 31 84 } 32 85 33 86 @override 34 87 void didUpdateWidget(RecordingButton oldWidget) { 35 88 super.didUpdateWidget(oldWidget); 36 - if (widget.isRecording && !oldWidget.isRecording) { 37 - _pulseController.repeat(reverse: true); 38 - } else if (!widget.isRecording && oldWidget.isRecording) { 39 - _pulseController 40 - ..stop() 41 - ..reset(); 89 + if (widget.isRecording != oldWidget.isRecording) { 90 + if (widget.isRecording) { 91 + _controller.forward(); 92 + } else { 93 + _controller.reverse(); 94 + } 42 95 } 43 96 } 44 97 45 98 @override 46 99 void dispose() { 47 - _pulseController.dispose(); 100 + _controller.dispose(); 48 101 super.dispose(); 49 102 } 50 103 51 104 void _handleTap() { 52 - if (widget.onPressed == null) return; 105 + if (widget.onTap == null) return; 106 + HapticFeedback.mediumImpact(); 107 + widget.onTap!(); 108 + } 53 109 110 + void _handleLongPressStart(LongPressStartDetails details) { 111 + if (widget.mode != CaptureMode.hybrid) return; 112 + if (widget.onRecordStart == null) return; 54 113 HapticFeedback.mediumImpact(); 55 - widget.onPressed!(); 114 + widget.onRecordStart!(); 115 + } 116 + 117 + void _handleLongPressEnd(LongPressEndDetails details) { 118 + if (widget.mode != CaptureMode.hybrid) return; 119 + if (widget.onRecordStop == null) return; 120 + HapticFeedback.lightImpact(); 121 + widget.onRecordStop!(); 56 122 } 57 123 58 124 @override 59 125 Widget build(BuildContext context) { 60 - const size = 72.0; 61 - 62 - return InteractivePressable( 126 + return GestureDetector( 63 127 onTap: _handleTap, 64 - pressedScale: 0.9, 65 - borderRadius: BorderRadius.circular(size), 66 - child: Container( 67 - width: size, 68 - height: size, 69 - decoration: BoxDecoration( 70 - shape: BoxShape.circle, 71 - border: Border.all( 72 - color: Colors.white.withAlpha(100), 73 - width: 3, 74 - ), 75 - ), 76 - child: AnimatedBuilder( 77 - animation: _pulseController, 78 - builder: (context, child) { 79 - return Container( 80 - margin: EdgeInsets.all(widget.isRecording ? 8 : 4), 128 + onLongPressStart: widget.mode == CaptureMode.hybrid 129 + ? _handleLongPressStart 130 + : null, 131 + onLongPressEnd: widget.mode == CaptureMode.hybrid 132 + ? _handleLongPressEnd 133 + : null, 134 + behavior: HitTestBehavior.opaque, 135 + child: SizedBox( 136 + width: _outerSize, 137 + height: _outerSize, 138 + child: Stack( 139 + alignment: Alignment.center, 140 + children: [ 141 + // Outer white ring 142 + Container( 143 + width: _outerSize, 144 + height: _outerSize, 81 145 decoration: BoxDecoration( 82 146 shape: BoxShape.circle, 83 - color: widget.isRecording 84 - ? AppColors.red500 85 - : AppColors.greyWhite, 86 - boxShadow: widget.isRecording 87 - ? [ 88 - BoxShadow( 89 - color: AppColors.red500.withAlpha( 90 - (128 * (0.5 + 0.5 * _pulseController.value)) 91 - .toInt(), 92 - ), 93 - blurRadius: 20, 94 - spreadRadius: 4, 95 - ), 96 - ] 97 - : null, 98 - ), 99 - child: Center( 100 - child: widget.isRecording 101 - ? Container( 102 - width: 12, 103 - height: 12, 104 - decoration: BoxDecoration( 105 - color: AppColors.greyWhite, 106 - borderRadius: BorderRadius.circular(2), 107 - ), 108 - ) 109 - : const Icon( 110 - Icons.circle, 111 - color: AppColors.red500, 112 - size: 24, 113 - ), 147 + border: Border.all( 148 + color: Colors.white, 149 + width: _ringWidth, 150 + ), 114 151 ), 115 - ); 116 - }, 152 + ), 153 + // Animated inner shape (white circle -> red square) 154 + AnimatedBuilder( 155 + animation: _controller, 156 + builder: (context, child) { 157 + return Container( 158 + width: _sizeAnimation.value, 159 + height: _sizeAnimation.value, 160 + decoration: BoxDecoration( 161 + color: _colorAnimation.value, 162 + borderRadius: BorderRadius.circular( 163 + _borderRadiusAnimation.value, 164 + ), 165 + ), 166 + ); 167 + }, 168 + ), 169 + ], 117 170 ), 118 171 ), 119 172 );
+136 -66
lib/src/core/design_system/templates/recording_page_template.dart
··· 1 + import 'dart:ui'; 2 + 1 3 import 'package:flutter/material.dart'; 2 - import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; 4 + import 'package:flutter/services.dart'; 3 5 import 'package:spark/src/core/design_system/components/molecules/recording_button.dart'; 4 6 import 'package:spark/src/core/design_system/components/molecules/recording_timer.dart'; 5 - import 'package:spark/src/core/design_system/tokens/colors.dart'; 7 + 8 + export 'package:spark/src/core/design_system/components/molecules/recording_button.dart' 9 + show CaptureMode; 6 10 7 11 class RecordingPageTemplate extends StatelessWidget { 8 12 const RecordingPageTemplate({ ··· 13 17 required this.maxDuration, 14 18 required this.onBack, 15 19 required this.onFlipCamera, 16 - required this.onRecordPressed, 17 20 required this.canFlipCamera, 21 + required this.captureMode, 22 + this.onTap, 23 + this.onRecordStart, 24 + this.onRecordStop, 18 25 super.key, 19 26 }); 20 27 ··· 25 32 final Duration maxDuration; 26 33 final VoidCallback onBack; 27 34 final VoidCallback? onFlipCamera; 28 - final VoidCallback? onRecordPressed; 29 35 final bool canFlipCamera; 36 + final CaptureMode captureMode; 37 + 38 + /// Called on tap. In videoOnly: toggle recording. In hybrid: take photo. 39 + final VoidCallback? onTap; 40 + 41 + /// Called when hold starts (hybrid mode only). 42 + final VoidCallback? onRecordStart; 43 + 44 + /// Called when hold ends (hybrid mode only). 45 + final VoidCallback? onRecordStop; 30 46 31 47 @override 32 48 Widget build(BuildContext context) { 33 49 final size = MediaQuery.sizeOf(context); 50 + final footerHeight = kBottomNavigationBarHeight + 12; 51 + const borderRadius = BorderRadius.all(Radius.circular(20)); 34 52 35 53 // Calculate scale based on camera aspect ratio and screen aspect ratio 36 54 var scale = size.aspectRatio * aspectRatio; ··· 40 58 41 59 return Scaffold( 42 60 backgroundColor: Colors.black, 43 - body: Stack( 44 - fit: StackFit.expand, 45 - children: [ 46 - // Camera preview fills entire screen (including safe areas) 47 - // For camera package v0.7.0+, AspectRatio is handled by the package 48 - Positioned.fill( 49 - child: Transform.scale( 50 - scale: scale, 51 - child: Center( 52 - child: cameraPreview, 53 - ), 54 - ), 55 - ), 56 - // Top controls respect safe area 57 - SafeArea( 58 - bottom: false, 59 - child: _TopOverlay( 60 - onBack: onBack, 61 - timer: RecordingTimer( 62 - duration: elapsedDuration, 63 - maxDuration: maxDuration, 61 + body: SafeArea( 62 + child: Column( 63 + children: [ 64 + Expanded( 65 + child: ClipRRect( 66 + borderRadius: borderRadius, 67 + child: Stack( 68 + fit: StackFit.expand, 69 + children: [ 70 + // Camera preview fills rounded view area 71 + Positioned.fill( 72 + child: Transform.scale( 73 + scale: scale, 74 + child: Center( 75 + child: cameraPreview, 76 + ), 77 + ), 78 + ), 79 + // Top controls aligned within rounded view 80 + _TopOverlay( 81 + onBack: onBack, 82 + timer: RecordingTimer( 83 + duration: elapsedDuration, 84 + maxDuration: maxDuration, 85 + ), 86 + ), 87 + // Bottom overlay sits inside rounded view 88 + _BottomOverlay( 89 + onFlipCamera: canFlipCamera ? onFlipCamera : null, 90 + recordingButton: RecordingButton( 91 + isRecording: isRecording, 92 + mode: captureMode, 93 + onTap: onTap, 94 + onRecordStart: onRecordStart, 95 + onRecordStop: onRecordStop, 96 + ), 97 + bottomPadding: 24, 98 + ), 99 + ], 100 + ), 64 101 ), 65 102 ), 66 - ), 67 - // Bottom overlay extends to bottom edge (no safe area) 68 - _BottomOverlay( 69 - onFlipCamera: canFlipCamera ? onFlipCamera : null, 70 - recordingButton: RecordingButton( 71 - isRecording: isRecording, 72 - onPressed: onRecordPressed, 103 + SizedBox( 104 + height: footerHeight, 105 + child: const ColoredBox(color: Colors.black), 73 106 ), 74 - ), 75 - ], 107 + ], 108 + ), 76 109 ), 77 110 ); 78 111 } ··· 96 129 child: Row( 97 130 mainAxisAlignment: MainAxisAlignment.spaceBetween, 98 131 children: [ 99 - Container( 100 - decoration: BoxDecoration( 101 - color: Colors.black.withAlpha(128), 102 - borderRadius: BorderRadius.circular(20), 103 - border: Border.all( 104 - color: Colors.white.withAlpha(50), 105 - width: 1.5, 106 - ), 107 - ), 108 - child: const AppLeadingButton(color: AppColors.greyWhite), 109 - ), 132 + _CloseButton(onPressed: onBack), 110 133 timer, 111 - const SizedBox(width: 48), 134 + const SizedBox(width: 40), 112 135 ], 113 136 ), 114 137 ), ··· 116 139 } 117 140 } 118 141 142 + /// iOS-style close button with blur background. 143 + class _CloseButton extends StatelessWidget { 144 + const _CloseButton({required this.onPressed}); 145 + 146 + final VoidCallback onPressed; 147 + 148 + @override 149 + Widget build(BuildContext context) { 150 + return GestureDetector( 151 + onTap: () { 152 + HapticFeedback.lightImpact(); 153 + onPressed(); 154 + }, 155 + child: ClipOval( 156 + child: BackdropFilter( 157 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 158 + child: Container( 159 + width: 40, 160 + height: 40, 161 + decoration: BoxDecoration( 162 + shape: BoxShape.circle, 163 + color: Colors.black.withAlpha(90), 164 + ), 165 + child: const Icon( 166 + Icons.close_rounded, 167 + color: Colors.white, 168 + size: 22, 169 + ), 170 + ), 171 + ), 172 + ), 173 + ); 174 + } 175 + } 176 + 119 177 class _BottomOverlay extends StatelessWidget { 120 178 const _BottomOverlay({ 121 179 required this.onFlipCamera, 122 180 required this.recordingButton, 181 + required this.bottomPadding, 123 182 }); 124 183 125 184 final VoidCallback? onFlipCamera; 126 185 final Widget recordingButton; 186 + final double bottomPadding; 127 187 128 188 @override 129 189 Widget build(BuildContext context) { ··· 140 200 ], 141 201 ), 142 202 ), 143 - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), 203 + padding: EdgeInsets.only( 204 + left: 24, 205 + right: 24, 206 + top: 40, 207 + bottom: bottomPadding, 208 + ), 144 209 child: Row( 145 210 mainAxisAlignment: MainAxisAlignment.spaceEvenly, 146 211 children: [ 147 212 if (onFlipCamera != null) 148 213 _FlipCameraButton(onPressed: onFlipCamera!) 149 214 else 150 - const SizedBox(width: 72), 215 + const SizedBox(width: 80), 151 216 recordingButton, 152 - const SizedBox(width: 72), 217 + const SizedBox(width: 80), 153 218 ], 154 219 ), 155 220 ), ··· 157 222 } 158 223 } 159 224 225 + /// iOS-style flip camera button with blur background. 160 226 class _FlipCameraButton extends StatelessWidget { 161 227 const _FlipCameraButton({required this.onPressed}); 162 228 ··· 165 231 @override 166 232 Widget build(BuildContext context) { 167 233 return GestureDetector( 168 - onTap: onPressed, 234 + onTap: () { 235 + HapticFeedback.lightImpact(); 236 + onPressed(); 237 + }, 169 238 child: SizedBox( 170 - width: 72, 171 - height: 72, 239 + width: 80, 240 + height: 80, 172 241 child: Center( 173 - child: Container( 174 - width: 56, 175 - height: 56, 176 - decoration: BoxDecoration( 177 - shape: BoxShape.circle, 178 - color: Colors.black.withAlpha(128), 179 - border: Border.all( 180 - color: Colors.white.withAlpha(100), 181 - width: 2, 242 + child: ClipOval( 243 + child: BackdropFilter( 244 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 245 + child: Container( 246 + width: 50, 247 + height: 50, 248 + decoration: BoxDecoration( 249 + shape: BoxShape.circle, 250 + color: Colors.black.withAlpha(90), 251 + ), 252 + child: const Icon( 253 + Icons.flip_camera_ios_rounded, 254 + color: Colors.white, 255 + size: 26, 256 + ), 182 257 ), 183 - ), 184 - child: const Icon( 185 - Icons.flip_camera_ios, 186 - color: AppColors.greyWhite, 187 - size: 28, 188 258 ), 189 259 ), 190 260 ),
+63 -19
lib/src/core/media/create_media_actions.dart
··· 7 7 import 'package:pro_video_editor/pro_video_editor.dart'; 8 8 import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; 9 9 import 'package:spark/src/core/routing/app_router.dart'; 10 + import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; 10 11 11 12 /// Centralized factories for the create media actions used across the app. 12 13 /// ··· 16 17 const CreateMediaActions._(); 17 18 18 19 /// Record flow: camera capture -> editor -> review. 20 + /// 21 + /// For stories, uses hybrid mode (tap for photo, hold for video). 22 + /// For posts, uses video-only mode (tap toggles recording). 19 23 static VoidCallback onRecord( 20 24 BuildContext context, { 21 25 required bool storyMode, 22 26 }) { 23 27 return () async { 24 28 if (!context.mounted) return; 25 - await context.router.push(RecordingRoute(storyMode: storyMode)); 29 + await context.router.push( 30 + RecordingRoute( 31 + storyMode: storyMode, 32 + captureMode: storyMode ? CaptureMode.hybrid : CaptureMode.videoOnly, 33 + ), 34 + ); 26 35 }; 27 36 } 28 37 29 - /// Upload video flow: pick from gallery -> open editor -> review (story/post mode decided here). 38 + /// Upload video flow: pick from gallery -> open editor -> direct post/review. 39 + /// 40 + /// For stories, posts directly after editing (with story-specific tools). 41 + /// For posts, goes to review page (with full editing tools). 30 42 static VoidCallback onUploadVideo( 31 43 BuildContext context, { 32 44 required bool storyMode, ··· 38 50 ); 39 51 if (pickedVideo != null && context.mounted) { 40 52 final editorVideo = EditorVideo.file(File(pickedVideo.path)); 41 - final result = await GetIt.I<ProVideoEditorRepository>() 42 - .openVideoEditor(context, editorVideo); 53 + final repository = GetIt.I<ProVideoEditorRepository>(); 54 + final result = storyMode 55 + ? await repository.openStoryVideoEditor(context, editorVideo) 56 + : await repository.openVideoEditor(context, editorVideo); 43 57 if (result != null && context.mounted) { 44 - await context.router.push( 45 - VideoReviewRoute( 46 - videoPath: result.video.path, 47 - storyMode: storyMode, 48 - soundRef: result.soundRef, 49 - ), 50 - ); 58 + if (storyMode) { 59 + // For stories, post directly 60 + await context.router.push( 61 + StoryPostRoute(videoPath: result.video.path), 62 + ); 63 + } else { 64 + // For posts, go to review 65 + await context.router.push( 66 + VideoReviewRoute( 67 + videoPath: result.video.path, 68 + storyMode: storyMode, 69 + soundRef: result.soundRef, 70 + ), 71 + ); 72 + } 51 73 } 52 74 } 53 75 }; 54 76 } 55 77 56 - /// Upload images flow: multi-pick -> image review (story/post mode decided here). 78 + /// Upload images flow: multi-pick -> story editor/image review. 79 + /// 80 + /// For stories, opens story editor then posts directly. 81 + /// For posts, goes to image review page. 57 82 static VoidCallback onUploadImages( 58 83 BuildContext context, { 59 84 required bool storyMode, 60 85 }) { 61 86 return () async { 62 - final pickedImages = await ImagePicker().pickMultiImage(limit: 12); 63 - if (context.mounted && pickedImages.isNotEmpty) { 64 - await context.router.push( 65 - ImageReviewRoute( 66 - imageFiles: pickedImages, 67 - storyMode: storyMode, 68 - ), 87 + if (storyMode) { 88 + // For stories, pick single image and open story editor 89 + final pickedImage = await ImagePicker().pickImage( 90 + source: ImageSource.gallery, 69 91 ); 92 + if (pickedImage != null && context.mounted) { 93 + // Open story editor 94 + final editedImage = await GetIt.I<ProVideoEditorRepository>() 95 + .openStoryImageEditor(context, pickedImage); 96 + if (editedImage != null && context.mounted) { 97 + // Post directly 98 + await context.router.push( 99 + StoryPostRoute(imageFile: editedImage), 100 + ); 101 + } 102 + } 103 + } else { 104 + // For posts, multi-pick and go to review 105 + final pickedImages = await ImagePicker().pickMultiImage(limit: 12); 106 + if (context.mounted && pickedImages.isNotEmpty) { 107 + await context.router.push( 108 + ImageReviewRoute( 109 + imageFiles: pickedImages, 110 + storyMode: storyMode, 111 + ), 112 + ); 113 + } 70 114 } 71 115 }; 72 116 }
+294
lib/src/core/pro_image_editor/story_image_editor_configs.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:google_fonts/google_fonts.dart'; 4 + import 'package:pro_image_editor/designs/grounded/grounded_design.dart'; 5 + import 'package:pro_image_editor/pro_image_editor.dart'; 6 + import 'package:spark/src/core/design_system/theme/color_scheme.dart'; 7 + import 'package:spark/src/core/design_system/theme/text_theme.dart'; 8 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 9 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart'; 10 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_header.dart'; 11 + import 'package:spark/src/core/pro_video_editor/ui/widgets/blur/blur_editor_bar.dart'; 12 + import 'package:spark/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart'; 13 + import 'package:spark/src/core/pro_video_editor/ui/widgets/filter/filter_editor_bar.dart'; 14 + import 'package:spark/src/core/pro_video_editor/ui/widgets/paint/paint_editor_bar.dart'; 15 + import 'package:spark/src/core/pro_video_editor/ui/widgets/text/text_editor_bar.dart'; 16 + import 'package:spark/src/core/pro_video_editor/ui/widgets/text/text_editor_color_picker.dart'; 17 + 18 + /// Border radius for the story editor preview area (top and bottom). 19 + const _storyEditorBorderRadius = BorderRadius.vertical( 20 + top: Radius.circular(20), 21 + bottom: Radius.circular(20), 22 + ); 23 + 24 + /// Configuration builder for the Story Image Editor. 25 + /// 26 + /// Creates a fixed 9:16 aspect ratio editor optimized for stories. 27 + class StoryImageEditorConfigs { 28 + const StoryImageEditorConfigs._(); 29 + 30 + /// Fixed story canvas size (1080x1920 = 9:16 aspect ratio). 31 + static const Size storySize = Size(1080, 1920); 32 + 33 + /// Builds the ProImageEditor configuration for story editing. 34 + /// 35 + /// This configuration: 36 + /// - Uses a fixed 9:16 aspect ratio canvas 37 + /// - Excludes crop/rotate tools to maintain aspect ratio 38 + /// - Provides text, paint, stickers, emoji, filter, and blur tools 39 + static ProImageEditorConfigs build({ 40 + required bool useMaterialDesign, 41 + required Widget Function() imagePreviewBuilder, 42 + }) { 43 + return ProImageEditorConfigs( 44 + designMode: platformDesignMode, 45 + theme: ThemeData( 46 + useMaterial3: true, 47 + colorScheme: AppColorScheme.dark, 48 + textTheme: AppTextTheme.dark, 49 + ), 50 + // Force output to story dimensions 51 + imageGeneration: const ImageGenerationConfigs( 52 + outputFormat: OutputFormat.png, 53 + maxOutputSize: storySize, 54 + ), 55 + mainEditor: MainEditorConfigs( 56 + // Story-appropriate tools only - NO crop/rotate 57 + tools: const [ 58 + SubEditorMode.paint, 59 + SubEditorMode.text, 60 + SubEditorMode.filter, 61 + SubEditorMode.blur, 62 + SubEditorMode.emoji, 63 + SubEditorMode.sticker, 64 + ], 65 + widgets: MainEditorWidgets( 66 + removeLayerArea: 67 + ( 68 + removeAreaKey, 69 + editor, 70 + rebuildStream, 71 + isLayerBeingTransformed, 72 + ) => VideoEditorRemoveArea( 73 + removeAreaKey: removeAreaKey, 74 + editor: editor, 75 + rebuildStream: rebuildStream, 76 + isLayerBeingTransformed: isLayerBeingTransformed, 77 + ), 78 + appBar: (editor, rebuildStream) => null, 79 + bottomBar: (editor, rebuildStream, key) => ReactiveWidget( 80 + key: key, 81 + builder: (context) { 82 + return StoryEditorBottomSection(editor: editor); 83 + }, 84 + stream: rebuildStream, 85 + ), 86 + wrapBody: (editor, rebuildStream, content) { 87 + // Fill behind content so no letterboxing shows as dark lines on sides 88 + return ClipRRect( 89 + borderRadius: _storyEditorBorderRadius, 90 + child: Container( 91 + width: double.infinity, 92 + height: double.infinity, 93 + color: Colors.black, 94 + child: content, 95 + ), 96 + ); 97 + }, 98 + bodyItems: (editor, rebuildStream) => [ 99 + ReactiveWidget( 100 + stream: rebuildStream, 101 + builder: (_) => Positioned( 102 + top: 0, 103 + left: 0, 104 + right: 0, 105 + child: SafeArea( 106 + bottom: false, 107 + child: StoryEditorHeader( 108 + onBack: editor.closeEditor, 109 + onDone: editor.doneEditing, 110 + canUndo: editor.canUndo, 111 + canRedo: editor.canRedo, 112 + onUndo: editor.undoAction, 113 + onRedo: editor.redoAction, 114 + ), 115 + ), 116 + ), 117 + ), 118 + ], 119 + ), 120 + style: const MainEditorStyle( 121 + background: Colors.black, 122 + bottomBarBackground: AppColors.grey800, 123 + ), 124 + ), 125 + paintEditor: PaintEditorConfigs( 126 + style: const PaintEditorStyle( 127 + background: AppColors.greyBlack, 128 + bottomBarBackground: AppColors.grey800, 129 + initialStrokeWidth: 5, 130 + ), 131 + widgets: PaintEditorWidgets( 132 + appBar: (paintEditor, rebuildStream) => null, 133 + colorPicker: (paintEditor, rebuildStream, currentColor, setColor) => 134 + null, 135 + bottomBar: (editorState, rebuildStream) { 136 + return ReactiveWidget( 137 + builder: (context) { 138 + return PaintEditorBar( 139 + configs: editorState.configs, 140 + callbacks: editorState.callbacks, 141 + editor: editorState, 142 + i18nColor: 'Color', 143 + showColorPicker: (currentColor) {}, 144 + ); 145 + }, 146 + stream: rebuildStream, 147 + ); 148 + }, 149 + ), 150 + ), 151 + textEditor: TextEditorConfigs( 152 + customTextStyles: [ 153 + GoogleFonts.roboto(), 154 + GoogleFonts.averiaLibre(), 155 + GoogleFonts.lato(), 156 + GoogleFonts.comicNeue(), 157 + GoogleFonts.actor(), 158 + GoogleFonts.odorMeanChey(), 159 + GoogleFonts.nabla(), 160 + ], 161 + style: TextEditorStyle( 162 + textFieldMargin: const EdgeInsets.only(top: kToolbarHeight), 163 + bottomBarBackground: AppColors.grey800, 164 + bottomBarMainAxisAlignment: !useMaterialDesign 165 + ? MainAxisAlignment.spaceEvenly 166 + : MainAxisAlignment.start, 167 + ), 168 + widgets: TextEditorWidgets( 169 + appBar: (textEditor, rebuildStream) => null, 170 + colorPicker: (textEditor, rebuildStream, currentColor, setColor) { 171 + return ReactiveWidget( 172 + stream: rebuildStream, 173 + builder: (_) => TextEditorColorPicker( 174 + configs: textEditor.configs, 175 + primaryColor: currentColor, 176 + onUpdateColor: setColor, 177 + rebuildStream: rebuildStream, 178 + ), 179 + ); 180 + }, 181 + bottomBar: (editorState, rebuildStream) { 182 + return ReactiveWidget( 183 + builder: (context) { 184 + return TextEditorBar( 185 + configs: editorState.configs, 186 + callbacks: editorState.callbacks, 187 + editor: editorState, 188 + i18nColor: 'Color', 189 + showColorPicker: (currentColor) {}, 190 + ); 191 + }, 192 + stream: rebuildStream, 193 + ); 194 + }, 195 + bodyItems: (editorState, rebuildStream) => [ 196 + ReactiveWidget( 197 + stream: rebuildStream, 198 + builder: (_) => Padding( 199 + padding: const EdgeInsets.only(top: kToolbarHeight), 200 + child: GroundedTextSizeSlider(textEditor: editorState), 201 + ), 202 + ), 203 + ], 204 + ), 205 + ), 206 + filterEditor: FilterEditorConfigs( 207 + style: const FilterEditorStyle( 208 + filterListSpacing: 7, 209 + filterListMargin: EdgeInsets.fromLTRB(8, 0, 8, 8), 210 + background: AppColors.greyBlack, 211 + ), 212 + widgets: FilterEditorWidgets( 213 + slider: (editorState, rebuildStream, value, onChanged, onChangeEnd) => 214 + ReactiveWidget( 215 + stream: rebuildStream, 216 + builder: (_) => Slider( 217 + onChanged: onChanged, 218 + onChangeEnd: onChangeEnd, 219 + value: value, 220 + activeColor: AppColors.primary400, 221 + ), 222 + ), 223 + appBar: (editorState, rebuildStream) => null, 224 + bottomBar: (editorState, rebuildStream) { 225 + return ReactiveWidget( 226 + builder: (context) { 227 + return FilterEditorBar( 228 + configs: editorState.configs, 229 + callbacks: editorState.callbacks, 230 + editor: editorState, 231 + image: imagePreviewBuilder(), 232 + ); 233 + }, 234 + stream: rebuildStream, 235 + ); 236 + }, 237 + ), 238 + ), 239 + blurEditor: BlurEditorConfigs( 240 + maxBlur: 25, 241 + style: const BlurEditorStyle( 242 + background: AppColors.greyBlack, 243 + ), 244 + widgets: BlurEditorWidgets( 245 + appBar: (blurEditor, rebuildStream) => null, 246 + bottomBar: (editorState, rebuildStream) { 247 + return ReactiveWidget( 248 + builder: (context) { 249 + return BlurEditorBar( 250 + configs: editorState.configs, 251 + callbacks: editorState.callbacks, 252 + editor: editorState, 253 + ); 254 + }, 255 + stream: rebuildStream, 256 + ); 257 + }, 258 + ), 259 + ), 260 + emojiEditor: EmojiEditorConfigs( 261 + checkPlatformCompatibility: !kIsWeb, 262 + style: EmojiEditorStyle( 263 + backgroundColor: Colors.transparent, 264 + textStyle: DefaultEmojiTextStyle.copyWith( 265 + fontFamily: !kIsWeb 266 + ? null 267 + : GoogleFonts.notoColorEmoji().fontFamily, 268 + fontSize: useMaterialDesign ? 48 : 30, 269 + ), 270 + bottomActionBarConfig: const BottomActionBarConfig(enabled: false), 271 + ), 272 + ), 273 + stickerEditor: StickerEditorConfigs( 274 + builder: (setLayer, scrollController) => DemoBuildStickers( 275 + setLayer: setLayer, 276 + scrollController: scrollController, 277 + ), 278 + style: const StickerEditorStyle( 279 + showDragHandle: false, 280 + ), 281 + ), 282 + i18n: const I18n( 283 + paintEditor: I18nPaintEditor( 284 + changeOpacity: 'Opacity', 285 + lineWidth: 'Thickness', 286 + ), 287 + textEditor: I18nTextEditor( 288 + backgroundMode: 'Mode', 289 + textAlign: 'Align', 290 + ), 291 + ), 292 + ); 293 + } 294 + }
+394
lib/src/core/pro_image_editor/ui/story_image_editor_page.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + import 'dart:ui' as ui; 4 + 5 + import 'package:flutter/foundation.dart'; 6 + import 'package:flutter/material.dart'; 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:path_provider/path_provider.dart'; 9 + import 'package:pro_image_editor/pro_image_editor.dart'; 10 + import 'package:spark/src/core/pro_image_editor/story_image_editor_configs.dart'; 11 + import 'package:spark/src/core/pro_image_editor/utils/story_image_cropper.dart'; 12 + 13 + /// A story-specific image editor page. 14 + /// 15 + /// This editor uses a fixed 9:16 aspect ratio canvas (1080x1920) optimized 16 + /// for Instagram Stories-style content. 17 + /// 18 + /// Features: 19 + /// - Auto-crops input images to 9:16 aspect ratio 20 + /// - Rounded preview area matching the camera view style 21 + /// - Text, paint, stickers, emoji, filter, and blur tools 22 + /// - NO crop/rotate tools (to maintain aspect ratio) 23 + /// - Custom UI matching the app's design language 24 + class StoryImageEditorPage extends StatefulWidget { 25 + const StoryImageEditorPage({ 26 + required this.imageFile, 27 + super.key, 28 + }); 29 + 30 + /// The source image file to edit. 31 + final File imageFile; 32 + 33 + /// Opens the story image editor and returns the edited image. 34 + /// 35 + /// Returns `null` if the user cancels without completing the edit. 36 + static Future<XFile?> open(BuildContext context, File imageFile) async { 37 + return Navigator.of(context).push<XFile?>( 38 + MaterialPageRoute( 39 + builder: (_) => StoryImageEditorPage(imageFile: imageFile), 40 + ), 41 + ); 42 + } 43 + 44 + @override 45 + State<StoryImageEditorPage> createState() => _StoryImageEditorPageState(); 46 + } 47 + 48 + class _StoryImageEditorPageState extends State<StoryImageEditorPage> { 49 + final _editorKey = GlobalKey<ProImageEditorState>(); 50 + final bool _useMaterialDesign = 51 + platformDesignMode == ImageEditorDesignMode.material; 52 + 53 + late ProImageEditorConfigs _configs; 54 + File? _croppedImageFile; 55 + bool _isLoading = true; 56 + String? _error; 57 + 58 + @override 59 + void initState() { 60 + super.initState(); 61 + _prepareImage(); 62 + } 63 + 64 + @override 65 + void dispose() { 66 + // Clean up cropped temp file if it exists and is different from original 67 + if (_croppedImageFile != null && 68 + _croppedImageFile!.path != widget.imageFile.path) { 69 + _croppedImageFile!.delete().catchError((_) => _croppedImageFile!); 70 + } 71 + super.dispose(); 72 + } 73 + 74 + Future<void> _prepareImage() async { 75 + try { 76 + // Check if image needs cropping to 9:16 77 + final needsCrop = await StoryImageCropper.needsCropping(widget.imageFile); 78 + 79 + if (needsCrop) { 80 + // Crop to 9:16 aspect ratio 81 + _croppedImageFile = await StoryImageCropper.cropToStoryAspectRatio( 82 + widget.imageFile, 83 + ); 84 + } else { 85 + _croppedImageFile = widget.imageFile; 86 + } 87 + 88 + // Initialize editor config 89 + _configs = StoryImageEditorConfigs.build( 90 + useMaterialDesign: _useMaterialDesign, 91 + imagePreviewBuilder: () => Image.file( 92 + _croppedImageFile!, 93 + fit: BoxFit.cover, 94 + ), 95 + ); 96 + 97 + if (mounted) { 98 + setState(() { 99 + _isLoading = false; 100 + }); 101 + } 102 + } catch (e) { 103 + if (mounted) { 104 + setState(() { 105 + _isLoading = false; 106 + _error = e.toString(); 107 + }); 108 + } 109 + } 110 + } 111 + 112 + Future<void> _onImageEditingComplete(Uint8List bytes) async { 113 + final directory = await getTemporaryDirectory(); 114 + final timestamp = DateTime.now().millisecondsSinceEpoch; 115 + final filename = 'spark_story_$timestamp.png'; 116 + final file = File('${directory.path}/$filename'); 117 + await file.writeAsBytes(bytes, flush: true); 118 + 119 + if (mounted) { 120 + Navigator.of(context).pop( 121 + XFile( 122 + file.path, 123 + mimeType: 'image/png', 124 + name: filename, 125 + ), 126 + ); 127 + } 128 + } 129 + 130 + void _onCloseEditor(EditorMode editorMode) { 131 + if (editorMode == EditorMode.main) { 132 + Navigator.of(context).pop(); 133 + } 134 + } 135 + 136 + @override 137 + Widget build(BuildContext context) { 138 + if (_isLoading) { 139 + return const Scaffold( 140 + backgroundColor: Colors.black, 141 + body: Center( 142 + child: Column( 143 + mainAxisSize: MainAxisSize.min, 144 + children: [ 145 + CircularProgressIndicator(color: Colors.white), 146 + SizedBox(height: 16), 147 + Text( 148 + 'Preparing image...', 149 + style: TextStyle(color: Colors.white70), 150 + ), 151 + ], 152 + ), 153 + ), 154 + ); 155 + } 156 + 157 + if (_error != null) { 158 + return Scaffold( 159 + backgroundColor: Colors.black, 160 + body: Center( 161 + child: Column( 162 + mainAxisSize: MainAxisSize.min, 163 + children: [ 164 + const Icon(Icons.error_outline, color: Colors.red, size: 48), 165 + const SizedBox(height: 16), 166 + const Text( 167 + 'Failed to load image', 168 + style: TextStyle(color: Colors.white, fontSize: 18), 169 + ), 170 + const SizedBox(height: 8), 171 + TextButton( 172 + onPressed: () => Navigator.of(context).pop(), 173 + child: const Text('Go Back'), 174 + ), 175 + ], 176 + ), 177 + ), 178 + ); 179 + } 180 + 181 + return Scaffold( 182 + backgroundColor: Colors.black, 183 + body: ProImageEditor.file( 184 + _croppedImageFile!, 185 + key: _editorKey, 186 + callbacks: ProImageEditorCallbacks( 187 + onImageEditingComplete: _onImageEditingComplete, 188 + onCloseEditor: _onCloseEditor, 189 + stickerEditorCallbacks: StickerEditorCallbacks( 190 + onSearchChanged: (value) { 191 + debugPrint('Sticker search: $value'); 192 + }, 193 + ), 194 + ), 195 + configs: _configs, 196 + ), 197 + ); 198 + } 199 + } 200 + 201 + /// A blank canvas story editor that allows adding images as movable layers. 202 + /// 203 + /// This approach gives more flexibility - the user's image becomes a movable 204 + /// layer on a fixed 9:16 canvas, allowing them to position it anywhere. 205 + class StoryBlankCanvasEditorPage extends StatefulWidget { 206 + const StoryBlankCanvasEditorPage({ 207 + this.backgroundImage, 208 + this.backgroundColor = Colors.black, 209 + super.key, 210 + }); 211 + 212 + /// Optional background image to add as a movable layer. 213 + final File? backgroundImage; 214 + 215 + /// Background color for the canvas. 216 + final Color backgroundColor; 217 + 218 + /// Opens the blank canvas story editor and returns the edited image. 219 + static Future<XFile?> open( 220 + BuildContext context, { 221 + File? backgroundImage, 222 + Color backgroundColor = Colors.black, 223 + }) async { 224 + return Navigator.of(context).push<XFile?>( 225 + MaterialPageRoute( 226 + builder: (_) => StoryBlankCanvasEditorPage( 227 + backgroundImage: backgroundImage, 228 + backgroundColor: backgroundColor, 229 + ), 230 + ), 231 + ); 232 + } 233 + 234 + @override 235 + State<StoryBlankCanvasEditorPage> createState() => 236 + _StoryBlankCanvasEditorPageState(); 237 + } 238 + 239 + class _StoryBlankCanvasEditorPageState 240 + extends State<StoryBlankCanvasEditorPage> { 241 + final _editorKey = GlobalKey<ProImageEditorState>(); 242 + final bool _useMaterialDesign = 243 + platformDesignMode == ImageEditorDesignMode.material; 244 + 245 + late ProImageEditorConfigs _configs; 246 + bool _isInitialized = false; 247 + Size? _canvasSize; 248 + Size? _imageSize; 249 + bool _hasAddedBackgroundLayer = false; 250 + 251 + @override 252 + void initState() { 253 + super.initState(); 254 + _loadBackgroundImageSize(); 255 + _initializeEditor(); 256 + } 257 + 258 + Future<void> _loadBackgroundImageSize() async { 259 + if (widget.backgroundImage == null) return; 260 + try { 261 + final bytes = await widget.backgroundImage!.readAsBytes(); 262 + final completer = Completer<ui.Image>(); 263 + ui.decodeImageFromList(bytes, completer.complete); 264 + final image = await completer.future; 265 + final size = Size(image.width.toDouble(), image.height.toDouble()); 266 + image.dispose(); 267 + if (mounted) { 268 + setState(() { 269 + _imageSize = size; 270 + }); 271 + _addBackgroundImageLayer(); 272 + } 273 + } catch (e) { 274 + debugPrint('Failed to load image size: $e'); 275 + } 276 + } 277 + 278 + void _initializeEditor() { 279 + _configs = StoryImageEditorConfigs.build( 280 + useMaterialDesign: _useMaterialDesign, 281 + imagePreviewBuilder: () => widget.backgroundImage != null 282 + ? Image.file(widget.backgroundImage!, fit: BoxFit.cover) 283 + : const SizedBox.shrink(), 284 + ); 285 + 286 + setState(() { 287 + _isInitialized = true; 288 + }); 289 + } 290 + 291 + Future<void> _onImageEditingComplete(Uint8List bytes) async { 292 + final directory = await getTemporaryDirectory(); 293 + final timestamp = DateTime.now().millisecondsSinceEpoch; 294 + final filename = 'spark_story_$timestamp.png'; 295 + final file = File('${directory.path}/$filename'); 296 + await file.writeAsBytes(bytes, flush: true); 297 + 298 + if (mounted) { 299 + Navigator.of(context).pop( 300 + XFile( 301 + file.path, 302 + mimeType: 'image/png', 303 + name: filename, 304 + ), 305 + ); 306 + } 307 + } 308 + 309 + void _onCloseEditor(EditorMode editorMode) { 310 + if (editorMode == EditorMode.main) { 311 + Navigator.of(context).pop(); 312 + } 313 + } 314 + 315 + void _addBackgroundImageLayer() { 316 + if (widget.backgroundImage == null) return; 317 + if (_hasAddedBackgroundLayer) return; 318 + if (_imageSize == null) return; 319 + 320 + final editorState = _editorKey.currentState; 321 + if (editorState == null) return; 322 + 323 + final size = _canvasSize ?? StoryImageEditorConfigs.storySize; 324 + final initWidth = _configs.stickerEditor.initWidth; 325 + final imageSize = _imageSize!; 326 + final widthForCover = size.height * (imageSize.width / imageSize.height); 327 + final targetWidth = widthForCover > size.width ? widthForCover : size.width; 328 + final scale = initWidth > 0 ? targetWidth / initWidth : 1.0; 329 + // Use original image aspect (no forced 9:16 crop). 330 + // Scale fills the canvas (cover), while preserving aspect ratio. 331 + editorState.addLayer( 332 + WidgetLayer( 333 + widget: Image.file( 334 + widget.backgroundImage!, 335 + fit: BoxFit.contain, 336 + ), 337 + scale: scale, 338 + ), 339 + ); 340 + _hasAddedBackgroundLayer = true; 341 + } 342 + 343 + @override 344 + Widget build(BuildContext context) { 345 + if (!_isInitialized) { 346 + return const Scaffold( 347 + backgroundColor: Colors.black, 348 + body: Center( 349 + child: CircularProgressIndicator(color: Colors.white), 350 + ), 351 + ); 352 + } 353 + 354 + return Scaffold( 355 + backgroundColor: Colors.black, 356 + body: LayoutBuilder( 357 + builder: (context, constraints) { 358 + final viewWidth = constraints.maxWidth; 359 + final aspect = 360 + StoryImageEditorConfigs.storySize.height / 361 + StoryImageEditorConfigs.storySize.width; 362 + final canvasSize = Size(viewWidth, viewWidth * aspect); 363 + _canvasSize = canvasSize; 364 + 365 + return ProImageEditor.blank( 366 + canvasSize, 367 + key: _editorKey, 368 + callbacks: ProImageEditorCallbacks( 369 + onImageEditingComplete: _onImageEditingComplete, 370 + onCloseEditor: _onCloseEditor, 371 + mainEditorCallbacks: MainEditorCallbacks( 372 + onAfterViewInit: _addBackgroundImageLayer, 373 + ), 374 + stickerEditorCallbacks: StickerEditorCallbacks( 375 + onSearchChanged: (value) { 376 + debugPrint('Sticker search: $value'); 377 + }, 378 + ), 379 + ), 380 + configs: _configs.copyWith( 381 + mainEditor: _configs.mainEditor.copyWith( 382 + style: MainEditorStyle( 383 + background: widget.backgroundColor, 384 + bottomBarBackground: 385 + _configs.mainEditor.style.bottomBarBackground, 386 + ), 387 + ), 388 + ), 389 + ); 390 + }, 391 + ), 392 + ); 393 + } 394 + }
+34
lib/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:pro_image_editor/pro_image_editor.dart'; 3 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 4 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart'; 5 + 6 + /// Bottom section for the Story Image Editor. 7 + /// 8 + /// Contains the toolbar with editing tools. 9 + class StoryEditorBottomSection extends StatelessWidget { 10 + const StoryEditorBottomSection({ 11 + required this.editor, 12 + super.key, 13 + }); 14 + 15 + final ProImageEditorState editor; 16 + 17 + @override 18 + Widget build(BuildContext context) { 19 + return ColoredBox( 20 + color: AppColors.greyBlack, 21 + child: SafeArea( 22 + top: false, 23 + child: StoryEditorToolbar( 24 + onPaint: editor.openPaintEditor, 25 + onText: editor.openTextEditor, 26 + onFilter: editor.openFilterEditor, 27 + onBlur: editor.openBlurEditor, 28 + onEmoji: editor.openEmojiEditor, 29 + onStickers: editor.openStickerEditor, 30 + ), 31 + ), 32 + ); 33 + } 34 + }
+112
lib/src/core/pro_image_editor/ui/widgets/story_editor_header.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:spark/src/core/design_system/components/atoms/buttons/circle_icon_button.dart'; 3 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 4 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 5 + 6 + /// Header widget for the Story Image Editor. 7 + /// 8 + /// Shows back button, undo/redo controls, and done button. 9 + class StoryEditorHeader extends StatelessWidget { 10 + const StoryEditorHeader({ 11 + required this.onBack, 12 + required this.onDone, 13 + required this.canUndo, 14 + required this.canRedo, 15 + required this.onUndo, 16 + required this.onRedo, 17 + super.key, 18 + }); 19 + 20 + final VoidCallback onBack; 21 + final VoidCallback onDone; 22 + final bool canUndo; 23 + final bool canRedo; 24 + final VoidCallback onUndo; 25 + final VoidCallback onRedo; 26 + 27 + @override 28 + Widget build(BuildContext context) { 29 + return Padding( 30 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 31 + child: Row( 32 + children: [ 33 + // Back button 34 + CircleIconButton( 35 + onPressed: onBack, 36 + backgroundColor: AppColors.grey600.withAlpha(180), 37 + icon: AppIcons.chevronleft(), 38 + semanticLabel: 'Back', 39 + ), 40 + const Spacer(), 41 + // Undo/Redo controls 42 + Row( 43 + mainAxisSize: MainAxisSize.min, 44 + children: [ 45 + _UndoRedoButton( 46 + icon: Icons.undo_rounded, 47 + onPressed: canUndo ? onUndo : null, 48 + semanticLabel: 'Undo', 49 + ), 50 + const SizedBox(width: 8), 51 + _UndoRedoButton( 52 + icon: Icons.redo_rounded, 53 + onPressed: canRedo ? onRedo : null, 54 + semanticLabel: 'Redo', 55 + ), 56 + ], 57 + ), 58 + const Spacer(), 59 + // Done button 60 + CircleIconButton( 61 + onPressed: onDone, 62 + backgroundColor: AppColors.primary500, 63 + icon: const Icon(Icons.arrow_forward, size: 22), 64 + iconColor: AppColors.greyWhite, 65 + semanticLabel: 'Done', 66 + ), 67 + ], 68 + ), 69 + ); 70 + } 71 + } 72 + 73 + class _UndoRedoButton extends StatelessWidget { 74 + const _UndoRedoButton({ 75 + required this.icon, 76 + required this.onPressed, 77 + required this.semanticLabel, 78 + }); 79 + 80 + final IconData icon; 81 + final VoidCallback? onPressed; 82 + final String semanticLabel; 83 + 84 + @override 85 + Widget build(BuildContext context) { 86 + final isEnabled = onPressed != null; 87 + 88 + return Semantics( 89 + label: semanticLabel, 90 + button: true, 91 + enabled: isEnabled, 92 + child: GestureDetector( 93 + onTap: onPressed, 94 + child: Container( 95 + width: 36, 96 + height: 36, 97 + decoration: BoxDecoration( 98 + shape: BoxShape.circle, 99 + color: AppColors.grey600.withAlpha(isEnabled ? 180 : 90), 100 + ), 101 + child: Icon( 102 + icon, 103 + size: 20, 104 + color: isEnabled 105 + ? AppColors.greyWhite 106 + : AppColors.greyWhite.withAlpha(100), 107 + ), 108 + ), 109 + ), 110 + ); 111 + } 112 + }
+111
lib/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 3 + 4 + /// Toolbar widget for the Story Image Editor. 5 + /// 6 + /// Displays horizontal list of editing tools optimized for stories. 7 + class StoryEditorToolbar extends StatelessWidget { 8 + const StoryEditorToolbar({ 9 + required this.onPaint, 10 + required this.onText, 11 + required this.onFilter, 12 + required this.onBlur, 13 + required this.onEmoji, 14 + required this.onStickers, 15 + super.key, 16 + }); 17 + 18 + final VoidCallback onPaint; 19 + final VoidCallback onText; 20 + final VoidCallback onFilter; 21 + final VoidCallback onBlur; 22 + final VoidCallback onEmoji; 23 + final VoidCallback onStickers; 24 + 25 + @override 26 + Widget build(BuildContext context) { 27 + return Container( 28 + height: 80, 29 + padding: const EdgeInsets.symmetric(horizontal: 16), 30 + child: Row( 31 + mainAxisAlignment: MainAxisAlignment.spaceEvenly, 32 + children: [ 33 + _ToolbarItem( 34 + icon: Icons.brush_rounded, 35 + label: 'Draw', 36 + onTap: onPaint, 37 + ), 38 + _ToolbarItem( 39 + icon: Icons.text_fields_rounded, 40 + label: 'Text', 41 + onTap: onText, 42 + ), 43 + _ToolbarItem( 44 + icon: Icons.auto_awesome_rounded, 45 + label: 'Filter', 46 + onTap: onFilter, 47 + ), 48 + _ToolbarItem( 49 + icon: Icons.blur_on_rounded, 50 + label: 'Blur', 51 + onTap: onBlur, 52 + ), 53 + _ToolbarItem( 54 + icon: Icons.emoji_emotions_rounded, 55 + label: 'Emoji', 56 + onTap: onEmoji, 57 + ), 58 + _ToolbarItem( 59 + icon: Icons.sticky_note_2_rounded, 60 + label: 'Stickers', 61 + onTap: onStickers, 62 + ), 63 + ], 64 + ), 65 + ); 66 + } 67 + } 68 + 69 + class _ToolbarItem extends StatelessWidget { 70 + const _ToolbarItem({ 71 + required this.icon, 72 + required this.label, 73 + required this.onTap, 74 + }); 75 + 76 + final IconData icon; 77 + final String label; 78 + final VoidCallback onTap; 79 + 80 + @override 81 + Widget build(BuildContext context) { 82 + return GestureDetector( 83 + onTap: onTap, 84 + behavior: HitTestBehavior.opaque, 85 + child: SizedBox( 86 + width: 56, 87 + child: Column( 88 + mainAxisAlignment: MainAxisAlignment.center, 89 + children: [ 90 + Icon( 91 + icon, 92 + color: AppColors.greyWhite, 93 + size: 26, 94 + ), 95 + const SizedBox(height: 4), 96 + Text( 97 + label, 98 + style: const TextStyle( 99 + color: AppColors.grey300, 100 + fontSize: 11, 101 + fontWeight: FontWeight.w500, 102 + ), 103 + maxLines: 1, 104 + overflow: TextOverflow.ellipsis, 105 + ), 106 + ], 107 + ), 108 + ), 109 + ); 110 + } 111 + }
+99
lib/src/core/pro_image_editor/utils/story_image_cropper.dart
··· 1 + import 'dart:io'; 2 + import 'dart:ui' as ui; 3 + 4 + import 'package:flutter/material.dart'; 5 + import 'package:path_provider/path_provider.dart'; 6 + 7 + /// Utility to crop images to story aspect ratio (9:16 / 1080x1920). 8 + class StoryImageCropper { 9 + StoryImageCropper._(); 10 + 11 + /// Target story dimensions. 12 + static const double targetWidth = 1080; 13 + static const double targetHeight = 1920; 14 + static const double targetAspectRatio = targetWidth / targetHeight; // 0.5625 15 + 16 + /// Crops the given image file to 9:16 aspect ratio (center crop). 17 + /// 18 + /// Returns the path to the cropped image file. 19 + static Future<File> cropToStoryAspectRatio(File imageFile) async { 20 + // Decode the image 21 + final bytes = await imageFile.readAsBytes(); 22 + final codec = await ui.instantiateImageCodec(bytes); 23 + final frame = await codec.getNextFrame(); 24 + final image = frame.image; 25 + 26 + final originalWidth = image.width.toDouble(); 27 + final originalHeight = image.height.toDouble(); 28 + final originalAspectRatio = originalWidth / originalHeight; 29 + 30 + // Calculate crop area (center crop to 9:16) 31 + double cropWidth; 32 + double cropHeight; 33 + double cropX; 34 + double cropY; 35 + 36 + if (originalAspectRatio > targetAspectRatio) { 37 + // Image is wider than 9:16 - crop horizontally 38 + cropHeight = originalHeight; 39 + cropWidth = originalHeight * targetAspectRatio; 40 + cropX = (originalWidth - cropWidth) / 2; 41 + cropY = 0; 42 + } else { 43 + // Image is taller than 9:16 - crop vertically 44 + cropWidth = originalWidth; 45 + cropHeight = originalWidth / targetAspectRatio; 46 + cropX = 0; 47 + cropY = (originalHeight - cropHeight) / 2; 48 + } 49 + 50 + // Create a picture recorder to draw the cropped image 51 + final recorder = ui.PictureRecorder(); 52 + final canvas = Canvas(recorder); 53 + 54 + // Draw the cropped portion scaled to target size 55 + final srcRect = Rect.fromLTWH(cropX, cropY, cropWidth, cropHeight); 56 + final dstRect = Rect.fromLTWH(0, 0, targetWidth, targetHeight); 57 + 58 + canvas.drawImageRect(image, srcRect, dstRect, Paint()); 59 + 60 + // Convert to image 61 + final picture = recorder.endRecording(); 62 + final croppedImage = await picture.toImage( 63 + targetWidth.toInt(), 64 + targetHeight.toInt(), 65 + ); 66 + 67 + // Encode to PNG 68 + final byteData = await croppedImage.toByteData( 69 + format: ui.ImageByteFormat.png, 70 + ); 71 + final pngBytes = byteData!.buffer.asUint8List(); 72 + 73 + // Save to temp file 74 + final tempDir = await getTemporaryDirectory(); 75 + final timestamp = DateTime.now().millisecondsSinceEpoch; 76 + final croppedFile = File('${tempDir.path}/story_cropped_$timestamp.png'); 77 + await croppedFile.writeAsBytes(pngBytes); 78 + 79 + // Clean up 80 + image.dispose(); 81 + croppedImage.dispose(); 82 + 83 + return croppedFile; 84 + } 85 + 86 + /// Checks if an image needs to be cropped to 9:16 aspect ratio. 87 + static Future<bool> needsCropping(File imageFile) async { 88 + final bytes = await imageFile.readAsBytes(); 89 + final codec = await ui.instantiateImageCodec(bytes); 90 + final frame = await codec.getNextFrame(); 91 + final image = frame.image; 92 + 93 + final aspectRatio = image.width / image.height; 94 + image.dispose(); 95 + 96 + // Allow small tolerance for floating point comparison 97 + return (aspectRatio - targetAspectRatio).abs() > 0.01; 98 + } 99 + }
+31
lib/src/core/pro_video_editor/pro_video_editor_repository.dart
··· 45 45 BuildContext context, 46 46 EditorVideo video, 47 47 ); 48 + 49 + /// Opens the Story Image Editor with a fixed 9:16 aspect ratio canvas. 50 + /// 51 + /// The [source] image is displayed in the editor with story-appropriate 52 + /// tools (text, paint, stickers, emoji, filter, blur - NO crop/rotate). 53 + /// 54 + /// Returns `null` if the user cancels without completing the edit. 55 + Future<XFile?> openStoryImageEditor(BuildContext context, XFile source); 56 + 57 + /// Opens a blank canvas Story Image Editor (1080x1920). 58 + /// 59 + /// Optionally adds [backgroundImage] as a movable layer on the canvas. 60 + /// This gives more flexibility for positioning the image. 61 + /// 62 + /// Returns `null` if the user cancels without completing the edit. 63 + Future<XFile?> openStoryBlankCanvasEditor( 64 + BuildContext context, { 65 + XFile? backgroundImage, 66 + Color backgroundColor = const Color(0xFF000000), 67 + }); 68 + 69 + /// Opens the Story Video Editor with story-appropriate tools. 70 + /// 71 + /// Uses the same limited toolset as the story image editor 72 + /// (paint, text, filter, blur, emoji, stickers - NO crop/rotate/tune). 73 + /// 74 + /// Returns `null` if the user cancels without completing the edit. 75 + Future<VideoEditorResult?> openStoryVideoEditor( 76 + BuildContext context, 77 + EditorVideo video, 78 + ); 48 79 }
+35
lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart
··· 6 6 import 'package:path_provider/path_provider.dart'; 7 7 import 'package:pro_image_editor/pro_image_editor.dart'; 8 8 import 'package:pro_video_editor/pro_video_editor.dart'; 9 + import 'package:spark/src/core/pro_image_editor/ui/story_image_editor_page.dart'; 9 10 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 10 11 import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; 11 12 import 'package:spark/src/core/pro_video_editor/ui/video_editor_grounded_page.dart'; ··· 82 83 return Navigator.of(context).push<VideoEditorResult?>( 83 84 MaterialPageRoute( 84 85 builder: (_) => VideoEditorGroundedPage(video: video), 86 + ), 87 + ); 88 + } 89 + 90 + @override 91 + Future<XFile?> openStoryImageEditor(BuildContext context, XFile source) { 92 + return StoryBlankCanvasEditorPage.open( 93 + context, 94 + backgroundImage: File(source.path), 95 + ); 96 + } 97 + 98 + @override 99 + Future<XFile?> openStoryBlankCanvasEditor( 100 + BuildContext context, { 101 + XFile? backgroundImage, 102 + Color backgroundColor = const Color(0xFF000000), 103 + }) { 104 + return StoryBlankCanvasEditorPage.open( 105 + context, 106 + backgroundImage: 107 + backgroundImage != null ? File(backgroundImage.path) : null, 108 + backgroundColor: backgroundColor, 109 + ); 110 + } 111 + 112 + @override 113 + Future<VideoEditorResult?> openStoryVideoEditor( 114 + BuildContext context, 115 + EditorVideo video, 116 + ) async { 117 + return Navigator.of(context).push<VideoEditorResult?>( 118 + MaterialPageRoute( 119 + builder: (_) => VideoEditorGroundedPage(video: video, storyMode: true), 85 120 ), 86 121 ); 87 122 }
+6 -12
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 27 27 class VideoEditorGroundedPage extends StatefulWidget { 28 28 const VideoEditorGroundedPage({ 29 29 required this.video, 30 + this.storyMode = false, 30 31 super.key, 31 32 }); 32 33 33 34 /// Input video to be edited. 34 35 final EditorVideo video; 36 + 37 + /// When true, uses story-specific tools (no crop/rotate/tune). 38 + final bool storyMode; 35 39 36 40 @override 37 41 State<VideoEditorGroundedPage> createState() => ··· 192 196 video: widget.video, 193 197 taskId: _taskId, 194 198 useMaterialDesign: _useMaterialDesign, 199 + storyMode: widget.storyMode, 195 200 videoPlayerBuilder: () => VideoPlayerWidget( 196 201 controller: _videoController, 197 202 isLoadingListenable: _updateClipsNotifier, ··· 227 232 ]); 228 233 if (!mounted) return; 229 234 230 - // Adjust resolution based on rotation metadata 231 - final rotation = _videoMetadata.rotation; 232 - final convertedRotation = rotation % 360; 233 - final is90DegRotated = convertedRotation == 90 || convertedRotation == 270; 234 - final adjustedResolution = is90DegRotated 235 - ? Size( 236 - _videoMetadata.resolution.height, 237 - _videoMetadata.resolution.width, 238 - ) 239 - : _videoMetadata.resolution; 240 - 241 235 _proVideoController = ProVideoController( 242 236 videoPlayer: VideoPlayerWidget( 243 237 controller: _videoController, 244 238 isLoadingListenable: _updateClipsNotifier, 245 239 ), 246 - initialResolution: adjustedResolution, 240 + initialResolution: _videoMetadata.resolution, 247 241 videoDuration: _videoMetadata.duration, 248 242 fileSize: _videoMetadata.fileSize, 249 243 thumbnails: _thumbnails,
+28 -12
lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart
··· 25 25 class VideoEditorConfigsBuilder { 26 26 const VideoEditorConfigsBuilder._(); 27 27 28 + /// Tools available in story mode (matches story image editor). 29 + static const _storyModeTools = [ 30 + SubEditorMode.audio, 31 + SubEditorMode.paint, 32 + SubEditorMode.text, 33 + SubEditorMode.filter, 34 + SubEditorMode.blur, 35 + SubEditorMode.emoji, 36 + SubEditorMode.sticker, 37 + ]; 38 + 39 + /// Full set of tools for regular video editing. 40 + static const _fullTools = [ 41 + SubEditorMode.audio, 42 + SubEditorMode.paint, 43 + SubEditorMode.text, 44 + SubEditorMode.cropRotate, 45 + SubEditorMode.tune, 46 + SubEditorMode.filter, 47 + SubEditorMode.blur, 48 + SubEditorMode.emoji, 49 + SubEditorMode.sticker, 50 + ]; 51 + 28 52 static ProImageEditorConfigs build({ 29 53 required EditorVideo video, 30 54 required String taskId, ··· 36 60 required VoidCallback onToggleMute, 37 61 required VoidCallback onAddSound, 38 62 required VoidCallback onToggleFullscreen, 63 + bool storyMode = false, 39 64 List<AudioTrack> audioTracks = const [], 40 65 VideoEditorConfigs videoEditorConfigs = const VideoEditorConfigs( 41 66 initialMuted: true, ··· 46 71 ), 47 72 ), 48 73 }) { 74 + final tools = storyMode ? _storyModeTools : _fullTools; 75 + 49 76 return ProImageEditorConfigs( 50 77 designMode: platformDesignMode, 51 78 dialogConfigs: DialogConfigs( ··· 60 87 textTheme: AppTextTheme.dark, 61 88 ), 62 89 mainEditor: MainEditorConfigs( 63 - tools: const [ 64 - //SubEditorMode.videoClips, 65 - SubEditorMode.audio, 66 - SubEditorMode.paint, 67 - SubEditorMode.text, 68 - SubEditorMode.cropRotate, 69 - SubEditorMode.tune, 70 - SubEditorMode.filter, 71 - SubEditorMode.blur, 72 - SubEditorMode.emoji, 73 - SubEditorMode.sticker, 74 - ], 90 + tools: tools, 75 91 widgets: MainEditorWidgets( 76 92 removeLayerArea: 77 93 (
+1 -5
lib/src/core/pro_video_editor/ui/widgets/player/preview_video.dart
··· 96 96 } 97 97 98 98 final aspectRatio = snapshot.data?.resolution.aspectRatio ?? 1; 99 - final rotation = snapshot.data?.rotation ?? 0; 100 - final convertedRotation = rotation % 360; 101 - final is90DegRotated = 102 - convertedRotation == 90 || convertedRotation == 270; 103 99 104 100 final maxWidth = constraints.maxWidth; 105 101 final maxHeight = constraints.maxHeight; 106 102 107 103 var width = maxWidth; 108 - var height = is90DegRotated ? width * aspectRatio : width / aspectRatio; 104 + var height = width / aspectRatio; 109 105 110 106 if (height > maxHeight) { 111 107 height = maxHeight;
+1
lib/src/core/routing/app_router.dart
··· 132 132 AutoRoute(page: UserListRoute.page, path: '/profile/:did/users'), 133 133 AutoRoute(page: VideoReviewRoute.page, path: '/video-review'), 134 134 AutoRoute(page: ImageReviewRoute.page, path: '/image-review'), 135 + AutoRoute(page: StoryPostRoute.page, path: '/story-post'), 135 136 AutoRoute( 136 137 page: VideoEditorGroundedRoute.page, 137 138 path: '/video-editor-grounded',
+1
lib/src/core/routing/pages.dart
··· 16 16 export 'package:spark/src/features/notifications/ui/pages/notifications_page.dart'; 17 17 export 'package:spark/src/features/posting/ui/pages/image_review_page.dart'; 18 18 export 'package:spark/src/features/posting/ui/pages/recording_page.dart'; 19 + export 'package:spark/src/features/posting/ui/pages/story_post_page.dart'; 19 20 export 'package:spark/src/features/posting/ui/pages/video_review_page.dart'; 20 21 export 'package:spark/src/features/profile/ui/pages/blocks_page.dart'; 21 22 export 'package:spark/src/features/profile/ui/pages/edit_profile_page.dart';
+54 -29
lib/src/features/posting/providers/camera_provider.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:camera/camera.dart'; 4 + import 'package:flutter/scheduler.dart'; 4 5 import 'package:get_it/get_it.dart'; 5 6 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 7 import 'package:spark/src/core/utils/logging/logging.dart'; ··· 20 21 21 22 _logger.i('Initializing camera provider'); 22 23 23 - // Initialize camera on build 24 + // Yield so the loading UI can paint before blocking on camera init 25 + await Future<void>.delayed(Duration.zero); 26 + if (!ref.mounted) return const CameraState(error: 'Disposed during init'); 27 + 24 28 try { 25 29 return await _initializeCamera(); 26 30 } catch (e, stackTrace) { ··· 76 80 await controller.initialize(); 77 81 78 82 if (controller.value.isInitialized) { 83 + // Pre-initialize audio session on iOS to eliminate recording start lag 84 + await controller.prepareForVideoRecording(); 79 85 _logger.i('Camera controller successfully initialized'); 80 86 return controller; 81 87 } else { ··· 117 123 118 124 _logger.d('Flipping camera'); 119 125 120 - try { 121 - final newIndex = 122 - (currentState.selectedCameraIndex + 1) % currentState.cameras.length; 123 - final newCamera = currentState.cameras[newIndex]; 126 + final newIndex = 127 + (currentState.selectedCameraIndex + 1) % currentState.cameras.length; 128 + final newCamera = currentState.cameras[newIndex]; 124 129 125 - _logger.d('Switching to camera: ${newCamera.name}'); 130 + // Show flipping state immediately so the UI can paint a loading overlay 131 + state = AsyncValue.data( 132 + currentState.copyWith(isFlipping: true, error: null), 133 + ); 126 134 127 - // Dispose current controller 128 - await currentState.controller?.dispose(); 135 + // Defer heavy work to after the next frame so the current frame paints 136 + SchedulerBinding.instance.addPostFrameCallback((_) async { 137 + if (!ref.mounted) return; 138 + try { 139 + _logger.d('Switching to camera: ${newCamera.name}'); 140 + 141 + await currentState.controller?.dispose(); 142 + if (!ref.mounted) return; 129 143 130 - // Create new controller 131 - final newController = await _createCameraController(newCamera); 144 + final newController = await _createCameraController(newCamera); 145 + if (!ref.mounted) return; 132 146 133 - state = AsyncValue.data( 134 - currentState.copyWith( 135 - controller: newController, 136 - selectedCameraIndex: newIndex, 137 - isInitialized: true, 138 - error: null, 139 - ), 140 - ); 147 + state = AsyncValue.data( 148 + currentState.copyWith( 149 + controller: newController, 150 + selectedCameraIndex: newIndex, 151 + isInitialized: true, 152 + isFlipping: false, 153 + error: null, 154 + ), 155 + ); 141 156 142 - _logger.i('Camera flipped successfully to ${newCamera.name}'); 143 - } catch (e, stackTrace) { 144 - _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); 145 - state = AsyncValue.data(currentState.copyWith(error: e.toString())); 146 - } 157 + _logger.i('Camera flipped successfully to ${newCamera.name}'); 158 + } catch (e, stackTrace) { 159 + _logger.e('Error flipping camera', error: e, stackTrace: stackTrace); 160 + if (ref.mounted) { 161 + state = AsyncValue.data( 162 + currentState.copyWith(isFlipping: false, error: e.toString()), 163 + ); 164 + } 165 + } 166 + }); 147 167 } 148 168 149 169 Future<XFile?> takePhoto() async { ··· 189 209 190 210 _logger.d('Starting video recording'); 191 211 212 + // Update state optimistically BEFORE native call so UI responds immediately 213 + state = AsyncValue.data(currentState.copyWith(isRecording: true)); 214 + 192 215 try { 193 216 await currentState.controller!.startVideoRecording(); 194 - state = AsyncValue.data(currentState.copyWith(isRecording: true)); 195 217 _logger.i('Video recording started successfully'); 196 218 return true; 197 219 } catch (e, stackTrace) { ··· 200 222 error: e, 201 223 stackTrace: stackTrace, 202 224 ); 203 - state = AsyncValue.data(currentState.copyWith(error: e.toString())); 225 + // Revert optimistic update on failure 226 + state = AsyncValue.data( 227 + currentState.copyWith(isRecording: false, error: e.toString()), 228 + ); 204 229 return false; 205 230 } 206 231 } ··· 221 246 222 247 _logger.d('Stopping video recording'); 223 248 249 + // Update state optimistically BEFORE the native call so UI responds immediately 250 + state = AsyncValue.data(currentState.copyWith(isRecording: false)); 251 + 224 252 try { 225 253 final file = await currentState.controller!.stopVideoRecording(); 226 - state = AsyncValue.data(currentState.copyWith(isRecording: false)); 227 254 _logger.i('Video recording stopped successfully: ${file.path}'); 228 255 return file; 229 256 } catch (e, stackTrace) { ··· 232 259 error: e, 233 260 stackTrace: stackTrace, 234 261 ); 235 - state = AsyncValue.data( 236 - currentState.copyWith(isRecording: false, error: e.toString()), 237 - ); 262 + state = AsyncValue.data(currentState.copyWith(error: e.toString())); 238 263 return null; 239 264 } 240 265 }
+1
lib/src/features/posting/providers/camera_state.dart
··· 11 11 @Default(0) int selectedCameraIndex, 12 12 @Default(false) bool isInitialized, 13 13 @Default(false) bool isRecording, 14 + @Default(false) bool isFlipping, 14 15 @Default(0) int initAttempts, 15 16 String? error, 16 17 }) = _CameraState;
+286 -56
lib/src/features/posting/ui/pages/recording_page.dart
··· 12 12 import 'package:spark/src/core/utils/logging/logging.dart'; 13 13 import 'package:spark/src/features/posting/providers/camera_provider.dart'; 14 14 import 'package:spark/src/features/posting/providers/recording_provider.dart'; 15 + import 'package:spark/src/features/posting/utils/story_direct_post.dart'; 16 + 17 + export 'package:spark/src/core/design_system/templates/recording_page_template.dart' 18 + show CaptureMode; 15 19 16 20 @RoutePage() 17 21 class RecordingPage extends ConsumerStatefulWidget { 18 22 const RecordingPage({ 19 23 required this.storyMode, 24 + this.captureMode = CaptureMode.videoOnly, 20 25 super.key, 21 26 }); 22 27 23 28 final bool storyMode; 24 29 30 + /// Camera capture mode: 31 + /// - [CaptureMode.videoOnly]: tap to start/stop recording (default) 32 + /// - [CaptureMode.hybrid]: tap for photo, hold for video 33 + final CaptureMode captureMode; 34 + 25 35 @override 26 36 ConsumerState<RecordingPage> createState() => _RecordingPageState(); 27 37 } ··· 29 39 class _RecordingPageState extends ConsumerState<RecordingPage> { 30 40 late final SparkLogger _logger; 31 41 bool _isProcessing = false; 42 + bool _isExiting = false; 43 + 44 + // Store notifier reference for safe disposal 45 + Recording? _recordingNotifier; 32 46 33 47 @override 34 48 void initState() { 35 49 super.initState(); 36 50 _logger = GetIt.instance<LogService>().getLogger('RecordingPage'); 51 + // Save reference to notifier for use in dispose 52 + WidgetsBinding.instance.addPostFrameCallback((_) { 53 + if (mounted) { 54 + _recordingNotifier = ref.read(recordingProvider.notifier); 55 + } 56 + }); 37 57 } 38 58 39 - Future<void> _handleRecordPressed() async { 59 + bool _isCameraReady() { 40 60 final cameraAsync = ref.read(cameraProvider); 61 + if (cameraAsync.hasError) return false; 62 + final cameraState = cameraAsync.value; 63 + return cameraState != null && 64 + cameraState.isInitialized && 65 + cameraState.controller != null; 66 + } 67 + 68 + /// Handle tap on record button. 69 + /// In videoOnly mode: toggle recording. 70 + /// In hybrid mode: take photo. 71 + void _handleTap() { 72 + if (!_isCameraReady()) return; 73 + 74 + if (widget.captureMode == CaptureMode.videoOnly) { 75 + final recordingState = ref.read(recordingProvider); 76 + if (recordingState.isRecording) { 77 + _stopRecording(); 78 + } else { 79 + _startRecording(); 80 + } 81 + } else { 82 + // Hybrid mode - tap takes photo 83 + _takePhoto(); 84 + } 85 + } 86 + 87 + /// Handle hold start (hybrid mode only). 88 + void _handleRecordStart() { 89 + if (!_isCameraReady()) return; 90 + if (widget.captureMode != CaptureMode.hybrid) return; 91 + _startRecording(); 92 + } 93 + 94 + /// Handle hold end (hybrid mode only). 95 + void _handleRecordStop() { 96 + if (!_isCameraReady()) return; 97 + if (widget.captureMode != CaptureMode.hybrid) return; 41 98 final recordingState = ref.read(recordingProvider); 99 + if (recordingState.isRecording) { 100 + _stopRecording(); 101 + } 102 + } 42 103 43 - if (cameraAsync.hasError) { 104 + Future<void> _takePhoto() async { 105 + if (_isProcessing) return; 106 + 107 + final cameraNotifier = ref.read(cameraProvider.notifier); 108 + _logger.d('Taking photo'); 109 + 110 + setState(() { 111 + _isProcessing = true; 112 + }); 113 + 114 + final photoFile = await cameraNotifier.takePhoto(); 115 + 116 + if (!mounted) return; 117 + if (photoFile == null) { 118 + setState(() { 119 + _isProcessing = false; 120 + }); 44 121 return; 45 122 } 46 123 47 - final cameraState = cameraAsync.value; 48 - if (cameraState == null || 49 - !cameraState.isInitialized || 50 - cameraState.controller == null) { 51 - return; 52 - } 124 + await _processPhoto(photoFile); 125 + } 126 + 127 + Future<void> _processPhoto(XFile photoFile) async { 128 + if (!mounted) return; 129 + 130 + try { 131 + _logger.d('Processing photo: ${photoFile.path}'); 132 + 133 + // Open the story image editor 134 + final editedImage = await GetIt.I<ProVideoEditorRepository>() 135 + .openStoryImageEditor(context, photoFile); 136 + 137 + if (!mounted) return; 138 + 139 + if (editedImage != null) { 140 + if (widget.storyMode) { 141 + // For stories, post directly without review 142 + // Show exiting state to prevent camera rendering issues 143 + setState(() { 144 + _isExiting = true; 145 + }); 146 + 147 + try { 148 + final result = await StoryDirectPost.postPhotoStory( 149 + context, 150 + ref, 151 + editedImage, 152 + ); 153 + if (result != null && mounted) { 154 + _logger.i('Story posted successfully'); 155 + // Exit the recording flow completely 156 + if (mounted) context.router.maybePop(); 157 + return; 158 + } 159 + } catch (e, stackTrace) { 160 + _logger.e('Error posting story', error: e, stackTrace: stackTrace); 161 + if (mounted) { 162 + setState(() { 163 + _isExiting = false; 164 + }); 165 + ScaffoldMessenger.of(context).showSnackBar( 166 + SnackBar(content: Text('Failed to post story: $e')), 167 + ); 168 + } 169 + } 170 + } else { 171 + // For posts, go to review page 172 + await context.router.push( 173 + ImageReviewRoute( 174 + imageFiles: [editedImage], 175 + storyMode: widget.storyMode, 176 + ), 177 + ); 178 + } 179 + } 53 180 54 - if (recordingState.isRecording) { 55 - await _stopRecording(); 56 - } else { 57 - await _startRecording(); 181 + // Reset processing state and reinitialize camera 182 + if (mounted) { 183 + setState(() { 184 + _isProcessing = false; 185 + }); 186 + // Reinitialize camera after returning from editor 187 + ref.read(cameraProvider.notifier).reinitializeCamera(); 188 + } 189 + } catch (e, stackTrace) { 190 + _logger.e('Error processing photo', error: e, stackTrace: stackTrace); 191 + if (mounted) { 192 + setState(() { 193 + _isProcessing = false; 194 + }); 195 + ScaffoldMessenger.of(context).showSnackBar( 196 + SnackBar(content: Text('Error: $e')), 197 + ); 198 + } 58 199 } 59 200 } 60 201 61 - Future<void> _startRecording() async { 202 + void _startRecording() { 62 203 final cameraNotifier = ref.read(cameraProvider.notifier); 63 204 final recordingNotifier = ref.read(recordingProvider.notifier); 64 205 65 206 _logger.d('Starting video recording'); 66 207 67 - final success = await cameraNotifier.startVideoRecording(); 68 - if (success) { 69 - recordingNotifier.startRecording(); 70 - } 208 + // Start timer optimistically so UI responds immediately 209 + recordingNotifier.startRecording(); 210 + 211 + // Start native recording; revert timer if it fails 212 + cameraNotifier.startVideoRecording().then((success) { 213 + if (!success && mounted) { 214 + recordingNotifier.stopRecording(); 215 + } 216 + }); 71 217 } 72 218 73 - Future<void> _stopRecording() async { 219 + void _stopRecording() { 74 220 if (_isProcessing) return; 75 221 76 222 setState(() { ··· 83 229 _logger.d('Stopping video recording'); 84 230 85 231 recordingNotifier.stopRecording(); 86 - final videoFile = await cameraNotifier.stopVideoRecording(); 87 232 88 - if (videoFile == null) { 89 - setState(() { 90 - _isProcessing = false; 91 - }); 92 - return; 93 - } 233 + // Defer heavy stop so the "processing" frame paints before blocking 234 + WidgetsBinding.instance.addPostFrameCallback((_) async { 235 + final videoFile = await cameraNotifier.stopVideoRecording(); 236 + 237 + if (!mounted) return; 238 + if (videoFile == null) { 239 + setState(() { 240 + _isProcessing = false; 241 + }); 242 + return; 243 + } 94 244 95 - await _processVideo(videoFile); 245 + await _processVideo(videoFile); 246 + }); 96 247 } 97 248 98 249 Future<void> _processVideo(XFile videoFile) async { ··· 107 258 if (!mounted) return; 108 259 109 260 final editorVideo = EditorVideo.file(File(videoFile.path)); 110 - final result = await GetIt.I<ProVideoEditorRepository>().openVideoEditor( 111 - context, 112 - editorVideo, 113 - ); 261 + final repository = GetIt.I<ProVideoEditorRepository>(); 262 + final result = widget.storyMode 263 + ? await repository.openStoryVideoEditor(context, editorVideo) 264 + : await repository.openVideoEditor(context, editorVideo); 114 265 115 266 if (!mounted) return; 116 267 ··· 123 274 return; 124 275 } 125 276 126 - await context.router.push( 127 - VideoReviewRoute( 128 - videoPath: result.video.path, 129 - storyMode: widget.storyMode, 130 - soundRef: result.soundRef, 131 - ), 132 - ); 277 + if (widget.storyMode) { 278 + // For stories, post directly without review 279 + // Show exiting state to prevent camera rendering issues 280 + setState(() { 281 + _isExiting = true; 282 + }); 283 + 284 + try { 285 + final postResult = await StoryDirectPost.postVideoStory( 286 + context, 287 + ref, 288 + result.video.path, 289 + soundRef: result.soundRef, 290 + ); 291 + if (postResult != null && mounted) { 292 + _logger.i('Video story posted successfully'); 293 + // Exit the recording flow completely 294 + if (mounted) context.router.maybePop(); 295 + return; 296 + } 297 + } catch (e, stackTrace) { 298 + _logger.e( 299 + 'Error posting video story', 300 + error: e, 301 + stackTrace: stackTrace, 302 + ); 303 + if (mounted) { 304 + ScaffoldMessenger.of(context).showSnackBar( 305 + SnackBar(content: Text('Failed to post story: $e')), 306 + ); 307 + } 308 + } 309 + // If posting failed or was cancelled, reset state 310 + if (mounted) { 311 + setState(() { 312 + _isExiting = false; 313 + _isProcessing = false; 314 + }); 315 + } 316 + } else { 317 + // For posts, go to review page 318 + await context.router.push( 319 + VideoReviewRoute( 320 + videoPath: result.video.path, 321 + storyMode: widget.storyMode, 322 + soundRef: result.soundRef, 323 + ), 324 + ); 133 325 134 - if (mounted) { 135 - context.router.pop(); 326 + if (mounted) { 327 + context.router.pop(); 328 + } 136 329 } 137 330 } catch (e, stackTrace) { 138 331 _logger.e('Error processing video', error: e, stackTrace: stackTrace); ··· 140 333 setState(() { 141 334 _isProcessing = false; 142 335 }); 336 + ScaffoldMessenger.of(context).showSnackBar( 337 + SnackBar(content: Text('Error: $e')), 338 + ); 143 339 } 144 340 } 145 341 } ··· 211 407 ); 212 408 } 213 409 410 + // Show loading when exiting to prevent camera rendering issues 411 + if (_isExiting) { 412 + return const Scaffold( 413 + backgroundColor: Colors.black, 414 + body: Center( 415 + child: CircularProgressIndicator(color: Colors.white), 416 + ), 417 + ); 418 + } 419 + 214 420 final canFlipCamera = 215 - cameraState.cameras.length > 1 && !recordingState.isRecording; 421 + cameraState.cameras.length > 1 && 422 + !recordingState.isRecording && 423 + !cameraState.isFlipping; 216 424 final aspectRatio = cameraState.controller!.value.aspectRatio; 217 425 218 - return RecordingPageTemplate( 219 - cameraPreview: CameraPreview(cameraState.controller!), 220 - aspectRatio: aspectRatio, 221 - isRecording: recordingState.isRecording, 222 - elapsedDuration: recordingState.elapsedDuration, 223 - maxDuration: recordingState.maxDuration, 224 - onBack: () { 225 - if (recordingState.isRecording) { 226 - return; 227 - } 228 - context.router.pop(); 229 - }, 230 - onFlipCamera: canFlipCamera ? _handleFlipCamera : null, 231 - onRecordPressed: _isProcessing ? null : _handleRecordPressed, 232 - canFlipCamera: canFlipCamera, 426 + return Stack( 427 + children: [ 428 + RecordingPageTemplate( 429 + cameraPreview: RepaintBoundary( 430 + child: CameraPreview(cameraState.controller!), 431 + ), 432 + aspectRatio: aspectRatio, 433 + isRecording: recordingState.isRecording, 434 + elapsedDuration: recordingState.elapsedDuration, 435 + maxDuration: recordingState.maxDuration, 436 + onBack: () { 437 + if (recordingState.isRecording) { 438 + return; 439 + } 440 + context.router.pop(); 441 + }, 442 + onFlipCamera: canFlipCamera ? _handleFlipCamera : null, 443 + canFlipCamera: canFlipCamera, 444 + captureMode: widget.captureMode, 445 + onTap: _isProcessing ? null : _handleTap, 446 + onRecordStart: _isProcessing ? null : _handleRecordStart, 447 + onRecordStop: _isProcessing ? null : _handleRecordStop, 448 + ), 449 + if (cameraState.isFlipping) 450 + const Positioned.fill( 451 + child: ColoredBox( 452 + color: Colors.black, 453 + child: Center( 454 + child: CircularProgressIndicator(color: Colors.white), 455 + ), 456 + ), 457 + ), 458 + ], 233 459 ); 234 460 }, 235 461 loading: () => const Scaffold( ··· 274 500 275 501 @override 276 502 void dispose() { 277 - ref.read(recordingProvider.notifier).reset(); 503 + // Defer provider modification to avoid modifying during widget tree finalization 504 + final notifier = _recordingNotifier; 505 + if (notifier != null) { 506 + Future(notifier.reset); 507 + } 278 508 super.dispose(); 279 509 } 280 510 }
+175
lib/src/features/posting/ui/pages/story_post_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:image_picker/image_picker.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 + import 'package:spark/src/features/posting/providers/post_story.dart'; 9 + import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; 10 + 11 + /// Page that handles posting a story directly without a review UI. 12 + /// 13 + /// This is used when uploading media from gallery for stories. 14 + /// Shows a loading indicator while uploading and posting. 15 + @RoutePage() 16 + class StoryPostPage extends ConsumerStatefulWidget { 17 + const StoryPostPage({ 18 + this.imageFile, 19 + this.videoPath, 20 + super.key, 21 + }) : assert( 22 + imageFile != null || videoPath != null, 23 + 'Either imageFile or videoPath must be provided', 24 + ); 25 + 26 + final XFile? imageFile; 27 + final String? videoPath; 28 + 29 + @override 30 + ConsumerState<StoryPostPage> createState() => _StoryPostPageState(); 31 + } 32 + 33 + class _StoryPostPageState extends ConsumerState<StoryPostPage> { 34 + bool _isPosting = false; 35 + String _statusMessage = 'Preparing...'; 36 + String? _error; 37 + 38 + @override 39 + void initState() { 40 + super.initState(); 41 + WidgetsBinding.instance.addPostFrameCallback((_) { 42 + _postStory(); 43 + }); 44 + } 45 + 46 + Future<void> _postStory() async { 47 + if (_isPosting) return; 48 + 49 + setState(() { 50 + _isPosting = true; 51 + _error = null; 52 + }); 53 + 54 + try { 55 + if (widget.imageFile != null) { 56 + await _postImageStory(); 57 + } else if (widget.videoPath != null) { 58 + await _postVideoStory(); 59 + } 60 + 61 + if (mounted) { 62 + // Success - pop back 63 + context.router.pop(true); 64 + } 65 + } catch (e) { 66 + if (mounted) { 67 + setState(() { 68 + _isPosting = false; 69 + _error = e.toString(); 70 + }); 71 + } 72 + } 73 + } 74 + 75 + Future<void> _postImageStory() async { 76 + setState(() { 77 + _statusMessage = 'Uploading image...'; 78 + }); 79 + 80 + final feedRepository = GetIt.I<SprkRepository>().feed; 81 + final uploadedImages = await feedRepository.uploadImages( 82 + imageFiles: [widget.imageFile!], 83 + altTexts: {widget.imageFile!.path: ''}, 84 + ); 85 + 86 + if (uploadedImages.isEmpty) { 87 + throw Exception('Failed to upload image'); 88 + } 89 + 90 + setState(() { 91 + _statusMessage = 'Posting story...'; 92 + }); 93 + 94 + final uploadedImage = uploadedImages.first; 95 + final result = await ref.read( 96 + postStoryProvider( 97 + Media.image(image: uploadedImage.image, alt: uploadedImage.alt), 98 + ).future, 99 + ); 100 + 101 + if (result == null) { 102 + throw Exception('Failed to post story'); 103 + } 104 + } 105 + 106 + Future<void> _postVideoStory() async { 107 + setState(() { 108 + _statusMessage = 'Processing video...'; 109 + }); 110 + 111 + final result = await ref.read( 112 + processAndPostVideoProvider( 113 + videoPath: widget.videoPath!, 114 + storyMode: true, 115 + ).future, 116 + ); 117 + 118 + if (result == null) { 119 + throw Exception('Failed to post video story'); 120 + } 121 + } 122 + 123 + @override 124 + Widget build(BuildContext context) { 125 + return Scaffold( 126 + backgroundColor: Colors.black, 127 + body: Center( 128 + child: Padding( 129 + padding: const EdgeInsets.all(32), 130 + child: Column( 131 + mainAxisSize: MainAxisSize.min, 132 + children: [ 133 + if (_error != null) ...[ 134 + const Icon(Icons.error_outline, color: Colors.red, size: 48), 135 + const SizedBox(height: 16), 136 + const Text( 137 + 'Failed to post story', 138 + style: TextStyle(color: Colors.white, fontSize: 18), 139 + ), 140 + const SizedBox(height: 8), 141 + Text( 142 + _error!, 143 + style: const TextStyle(color: Colors.white70, fontSize: 14), 144 + textAlign: TextAlign.center, 145 + ), 146 + const SizedBox(height: 24), 147 + Row( 148 + mainAxisAlignment: MainAxisAlignment.center, 149 + children: [ 150 + TextButton( 151 + onPressed: () => context.router.pop(false), 152 + child: const Text('Cancel'), 153 + ), 154 + const SizedBox(width: 16), 155 + ElevatedButton( 156 + onPressed: _postStory, 157 + child: const Text('Retry'), 158 + ), 159 + ], 160 + ), 161 + ] else ...[ 162 + const CircularProgressIndicator(color: Colors.white), 163 + const SizedBox(height: 24), 164 + Text( 165 + _statusMessage, 166 + style: const TextStyle(color: Colors.white, fontSize: 16), 167 + ), 168 + ], 169 + ], 170 + ), 171 + ), 172 + ), 173 + ); 174 + } 175 + }
+162
lib/src/features/posting/utils/story_direct_post.dart
··· 1 + import 'package:atproto/com_atproto_repo_strongref.dart'; 2 + import 'package:flutter/foundation.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:image_picker/image_picker.dart'; 7 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 8 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 9 + import 'package:spark/src/features/posting/providers/post_story.dart'; 10 + import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; 11 + 12 + /// Utility for posting stories directly without a review page. 13 + class StoryDirectPost { 14 + StoryDirectPost._(); 15 + 16 + /// Posts a photo story directly. 17 + /// 18 + /// Shows a loading indicator while posting. 19 + /// Returns the post reference if successful, null if cancelled/failed. 20 + static Future<RepoStrongRef?> postPhotoStory( 21 + BuildContext context, 22 + WidgetRef ref, 23 + XFile imageFile, 24 + ) async { 25 + debugPrint('[StoryDirectPost] Starting photo story post'); 26 + debugPrint('[StoryDirectPost] Image path: ${imageFile.path}'); 27 + 28 + // Show loading overlay 29 + final navigator = Navigator.of(context); 30 + 31 + showDialog( 32 + context: context, 33 + barrierDismissible: false, 34 + builder: (_) => const _PostingOverlay(message: 'Posting story...'), 35 + ); 36 + 37 + try { 38 + final feedRepository = GetIt.I<SprkRepository>().feed; 39 + 40 + debugPrint('[StoryDirectPost] Uploading image...'); 41 + // Upload the image 42 + final uploadedImages = await feedRepository.uploadImages( 43 + imageFiles: [imageFile], 44 + altTexts: {imageFile.path: ''}, 45 + ); 46 + 47 + debugPrint('[StoryDirectPost] Upload result: ${uploadedImages.length} images'); 48 + 49 + if (uploadedImages.isEmpty) { 50 + throw Exception('Failed to upload image'); 51 + } 52 + 53 + final uploadedImage = uploadedImages.first; 54 + debugPrint('[StoryDirectPost] Image uploaded, posting story...'); 55 + 56 + // Post the story 57 + final result = await ref.read( 58 + postStoryProvider( 59 + Media.image(image: uploadedImage.image, alt: uploadedImage.alt), 60 + ).future, 61 + ); 62 + 63 + if (result == null) { 64 + throw Exception('Failed to post story'); 65 + } 66 + 67 + debugPrint('[StoryDirectPost] Story posted: ${result.uri}'); 68 + 69 + // Dismiss loading 70 + if (navigator.mounted) { 71 + navigator.pop(); 72 + } 73 + 74 + return result; 75 + } catch (e, stackTrace) { 76 + debugPrint('[StoryDirectPost] Error: $e'); 77 + debugPrint('[StoryDirectPost] StackTrace: $stackTrace'); 78 + // Dismiss loading 79 + if (navigator.mounted) { 80 + navigator.pop(); 81 + } 82 + rethrow; 83 + } 84 + } 85 + 86 + /// Posts a video story directly. 87 + /// 88 + /// Shows a loading indicator while processing and posting. 89 + /// Returns the post reference if successful, null if cancelled/failed. 90 + static Future<RepoStrongRef?> postVideoStory( 91 + BuildContext context, 92 + WidgetRef ref, 93 + String videoPath, { 94 + RepoStrongRef? soundRef, 95 + }) async { 96 + // Show loading overlay 97 + final navigator = Navigator.of(context); 98 + 99 + showDialog( 100 + context: context, 101 + barrierDismissible: false, 102 + builder: (_) => const _PostingOverlay(message: 'Processing video...'), 103 + ); 104 + 105 + try { 106 + // Use existing video upload provider which handles story posting 107 + final result = await ref.read( 108 + processAndPostVideoProvider( 109 + videoPath: videoPath, 110 + storyMode: true, 111 + soundRef: soundRef, 112 + ).future, 113 + ); 114 + 115 + // Dismiss loading 116 + if (navigator.mounted) { 117 + navigator.pop(); 118 + } 119 + 120 + return result; 121 + } catch (e) { 122 + // Dismiss loading 123 + if (navigator.mounted) { 124 + navigator.pop(); 125 + } 126 + rethrow; 127 + } 128 + } 129 + } 130 + 131 + class _PostingOverlay extends StatelessWidget { 132 + const _PostingOverlay({required this.message}); 133 + 134 + final String message; 135 + 136 + @override 137 + Widget build(BuildContext context) { 138 + return PopScope( 139 + canPop: false, 140 + child: Center( 141 + child: Container( 142 + padding: const EdgeInsets.all(24), 143 + decoration: BoxDecoration( 144 + color: Colors.black87, 145 + borderRadius: BorderRadius.circular(16), 146 + ), 147 + child: Column( 148 + mainAxisSize: MainAxisSize.min, 149 + children: [ 150 + const CircularProgressIndicator(color: Colors.white), 151 + const SizedBox(height: 16), 152 + Text( 153 + message, 154 + style: const TextStyle(color: Colors.white), 155 + ), 156 + ], 157 + ), 158 + ), 159 + ), 160 + ); 161 + } 162 + }
+4 -6
lib/src/features/profile/ui/pages/profile_page.dart
··· 14 14 import 'package:spark/src/core/network/atproto/data/models/actor_models.dart' 15 15 as actor_models; 16 16 import 'package:spark/src/core/routing/app_router.dart'; 17 + import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; 17 18 import 'package:spark/src/core/ui/widgets/options_panel.dart'; 18 19 import 'package:spark/src/core/ui/widgets/report_dialog.dart'; 19 20 import 'package:spark/src/core/utils/blocking_utils.dart'; ··· 169 170 } 170 171 171 172 Future<void> _handleAddStory(BuildContext context) async { 172 - showCreateMediaSheet( 173 - context, 174 - onRecord: CreateMediaActions.onRecord(context, storyMode: true), 175 - onUploadVideo: CreateMediaActions.onUploadVideo(context, storyMode: true), 176 - onUploadImages: CreateMediaActions.onUploadImages( 177 - context, 173 + context.router.push( 174 + RecordingRoute( 178 175 storyMode: true, 176 + captureMode: CaptureMode.hybrid, 179 177 ), 180 178 ); 181 179 }
+57 -40
lib/src/features/stories/ui/pages/story_page.dart
··· 105 105 106 106 @override 107 107 Widget build(BuildContext context) { 108 + final footerHeight = kBottomNavigationBarHeight + 12; 109 + const borderRadius = BorderRadius.all(Radius.circular(20)); 110 + 108 111 // Determine the main media widget (video or image) first. 109 112 late final Widget mediaContent; 110 113 ··· 116 119 color: Colors.black, 117 120 alignment: Alignment.center, 118 121 child: FittedBox( 122 + fit: BoxFit.cover, 119 123 child: SizedBox( 120 124 width: (size.width > 0 ? size.width : 1280), 121 125 height: (size.height > 0 ? size.height : 720), ··· 169 173 } 170 174 171 175 // Wrap the media in a Stack to overlay gradient shadows for readability. 172 - return Stack( 173 - fit: StackFit.expand, 176 + return Column( 174 177 children: [ 175 - mediaContent, 176 - // Top shadow overlay 177 - Positioned( 178 - left: 0, 179 - right: 0, 180 - top: 0, 181 - child: IgnorePointer( 182 - child: Container( 183 - height: 120, 184 - decoration: BoxDecoration( 185 - gradient: LinearGradient( 186 - begin: Alignment.topCenter, 187 - end: Alignment.bottomCenter, 188 - colors: [ 189 - Colors.black87.withAlpha(100), 190 - Colors.transparent, 191 - ], 178 + Expanded( 179 + child: ClipRRect( 180 + borderRadius: borderRadius, 181 + child: Stack( 182 + fit: StackFit.expand, 183 + children: [ 184 + mediaContent, 185 + // Top shadow overlay 186 + Positioned( 187 + left: 0, 188 + right: 0, 189 + top: 0, 190 + child: IgnorePointer( 191 + child: Container( 192 + height: 120, 193 + decoration: BoxDecoration( 194 + gradient: LinearGradient( 195 + begin: Alignment.topCenter, 196 + end: Alignment.bottomCenter, 197 + colors: [ 198 + Colors.black87.withAlpha(100), 199 + Colors.transparent, 200 + ], 201 + ), 202 + ), 203 + ), 204 + ), 205 + ), 206 + // Bottom shadow overlay 207 + Positioned( 208 + left: 0, 209 + right: 0, 210 + bottom: 0, 211 + child: IgnorePointer( 212 + child: Container( 213 + height: 120, 214 + decoration: BoxDecoration( 215 + gradient: LinearGradient( 216 + begin: Alignment.bottomCenter, 217 + end: Alignment.topCenter, 218 + colors: [ 219 + Colors.black87.withAlpha(100), 220 + Colors.transparent, 221 + ], 222 + ), 223 + ), 224 + ), 225 + ), 192 226 ), 193 - ), 227 + ], 194 228 ), 195 229 ), 196 230 ), 197 - // Bottom shadow overlay 198 - Positioned( 199 - left: 0, 200 - right: 0, 201 - bottom: 0, 202 - child: IgnorePointer( 203 - child: Container( 204 - height: 120, 205 - decoration: BoxDecoration( 206 - gradient: LinearGradient( 207 - begin: Alignment.bottomCenter, 208 - end: Alignment.topCenter, 209 - colors: [ 210 - Colors.black87.withAlpha(100), 211 - Colors.transparent, 212 - ], 213 - ), 214 - ), 215 - ), 216 - ), 231 + SizedBox( 232 + height: footerHeight, 233 + child: const ColoredBox(color: Colors.black), 217 234 ), 218 235 ], 219 236 );
+6 -10
lib/src/features/stories/ui/widgets/stories_list.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 - import 'package:spark/src/core/design_system/components/molecules/create_media_sheet.dart'; 5 4 import 'package:spark/src/core/design_system/components/molecules/story_circle.dart'; 6 - import 'package:spark/src/core/media/create_media_actions.dart'; 7 5 import 'package:spark/src/core/routing/app_router.dart'; 8 6 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 7 + import 'package:spark/src/features/posting/ui/pages/recording_page.dart'; 9 8 import 'package:spark/src/features/profile/providers/profile_provider.dart'; 10 9 import 'package:spark/src/features/stories/providers/stories_by_author.dart'; 11 10 ··· 19 18 class _StoriesListState extends ConsumerState<StoriesList> { 20 19 String? _cursor; 21 20 22 - void _showCreateMenu(BuildContext context) { 23 - showCreateMediaSheet( 24 - context, 25 - onRecord: CreateMediaActions.onRecord(context, storyMode: true), 26 - onUploadVideo: CreateMediaActions.onUploadVideo(context, storyMode: true), 27 - onUploadImages: CreateMediaActions.onUploadImages( 28 - context, 21 + void _openStoryRecorder(BuildContext context) { 22 + context.router.push( 23 + RecordingRoute( 29 24 storyMode: true, 25 + captureMode: CaptureMode.hybrid, 30 26 ), 31 27 ); 32 28 } ··· 93 89 ); 94 90 95 91 return GestureDetector( 96 - onTap: () => _showCreateMenu(context), 92 + onTap: () => _openStoryRecorder(context), 97 93 child: Padding( 98 94 padding: const EdgeInsets.only(right: 12), 99 95 child: StoryCircle.create(