From 934db08e5b3e9506ef988550eec803d729888b31 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sun, 5 Apr 2026 20:20:06 -0400 Subject: [PATCH] feat: interactive story mentions --- lib/src/core/media/create_media_actions.dart | 13 +- .../data/adapters/bsky/feed_adapter.dart | 3 +- .../atproto/data/models/feed_models.dart | 2 + .../network/atproto/data/models/models.dart | 1 + .../atproto/data/models/record_models.dart | 2 + .../data/models/story_embed_models.dart | 175 ++++++++++++ .../data/repositories/story_repository.dart | 2 + .../repositories/story_repository_impl.dart | 5 + .../models/story_image_editor_result.dart | 9 + .../story_image_editor_configs.dart | 11 +- .../story_mention_editing.dart | 45 ++++ .../pro_image_editor/story_mention_layer.dart | 143 ++++++++++ .../ui/story_image_editor_page.dart | 54 +++- .../widgets/story_editor_bottom_section.dart | 8 +- .../ui/widgets/story_editor_toolbar.dart | 76 +++--- .../widgets/story_mention_picker_sheet.dart | 188 +++++++++++++ .../models/video_editor_result.dart | 10 +- .../pro_video_editor_repository.dart | 8 +- .../pro_video_editor_repository_impl.dart | 8 +- .../ui/video_editor_grounded_page.dart | 16 +- .../common/video_editor_configs_builder.dart | 13 +- .../core/ui/widgets/story_mention_chip.dart | 192 ++++++++++++++ .../mention_text_editing_controller.dart | 16 +- .../posting/providers/camera_provider.dart | 2 +- .../posting/providers/post_story.dart | 4 + .../providers/video_upload_provider.dart | 3 + .../posting/ui/pages/recording_page.dart | 57 +++- .../posting/ui/pages/story_post_page.dart | 21 +- .../posting/utils/story_direct_post.dart | 8 +- .../stories/ui/pages/author_stories_page.dart | 24 +- .../features/stories/ui/pages/story_page.dart | 250 ++++++++++++++---- .../data/models/story_embed_models_test.dart | 149 +++++++++++ .../story_mention_layer_test.dart | 132 +++++++++ 33 files changed, 1492 insertions(+), 158 deletions(-) create mode 100644 lib/src/core/network/atproto/data/models/story_embed_models.dart create mode 100644 lib/src/core/pro_image_editor/models/story_image_editor_result.dart create mode 100644 lib/src/core/pro_image_editor/story_mention_editing.dart create mode 100644 lib/src/core/pro_image_editor/story_mention_layer.dart create mode 100644 lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart create mode 100644 lib/src/core/ui/widgets/story_mention_chip.dart create mode 100644 test/core/network/atproto/data/models/story_embed_models_test.dart create mode 100644 test/core/pro_image_editor/story_mention_layer_test.dart diff --git a/lib/src/core/media/create_media_actions.dart b/lib/src/core/media/create_media_actions.dart index 0e596d55..1c6e99a7 100644 --- a/lib/src/core/media/create_media_actions.dart +++ b/lib/src/core/media/create_media_actions.dart @@ -67,7 +67,11 @@ class CreateMediaActions { if (storyMode) { // For stories, post directly await context.router.push( - StoryPostRoute(videoPath: result.video.path), + StoryPostRoute( + videoPath: result.video.path, + soundRef: result.soundRef, + embeds: result.embeds, + ), ); } else { // For posts, go to review @@ -103,7 +107,12 @@ class CreateMediaActions { .openStoryImageEditor(context, pickedImage); if (editedImage != null && context.mounted) { // Post directly - await context.router.push(StoryPostRoute(imageFile: editedImage)); + await context.router.push( + StoryPostRoute( + imageFile: editedImage.image, + embeds: editedImage.embeds, + ), + ); } } } else { diff --git a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart index 82625ba3..38e23b07 100644 --- a/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart +++ b/lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart @@ -842,12 +842,13 @@ extension BskyRecordAdapter on Record { labels: labels, media: media, ), - story: (media, createdAt, sound, labels, tags) => StoryRecord( + story: (media, createdAt, sound, labels, tags, embeds) => StoryRecord( media: media, createdAt: createdAt, sound: sound, labels: labels, tags: tags, + embeds: embeds, ), profile: ( diff --git a/lib/src/core/network/atproto/data/models/feed_models.dart b/lib/src/core/network/atproto/data/models/feed_models.dart index 0dacb643..3777475d 100644 --- a/lib/src/core/network/atproto/data/models/feed_models.dart +++ b/lib/src/core/network/atproto/data/models/feed_models.dart @@ -1408,6 +1408,8 @@ abstract class StoryView with _$StoryView { required StoryRecord record, required DateTime indexedAt, MediaView? media, + @JsonKey(fromJson: storyEmbedViewsFromJson, toJson: storyEmbedViewsToJson) + List? embeds, // viewer eventually i think }) = _StoryView; const StoryView._(); diff --git a/lib/src/core/network/atproto/data/models/models.dart b/lib/src/core/network/atproto/data/models/models.dart index 42be0d7c..fcae5f7c 100644 --- a/lib/src/core/network/atproto/data/models/models.dart +++ b/lib/src/core/network/atproto/data/models/models.dart @@ -6,3 +6,4 @@ export 'package:spark/src/core/network/atproto/data/models/notification_models.d export 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; export 'package:spark/src/core/network/atproto/data/models/record_models.dart'; export 'package:spark/src/core/network/atproto/data/models/sound_models.dart'; +export 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; diff --git a/lib/src/core/network/atproto/data/models/record_models.dart b/lib/src/core/network/atproto/data/models/record_models.dart index d911428e..5a8421c4 100644 --- a/lib/src/core/network/atproto/data/models/record_models.dart +++ b/lib/src/core/network/atproto/data/models/record_models.dart @@ -44,6 +44,8 @@ abstract class Record with _$Record { RepoStrongRef? sound, List? labels, List? tags, + @JsonKey(fromJson: storyEmbedsFromJson, toJson: storyEmbedsToJson) + List? embeds, }) = StoryRecord; @JsonSerializable(explicitToJson: true) diff --git a/lib/src/core/network/atproto/data/models/story_embed_models.dart b/lib/src/core/network/atproto/data/models/story_embed_models.dart new file mode 100644 index 00000000..36c7a54b --- /dev/null +++ b/lib/src/core/network/atproto/data/models/story_embed_models.dart @@ -0,0 +1,175 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; + +part 'story_embed_models.freezed.dart'; +part 'story_embed_models.g.dart'; + +const _storyMentionEmbedType = 'so.sprk.embed.mention'; +const _storyMentionEmbedViewType = 'so.sprk.embed.mention#view'; + +List storyEmbedsFromJson(dynamic json) { + if (json is! List) { + return const []; + } + + final embeds = []; + for (final item in json) { + if (item is! Map) { + continue; + } + + try { + embeds.add(_storyEmbedFromJson(item)); + } catch (_) { + continue; + } + } + + return embeds; +} + +List>? storyEmbedsToJson(List? embeds) { + if (embeds == null || embeds.isEmpty) { + return null; + } + + return embeds.map(_storyEmbedToJson).toList(); +} + +List storyEmbedViewsFromJson(dynamic json) { + if (json is! List) { + return const []; + } + + final embeds = []; + for (final item in json) { + if (item is! Map) { + continue; + } + + try { + embeds.add(_storyEmbedViewFromJson(item)); + } catch (_) { + continue; + } + } + + return embeds; +} + +List>? storyEmbedViewsToJson( + List? embeds, +) { + if (embeds == null || embeds.isEmpty) { + return null; + } + + return embeds.map(_storyEmbedViewToJson).toList(); +} + +StoryEmbed _storyEmbedFromJson(Map json) { + final type = json[r'$type'] as String?; + if (type == null || type == _storyMentionEmbedType) { + return StoryEmbed.fromJson(json); + } + + throw FormatException('Unsupported story embed type: $type'); +} + +Map _storyEmbedToJson(StoryEmbed embed) { + return embed.map( + mention: (value) => { + r'$type': _storyMentionEmbedType, + ...value.toJson(), + }, + ); +} + +StoryEmbedView _storyEmbedViewFromJson(Map json) { + final type = json[r'$type'] as String?; + if (type == null || type == _storyMentionEmbedViewType) { + return StoryEmbedView.fromJson(json); + } + + throw FormatException('Unsupported story embed view type: $type'); +} + +Map _storyEmbedViewToJson(StoryEmbedView embed) { + return embed.map( + mention: (value) => { + r'$type': _storyMentionEmbedViewType, + ...value.toJson(), + }, + ); +} + +@freezed +abstract class StoryEmbedFrame with _$StoryEmbedFrame { + @JsonSerializable(explicitToJson: true) + const factory StoryEmbedFrame({ + required int x, + required int y, + required int w, + required int h, + }) = _StoryEmbedFrame; + const StoryEmbedFrame._(); + + factory StoryEmbedFrame.fromJson(Map json) => + _$StoryEmbedFrameFromJson(json); +} + +@freezed +abstract class StoryEmbedMediaRef with _$StoryEmbedMediaRef { + @JsonSerializable(explicitToJson: true) + const factory StoryEmbedMediaRef({required int index}) = _StoryEmbedMediaRef; + const StoryEmbedMediaRef._(); + + factory StoryEmbedMediaRef.fromJson(Map json) => + _$StoryEmbedMediaRefFromJson(json); +} + +@freezed +abstract class StoryEmbedPlacement with _$StoryEmbedPlacement { + @JsonSerializable(explicitToJson: true) + const factory StoryEmbedPlacement({ + required StoryEmbedFrame frame, + StoryEmbedMediaRef? mediaRef, + int? zIndex, + int? rotation, + }) = _StoryEmbedPlacement; + const StoryEmbedPlacement._(); + + factory StoryEmbedPlacement.fromJson(Map json) => + _$StoryEmbedPlacementFromJson(json); +} + +@Freezed(unionKey: r'$type') +sealed class StoryEmbed with _$StoryEmbed { + const StoryEmbed._(); + + @FreezedUnionValue(_storyMentionEmbedType) + @JsonSerializable(explicitToJson: true) + const factory StoryEmbed.mention({ + required StoryEmbedPlacement placement, + required String did, + }) = StoryMentionEmbed; + + factory StoryEmbed.fromJson(Map json) => + _$StoryEmbedFromJson(json); +} + +@Freezed(unionKey: r'$type') +sealed class StoryEmbedView with _$StoryEmbedView { + const StoryEmbedView._(); + + @FreezedUnionValue(_storyMentionEmbedViewType) + @JsonSerializable(explicitToJson: true) + const factory StoryEmbedView.mention({ + required StoryEmbedPlacement placement, + required String did, + ProfileViewBasic? actor, + }) = StoryMentionEmbedView; + + factory StoryEmbedView.fromJson(Map json) => + _$StoryEmbedViewFromJson(json); +} diff --git a/lib/src/core/network/atproto/data/repositories/story_repository.dart b/lib/src/core/network/atproto/data/repositories/story_repository.dart index 8acfc150..01632083 100644 --- a/lib/src/core/network/atproto/data/repositories/story_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/story_repository.dart @@ -14,6 +14,8 @@ abstract class StoryRepository { Media media, { List? selfLabels, List? tags, + RepoStrongRef? soundRef, + List? embeds, }); /// Get stories timeline diff --git a/lib/src/core/network/atproto/data/repositories/story_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/story_repository_impl.dart index db43737a..3dabf2f9 100644 --- a/lib/src/core/network/atproto/data/repositories/story_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/story_repository_impl.dart @@ -181,17 +181,22 @@ class StoryRepositoryImpl implements StoryRepository { Media media, { List? selfLabels, List? tags, + RepoStrongRef? soundRef, + List? embeds, }) async { final normalizedLabels = selfLabels == null || selfLabels.isEmpty ? null : selfLabels; final normalizedTags = tags == null || tags.isEmpty ? null : tags; + final normalizedEmbeds = embeds == null || embeds.isEmpty ? null : embeds; final record = StoryRecord( createdAt: DateTime.now().toUtc(), media: media, tags: normalizedTags, labels: normalizedLabels, + sound: soundRef, + embeds: normalizedEmbeds, ); return _client.repo.createRecord( diff --git a/lib/src/core/pro_image_editor/models/story_image_editor_result.dart b/lib/src/core/pro_image_editor/models/story_image_editor_result.dart new file mode 100644 index 00000000..6d96ca91 --- /dev/null +++ b/lib/src/core/pro_image_editor/models/story_image_editor_result.dart @@ -0,0 +1,9 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; + +class StoryImageEditorResult { + const StoryImageEditorResult({required this.image, this.embeds = const []}); + + final XFile image; + final List embeds; +} 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 251c079d..40482eff 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 @@ -39,6 +39,8 @@ class StoryImageEditorConfigs { static ProImageEditorConfigs build({ required bool useMaterialDesign, required Widget Function() imagePreviewBuilder, + Future Function()? onMention, + void Function(ProImageEditorState editor)? onDone, }) { return ProImageEditorConfigs( designMode: platformDesignMode, @@ -79,9 +81,8 @@ class StoryImageEditorConfigs { appBar: (editor, rebuildStream) => null, bottomBar: (editor, rebuildStream, key) => ReactiveWidget( key: key, - builder: (context) { - return StoryEditorBottomSection(editor: editor); - }, + builder: (_) => + StoryEditorBottomSection(editor: editor, onMention: onMention), stream: rebuildStream, ), wrapBody: (editor, rebuildStream, content) { @@ -106,7 +107,9 @@ class StoryImageEditorConfigs { bottom: false, child: StoryEditorHeader( onBack: editor.closeEditor, - onDone: editor.doneEditing, + onDone: onDone != null + ? () => onDone(editor) + : editor.doneEditing, canUndo: editor.canUndo, canRedo: editor.canRedo, onUndo: editor.undoAction, diff --git a/lib/src/core/pro_image_editor/story_mention_editing.dart b/lib/src/core/pro_image_editor/story_mention_editing.dart new file mode 100644 index 00000000..e4757ad3 --- /dev/null +++ b/lib/src/core/pro_image_editor/story_mention_editing.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; +import 'package:spark/src/core/pro_image_editor/story_mention_layer.dart'; +import 'package:spark/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart'; + +mixin StoryMentionEditing on State { + GlobalKey get storyEditorKey; + Size get storyCanvasFallbackSize; + + List _pendingStoryEmbeds = const []; + + List get pendingStoryEmbeds => _pendingStoryEmbeds; + + void clearPendingStoryEmbeds() { + _pendingStoryEmbeds = const []; + } + + void finishStoryEditing(ProImageEditorState editor) { + _pendingStoryEmbeds = extractStoryMentionEmbeds( + editor.activeLayers, + canvasSize: _activeStoryCanvasSize, + ); + editor.doneEditing(); + } + + Future addStoryMention() async { + final actor = await showStoryMentionPickerSheet(context); + final editor = storyEditorKey.currentState; + if (actor == null || !mounted || editor == null) { + return; + } + + editor.addLayer(createStoryMentionLayer(actor)); + } + + Size get _activeStoryCanvasSize { + final size = storyEditorKey.currentState?.sizesManager.bodySize; + if (size != null && size.width > 0 && size.height > 0) { + return size; + } + + return storyCanvasFallbackSize; + } +} diff --git a/lib/src/core/pro_image_editor/story_mention_layer.dart b/lib/src/core/pro_image_editor/story_mention_layer.dart new file mode 100644 index 00000000..a9527e13 --- /dev/null +++ b/lib/src/core/pro_image_editor/story_mention_layer.dart @@ -0,0 +1,143 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; +import 'package:spark/src/core/ui/widgets/story_mention_chip.dart'; + +const _kStoryMentionLayerTypeKey = 'storyMentionType'; +const _kStoryMentionLayerTypeValue = 'mention'; +const _kStoryMentionDidKey = 'did'; +const _kStoryMentionHandleKey = 'handle'; +const _kStoryMentionDisplayNameKey = 'displayName'; + +WidgetLayer createStoryMentionLayer(ProfileViewBasic actor) { + final primaryText = '@${actor.handle}'; + final initialSize = measureStoryMentionChipSize( + primaryText: primaryText, + height: kStoryMentionInitialHeight, + ); + + return WidgetLayer( + width: initialSize.width, + widget: StoryMentionChip( + primaryText: primaryText, + fixedHeight: kStoryMentionInitialHeight, + ), + meta: { + _kStoryMentionLayerTypeKey: _kStoryMentionLayerTypeValue, + _kStoryMentionDidKey: actor.did, + _kStoryMentionHandleKey: actor.handle, + if (actor.displayName != null && actor.displayName!.isNotEmpty) + _kStoryMentionDisplayNameKey: actor.displayName, + }, + ); +} + +bool isStoryMentionLayer(Layer layer) { + final meta = layer.meta; + return layer is WidgetLayer && + meta != null && + meta[_kStoryMentionLayerTypeKey] == _kStoryMentionLayerTypeValue; +} + +List extractStoryMentionEmbeds( + Iterable layers, { + required Size canvasSize, +}) { + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return const []; + } + + final halfCanvasWidth = canvasSize.width / 2; + final halfCanvasHeight = canvasSize.height / 2; + final canvasRect = Offset.zero & canvasSize; + final embeds = []; + + for (final indexedLayer in layers.indexed) { + final index = indexedLayer.$1; + final layer = indexedLayer.$2; + if (!isStoryMentionLayer(layer)) { + continue; + } + + final widgetLayer = layer as WidgetLayer; + final did = widgetLayer.meta?[_kStoryMentionDidKey] as String?; + if (did == null || did.isEmpty) { + continue; + } + + final frameSize = _resolvedLayerSize(widgetLayer); + if (frameSize.isEmpty) { + continue; + } + + final left = layer.offset.dx + halfCanvasWidth - (frameSize.width / 2); + final top = layer.offset.dy + halfCanvasHeight - (frameSize.height / 2); + final visibleRect = Rect.fromLTWH( + left, + top, + frameSize.width, + frameSize.height, + ).intersect(canvasRect); + if (visibleRect.width <= 0 || visibleRect.height <= 0) { + continue; + } + + embeds.add( + StoryEmbed.mention( + placement: StoryEmbedPlacement( + frame: StoryEmbedFrame( + x: _normalize(visibleRect.left, canvasSize.width, clampMin: 0), + y: _normalize(visibleRect.top, canvasSize.height, clampMin: 0), + w: _normalize(visibleRect.width, canvasSize.width, clampMin: 1), + h: _normalize(visibleRect.height, canvasSize.height, clampMin: 1), + ), + zIndex: index, + rotation: _rotationDegrees(layer.rotation), + ), + did: did, + ), + ); + } + + return embeds; +} + +Size _resolvedLayerSize(WidgetLayer layer) { + final renderObject = layer.keyInternalSize.currentContext?.findRenderObject(); + final renderBox = renderObject is RenderBox ? renderObject : null; + if (renderBox != null && renderBox.hasSize && !renderBox.size.isEmpty) { + return renderBox.size; + } + + final handle = layer.meta?[_kStoryMentionHandleKey] as String?; + final baseSize = measureStoryMentionChipSize( + primaryText: '@${handle ?? 'mention'}', + height: kStoryMentionInitialHeight, + ); + final width = (layer.width ?? baseSize.width) * layer.scale; + if (!width.isFinite || width <= 0) { + return Size.zero; + } + + final aspectRatio = baseSize.width <= 0 + ? 1 + : baseSize.height / baseSize.width; + return Size(width, width * aspectRatio); +} + +int _normalize(double value, double max, {required int clampMin}) { + if (!value.isFinite || max <= 0 || !max.isFinite) { + return clampMin; + } + + return ((value / max) * 10000).round().clamp(clampMin, 10000); +} + +int _rotationDegrees(double radians) { + final degrees = radians * 180 / math.pi; + final normalized = degrees % 360; + return normalized.round().clamp(0, 359); +} 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 8908e7a7..6a5cbb24 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 @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; +import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; import 'package:spark/src/core/pro_image_editor/story_image_editor_configs.dart'; import 'package:spark/src/core/pro_image_editor/utils/story_image_cropper.dart'; @@ -30,8 +32,11 @@ class StoryImageEditorPage extends StatefulWidget { /// Opens the story image editor and returns the edited image. /// /// Returns `null` if the user cancels without completing the edit. - static Future open(BuildContext context, File imageFile) async { - return Navigator.of(context).push( + static Future open( + BuildContext context, + File imageFile, + ) async { + return Navigator.of(context).push( MaterialPageRoute( builder: (_) => StoryImageEditorPage(imageFile: imageFile), ), @@ -42,7 +47,8 @@ class StoryImageEditorPage extends StatefulWidget { State createState() => _StoryImageEditorPageState(); } -class _StoryImageEditorPageState extends State { +class _StoryImageEditorPageState extends State + with StoryMentionEditing { final _editorKey = GlobalKey(); final bool _useMaterialDesign = platformDesignMode == ImageEditorDesignMode.material; @@ -52,6 +58,12 @@ class _StoryImageEditorPageState extends State { bool _isLoading = true; String? _error; + @override + GlobalKey get storyEditorKey => _editorKey; + + @override + Size get storyCanvasFallbackSize => StoryImageEditorConfigs.storySize; + @override void initState() { super.initState(); @@ -87,6 +99,8 @@ class _StoryImageEditorPageState extends State { useMaterialDesign: _useMaterialDesign, imagePreviewBuilder: () => Image.file(_croppedImageFile!, fit: BoxFit.cover), + onMention: addStoryMention, + onDone: finishStoryEditing, ); if (mounted) { @@ -112,9 +126,12 @@ class _StoryImageEditorPageState extends State { await file.writeAsBytes(bytes, flush: true); if (mounted) { - Navigator.of( - context, - ).pop(XFile(file.path, mimeType: 'image/png', name: filename)); + Navigator.of(context).pop( + StoryImageEditorResult( + image: XFile(file.path, mimeType: 'image/png', name: filename), + embeds: pendingStoryEmbeds, + ), + ); } } @@ -205,12 +222,12 @@ class StoryBlankCanvasEditorPage extends StatefulWidget { final Color backgroundColor; /// Opens the blank canvas story editor and returns the edited image. - static Future open( + static Future open( BuildContext context, { File? backgroundImage, Color backgroundColor = Colors.black, }) async { - return Navigator.of(context).push( + return Navigator.of(context).push( MaterialPageRoute( builder: (_) => StoryBlankCanvasEditorPage( backgroundImage: backgroundImage, @@ -225,8 +242,8 @@ class StoryBlankCanvasEditorPage extends StatefulWidget { _StoryBlankCanvasEditorPageState(); } -class _StoryBlankCanvasEditorPageState - extends State { +class _StoryBlankCanvasEditorPageState extends State + with StoryMentionEditing { final _editorKey = GlobalKey(); final bool _useMaterialDesign = platformDesignMode == ImageEditorDesignMode.material; @@ -238,6 +255,12 @@ class _StoryBlankCanvasEditorPageState Size? _imageSize; ImportStateHistory? _initialStateHistory; + @override + GlobalKey get storyEditorKey => _editorKey; + + @override + Size get storyCanvasFallbackSize => StoryImageEditorConfigs.storySize; + @override void initState() { super.initState(); @@ -269,6 +292,8 @@ class _StoryBlankCanvasEditorPageState imagePreviewBuilder: () => widget.backgroundImage != null ? Image.file(widget.backgroundImage!, fit: BoxFit.cover) : const SizedBox.shrink(), + onMention: addStoryMention, + onDone: finishStoryEditing, ); if (!mounted) return; @@ -288,9 +313,12 @@ class _StoryBlankCanvasEditorPageState await file.writeAsBytes(bytes, flush: true); if (mounted) { - Navigator.of( - context, - ).pop(XFile(file.path, mimeType: 'image/png', name: filename)); + Navigator.of(context).pop( + StoryImageEditorResult( + image: XFile(file.path, mimeType: 'image/png', name: filename), + embeds: pendingStoryEmbeds, + ), + ); } } 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 dc474d0a..2d68c05b 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 @@ -7,9 +7,14 @@ import 'package:spark/src/core/pro_image_editor/ui/widgets/story_editor_toolbar. /// /// Contains the toolbar with editing tools. class StoryEditorBottomSection extends StatelessWidget { - const StoryEditorBottomSection({required this.editor, super.key}); + const StoryEditorBottomSection({ + required this.editor, + this.onMention, + super.key, + }); final ProImageEditorState editor; + final Future Function()? onMention; @override Widget build(BuildContext context) { @@ -18,6 +23,7 @@ class StoryEditorBottomSection extends StatelessWidget { child: SafeArea( top: false, child: StoryEditorToolbar( + onMention: onMention, onPaint: editor.openPaintEditor, onText: editor.openTextEditor, onFilter: editor.openFilterEditor, 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 bd393029..1366bd18 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 @@ -6,6 +6,7 @@ import 'package:spark/src/core/design_system/tokens/colors.dart'; /// Displays horizontal list of editing tools optimized for stories. class StoryEditorToolbar extends StatelessWidget { const StoryEditorToolbar({ + this.onMention, required this.onPaint, required this.onText, required this.onFilter, @@ -15,6 +16,7 @@ class StoryEditorToolbar extends StatelessWidget { super.key, }); + final Future Function()? onMention; final VoidCallback onPaint; final VoidCallback onText; final VoidCallback onFilter; @@ -24,43 +26,45 @@ class StoryEditorToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + final items = [ + if (onMention != null) + _ToolbarItem( + icon: Icons.alternate_email_rounded, + label: 'Mention', + onTap: () => onMention!.call(), + ), + _ToolbarItem(icon: Icons.brush_rounded, label: 'Draw', onTap: onPaint), + _ToolbarItem( + icon: Icons.text_fields_rounded, + label: 'Text', + onTap: onText, + ), + _ToolbarItem( + icon: Icons.auto_awesome_rounded, + label: 'Filter', + onTap: onFilter, + ), + _ToolbarItem(icon: Icons.blur_on_rounded, label: 'Blur', onTap: onBlur), + _ToolbarItem( + icon: Icons.emoji_emotions_rounded, + label: 'Emoji', + onTap: onEmoji, + ), + _ToolbarItem( + icon: Icons.sticky_note_2_rounded, + label: 'Stickers', + onTap: onStickers, + ), + ]; + + return SizedBox( height: 80, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ToolbarItem( - icon: Icons.brush_rounded, - label: 'Draw', - onTap: onPaint, - ), - _ToolbarItem( - icon: Icons.text_fields_rounded, - label: 'Text', - onTap: onText, - ), - _ToolbarItem( - icon: Icons.auto_awesome_rounded, - label: 'Filter', - onTap: onFilter, - ), - _ToolbarItem( - icon: Icons.blur_on_rounded, - label: 'Blur', - onTap: onBlur, - ), - _ToolbarItem( - icon: Icons.emoji_emotions_rounded, - label: 'Emoji', - onTap: onEmoji, - ), - _ToolbarItem( - icon: Icons.sticky_note_2_rounded, - label: 'Stickers', - onTap: onStickers, - ), - ], + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, index) => items[index], ), ); } diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart new file mode 100644 index 00000000..9ba0408b --- /dev/null +++ b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; +import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; + +Future showStoryMentionPickerSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: const Color(0xFF0F172A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (_) => const _StoryMentionPickerSheet(), + ); +} + +class _StoryMentionPickerSheet extends ConsumerStatefulWidget { + const _StoryMentionPickerSheet(); + + @override + ConsumerState<_StoryMentionPickerSheet> createState() => + _StoryMentionPickerSheetState(); +} + +class _StoryMentionPickerSheetState + extends ConsumerState<_StoryMentionPickerSheet> { + final TextEditingController _textController = TextEditingController(); + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final typeaheadState = ref.watch(actorTypeaheadProvider); + + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: const Color(0x334B5563), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 18), + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Mention someone', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 8), + const Align( + alignment: Alignment.centerLeft, + child: Text( + 'Search for an account to place on your story.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _textController, + autofocus: true, + style: const TextStyle(color: Colors.white), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: 'Search by handle or display name', + hintStyle: const TextStyle(color: Color(0xFF64748B)), + prefixIcon: const Icon(Icons.search, color: Color(0xFF94A3B8)), + filled: true, + fillColor: const Color(0xFF111827), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide(color: Color(0x334B5563)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: const BorderSide(color: Color(0x99FFFFFF)), + ), + ), + onChanged: (value) { + ref.read(actorTypeaheadProvider.notifier).updateQuery(value); + }, + ), + const SizedBox(height: 16), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: _ResultsList(typeaheadState: typeaheadState), + ), + ), + ], + ), + ); + } +} + +class _ResultsList extends StatelessWidget { + const _ResultsList({required this.typeaheadState}); + + final ActorTypeaheadState typeaheadState; + + @override + Widget build(BuildContext context) { + if (typeaheadState.isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (typeaheadState.error != null) { + return Center( + child: Text( + typeaheadState.error!, + style: const TextStyle(color: Color(0xFFFCA5A5)), + ), + ); + } + + if (typeaheadState.query.isEmpty) { + return const Center( + child: Text( + 'Start typing to find someone.', + style: TextStyle(color: Color(0xFF94A3B8)), + ), + ); + } + + if (typeaheadState.results.isEmpty) { + return const Center( + child: Text( + 'No people found for that search.', + style: TextStyle(color: Color(0xFF94A3B8)), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + itemCount: typeaheadState.results.length, + separatorBuilder: (_, _) => const Divider(color: Color(0x1FFFFFFF)), + itemBuilder: (context, index) { + final actor = typeaheadState.results[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: const Color(0x1AFFFFFF), + backgroundImage: actor.avatar != null + ? NetworkImage(actor.avatar.toString()) + : null, + child: actor.avatar == null + ? const Icon(Icons.person_outline, color: Colors.white) + : null, + ), + title: Text( + actor.displayName ?? actor.handle, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + '@${actor.handle}', + style: const TextStyle(color: Color(0xFF94A3B8)), + ), + onTap: () => Navigator.of(context).pop(actor), + ); + }, + ); + } +} diff --git a/lib/src/core/pro_video_editor/models/video_editor_result.dart b/lib/src/core/pro_video_editor/models/video_editor_result.dart index ee223a22..fc1e4850 100644 --- a/lib/src/core/pro_video_editor/models/video_editor_result.dart +++ b/lib/src/core/pro_video_editor/models/video_editor_result.dart @@ -1,14 +1,22 @@ import 'package:atproto/com_atproto_repo_strongref.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; /// Result returned from the video editor containing the edited video /// and optional metadata about audio used. class VideoEditorResult { - const VideoEditorResult({required this.video, this.soundRef}); + const VideoEditorResult({ + required this.video, + this.soundRef, + this.embeds = const [], + }); /// The edited video file. final XFile video; /// Reference to the audio track used, if any. final RepoStrongRef? soundRef; + + /// Story embeds extracted alongside the exported video. + final List embeds; } diff --git a/lib/src/core/pro_video_editor/pro_video_editor_repository.dart b/lib/src/core/pro_video_editor/pro_video_editor_repository.dart index 037e4cb5..cfcbb97d 100644 --- a/lib/src/core/pro_video_editor/pro_video_editor_repository.dart +++ b/lib/src/core/pro_video_editor/pro_video_editor_repository.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:image_picker/image_picker.dart'; import 'package:pro_video_editor/pro_video_editor.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; /// Abstraction over the pro_video_editor plugin. @@ -52,7 +53,10 @@ abstract class ProVideoEditorRepository { /// tools (text, paint, stickers, emoji, filter, blur - NO crop/rotate). /// /// Returns `null` if the user cancels without completing the edit. - Future openStoryImageEditor(BuildContext context, XFile source); + Future openStoryImageEditor( + BuildContext context, + XFile source, + ); /// Opens a blank canvas Story Image Editor (1080x1920). /// @@ -60,7 +64,7 @@ abstract class ProVideoEditorRepository { /// This gives more flexibility for positioning the image. /// /// Returns `null` if the user cancels without completing the edit. - Future openStoryBlankCanvasEditor( + Future openStoryBlankCanvasEditor( BuildContext context, { XFile? backgroundImage, Color backgroundColor = const Color(0xFF000000), diff --git a/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart b/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart index ab21b3ee..e764305a 100644 --- a/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart +++ b/lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart @@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart'; +import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; import 'package:spark/src/core/pro_image_editor/ui/story_image_editor_page.dart'; import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; @@ -82,7 +83,10 @@ class ProVideoEditorRepositoryImpl implements ProVideoEditorRepository { } @override - Future openStoryImageEditor(BuildContext context, XFile source) { + Future openStoryImageEditor( + BuildContext context, + XFile source, + ) { return StoryBlankCanvasEditorPage.open( context, backgroundImage: File(source.path), @@ -90,7 +94,7 @@ class ProVideoEditorRepositoryImpl implements ProVideoEditorRepository { } @override - Future openStoryBlankCanvasEditor( + Future openStoryBlankCanvasEditor( BuildContext context, { XFile? backgroundImage, Color backgroundColor = const Color(0xFF000000), diff --git a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart index 8ea88d4a..7fce247b 100644 --- a/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart +++ b/lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart @@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; +import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; import 'package:spark/src/core/pro_video_editor/services/audio_waveform_extractor.dart'; @@ -43,7 +44,8 @@ class VideoEditorGroundedPage extends StatefulWidget { _VideoEditorGroundedPageState(); } -class _VideoEditorGroundedPageState extends State { +class _VideoEditorGroundedPageState extends State + with StoryMentionEditing { static const _storyCanvasSize = Size(1440, 2560); final _editorKey = GlobalKey(); @@ -105,6 +107,12 @@ class _VideoEditorGroundedPageState extends State { late VideoTimelineState _videoTimelineState; List _audioTracks = []; + @override + GlobalKey get storyEditorKey => _editorKey; + + @override + Size get storyCanvasFallbackSize => _storyCanvasSize; + @override void initState() { super.initState(); @@ -211,6 +219,8 @@ class _VideoEditorGroundedPageState extends State { onToggleMute: _onToggleMute, onAddSound: _showAudioSelectionBottomSheet, onToggleFullscreen: _openFullscreenPreview, + onMention: widget.storyMode ? addStoryMention : null, + onDone: widget.storyMode ? finishStoryEditing : null, ); // Update clip duration and thumbnails after first frame @@ -618,11 +628,14 @@ class _VideoEditorGroundedPageState extends State { VideoEditorResult( video: XFile(_outputPath!, mimeType: 'video/mp4'), soundRef: _selectedSoundRef, + embeds: pendingStoryEmbeds, ), ); _outputPath = null; _selectedSoundRef = null; + clearPendingStoryEmbeds(); } else { + clearPendingStoryEmbeds(); Navigator.pop(context); } } @@ -696,7 +709,6 @@ class _VideoEditorGroundedPageState extends State { await _audioService.pause(); }, ), - mainEditorCallbacks: const MainEditorCallbacks(), stickerEditorCallbacks: StickerEditorCallbacks( onSearchChanged: (_) {}, ), 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 3ac566c2..a642bb15 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 @@ -66,6 +66,8 @@ class VideoEditorConfigsBuilder { required VoidCallback onToggleMute, required VoidCallback onAddSound, required VoidCallback onToggleFullscreen, + Future Function()? onMention, + void Function(ProImageEditorState editor)? onDone, bool storyMode = false, List audioTracks = const [], VideoEditorConfigs videoEditorConfigs = const VideoEditorConfigs( @@ -104,9 +106,12 @@ class VideoEditorConfigsBuilder { appBar: (editor, rebuildStream) => null, bottomBar: (editor, rebuildStream, key) => ReactiveWidget( key: key, - builder: (context) { + builder: (_) { if (storyMode) { - return StoryEditorBottomSection(editor: editor); + return StoryEditorBottomSection( + editor: editor, + onMention: onMention, + ); } return VideoEditorBottomSection( @@ -148,7 +153,9 @@ class VideoEditorConfigsBuilder { child: storyMode ? StoryEditorHeader( onBack: editor.closeEditor, - onDone: editor.doneEditing, + onDone: onDone != null + ? () => onDone(editor) + : editor.doneEditing, canUndo: editor.canUndo, canRedo: editor.canRedo, onUndo: editor.undoAction, diff --git a/lib/src/core/ui/widgets/story_mention_chip.dart b/lib/src/core/ui/widgets/story_mention_chip.dart new file mode 100644 index 00000000..8ca0c0da --- /dev/null +++ b/lib/src/core/ui/widgets/story_mention_chip.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; + +const double kStoryMentionInitialHeight = 64; + +class StoryMentionChip extends StatelessWidget { + const StoryMentionChip({ + required this.primaryText, + this.secondaryText, + this.compact = false, + this.fixedHeight, + super.key, + }); + + final String primaryText; + final String? secondaryText; + final bool compact; + final double? fixedHeight; + + @override + Widget build(BuildContext context) { + final child = _MentionText( + primaryText: primaryText, + secondaryText: secondaryText, + compact: compact, + fixedHeight: fixedHeight, + ); + + if (fixedHeight == null) { + return child; + } + + return SizedBox(height: fixedHeight, child: child); + } +} + +Size measureStoryMentionChipSize({ + required String primaryText, + String? secondaryText, + bool compact = false, + double height = kStoryMentionInitialHeight, +}) { + final metrics = _StoryMentionMetrics.fromHeight( + height: height, + compact: compact, + ); + final handleText = primaryText.startsWith('@') + ? primaryText.substring(1) + : primaryText; + final baseStyle = TextStyle( + color: const Color(0xFF111827), + fontWeight: FontWeight.w700, + fontSize: metrics.fontSize, + height: compact ? 1 : 1.05, + ); + + final painter = TextPainter( + text: TextSpan( + style: const TextStyle(leadingDistribution: TextLeadingDistribution.even), + children: [ + TextSpan( + text: '@', + style: baseStyle.copyWith(color: AppColors.primary600), + ), + TextSpan(text: handleText, style: baseStyle), + if (!compact && secondaryText != null && secondaryText.isNotEmpty) + TextSpan( + text: '\n$secondaryText', + style: baseStyle.copyWith( + fontSize: metrics.sublineSize, + fontWeight: FontWeight.w500, + color: AppColors.grey400, + height: 1.15, + ), + ), + ], + ), + textDirection: TextDirection.ltr, + )..layout(); + + return Size(painter.width + metrics.horizontalPadding * 2, height); +} + +class _MentionText extends StatelessWidget { + const _MentionText({ + required this.primaryText, + required this.secondaryText, + required this.compact, + required this.fixedHeight, + }); + + final String primaryText; + final String? secondaryText; + final bool compact; + final double? fixedHeight; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final availableHeight = fixedHeight ?? constraints.maxHeight; + final metrics = _StoryMentionMetrics.fromHeight( + height: availableHeight.isFinite + ? availableHeight + : kStoryMentionInitialHeight, + compact: compact, + ); + final handleText = primaryText.startsWith('@') + ? primaryText.substring(1) + : primaryText; + final maxTextWidth = constraints.maxWidth.isFinite + ? (constraints.maxWidth - metrics.horizontalPadding * 2).clamp( + 24.0, + double.infinity, + ) + : double.infinity; + + final baseStyle = TextStyle( + color: const Color(0xFF111827), + fontWeight: FontWeight.w700, + fontSize: metrics.fontSize, + height: compact ? 1 : 1.05, + ); + + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: metrics.horizontalPadding, + ), + child: RoundedBackgroundText.rich( + maxTextWidth: maxTextWidth, + backgroundColor: Colors.white, + leadingDistribution: TextLeadingDistribution.even, + text: TextSpan( + children: [ + TextSpan( + text: '@', + style: baseStyle.copyWith(color: AppColors.primary600), + ), + TextSpan(text: handleText, style: baseStyle), + if (!compact && + secondaryText != null && + secondaryText!.isNotEmpty) + TextSpan( + text: '\n$secondaryText', + style: baseStyle.copyWith( + fontSize: metrics.sublineSize, + fontWeight: FontWeight.w500, + color: AppColors.grey400, + height: 1.15, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _StoryMentionMetrics { + const _StoryMentionMetrics({ + required this.fontSize, + required this.sublineSize, + required this.horizontalPadding, + }); + + final double fontSize; + final double sublineSize; + final double horizontalPadding; + + factory _StoryMentionMetrics.fromHeight({ + required double height, + required bool compact, + }) { + final normalizedHeight = height.clamp(compact ? 28.0 : 36.0, 96.0); + final fontSize = (normalizedHeight * (compact ? 0.42 : 0.32)).clamp( + compact ? 12.0 : 14.0, + compact ? 18.0 : 20.0, + ); + + return _StoryMentionMetrics( + fontSize: fontSize, + sublineSize: (fontSize * 0.62).clamp(10.0, 13.0), + horizontalPadding: compact ? 4 : 6, + ); + } +} diff --git a/lib/src/features/posting/models/mention_text_editing_controller.dart b/lib/src/features/posting/models/mention_text_editing_controller.dart index b64fd212..a0332882 100644 --- a/lib/src/features/posting/models/mention_text_editing_controller.dart +++ b/lib/src/features/posting/models/mention_text_editing_controller.dart @@ -29,7 +29,9 @@ class MentionTextEditingController extends TextEditingController { TextStyle? style, required bool withComposing, }) { - assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid); + assert( + !value.composing.isValid || !withComposing || value.isComposingRangeValid, + ); final theme = Theme.of(context); final effectiveStyle = @@ -54,7 +56,9 @@ class MentionTextEditingController extends TextEditingController { final composingRegionOutOfRange = !value.isComposingRangeValid || !withComposing; - final composingStart = composingRegionOutOfRange ? -1 : value.composing.start; + final composingStart = composingRegionOutOfRange + ? -1 + : value.composing.start; final composingEnd = composingRegionOutOfRange ? -1 : value.composing.end; final boundaries = {0, text.length}; @@ -73,9 +77,7 @@ class MentionTextEditingController extends TextEditingController { final charEnd = TextFormatter.byteIndexToCharIndex(text, mention.byteEnd); // Skip invalid mentions. - if (charStart < 0 || - charEnd > text.length || - charStart >= charEnd) { + if (charStart < 0 || charEnd > text.length || charStart >= charEnd) { continue; } @@ -113,7 +115,9 @@ class MentionTextEditingController extends TextEditingController { segmentStyle = segmentStyle.merge(composingStyle); } - spans.add(TextSpan(text: text.substring(start, end), style: segmentStyle)); + spans.add( + TextSpan(text: text.substring(start, end), style: segmentStyle), + ); } return TextSpan(children: spans, style: effectiveStyle); diff --git a/lib/src/features/posting/providers/camera_provider.dart b/lib/src/features/posting/providers/camera_provider.dart index 291505bc..f18186b1 100644 --- a/lib/src/features/posting/providers/camera_provider.dart +++ b/lib/src/features/posting/providers/camera_provider.dart @@ -44,7 +44,7 @@ class Camera extends _$Camera { final cameras = await availableCameras(); if (cameras.isEmpty) { _logger.w('No cameras found on device'); - throw Exception('No cameras found'); + return const CameraState(cameras: [], isInitialized: true); } _logger.i('Found ${cameras.length} cameras'); diff --git a/lib/src/features/posting/providers/post_story.dart b/lib/src/features/posting/providers/post_story.dart index 1cc68120..9978be29 100644 --- a/lib/src/features/posting/providers/post_story.dart +++ b/lib/src/features/posting/providers/post_story.dart @@ -12,11 +12,15 @@ FutureOr postStory( Media media, { List? selfLabels, List? tags, + RepoStrongRef? soundRef, + List? embeds, }) async { final storyRepository = GetIt.I(); return await storyRepository.postStory( media, selfLabels: selfLabels, tags: tags, + soundRef: soundRef, + embeds: embeds, ); } diff --git a/lib/src/features/posting/providers/video_upload_provider.dart b/lib/src/features/posting/providers/video_upload_provider.dart index 5ea41cd1..9ea38f2e 100644 --- a/lib/src/features/posting/providers/video_upload_provider.dart +++ b/lib/src/features/posting/providers/video_upload_provider.dart @@ -108,6 +108,7 @@ Future processAndPostVideo( bool storyMode = false, RepoStrongRef? soundRef, List facets = const [], + List storyEmbeds = const [], }) async { final logger = GetIt.I().getLogger('Process/Post Video') ..d( @@ -148,6 +149,8 @@ Future processAndPostVideo( final storyRepository = GetIt.I(); final res = await storyRepository.postStory( Media.video(video: videoBlob), + soundRef: effectiveSoundRef, + embeds: storyEmbeds, ); logger.i('Story posted: ${res.uri}'); return res; diff --git a/lib/src/features/posting/ui/pages/recording_page.dart b/lib/src/features/posting/ui/pages/recording_page.dart index 304ce872..d2a6fd66 100644 --- a/lib/src/features/posting/ui/pages/recording_page.dart +++ b/lib/src/features/posting/ui/pages/recording_page.dart @@ -59,13 +59,20 @@ class _RecordingPageState extends ConsumerState { }); } + bool _hasCameras() { + final cameraAsync = ref.read(cameraProvider); + final cameraState = cameraAsync.value; + return cameraState != null && cameraState.cameras.isNotEmpty; + } + bool _isCameraReady() { final cameraAsync = ref.read(cameraProvider); if (cameraAsync.hasError) return false; final cameraState = cameraAsync.value; return cameraState != null && cameraState.isInitialized && - cameraState.controller != null; + cameraState.controller != null && + cameraState.cameras.isNotEmpty; } /// Handle tap on record button. @@ -218,7 +225,8 @@ class _RecordingPageState extends ConsumerState { final result = await StoryDirectPost.postPhotoStory( context, ref, - editedImage, + editedImage.image, + embeds: editedImage.embeds, ); if (result != null && mounted) { // Exit the recording flow completely @@ -240,7 +248,7 @@ class _RecordingPageState extends ConsumerState { // For posts, go to review page await context.router.push( ImageReviewRoute( - imageFiles: [editedImage], + imageFiles: [editedImage.image], storyMode: widget.storyMode, ), ); @@ -351,6 +359,7 @@ class _RecordingPageState extends ConsumerState { ref, result.video.path, soundRef: result.soundRef, + embeds: result.embeds, ); if (postResult != null && mounted) { // Exit the recording flow completely @@ -421,6 +430,8 @@ class _RecordingPageState extends ConsumerState { }); } + final hasCameras = _hasCameras(); + return cameraAsync.when( data: (cameraState) { if (cameraState.error != null) { @@ -461,7 +472,7 @@ class _RecordingPageState extends ConsumerState { ); } - if (!cameraState.isInitialized || cameraState.controller == null) { + if (!cameraState.isInitialized) { return const Scaffold( backgroundColor: Colors.black, body: Center(child: CircularProgressIndicator(color: Colors.white)), @@ -476,6 +487,44 @@ class _RecordingPageState extends ConsumerState { ); } + // No cameras available - show placeholder with library picker + if (!hasCameras || cameraState.controller == null) { + return RecordingPageTemplate( + cameraPreview: Container( + color: Colors.black, + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.videocam_off_outlined, + color: Colors.white54, + size: 64, + ), + SizedBox(height: 16), + Text( + 'No cameras available', + style: TextStyle(color: Colors.white54, fontSize: 16), + ), + ], + ), + ), + ), + aspectRatio: 9 / 16, + isRecording: false, + elapsedDuration: Duration.zero, + maxDuration: recordingState.maxDuration, + onBack: () => context.router.pop(), + onFlipCamera: null, + canFlipCamera: false, + captureMode: widget.captureMode, + onTap: null, + onRecordStart: null, + onRecordStop: null, + onOpenLibrary: _isProcessing ? null : _openMediaLibraryPicker, + ); + } + final availableLensDirections = cameraState.cameras .map((camera) => camera.lensDirection) .toSet(); diff --git a/lib/src/features/posting/ui/pages/story_post_page.dart b/lib/src/features/posting/ui/pages/story_post_page.dart index 6f5e6574..96145ee8 100644 --- a/lib/src/features/posting/ui/pages/story_post_page.dart +++ b/lib/src/features/posting/ui/pages/story_post_page.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.dart'; +import 'package:atproto/com_atproto_repo_strongref.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; @@ -15,14 +16,21 @@ import 'package:spark/src/features/posting/providers/video_upload_provider.dart' /// Shows a loading indicator while uploading and posting. @RoutePage() class StoryPostPage extends ConsumerStatefulWidget { - const StoryPostPage({this.imageFile, this.videoPath, super.key}) - : assert( - imageFile != null || videoPath != null, - 'Either imageFile or videoPath must be provided', - ); + const StoryPostPage({ + this.imageFile, + this.videoPath, + this.soundRef, + this.embeds = const [], + super.key, + }) : assert( + imageFile != null || videoPath != null, + 'Either imageFile or videoPath must be provided', + ); final XFile? imageFile; final String? videoPath; + final RepoStrongRef? soundRef; + final List embeds; @override ConsumerState createState() => _StoryPostPageState(); @@ -93,6 +101,7 @@ class _StoryPostPageState extends ConsumerState { final result = await ref.read( postStoryProvider( Media.image(image: uploadedImage.image, alt: uploadedImage.alt), + embeds: widget.embeds, ).future, ); @@ -110,6 +119,8 @@ class _StoryPostPageState extends ConsumerState { processAndPostVideoProvider( videoPath: widget.videoPath!, storyMode: true, + soundRef: widget.soundRef, + storyEmbeds: widget.embeds, ).future, ); diff --git a/lib/src/features/posting/utils/story_direct_post.dart b/lib/src/features/posting/utils/story_direct_post.dart index a77526fb..972e1c40 100644 --- a/lib/src/features/posting/utils/story_direct_post.dart +++ b/lib/src/features/posting/utils/story_direct_post.dart @@ -20,8 +20,9 @@ class StoryDirectPost { static Future postPhotoStory( BuildContext context, WidgetRef ref, - XFile imageFile, - ) async { + XFile imageFile, { + List embeds = const [], + }) async { // Show loading overlay final navigator = Navigator.of(context); @@ -50,6 +51,7 @@ class StoryDirectPost { final result = await ref.read( postStoryProvider( Media.image(image: uploadedImage.image, alt: uploadedImage.alt), + embeds: embeds, ).future, ); @@ -81,6 +83,7 @@ class StoryDirectPost { WidgetRef ref, String videoPath, { RepoStrongRef? soundRef, + List embeds = const [], }) async { // Show loading overlay final navigator = Navigator.of(context); @@ -98,6 +101,7 @@ class StoryDirectPost { videoPath: videoPath, storyMode: true, soundRef: soundRef, + storyEmbeds: embeds, ).future, ); diff --git a/lib/src/features/stories/ui/pages/author_stories_page.dart b/lib/src/features/stories/ui/pages/author_stories_page.dart index 1f2c8e79..e5307b4d 100644 --- a/lib/src/features/stories/ui/pages/author_stories_page.dart +++ b/lib/src/features/stories/ui/pages/author_stories_page.dart @@ -292,6 +292,10 @@ class _AuthorStoriesPageState extends ConsumerState _onStoryLoadingStateChanged(index, isLoading), onStoryDurationChanged: (duration) => _onStoryDurationChanged(index, duration), + onPauseRequested: _pause, + onResumeRequested: _resume, + onPrevious: _previousStory, + onNext: _nextStory, ); }, ), @@ -396,26 +400,6 @@ class _AuthorStoriesPageState extends ConsumerState ], ), ), - Positioned( - top: 80, - bottom: 0, - left: 0, - width: MediaQuery.of(context).size.width * 0.3, - child: GestureDetector( - onTap: _previousStory, - child: Container(color: Colors.transparent), - ), - ), - Positioned( - top: 80, - bottom: 0, - right: 0, - width: MediaQuery.of(context).size.width * 0.3, - child: GestureDetector( - onTap: _nextStory, - child: Container(color: Colors.transparent), - ), - ), ], ), ), diff --git a/lib/src/features/stories/ui/pages/story_page.dart b/lib/src/features/stories/ui/pages/story_page.dart index bfabec4e..ef62e146 100644 --- a/lib/src/features/stories/ui/pages/story_page.dart +++ b/lib/src/features/stories/ui/pages/story_page.dart @@ -1,8 +1,13 @@ +import 'dart:math' as math; + import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart' as flutter_widgets show Image; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; +import 'package:spark/src/core/routing/app_router.dart'; import 'package:video_player/video_player.dart'; @RoutePage() @@ -12,11 +17,19 @@ class StoryPage extends ConsumerStatefulWidget { super.key, this.onLoadingStateChanged, this.onStoryDurationChanged, + this.onPauseRequested, + this.onResumeRequested, + this.onPrevious, + this.onNext, }); final StoryView story; final ValueChanged? onLoadingStateChanged; final ValueChanged? onStoryDurationChanged; + final VoidCallback? onPauseRequested; + final VoidCallback? onResumeRequested; + final VoidCallback? onPrevious; + final VoidCallback? onNext; @override ConsumerState createState() => _StoryPageState(); @@ -136,18 +149,45 @@ class _StoryPageState extends ConsumerState }; } + Future _handleEmbedTap(StoryMentionEmbedView embed) async { + final router = context.router; + widget.onPauseRequested?.call(); + final videoController = _videoController; + final wasPlaying = videoController?.value.isPlaying ?? false; + if (wasPlaying) { + await videoController?.pause(); + } + + await router.push( + ProfileRoute(did: embed.did, initialProfile: embed.actor), + ); + + if (!mounted) { + return; + } + + widget.onResumeRequested?.call(); + if (wasPlaying) { + await videoController?.play(); + } + } + @override Widget build(BuildContext context) { - const footerHeight = kBottomNavigationBarHeight + 12; const borderRadius = BorderRadius.all(Radius.circular(20)); // Determine the main media widget (video or image) first. late final Widget mediaContent; + late final Size sourceSize; if (_isVideoStory(widget.story)) { if (_videoController != null && _isVideoInitialized) { if (_videoController!.value.isInitialized) { final size = _videoController!.value.size; + sourceSize = Size( + size.width > 0 ? size.width : 1440, + size.height > 0 ? size.height : 2560, + ); mediaContent = Container( color: Colors.black, alignment: Alignment.center, @@ -161,15 +201,18 @@ class _StoryPageState extends ConsumerState ), ); } else { + sourceSize = const Size(1440, 2560); mediaContent = const Center( child: Icon(Icons.videocam_off, size: 48, color: Colors.white), ); } } else { + sourceSize = const Size(1440, 2560); mediaContent = const Center(child: CircularProgressIndicator()); } } else { final imageUrl = _getImageUrl(widget.story); + sourceSize = const Size(1440, 2560); mediaContent = CachedNetworkImage( imageUrl: imageUrl, @@ -200,72 +243,173 @@ class _StoryPageState extends ConsumerState _updateLoadingState(); } }); - return Image(image: imageProvider, fit: BoxFit.cover); + return flutter_widgets.Image(image: imageProvider, fit: BoxFit.cover); }, ); } // Wrap the media in a Stack to overlay gradient shadows for readability. - return Column( - children: [ - Expanded( - child: ClipRRect( - borderRadius: borderRadius, - child: Stack( - fit: StackFit.expand, - children: [ - mediaContent, - // Top shadow overlay - Positioned( - left: 0, - right: 0, - top: 0, - child: IgnorePointer( - child: Container( - height: 120, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black87.withAlpha(100), - Colors.transparent, - ], - ), + return ClipRRect( + borderRadius: borderRadius, + child: LayoutBuilder( + builder: (context, constraints) { + final destinationRect = _resolveMediaRect( + containerSize: constraints.biggest, + sourceSize: sourceSize, + ); + + return Stack( + fit: StackFit.expand, + children: [ + mediaContent, + Positioned( + top: 80, + bottom: 0, + left: 0, + width: constraints.maxWidth * 0.3, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onPrevious, + child: const SizedBox.expand(), + ), + ), + Positioned( + top: 80, + bottom: 0, + right: 0, + width: constraints.maxWidth * 0.3, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onNext, + child: const SizedBox.expand(), + ), + ), + ..._buildMentionEmbeds(destinationRect), + // Top shadow overlay + Positioned( + left: 0, + right: 0, + top: 0, + child: IgnorePointer( + child: Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black87.withAlpha(100), + Colors.transparent, + ], ), ), ), ), - // Bottom shadow overlay - Positioned( - left: 0, - right: 0, - bottom: 0, - child: IgnorePointer( - child: Container( - height: 120, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black87.withAlpha(100), - Colors.transparent, - ], - ), + ), + // Bottom shadow overlay + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black87.withAlpha(100), + Colors.transparent, + ], ), ), ), ), - ], - ), + ), + ], + ); + }, + ), + ); + } + + Rect _resolveMediaRect({ + required Size containerSize, + required Size sourceSize, + }) { + final fittedSizes = applyBoxFit(BoxFit.cover, sourceSize, containerSize); + return Alignment.center.inscribe( + fittedSizes.destination, + Offset.zero & containerSize, + ); + } + + Iterable _buildMentionEmbeds(Rect mediaRect) { + final mentionEmbeds = _storyMentionEmbeds.toList() + ..sort((a, b) { + final aIndex = a.placement.zIndex ?? 0; + final bIndex = b.placement.zIndex ?? 0; + return aIndex.compareTo(bIndex); + }); + + return mentionEmbeds.map((embed) { + final frame = embed.placement.frame; + final rect = Rect.fromLTWH( + mediaRect.left + mediaRect.width * frame.x / 10000, + mediaRect.top + mediaRect.height * frame.y / 10000, + mediaRect.width * frame.w / 10000, + mediaRect.height * frame.h / 10000, + ); + final rotationDegrees = embed.placement.rotation ?? 0; + + return Positioned( + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: Transform.rotate( + angle: rotationDegrees * math.pi / 180, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _handleEmbedTap(embed), + child: const SizedBox.expand(), ), ), - const SizedBox( - height: footerHeight, - child: ColoredBox(color: Colors.black), - ), - ], - ); + ); + }); + } + + List get _storyMentionEmbeds { + if (widget.story.embeds != null && widget.story.embeds!.isNotEmpty) { + final hydratedEmbeds = widget.story.embeds! + .whereType() + .where((embed) => _isValidMentionEmbed(embed.placement)) + .toList(growable: false); + + if (hydratedEmbeds.isNotEmpty) { + return hydratedEmbeds; + } + } + + final recordEmbeds = widget.story.record.embeds ?? const []; + return recordEmbeds + .whereType() + .where((embed) { + return _isValidMentionEmbed(embed.placement); + }) + .map((embed) { + return StoryEmbedView.mention( + placement: embed.placement, + did: embed.did, + ); + }) + .whereType() + .toList(growable: false); + } + + bool _isValidMentionEmbed(StoryEmbedPlacement placement) { + final frame = placement.frame; + return frame.w > 0 && frame.h > 0; } } diff --git a/test/core/network/atproto/data/models/story_embed_models_test.dart b/test/core/network/atproto/data/models/story_embed_models_test.dart new file mode 100644 index 00000000..51df25d9 --- /dev/null +++ b/test/core/network/atproto/data/models/story_embed_models_test.dart @@ -0,0 +1,149 @@ +import 'package:atproto/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/record_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; + +void main() { + group('story embed parsing', () { + test( + 'storyEmbedsFromJson parses mention embeds and skips malformed items', + () { + final embeds = storyEmbedsFromJson([ + { + r'$type': 'so.sprk.embed.mention', + 'placement': { + 'frame': {'x': 1200, 'y': 3400, 'w': 2500, 'h': 900}, + 'mediaRef': {'index': 0}, + 'zIndex': 2, + 'rotation': 15, + }, + 'did': 'did:plc:mentioned-user', + }, + { + r'$type': 'so.sprk.embed.mention', + 'placement': {'zIndex': 3}, + 'did': 'did:plc:broken-user', + }, + 'not-a-map', + ]); + + expect(embeds, hasLength(1)); + + final mention = embeds.single as StoryMentionEmbed; + expect(mention.did, 'did:plc:mentioned-user'); + expect(mention.placement.frame.x, 1200); + expect(mention.placement.frame.y, 3400); + expect(mention.placement.frame.w, 2500); + expect(mention.placement.frame.h, 900); + expect(mention.placement.mediaRef?.index, 0); + expect(mention.placement.zIndex, 2); + expect(mention.placement.rotation, 15); + }, + ); + + test( + 'StoryView.fromJson preserves hydrated embeds and skips malformed ones', + () { + final story = StoryView.fromJson({ + 'cid': 'bafyreicid', + 'uri': 'at://did:plc:author/so.sprk.story.post/3lk4example', + 'author': {'did': 'did:plc:author', 'handle': 'author.sprk.so'}, + 'record': { + r'$type': 'so.sprk.story.post', + 'media': { + r'$type': 'so.sprk.media.image', + 'image': { + r'$type': 'blob', + 'mimeType': 'image/jpeg', + 'size': 42, + 'ref': {r'$link': 'bafkreigh2akiscaildc2'}, + }, + }, + 'createdAt': '2026-04-05T12:00:00.000Z', + 'embeds': [ + { + r'$type': 'so.sprk.embed.mention', + 'placement': { + 'frame': {'x': 1000, 'y': 2000, 'w': 3000, 'h': 1000}, + 'zIndex': 1, + }, + 'did': 'did:plc:record-mentioned', + }, + ], + }, + 'indexedAt': '2026-04-05T12:00:01.000Z', + 'embeds': [ + { + r'$type': 'so.sprk.embed.mention#view', + 'placement': { + 'frame': {'x': 1000, 'y': 2000, 'w': 3000, 'h': 1000}, + 'zIndex': 1, + 'rotation': 30, + }, + 'did': 'did:plc:view-mentioned', + 'actor': { + 'did': 'did:plc:view-mentioned', + 'handle': 'mention.sprk.so', + 'displayName': 'Mentioned User', + }, + }, + { + r'$type': 'so.sprk.embed.mention#view', + 'placement': {'zIndex': 99}, + 'did': 'did:plc:broken-view', + }, + ], + }); + + expect(story.record, isA()); + expect(story.record.embeds, hasLength(1)); + expect(story.embeds, hasLength(1)); + + final recordMention = story.record.embeds!.single as StoryMentionEmbed; + expect(recordMention.did, 'did:plc:record-mentioned'); + + final viewMention = story.embeds!.single as StoryMentionEmbedView; + expect(viewMention.did, 'did:plc:view-mentioned'); + expect(viewMention.actor?.handle, 'mention.sprk.so'); + expect(viewMention.placement.rotation, 30); + }, + ); + + test('StoryRecord.toJson includes lexicon type on embeds', () { + final record = + Record.story( + media: Media.image( + image: Blob.fromJson({ + r'$type': 'blob', + 'mimeType': 'image/jpeg', + 'size': 42, + 'ref': {r'$link': 'bafkreigh2akiscaildc2'}, + }), + ), + createdAt: DateTime.parse('2026-04-05T12:00:00.000Z'), + embeds: [ + StoryEmbed.mention( + did: 'did:plc:mentioned-user', + placement: const StoryEmbedPlacement( + frame: StoryEmbedFrame( + x: 1000, + y: 2000, + w: 3000, + h: 1000, + ), + ), + ), + ], + ) + as StoryRecord; + + final json = record.toJson(); + final embeds = json['embeds'] as List; + final mention = embeds.single as Map; + + expect(mention[r'$type'], 'so.sprk.embed.mention'); + expect(mention['did'], 'did:plc:mentioned-user'); + }); + }); +} diff --git a/test/core/pro_image_editor/story_mention_layer_test.dart b/test/core/pro_image_editor/story_mention_layer_test.dart new file mode 100644 index 00000000..8028186e --- /dev/null +++ b/test/core/pro_image_editor/story_mention_layer_test.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; +import 'package:spark/src/core/pro_image_editor/story_mention_layer.dart'; +import 'package:spark/src/core/ui/widgets/story_mention_chip.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('story mention widget layers', () { + test('createStoryMentionLayer marks a widget layer as a story mention', () { + final layer = createStoryMentionLayer( + _actor('did:plc:first', 'first.sprk.so'), + ); + + expect(layer, isA()); + expect(isStoryMentionLayer(layer), isTrue); + expect(layer.meta?['did'], 'did:plc:first'); + expect(layer.meta?['handle'], 'first.sprk.so'); + }); + + test( + 'extractStoryMentionEmbeds exports normalized placement from layers', + () { + final baseSize = measureStoryMentionChipSize( + primaryText: '@first.sprk.so', + height: kStoryMentionInitialHeight, + ); + final mention = + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) + ..width = 200 + ..offset = const Offset(-100, 50) + ..scale = 1.5 + ..rotation = math.pi / 2; + + final embeds = extractStoryMentionEmbeds([ + mention, + ], canvasSize: const Size(1000, 1000)); + + expect(embeds, hasLength(1)); + + final embed = embeds.single as StoryMentionEmbed; + final expectedWidth = 3000; + final expectedHeight = + ((200 * 1.5) * (baseSize.height / baseSize.width) / 1000 * 10000) + .round(); + final renderedHeight = (200 * 1.5) * (baseSize.height / baseSize.width); + final expectedTop = 50 + 500 - renderedHeight / 2; + expect(embed.did, 'did:plc:first'); + expect(embed.placement.frame.x, 2500); + expect(embed.placement.frame.y, ((expectedTop / 1000) * 10000).round()); + expect(embed.placement.frame.w, expectedWidth); + expect(embed.placement.frame.h, expectedHeight); + expect(embed.placement.zIndex, 0); + expect(embed.placement.rotation, 90); + }, + ); + + test('extractStoryMentionEmbeds ignores non-mention widget layers', () { + final mention = createStoryMentionLayer( + _actor('did:plc:first', 'first.sprk.so'), + ); + final background = WidgetLayer( + widget: const SizedBox.shrink(), + meta: const {'kind': 'background'}, + ); + + final embeds = extractStoryMentionEmbeds([ + background, + mention, + ], canvasSize: const Size(1000, 1000)); + final embed = embeds.single as StoryMentionEmbed; + + expect(embeds, hasLength(1)); + expect(embed.did, 'did:plc:first'); + expect(embed.placement.zIndex, 1); + }); + + test('extractStoryMentionEmbeds clips partially off-canvas mentions', () { + final baseSize = measureStoryMentionChipSize( + primaryText: '@first.sprk.so', + height: kStoryMentionInitialHeight, + ); + final mention = + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) + ..width = 200 + ..offset = const Offset(-450, 0); + + final embeds = extractStoryMentionEmbeds([ + mention, + ], canvasSize: const Size(1000, 1000)); + + expect(embeds, hasLength(1)); + + final embed = embeds.single as StoryMentionEmbed; + final renderedWidth = 200.0; + final renderedHeight = renderedWidth * (baseSize.height / baseSize.width); + final unclippedLeft = -450 + 500 - renderedWidth / 2; + final unclippedTop = 500 - renderedHeight / 2; + final clippedWidth = renderedWidth - (0 - unclippedLeft); + + expect(embed.placement.frame.x, 0); + expect(embed.placement.frame.y, ((unclippedTop / 1000) * 10000).round()); + expect(embed.placement.frame.w, ((clippedWidth / 1000) * 10000).round()); + expect( + embed.placement.frame.h, + ((renderedHeight / 1000) * 10000).round(), + ); + }); + + test('extractStoryMentionEmbeds drops fully clipped mentions', () { + final mention = + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) + ..width = 200 + ..offset = const Offset(-700, 0); + + final embeds = extractStoryMentionEmbeds([ + mention, + ], canvasSize: const Size(1000, 1000)); + + expect(embeds, isEmpty); + }); + }); +} + +ProfileViewBasic _actor(String did, String handle) { + return ProfileViewBasic(did: did, handle: handle); +} -- 2.51.2