diff --git a/lib/src/core/pro_image_editor/story_editor_profile.dart b/lib/src/core/pro_image_editor/story_editor_profile.dart new file mode 100644 index 00000000..9050a53f --- /dev/null +++ b/lib/src/core/pro_image_editor/story_editor_profile.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_image_editor/story_sticker_editor.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_top_section.dart'; + +/// Canonical product-profile configuration shared by Story image and video +/// editors. +class StoryEditorProfile { + const StoryEditorProfile._(); + + static const safeArea = EditorSafeArea.symmetric(vertical: true); + static const outsideCaptureAreaLayerOpacity = 0.0; + static const tools = [ + SubEditorMode.paint, + SubEditorMode.text, + SubEditorMode.filter, + SubEditorMode.blur, + SubEditorMode.emoji, + SubEditorMode.sticker, + ]; + + static const _previewBorderRadius = BorderRadius.vertical( + top: Radius.circular(20), + bottom: Radius.circular(20), + ); + + static MainEditorWidgets buildMainEditorWidgets({ + Future Function()? onMention, + void Function(ProImageEditorState editor)? onDone, + Widget Function(ProImageEditorState editor)? contextualControlBuilder, + }) { + return 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, + stream: rebuildStream, + builder: (_) => StoryEditorBottomSection( + onShare: onDone != null ? () => onDone(editor) : editor.doneEditing, + contextualControl: contextualControlBuilder?.call(editor), + ), + ), + wrapBody: (editor, rebuildStream, content) => ClipRRect( + borderRadius: _previewBorderRadius, + child: ColoredBox(color: Colors.black, child: content), + ), + bodyItems: (editor, rebuildStream) => [ + ReactiveWidget( + stream: rebuildStream, + builder: (_) => Positioned( + top: 0, + left: 0, + right: 0, + child: StoryEditorTopSection( + onClose: editor.closeEditor, + onMention: onMention, + onPaint: editor.openPaintEditor, + onText: editor.openTextEditor, + onFilter: editor.openFilterEditor, + onBlur: editor.openBlurEditor, + onEmoji: editor.openEmojiEditor, + onStickers: () => openStoryStickerEditor(editor), + ), + ), + ), + ], + ); + } +} 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 index 40482eff..28d60ab5 100644 --- a/lib/src/core/pro_image_editor/story_image_editor_configs.dart +++ b/lib/src/core/pro_image_editor/story_image_editor_configs.dart @@ -6,8 +6,7 @@ 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_image_editor/story_editor_profile.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'; @@ -15,12 +14,6 @@ import 'package:spark/src/core/pro_video_editor/ui/widgets/paint/paint_editor_ba 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. @@ -60,72 +53,21 @@ class StoryImageEditorConfigs { 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: (_) => - StoryEditorBottomSection(editor: editor, onMention: onMention), - stream: rebuildStream, - ), - wrapBody: (editor, rebuildStream, content) { - 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: onDone != null - ? () => onDone(editor) - : editor.doneEditing, - canUndo: editor.canUndo, - canRedo: editor.canRedo, - onUndo: editor.undoAction, - onRedo: editor.redoAction, - ), - ), - ), - ), - ], + safeArea: StoryEditorProfile.safeArea, + tools: StoryEditorProfile.tools, + widgets: StoryEditorProfile.buildMainEditorWidgets( + onMention: onMention, + onDone: onDone, ), style: const MainEditorStyle( background: Colors.black, bottomBarBackground: AppColors.grey800, + outsideCaptureAreaLayerOpacity: + StoryEditorProfile.outsideCaptureAreaLayerOpacity, ), ), paintEditor: PaintEditorConfigs( + safeArea: StoryEditorProfile.safeArea, style: const PaintEditorStyle( background: AppColors.greyBlack, bottomBarBackground: AppColors.grey800, @@ -152,6 +94,7 @@ class StoryImageEditorConfigs { ), ), textEditor: TextEditorConfigs( + safeArea: StoryEditorProfile.safeArea, customTextStyles: [ GoogleFonts.roboto(), GoogleFonts.averiaLibre(), @@ -207,6 +150,7 @@ class StoryImageEditorConfigs { ), ), filterEditor: FilterEditorConfigs( + safeArea: StoryEditorProfile.safeArea, style: const FilterEditorStyle( filterListSpacing: 7, filterListMargin: EdgeInsets.fromLTRB(8, 0, 8, 8), @@ -240,6 +184,7 @@ class StoryImageEditorConfigs { ), ), blurEditor: BlurEditorConfigs( + safeArea: StoryEditorProfile.safeArea, maxBlur: 25, style: const BlurEditorStyle(background: AppColors.greyBlack), widgets: BlurEditorWidgets( diff --git a/lib/src/core/pro_image_editor/story_sticker_editor.dart b/lib/src/core/pro_image_editor/story_sticker_editor.dart new file mode 100644 index 00000000..5e2feec6 --- /dev/null +++ b/lib/src/core/pro_image_editor/story_sticker_editor.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; + +/// Opens the Story sticker picker edge-to-edge while preserving the editor's +/// selection, keyboard, and callback lifecycle. +Future openStoryStickerEditor(ProImageEditorState editor) async { + final configs = editor.configs; + final stickerStyle = configs.stickerEditor.style; + final sheetStyle = stickerStyle.draggableSheetStyle; + final constraints = stickerStyle.editorBoxConstraintsBuilder?.call( + editor.context, + configs, + ); + + editor + ..unselectAllLayers() + ..removeKeyEventListener(); + editor.interactiveViewer.currentState?.setEnableInteraction(true); + + WidgetLayer? layer; + try { + layer = await showModalBottomSheet( + context: editor.context, + backgroundColor: Colors.transparent, + constraints: constraints, + isScrollControlled: true, + showDragHandle: stickerStyle.showDragHandle, + useSafeArea: false, + builder: (context) => DraggableScrollableSheet( + expand: sheetStyle.expand, + initialChildSize: sheetStyle.initialChildSize, + maxChildSize: sheetStyle.maxChildSize, + minChildSize: sheetStyle.minChildSize, + shouldCloseOnMinExtent: sheetStyle.shouldCloseOnMinExtent, + snap: sheetStyle.snap, + snapAnimationDuration: sheetStyle.snapAnimationDuration, + snapSizes: sheetStyle.snapSizes, + builder: (_, scrollController) => StickerEditor( + configs: configs, + callbacks: editor.callbacks, + scrollController: scrollController, + ), + ), + ); + } finally { + if (editor.mounted) editor.initKeyEventListener(); + } + + if (layer == null || !editor.mounted) return; + editor.addLayer(layer); +} 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 index 0fef3b92..6eea09b1 100644 --- 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 @@ -136,11 +136,7 @@ class _StoryImageEditorPageState extends State } } - void _onCloseEditor(EditorMode editorMode) { - if (editorMode == EditorMode.main) { - Navigator.of(context).pop(); - } - } + void _onCloseEditor(EditorMode _) => Navigator.of(context).pop(); @override Widget build(BuildContext context) { @@ -323,11 +319,7 @@ class _StoryBlankCanvasEditorPageState extends State } } - void _onCloseEditor(EditorMode editorMode) { - if (editorMode == EditorMode.main) { - Navigator.of(context).pop(); - } - } + void _onCloseEditor(EditorMode _) => Navigator.of(context).pop(); double _computeInitialBackgroundScale(Size previewSize) { final imageSize = _imageSize; @@ -439,10 +431,8 @@ class _StoryBlankCanvasEditorPageState extends State initStateHistory: _initialStateHistory, ), mainEditor: _configs.mainEditor.copyWith( - style: MainEditorStyle( + style: _configs.mainEditor.style.copyWith( 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 index bdb4f108..f790a440 100644 --- 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 @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.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'; +import 'package:spark/src/core/l10n/app_localizations.dart'; -/// Bottom section for the Story Image Editor. +/// Bottom section for Story image and video editors. /// -/// Contains the toolbar with editing tools. +/// Keeps contextual video controls above an Instagram-style share action. class StoryEditorBottomSection extends StatelessWidget { const StoryEditorBottomSection({ - required this.editor, - this.onMention, + required this.onShare, this.contextualControl, super.key, }); - final ProImageEditorState editor; - final Future Function()? onMention; + final VoidCallback onShare; final Widget? contextualControl; @override @@ -28,14 +26,16 @@ class StoryEditorBottomSection extends StatelessWidget { ?contextualControl, SafeArea( top: false, - child: StoryEditorToolbar( - onMention: onMention, - onPaint: editor.openPaintEditor, - onText: editor.openTextEditor, - onFilter: editor.openFilterEditor, - onBlur: editor.openBlurEditor, - onEmoji: editor.openEmojiEditor, - onStickers: editor.openStickerEditor, + minimum: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: AppButton( + key: const ValueKey('story-editor-share-button'), + label: AppLocalizations.of(context).buttonShare, + onPressed: onShare, + size: AppButtonSize.large, + fullWidth: true, + minHeight: 54, + borderRadius: BorderRadius.circular(999), + trailing: const Icon(Icons.arrow_forward_rounded, size: 22), ), ), ], 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 deleted file mode 100644 index 5759295a..00000000 --- a/lib/src/core/pro_image_editor/ui/widgets/story_editor_header.dart +++ /dev/null @@ -1,112 +0,0 @@ -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 index 01a40c0d..c3bb9633 100644 --- 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 @@ -4,7 +4,7 @@ import 'package:spark/src/core/l10n/app_localizations.dart'; /// Toolbar widget for the Story Image Editor. /// -/// Displays horizontal list of editing tools optimized for stories. +/// Displays a compact vertical action rail over the story canvas. class StoryEditorToolbar extends StatelessWidget { const StoryEditorToolbar({ this.onMention, @@ -29,51 +29,64 @@ class StoryEditorToolbar extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final items = [ + _ToolbarItem( + key: const ValueKey('story-editor-tool-text'), + icon: Icons.text_fields_rounded, + label: l10n.labelText, + onTap: onText, + ), + _ToolbarItem( + key: const ValueKey('story-editor-tool-stickers'), + icon: Icons.sticky_note_2_rounded, + label: l10n.labelStickers, + onTap: onStickers, + ), + _ToolbarItem( + key: const ValueKey('story-editor-tool-draw'), + icon: Icons.brush_rounded, + label: l10n.labelDraw, + onTap: onPaint, + ), if (onMention != null) _ToolbarItem( + key: const ValueKey('story-editor-tool-mention'), icon: Icons.alternate_email_rounded, label: l10n.labelMention, onTap: () => onMention!.call(), ), _ToolbarItem( - icon: Icons.brush_rounded, - label: l10n.labelDraw, - onTap: onPaint, - ), - _ToolbarItem( - icon: Icons.text_fields_rounded, - label: l10n.labelText, - onTap: onText, + key: const ValueKey('story-editor-tool-emoji'), + icon: Icons.emoji_emotions_rounded, + label: l10n.labelEmoji, + onTap: onEmoji, ), _ToolbarItem( + key: const ValueKey('story-editor-tool-filter'), icon: Icons.auto_awesome_rounded, label: l10n.labelFilter, onTap: onFilter, ), _ToolbarItem( + key: const ValueKey('story-editor-tool-blur'), icon: Icons.blur_on_rounded, label: l10n.labelBlur, onTap: onBlur, ), - _ToolbarItem( - icon: Icons.emoji_emotions_rounded, - label: l10n.labelEmoji, - onTap: onEmoji, - ), - _ToolbarItem( - icon: Icons.sticky_note_2_rounded, - label: l10n.labelStickers, - onTap: onStickers, - ), ]; + const itemExtent = 42.0; + const itemSpacing = 8.0; + final naturalHeight = + items.length * itemExtent + (items.length - 1) * itemSpacing; + final availableHeight = MediaQuery.sizeOf(context).height * 0.55; + return SizedBox( - height: 80, + width: itemExtent, + height: naturalHeight.clamp(0, availableHeight).toDouble(), child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.zero, itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(width: 10), + separatorBuilder: (_, _) => const SizedBox(height: itemSpacing), itemBuilder: (context, index) => items[index], ), ); @@ -85,6 +98,7 @@ class _ToolbarItem extends StatelessWidget { required this.icon, required this.label, required this.onTap, + super.key, }); final IconData icon; @@ -93,27 +107,23 @@ class _ToolbarItem extends StatelessWidget { @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, + return Semantics( + label: label, + button: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Tooltip( + message: label, + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.grey900.withAlpha(150), ), - ], + child: Icon(icon, color: AppColors.greyWhite, size: 22), + ), ), ), ); diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_editor_top_section.dart b/lib/src/core/pro_image_editor/ui/widgets/story_editor_top_section.dart new file mode 100644 index 00000000..9d79b6cf --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/widgets/story_editor_top_section.dart @@ -0,0 +1,65 @@ +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/tokens/colors.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart'; + +/// Shared Story-profile chrome for image and video editors. +class StoryEditorTopSection extends StatelessWidget { + const StoryEditorTopSection({ + required this.onClose, + required this.onPaint, + required this.onText, + required this.onFilter, + required this.onBlur, + required this.onEmoji, + required this.onStickers, + this.onMention, + super.key, + }); + + final VoidCallback onClose; + final Future Function()? onMention; + final VoidCallback onPaint; + final VoidCallback onText; + final VoidCallback onFilter; + final VoidCallback onBlur; + final VoidCallback onEmoji; + final VoidCallback onStickers; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleIconButton( + key: const ValueKey('story-editor-close'), + onPressed: onClose, + size: 40, + backgroundColor: AppColors.grey900.withAlpha(150), + icon: const Icon(Icons.close_rounded, size: 24), + iconColor: AppColors.greyWhite, + semanticLabel: l10n.buttonClose, + ), + const Spacer(), + StoryEditorToolbar( + onMention: onMention, + onPaint: onPaint, + onText: onText, + onFilter: onFilter, + onBlur: onBlur, + onEmoji: onEmoji, + onStickers: onStickers, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart b/lib/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart index e217597e..6a363fe2 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/common/build_stickers.dart @@ -38,48 +38,48 @@ class _DemoBuildStickersState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: AppColors.grey900, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const _DragHandle(), - _SheetHeader( - title: AppLocalizations.of(context).labelStickers, - onClose: () => Navigator.of(context).pop(), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: _CategoryChips( - titles: _titles, - selectedIndex: _selectedCategoryIndex, - onSelected: _onSelectCategory, - ), + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _DragHandle(), + _SheetHeader( + title: AppLocalizations.of(context).labelStickers, + onClose: () => Navigator.of(context).pop(), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _CategoryChips( + titles: _titles, + selectedIndex: _selectedCategoryIndex, + onSelected: _onSelectCategory, ), - Flexible( - child: AnimatedSlide( - offset: _isSwitchingCategory - ? const Offset(0, 0.03) - : Offset.zero, + ), + Flexible( + child: AnimatedSlide( + offset: _isSwitchingCategory ? const Offset(0, 0.03) : Offset.zero, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: _isSwitchingCategory ? 0 : 1, duration: const Duration(milliseconds: 160), curve: Curves.easeOut, - child: AnimatedOpacity( - opacity: _isSwitchingCategory ? 0 : 1, - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - child: _StickerGrid( - categoryIndex: _selectedCategoryIndex, - scrollController: widget.scrollController, - onPickSticker: _onPickSticker, - ), + child: _StickerGrid( + categoryIndex: _selectedCategoryIndex, + scrollController: widget.scrollController, + onPickSticker: _onPickSticker, ), ), ), - ], + ), + ], + ); + + return Container( + decoration: const BoxDecoration( + color: AppColors.grey900, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), + child: SafeArea(top: false, child: content), ); } 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 cfccbd27..0123dca9 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 @@ -7,8 +7,7 @@ import 'package:pro_video_editor/pro_video_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_image_editor/story_editor_profile.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/clip/clip_editor_bar.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/clip/clips_editor_bar.dart'; @@ -24,24 +23,9 @@ import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeli import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/story_video_timeline_controls.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/tune/tune_editor_bar.dart'; -const _storyEditorBorderRadius = BorderRadius.vertical( - top: Radius.circular(20), - bottom: Radius.circular(20), -); - class VideoEditorConfigsBuilder { const VideoEditorConfigsBuilder._(); - /// Tools available in story mode (matches story image editor). - static const _storyModeTools = [ - SubEditorMode.paint, - SubEditorMode.text, - SubEditorMode.filter, - SubEditorMode.blur, - SubEditorMode.emoji, - SubEditorMode.sticker, - ]; - /// Full set of tools for regular video editing. static const _fullTools = [ SubEditorMode.paint, @@ -78,66 +62,22 @@ class VideoEditorConfigsBuilder { taskId: taskId, useMaterialDesign: useMaterialDesign, videoPlayerBuilder: videoPlayerBuilder, - tools: _storyModeTools, + tools: StoryEditorProfile.tools, enableZoom: false, - mainEditorWidgets: 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, - stream: rebuildStream, - builder: (_) => StoryEditorBottomSection( - editor: editor, - onMention: onMention, - contextualControl: StoryVideoTimelineControls( - editor: editor, - timelineState: timelineState, - onTogglePlay: onTogglePlay, - onSeek: onSeek, - onSeekStart: onSeekStart, - onSeekEnd: onSeekEnd, - ), - ), - ), - wrapBody: (editor, rebuildStream, content) => ClipRRect( - borderRadius: _storyEditorBorderRadius, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.black, - child: content, - ), + safeArea: StoryEditorProfile.safeArea, + outsideCaptureAreaLayerOpacity: + StoryEditorProfile.outsideCaptureAreaLayerOpacity, + mainEditorWidgets: StoryEditorProfile.buildMainEditorWidgets( + onMention: onMention, + onDone: onDone, + contextualControlBuilder: (editor) => StoryVideoTimelineControls( + editor: editor, + timelineState: timelineState, + onTogglePlay: onTogglePlay, + onSeek: onSeek, + onSeekStart: onSeekStart, + onSeekEnd: onSeekEnd, ), - bodyItems: (editor, rebuildStream) => [ - ReactiveWidget( - stream: rebuildStream, - builder: (_) => Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: StoryEditorHeader( - onBack: editor.closeEditor, - onDone: onDone != null - ? () => onDone(editor) - : editor.doneEditing, - canUndo: editor.canUndo, - canRedo: editor.canRedo, - onUndo: editor.undoAction, - onRedo: editor.redoAction, - ), - ), - ), - ), - ], ), videoEditorConfigs: videoEditorConfigs, ); @@ -194,6 +134,8 @@ class VideoEditorConfigsBuilder { required bool enableZoom, required MainEditorWidgets mainEditorWidgets, required VideoEditorConfigs videoEditorConfigs, + EditorSafeArea safeArea = const EditorSafeArea(), + double outsideCaptureAreaLayerOpacity = 0.5, }) { return ProImageEditorConfigs( designMode: platformDesignMode, @@ -213,18 +155,21 @@ class VideoEditorConfigsBuilder { initialSelected: true, ), mainEditor: MainEditorConfigs( + safeArea: safeArea, enableZoom: enableZoom, enableDoubleTapZoom: false, editorMinScale: 0.1, tools: tools, captureLayersOnDone: true, widgets: mainEditorWidgets, - style: const MainEditorStyle( + style: MainEditorStyle( background: AppColors.greyBlack, bottomBarBackground: AppColors.grey800, + outsideCaptureAreaLayerOpacity: outsideCaptureAreaLayerOpacity, ), ), paintEditor: PaintEditorConfigs( + safeArea: safeArea, style: const PaintEditorStyle( background: AppColors.greyBlack, bottomBarBackground: AppColors.grey800, @@ -254,6 +199,7 @@ class VideoEditorConfigsBuilder { ), textEditor: TextEditorConfigs( + safeArea: safeArea, customTextStyles: [ GoogleFonts.roboto(), GoogleFonts.averiaLibre(), @@ -311,6 +257,7 @@ class VideoEditorConfigsBuilder { ), ), cropRotateEditor: CropRotateEditorConfigs( + safeArea: safeArea, style: CropRotateEditorStyle( cropCornerColor: AppColors.greyWhite, cropCornerThickness: 4, @@ -334,6 +281,7 @@ class VideoEditorConfigsBuilder { ), ), filterEditor: FilterEditorConfigs( + safeArea: safeArea, style: const FilterEditorStyle( filterListSpacing: 7, filterListMargin: EdgeInsets.fromLTRB(8, 0, 8, 8), @@ -367,6 +315,7 @@ class VideoEditorConfigsBuilder { ), ), tuneEditor: TuneEditorConfigs( + safeArea: safeArea, style: const TuneEditorStyle( background: AppColors.greyBlack, bottomBarBackground: AppColors.grey800, @@ -388,6 +337,7 @@ class VideoEditorConfigsBuilder { ), ), blurEditor: BlurEditorConfigs( + safeArea: safeArea, maxBlur: 25, style: const BlurEditorStyle(background: AppColors.greyBlack), widgets: BlurEditorWidgets( @@ -434,6 +384,7 @@ class VideoEditorConfigsBuilder { textEditor: I18nTextEditor(backgroundMode: 'Mode', textAlign: 'Align'), ), clipsEditor: ClipsEditorConfigs( + safeArea: safeArea, style: const ClipsEditorStyle(reversedClipsList: true), widgets: ClipsEditorWidgets( appBar: (editorState, rebuildStream) => null, diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index cf062b14..caf0255a 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -12,6 +12,7 @@ import 'package:pro_video_editor/pro_video_editor.dart'; import 'package:spark/src/core/design_system/templates/recording_page_template.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/models.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.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'; @@ -24,6 +25,7 @@ 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/ui/models/media_selection.dart'; import 'package:spark/src/features/posting/ui/pages/media_picker_page.dart'; +import 'package:spark/src/features/posting/utils/captured_photo_flow.dart'; import 'package:spark/src/features/posting/utils/story_direct_post.dart'; export 'package:spark/src/core/design_system/templates/recording_page_template.dart' @@ -273,56 +275,28 @@ class _RecordingPageState extends ConsumerState { await ref.read(_cameraProvider.notifier).disposeCamera(); if (!mounted) return; - // Open the story image editor - final editedImage = await GetIt.I() - .openStoryImageEditor(context, photoFile); + final outcome = + await CapturedPhotoFlow( + openPostReview: (photo) async { + await context.router.push( + ImageReviewRoute(imageFiles: [photo], storyMode: false), + ); + }, + openStoryEditor: (photo) => GetIt.I() + .openStoryImageEditor(context, photo), + publishStory: _publishEditedStoryPhoto, + ).run( + profile: widget.storyMode + ? CapturedPhotoProfile.story + : CapturedPhotoProfile.post, + photo: 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.image, - embeds: editedImage.embeds, - ); - if (result != null && mounted) { - // 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( - ErrorMessages.getOperationErrorMessage('post', e), - ), - ), - ); - } - } - } else { - // For posts, go to review page - await context.router.push( - ImageReviewRoute( - imageFiles: [editedImage.image], - storyMode: widget.storyMode, - ), - ); - } + if (outcome == CapturedPhotoFlowOutcome.exitedRecorder) { + context.router.maybePop(); + return; } await _resumeCameraAfterPhotoFlow(); @@ -342,6 +316,36 @@ class _RecordingPageState extends ConsumerState { } } + Future _publishEditedStoryPhoto( + StoryImageEditorResult editedPhoto, + ) async { + if (!mounted) return false; + setState(() => _isExiting = true); + + try { + final result = await StoryDirectPost.postPhotoStory( + context, + ref, + editedPhoto.image, + embeds: editedPhoto.embeds, + ); + final posted = result != null; + if (!posted && mounted) setState(() => _isExiting = false); + return posted; + } catch (e, stackTrace) { + _logger.e('Error posting story', error: e, stackTrace: stackTrace); + if (mounted) { + setState(() => _isExiting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ErrorMessages.getOperationErrorMessage('post', e)), + ), + ); + } + return false; + } + } + Future _resumeCameraAfterPhotoFlow() async { if (!mounted) return; diff --git a/lib/src/features/posting/utils/captured_photo_flow.dart b/lib/src/features/posting/utils/captured_photo_flow.dart new file mode 100644 index 00000000..a3691c41 --- /dev/null +++ b/lib/src/features/posting/utils/captured_photo_flow.dart @@ -0,0 +1,46 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; + +enum CapturedPhotoProfile { story, post } + +enum CapturedPhotoFlowOutcome { returnedToRecorder, exitedRecorder } + +typedef PostPhotoReviewLauncher = Future Function(XFile photo); +typedef StoryPhotoEditorLauncher = + Future Function(XFile photo); +typedef StoryPhotoPublisher = + Future Function(StoryImageEditorResult editedPhoto); + +/// Dispatches a captured photo to the product flow selected by its profile. +class CapturedPhotoFlow { + const CapturedPhotoFlow({ + required this.openPostReview, + required this.openStoryEditor, + required this.publishStory, + }); + + final PostPhotoReviewLauncher openPostReview; + final StoryPhotoEditorLauncher openStoryEditor; + final StoryPhotoPublisher publishStory; + + Future run({ + required CapturedPhotoProfile profile, + required XFile photo, + }) async { + switch (profile) { + case CapturedPhotoProfile.post: + await openPostReview(photo); + return CapturedPhotoFlowOutcome.returnedToRecorder; + case CapturedPhotoProfile.story: + final editedPhoto = await openStoryEditor(photo); + if (editedPhoto == null) { + return CapturedPhotoFlowOutcome.returnedToRecorder; + } + + final posted = await publishStory(editedPhoto); + return posted + ? CapturedPhotoFlowOutcome.exitedRecorder + : CapturedPhotoFlowOutcome.returnedToRecorder; + } + } +} diff --git a/test/src/core/pro_image_editor/story_sticker_editor_test.dart b/test/src/core/pro_image_editor/story_sticker_editor_test.dart new file mode 100644 index 00000000..9ee134f2 --- /dev/null +++ b/test/src/core/pro_image_editor/story_sticker_editor_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_image_editor/story_sticker_editor.dart'; + +void main() { + testWidgets('Story sticker sheet preserves the configured editor lifecycle', ( + tester, + ) async { + final editorKey = GlobalKey(); + var constraintsBuildCount = 0; + var stickerInitCount = 0; + var mainEditorUpdateCount = 0; + + final callbacks = ProImageEditorCallbacks( + mainEditorCallbacks: MainEditorCallbacks( + onUpdateUI: () => mainEditorUpdateCount++, + ), + stickerEditorCallbacks: StickerEditorCallbacks( + onInit: () => stickerInitCount++, + ), + ); + final configs = ProImageEditorConfigs( + stickerEditor: StickerEditorConfigs( + style: StickerEditorStyle( + showDragHandle: false, + editorBoxConstraintsBuilder: (context, receivedConfigs) { + expect(receivedConfigs.stickerEditor.builder, isNotNull); + constraintsBuildCount++; + return null; + }, + ), + builder: (setLayer, scrollController) => Material( + child: Center( + child: TextButton( + key: const ValueKey('select-test-sticker'), + onPressed: () => setLayer( + WidgetLayer( + widget: const SizedBox( + key: ValueKey('selected-test-sticker'), + ), + ), + ), + child: const Text('Select sticker'), + ), + ), + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.blank( + const Size(390, 844), + key: editorKey, + callbacks: callbacks, + configs: configs, + ), + ), + ); + await tester.pumpAndSettle(); + + final editor = editorKey.currentState!; + editor.addLayer( + WidgetLayer(widget: const SizedBox()), + blockCaptureScreenshot: true, + ); + await tester.pump(); + expect(editor.selectedLayers, hasLength(1)); + + final updatesBeforeOpening = mainEditorUpdateCount; + final openFuture = openStoryStickerEditor(editor); + await tester.pumpAndSettle(); + + expect(constraintsBuildCount, 1); + expect(stickerInitCount, 1); + expect(editor.selectedLayers, isEmpty); + expect(find.byKey(const ValueKey('select-test-sticker')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('select-test-sticker'))); + await tester.pumpAndSettle(); + await openFuture; + + expect(editor.activeLayers, hasLength(2)); + expect(mainEditorUpdateCount, updatesBeforeOpening + 1); + }); +} diff --git a/test/src/core/pro_image_editor/ui/widgets/story_editor_chrome_test.dart b/test/src/core/pro_image_editor/ui/widgets/story_editor_chrome_test.dart new file mode 100644 index 00000000..3446df19 --- /dev/null +++ b/test/src/core/pro_image_editor/ui/widgets/story_editor_chrome_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/l10n/app_localizations.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_top_section.dart'; + +void main() { + testWidgets( + 'puts creation tools in the top overlay and keeps them tappable', + (tester) async { + var closeCount = 0; + var textCount = 0; + var stickerCount = 0; + var mentionCount = 0; + + await tester.pumpWidget( + _testApp( + StoryEditorTopSection( + onClose: () => closeCount++, + onMention: () async => mentionCount++, + onPaint: () {}, + onText: () => textCount++, + onFilter: () {}, + onBlur: () {}, + onEmoji: () {}, + onStickers: () => stickerCount++, + ), + ), + ); + + expect(find.byKey(const ValueKey('story-editor-close')), findsOneWidget); + expect(find.byKey(const ValueKey('story-editor-undo')), findsNothing); + expect(find.byKey(const ValueKey('story-editor-redo')), findsNothing); + expect( + find.byKey(const ValueKey('story-editor-tool-text')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('story-editor-tool-stickers')), + findsOneWidget, + ); + expect(find.text('Text'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('story-editor-close'))); + await tester.tap(find.byKey(const ValueKey('story-editor-tool-text'))); + await tester.tap( + find.byKey(const ValueKey('story-editor-tool-stickers')), + ); + await tester.tap(find.byKey(const ValueKey('story-editor-tool-mention'))); + + expect(closeCount, 1); + expect(textCount, 1); + expect(stickerCount, 1); + expect(mentionCount, 1); + }, + ); + + testWidgets( + 'keeps the share action at the bottom below contextual controls', + (tester) async { + var shareCount = 0; + + await tester.pumpWidget( + _testApp( + Align( + alignment: Alignment.bottomCenter, + child: StoryEditorBottomSection( + onShare: () => shareCount++, + contextualControl: const SizedBox( + key: ValueKey('story-contextual-control'), + height: 48, + ), + ), + ), + ), + ); + + final contextualTop = tester.getTopLeft( + find.byKey(const ValueKey('story-contextual-control')), + ); + final shareTop = tester.getTopLeft( + find.byKey(const ValueKey('story-editor-share-button')), + ); + + expect(contextualTop.dy, lessThan(shareTop.dy)); + expect(find.text('Share'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('story-editor-share-button'))); + expect(shareCount, 1); + }, + ); + + testWidgets('stacks actions vertically at the top right', (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _testApp( + StoryEditorTopSection( + onClose: () {}, + onMention: () async {}, + onPaint: () {}, + onText: () {}, + onFilter: () {}, + onBlur: () {}, + onEmoji: () {}, + onStickers: () {}, + ), + ), + ); + + final textCenter = tester.getCenter( + find.byKey(const ValueKey('story-editor-tool-text')), + ); + final closeTop = tester.getTopLeft( + find.byKey(const ValueKey('story-editor-close')), + ); + final textTop = tester.getTopLeft( + find.byKey(const ValueKey('story-editor-tool-text')), + ); + final stickerCenter = tester.getCenter( + find.byKey(const ValueKey('story-editor-tool-stickers')), + ); + + expect(textCenter.dx, greaterThan(300)); + expect(textTop.dy, closeTop.dy); + expect(stickerCenter.dx, textCenter.dx); + expect(stickerCenter.dy, greaterThan(textCenter.dy)); + }); + + testWidgets('vertical actions remain overflow-free on a compact phone', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _testApp( + StoryEditorTopSection( + onClose: () {}, + onMention: () async {}, + onPaint: () {}, + onText: () {}, + onFilter: () {}, + onBlur: () {}, + onEmoji: () {}, + onStickers: () {}, + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Scrollable), findsOneWidget); + }); +} + +Widget _testApp(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData.dark(), + home: Scaffold(backgroundColor: Colors.black, body: child), + ); +} diff --git a/test/src/features/posting/utils/captured_photo_flow_test.dart b/test/src/features/posting/utils/captured_photo_flow_test.dart new file mode 100644 index 00000000..e6985176 --- /dev/null +++ b/test/src/features/posting/utils/captured_photo_flow_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; +import 'package:spark/src/features/posting/utils/captured_photo_flow.dart'; + +void main() { + group('CapturedPhotoFlow', () { + final photo = XFile('/tmp/captured.jpg'); + final editedPhoto = StoryImageEditorResult(image: XFile('/tmp/edited.jpg')); + + test('routes post photos directly to review', () async { + XFile? reviewedPhoto; + var storyEditorOpenCount = 0; + var publishCount = 0; + final flow = CapturedPhotoFlow( + openPostReview: (photo) async => reviewedPhoto = photo, + openStoryEditor: (photo) async { + storyEditorOpenCount++; + return editedPhoto; + }, + publishStory: (editedPhoto) async { + publishCount++; + return true; + }, + ); + + final outcome = await flow.run( + profile: CapturedPhotoProfile.post, + photo: photo, + ); + + expect(reviewedPhoto?.path, photo.path); + expect(storyEditorOpenCount, 0); + expect(publishCount, 0); + expect(outcome, CapturedPhotoFlowOutcome.returnedToRecorder); + }); + + test('edits and publishes Story photos', () async { + var reviewCount = 0; + StoryImageEditorResult? publishedPhoto; + final flow = CapturedPhotoFlow( + openPostReview: (photo) async => reviewCount++, + openStoryEditor: (photo) async => editedPhoto, + publishStory: (photo) async { + publishedPhoto = photo; + return true; + }, + ); + + final outcome = await flow.run( + profile: CapturedPhotoProfile.story, + photo: photo, + ); + + expect(reviewCount, 0); + expect(publishedPhoto, same(editedPhoto)); + expect(outcome, CapturedPhotoFlowOutcome.exitedRecorder); + }); + + test('returns to the recorder when Story editing is canceled', () async { + var publishCount = 0; + final flow = CapturedPhotoFlow( + openPostReview: (photo) async {}, + openStoryEditor: (photo) async => null, + publishStory: (photo) async { + publishCount++; + return true; + }, + ); + + final outcome = await flow.run( + profile: CapturedPhotoProfile.story, + photo: photo, + ); + + expect(publishCount, 0); + expect(outcome, CapturedPhotoFlowOutcome.returnedToRecorder); + }); + + test( + 'returns to the recorder when Story publishing does not complete', + () async { + final flow = CapturedPhotoFlow( + openPostReview: (photo) async {}, + openStoryEditor: (photo) async => editedPhoto, + publishStory: (photo) async => false, + ); + + final outcome = await flow.run( + profile: CapturedPhotoProfile.story, + photo: photo, + ); + + expect(outcome, CapturedPhotoFlowOutcome.returnedToRecorder); + }, + ); + }); +}