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

Configure Feed

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

feat: interactive story mentions

+1492 -158
+11 -2
lib/src/core/media/create_media_actions.dart
··· 67 67 if (storyMode) { 68 68 // For stories, post directly 69 69 await context.router.push( 70 - StoryPostRoute(videoPath: result.video.path), 70 + StoryPostRoute( 71 + videoPath: result.video.path, 72 + soundRef: result.soundRef, 73 + embeds: result.embeds, 74 + ), 71 75 ); 72 76 } else { 73 77 // For posts, go to review ··· 103 107 .openStoryImageEditor(context, pickedImage); 104 108 if (editedImage != null && context.mounted) { 105 109 // Post directly 106 - await context.router.push(StoryPostRoute(imageFile: editedImage)); 110 + await context.router.push( 111 + StoryPostRoute( 112 + imageFile: editedImage.image, 113 + embeds: editedImage.embeds, 114 + ), 115 + ); 107 116 } 108 117 } 109 118 } else {
+2 -1
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 842 842 labels: labels, 843 843 media: media, 844 844 ), 845 - story: (media, createdAt, sound, labels, tags) => StoryRecord( 845 + story: (media, createdAt, sound, labels, tags, embeds) => StoryRecord( 846 846 media: media, 847 847 createdAt: createdAt, 848 848 sound: sound, 849 849 labels: labels, 850 850 tags: tags, 851 + embeds: embeds, 851 852 ), 852 853 profile: 853 854 (
+2
lib/src/core/network/atproto/data/models/feed_models.dart
··· 1408 1408 required StoryRecord record, 1409 1409 required DateTime indexedAt, 1410 1410 MediaView? media, 1411 + @JsonKey(fromJson: storyEmbedViewsFromJson, toJson: storyEmbedViewsToJson) 1412 + List<StoryEmbedView>? embeds, 1411 1413 // viewer eventually i think 1412 1414 }) = _StoryView; 1413 1415 const StoryView._();
+1
lib/src/core/network/atproto/data/models/models.dart
··· 6 6 export 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; 7 7 export 'package:spark/src/core/network/atproto/data/models/record_models.dart'; 8 8 export 'package:spark/src/core/network/atproto/data/models/sound_models.dart'; 9 + export 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart';
+2
lib/src/core/network/atproto/data/models/record_models.dart
··· 44 44 RepoStrongRef? sound, 45 45 List<SelfLabel>? labels, 46 46 List<String>? tags, 47 + @JsonKey(fromJson: storyEmbedsFromJson, toJson: storyEmbedsToJson) 48 + List<StoryEmbed>? embeds, 47 49 }) = StoryRecord; 48 50 49 51 @JsonSerializable(explicitToJson: true)
+175
lib/src/core/network/atproto/data/models/story_embed_models.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 3 + 4 + part 'story_embed_models.freezed.dart'; 5 + part 'story_embed_models.g.dart'; 6 + 7 + const _storyMentionEmbedType = 'so.sprk.embed.mention'; 8 + const _storyMentionEmbedViewType = 'so.sprk.embed.mention#view'; 9 + 10 + List<StoryEmbed> storyEmbedsFromJson(dynamic json) { 11 + if (json is! List<dynamic>) { 12 + return const []; 13 + } 14 + 15 + final embeds = <StoryEmbed>[]; 16 + for (final item in json) { 17 + if (item is! Map<String, dynamic>) { 18 + continue; 19 + } 20 + 21 + try { 22 + embeds.add(_storyEmbedFromJson(item)); 23 + } catch (_) { 24 + continue; 25 + } 26 + } 27 + 28 + return embeds; 29 + } 30 + 31 + List<Map<String, dynamic>>? storyEmbedsToJson(List<StoryEmbed>? embeds) { 32 + if (embeds == null || embeds.isEmpty) { 33 + return null; 34 + } 35 + 36 + return embeds.map(_storyEmbedToJson).toList(); 37 + } 38 + 39 + List<StoryEmbedView> storyEmbedViewsFromJson(dynamic json) { 40 + if (json is! List<dynamic>) { 41 + return const []; 42 + } 43 + 44 + final embeds = <StoryEmbedView>[]; 45 + for (final item in json) { 46 + if (item is! Map<String, dynamic>) { 47 + continue; 48 + } 49 + 50 + try { 51 + embeds.add(_storyEmbedViewFromJson(item)); 52 + } catch (_) { 53 + continue; 54 + } 55 + } 56 + 57 + return embeds; 58 + } 59 + 60 + List<Map<String, dynamic>>? storyEmbedViewsToJson( 61 + List<StoryEmbedView>? embeds, 62 + ) { 63 + if (embeds == null || embeds.isEmpty) { 64 + return null; 65 + } 66 + 67 + return embeds.map(_storyEmbedViewToJson).toList(); 68 + } 69 + 70 + StoryEmbed _storyEmbedFromJson(Map<String, dynamic> json) { 71 + final type = json[r'$type'] as String?; 72 + if (type == null || type == _storyMentionEmbedType) { 73 + return StoryEmbed.fromJson(json); 74 + } 75 + 76 + throw FormatException('Unsupported story embed type: $type'); 77 + } 78 + 79 + Map<String, dynamic> _storyEmbedToJson(StoryEmbed embed) { 80 + return embed.map( 81 + mention: (value) => <String, dynamic>{ 82 + r'$type': _storyMentionEmbedType, 83 + ...value.toJson(), 84 + }, 85 + ); 86 + } 87 + 88 + StoryEmbedView _storyEmbedViewFromJson(Map<String, dynamic> json) { 89 + final type = json[r'$type'] as String?; 90 + if (type == null || type == _storyMentionEmbedViewType) { 91 + return StoryEmbedView.fromJson(json); 92 + } 93 + 94 + throw FormatException('Unsupported story embed view type: $type'); 95 + } 96 + 97 + Map<String, dynamic> _storyEmbedViewToJson(StoryEmbedView embed) { 98 + return embed.map( 99 + mention: (value) => <String, dynamic>{ 100 + r'$type': _storyMentionEmbedViewType, 101 + ...value.toJson(), 102 + }, 103 + ); 104 + } 105 + 106 + @freezed 107 + abstract class StoryEmbedFrame with _$StoryEmbedFrame { 108 + @JsonSerializable(explicitToJson: true) 109 + const factory StoryEmbedFrame({ 110 + required int x, 111 + required int y, 112 + required int w, 113 + required int h, 114 + }) = _StoryEmbedFrame; 115 + const StoryEmbedFrame._(); 116 + 117 + factory StoryEmbedFrame.fromJson(Map<String, dynamic> json) => 118 + _$StoryEmbedFrameFromJson(json); 119 + } 120 + 121 + @freezed 122 + abstract class StoryEmbedMediaRef with _$StoryEmbedMediaRef { 123 + @JsonSerializable(explicitToJson: true) 124 + const factory StoryEmbedMediaRef({required int index}) = _StoryEmbedMediaRef; 125 + const StoryEmbedMediaRef._(); 126 + 127 + factory StoryEmbedMediaRef.fromJson(Map<String, dynamic> json) => 128 + _$StoryEmbedMediaRefFromJson(json); 129 + } 130 + 131 + @freezed 132 + abstract class StoryEmbedPlacement with _$StoryEmbedPlacement { 133 + @JsonSerializable(explicitToJson: true) 134 + const factory StoryEmbedPlacement({ 135 + required StoryEmbedFrame frame, 136 + StoryEmbedMediaRef? mediaRef, 137 + int? zIndex, 138 + int? rotation, 139 + }) = _StoryEmbedPlacement; 140 + const StoryEmbedPlacement._(); 141 + 142 + factory StoryEmbedPlacement.fromJson(Map<String, dynamic> json) => 143 + _$StoryEmbedPlacementFromJson(json); 144 + } 145 + 146 + @Freezed(unionKey: r'$type') 147 + sealed class StoryEmbed with _$StoryEmbed { 148 + const StoryEmbed._(); 149 + 150 + @FreezedUnionValue(_storyMentionEmbedType) 151 + @JsonSerializable(explicitToJson: true) 152 + const factory StoryEmbed.mention({ 153 + required StoryEmbedPlacement placement, 154 + required String did, 155 + }) = StoryMentionEmbed; 156 + 157 + factory StoryEmbed.fromJson(Map<String, dynamic> json) => 158 + _$StoryEmbedFromJson(json); 159 + } 160 + 161 + @Freezed(unionKey: r'$type') 162 + sealed class StoryEmbedView with _$StoryEmbedView { 163 + const StoryEmbedView._(); 164 + 165 + @FreezedUnionValue(_storyMentionEmbedViewType) 166 + @JsonSerializable(explicitToJson: true) 167 + const factory StoryEmbedView.mention({ 168 + required StoryEmbedPlacement placement, 169 + required String did, 170 + ProfileViewBasic? actor, 171 + }) = StoryMentionEmbedView; 172 + 173 + factory StoryEmbedView.fromJson(Map<String, dynamic> json) => 174 + _$StoryEmbedViewFromJson(json); 175 + }
+2
lib/src/core/network/atproto/data/repositories/story_repository.dart
··· 14 14 Media media, { 15 15 List<SelfLabel>? selfLabels, 16 16 List<String>? tags, 17 + RepoStrongRef? soundRef, 18 + List<StoryEmbed>? embeds, 17 19 }); 18 20 19 21 /// Get stories timeline
+5
lib/src/core/network/atproto/data/repositories/story_repository_impl.dart
··· 181 181 Media media, { 182 182 List<SelfLabel>? selfLabels, 183 183 List<String>? tags, 184 + RepoStrongRef? soundRef, 185 + List<StoryEmbed>? embeds, 184 186 }) async { 185 187 final normalizedLabels = selfLabels == null || selfLabels.isEmpty 186 188 ? null 187 189 : selfLabels; 188 190 final normalizedTags = tags == null || tags.isEmpty ? null : tags; 191 + final normalizedEmbeds = embeds == null || embeds.isEmpty ? null : embeds; 189 192 190 193 final record = StoryRecord( 191 194 createdAt: DateTime.now().toUtc(), 192 195 media: media, 193 196 tags: normalizedTags, 194 197 labels: normalizedLabels, 198 + sound: soundRef, 199 + embeds: normalizedEmbeds, 195 200 ); 196 201 197 202 return _client.repo.createRecord(
+9
lib/src/core/pro_image_editor/models/story_image_editor_result.dart
··· 1 + import 'package:image_picker/image_picker.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 3 + 4 + class StoryImageEditorResult { 5 + const StoryImageEditorResult({required this.image, this.embeds = const []}); 6 + 7 + final XFile image; 8 + final List<StoryEmbed> embeds; 9 + }
+7 -4
lib/src/core/pro_image_editor/story_image_editor_configs.dart
··· 39 39 static ProImageEditorConfigs build({ 40 40 required bool useMaterialDesign, 41 41 required Widget Function() imagePreviewBuilder, 42 + Future<void> Function()? onMention, 43 + void Function(ProImageEditorState editor)? onDone, 42 44 }) { 43 45 return ProImageEditorConfigs( 44 46 designMode: platformDesignMode, ··· 79 81 appBar: (editor, rebuildStream) => null, 80 82 bottomBar: (editor, rebuildStream, key) => ReactiveWidget( 81 83 key: key, 82 - builder: (context) { 83 - return StoryEditorBottomSection(editor: editor); 84 - }, 84 + builder: (_) => 85 + StoryEditorBottomSection(editor: editor, onMention: onMention), 85 86 stream: rebuildStream, 86 87 ), 87 88 wrapBody: (editor, rebuildStream, content) { ··· 106 107 bottom: false, 107 108 child: StoryEditorHeader( 108 109 onBack: editor.closeEditor, 109 - onDone: editor.doneEditing, 110 + onDone: onDone != null 111 + ? () => onDone(editor) 112 + : editor.doneEditing, 110 113 canUndo: editor.canUndo, 111 114 canRedo: editor.canRedo, 112 115 onUndo: editor.undoAction,
+45
lib/src/core/pro_image_editor/story_mention_editing.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:pro_image_editor/pro_image_editor.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 4 + import 'package:spark/src/core/pro_image_editor/story_mention_layer.dart'; 5 + import 'package:spark/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart'; 6 + 7 + mixin StoryMentionEditing<T extends StatefulWidget> on State<T> { 8 + GlobalKey<ProImageEditorState> get storyEditorKey; 9 + Size get storyCanvasFallbackSize; 10 + 11 + List<StoryEmbed> _pendingStoryEmbeds = const []; 12 + 13 + List<StoryEmbed> get pendingStoryEmbeds => _pendingStoryEmbeds; 14 + 15 + void clearPendingStoryEmbeds() { 16 + _pendingStoryEmbeds = const []; 17 + } 18 + 19 + void finishStoryEditing(ProImageEditorState editor) { 20 + _pendingStoryEmbeds = extractStoryMentionEmbeds( 21 + editor.activeLayers, 22 + canvasSize: _activeStoryCanvasSize, 23 + ); 24 + editor.doneEditing(); 25 + } 26 + 27 + Future<void> addStoryMention() async { 28 + final actor = await showStoryMentionPickerSheet(context); 29 + final editor = storyEditorKey.currentState; 30 + if (actor == null || !mounted || editor == null) { 31 + return; 32 + } 33 + 34 + editor.addLayer(createStoryMentionLayer(actor)); 35 + } 36 + 37 + Size get _activeStoryCanvasSize { 38 + final size = storyEditorKey.currentState?.sizesManager.bodySize; 39 + if (size != null && size.width > 0 && size.height > 0) { 40 + return size; 41 + } 42 + 43 + return storyCanvasFallbackSize; 44 + } 45 + }
+143
lib/src/core/pro_image_editor/story_mention_layer.dart
··· 1 + import 'dart:math' as math; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:pro_image_editor/pro_image_editor.dart'; 5 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 7 + import 'package:spark/src/core/ui/widgets/story_mention_chip.dart'; 8 + 9 + const _kStoryMentionLayerTypeKey = 'storyMentionType'; 10 + const _kStoryMentionLayerTypeValue = 'mention'; 11 + const _kStoryMentionDidKey = 'did'; 12 + const _kStoryMentionHandleKey = 'handle'; 13 + const _kStoryMentionDisplayNameKey = 'displayName'; 14 + 15 + WidgetLayer createStoryMentionLayer(ProfileViewBasic actor) { 16 + final primaryText = '@${actor.handle}'; 17 + final initialSize = measureStoryMentionChipSize( 18 + primaryText: primaryText, 19 + height: kStoryMentionInitialHeight, 20 + ); 21 + 22 + return WidgetLayer( 23 + width: initialSize.width, 24 + widget: StoryMentionChip( 25 + primaryText: primaryText, 26 + fixedHeight: kStoryMentionInitialHeight, 27 + ), 28 + meta: { 29 + _kStoryMentionLayerTypeKey: _kStoryMentionLayerTypeValue, 30 + _kStoryMentionDidKey: actor.did, 31 + _kStoryMentionHandleKey: actor.handle, 32 + if (actor.displayName != null && actor.displayName!.isNotEmpty) 33 + _kStoryMentionDisplayNameKey: actor.displayName, 34 + }, 35 + ); 36 + } 37 + 38 + bool isStoryMentionLayer(Layer layer) { 39 + final meta = layer.meta; 40 + return layer is WidgetLayer && 41 + meta != null && 42 + meta[_kStoryMentionLayerTypeKey] == _kStoryMentionLayerTypeValue; 43 + } 44 + 45 + List<StoryEmbed> extractStoryMentionEmbeds( 46 + Iterable<Layer> layers, { 47 + required Size canvasSize, 48 + }) { 49 + if (canvasSize.width <= 0 || canvasSize.height <= 0) { 50 + return const []; 51 + } 52 + 53 + final halfCanvasWidth = canvasSize.width / 2; 54 + final halfCanvasHeight = canvasSize.height / 2; 55 + final canvasRect = Offset.zero & canvasSize; 56 + final embeds = <StoryEmbed>[]; 57 + 58 + for (final indexedLayer in layers.indexed) { 59 + final index = indexedLayer.$1; 60 + final layer = indexedLayer.$2; 61 + if (!isStoryMentionLayer(layer)) { 62 + continue; 63 + } 64 + 65 + final widgetLayer = layer as WidgetLayer; 66 + final did = widgetLayer.meta?[_kStoryMentionDidKey] as String?; 67 + if (did == null || did.isEmpty) { 68 + continue; 69 + } 70 + 71 + final frameSize = _resolvedLayerSize(widgetLayer); 72 + if (frameSize.isEmpty) { 73 + continue; 74 + } 75 + 76 + final left = layer.offset.dx + halfCanvasWidth - (frameSize.width / 2); 77 + final top = layer.offset.dy + halfCanvasHeight - (frameSize.height / 2); 78 + final visibleRect = Rect.fromLTWH( 79 + left, 80 + top, 81 + frameSize.width, 82 + frameSize.height, 83 + ).intersect(canvasRect); 84 + if (visibleRect.width <= 0 || visibleRect.height <= 0) { 85 + continue; 86 + } 87 + 88 + embeds.add( 89 + StoryEmbed.mention( 90 + placement: StoryEmbedPlacement( 91 + frame: StoryEmbedFrame( 92 + x: _normalize(visibleRect.left, canvasSize.width, clampMin: 0), 93 + y: _normalize(visibleRect.top, canvasSize.height, clampMin: 0), 94 + w: _normalize(visibleRect.width, canvasSize.width, clampMin: 1), 95 + h: _normalize(visibleRect.height, canvasSize.height, clampMin: 1), 96 + ), 97 + zIndex: index, 98 + rotation: _rotationDegrees(layer.rotation), 99 + ), 100 + did: did, 101 + ), 102 + ); 103 + } 104 + 105 + return embeds; 106 + } 107 + 108 + Size _resolvedLayerSize(WidgetLayer layer) { 109 + final renderObject = layer.keyInternalSize.currentContext?.findRenderObject(); 110 + final renderBox = renderObject is RenderBox ? renderObject : null; 111 + if (renderBox != null && renderBox.hasSize && !renderBox.size.isEmpty) { 112 + return renderBox.size; 113 + } 114 + 115 + final handle = layer.meta?[_kStoryMentionHandleKey] as String?; 116 + final baseSize = measureStoryMentionChipSize( 117 + primaryText: '@${handle ?? 'mention'}', 118 + height: kStoryMentionInitialHeight, 119 + ); 120 + final width = (layer.width ?? baseSize.width) * layer.scale; 121 + if (!width.isFinite || width <= 0) { 122 + return Size.zero; 123 + } 124 + 125 + final aspectRatio = baseSize.width <= 0 126 + ? 1 127 + : baseSize.height / baseSize.width; 128 + return Size(width, width * aspectRatio); 129 + } 130 + 131 + int _normalize(double value, double max, {required int clampMin}) { 132 + if (!value.isFinite || max <= 0 || !max.isFinite) { 133 + return clampMin; 134 + } 135 + 136 + return ((value / max) * 10000).round().clamp(clampMin, 10000); 137 + } 138 + 139 + int _rotationDegrees(double radians) { 140 + final degrees = radians * 180 / math.pi; 141 + final normalized = degrees % 360; 142 + return normalized.round().clamp(0, 359); 143 + }
+41 -13
lib/src/core/pro_image_editor/ui/story_image_editor_page.dart
··· 7 7 import 'package:image_picker/image_picker.dart'; 8 8 import 'package:path_provider/path_provider.dart'; 9 9 import 'package:pro_image_editor/pro_image_editor.dart'; 10 + import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; 11 + import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; 10 12 import 'package:spark/src/core/pro_image_editor/story_image_editor_configs.dart'; 11 13 import 'package:spark/src/core/pro_image_editor/utils/story_image_cropper.dart'; 12 14 ··· 30 32 /// Opens the story image editor and returns the edited image. 31 33 /// 32 34 /// Returns `null` if the user cancels without completing the edit. 33 - static Future<XFile?> open(BuildContext context, File imageFile) async { 34 - return Navigator.of(context).push<XFile?>( 35 + static Future<StoryImageEditorResult?> open( 36 + BuildContext context, 37 + File imageFile, 38 + ) async { 39 + return Navigator.of(context).push<StoryImageEditorResult?>( 35 40 MaterialPageRoute( 36 41 builder: (_) => StoryImageEditorPage(imageFile: imageFile), 37 42 ), ··· 42 47 State<StoryImageEditorPage> createState() => _StoryImageEditorPageState(); 43 48 } 44 49 45 - class _StoryImageEditorPageState extends State<StoryImageEditorPage> { 50 + class _StoryImageEditorPageState extends State<StoryImageEditorPage> 51 + with StoryMentionEditing<StoryImageEditorPage> { 46 52 final _editorKey = GlobalKey<ProImageEditorState>(); 47 53 final bool _useMaterialDesign = 48 54 platformDesignMode == ImageEditorDesignMode.material; ··· 53 59 String? _error; 54 60 55 61 @override 62 + GlobalKey<ProImageEditorState> get storyEditorKey => _editorKey; 63 + 64 + @override 65 + Size get storyCanvasFallbackSize => StoryImageEditorConfigs.storySize; 66 + 67 + @override 56 68 void initState() { 57 69 super.initState(); 58 70 _prepareImage(); ··· 87 99 useMaterialDesign: _useMaterialDesign, 88 100 imagePreviewBuilder: () => 89 101 Image.file(_croppedImageFile!, fit: BoxFit.cover), 102 + onMention: addStoryMention, 103 + onDone: finishStoryEditing, 90 104 ); 91 105 92 106 if (mounted) { ··· 112 126 await file.writeAsBytes(bytes, flush: true); 113 127 114 128 if (mounted) { 115 - Navigator.of( 116 - context, 117 - ).pop(XFile(file.path, mimeType: 'image/png', name: filename)); 129 + Navigator.of(context).pop( 130 + StoryImageEditorResult( 131 + image: XFile(file.path, mimeType: 'image/png', name: filename), 132 + embeds: pendingStoryEmbeds, 133 + ), 134 + ); 118 135 } 119 136 } 120 137 ··· 205 222 final Color backgroundColor; 206 223 207 224 /// Opens the blank canvas story editor and returns the edited image. 208 - static Future<XFile?> open( 225 + static Future<StoryImageEditorResult?> open( 209 226 BuildContext context, { 210 227 File? backgroundImage, 211 228 Color backgroundColor = Colors.black, 212 229 }) async { 213 - return Navigator.of(context).push<XFile?>( 230 + return Navigator.of(context).push<StoryImageEditorResult?>( 214 231 MaterialPageRoute( 215 232 builder: (_) => StoryBlankCanvasEditorPage( 216 233 backgroundImage: backgroundImage, ··· 225 242 _StoryBlankCanvasEditorPageState(); 226 243 } 227 244 228 - class _StoryBlankCanvasEditorPageState 229 - extends State<StoryBlankCanvasEditorPage> { 245 + class _StoryBlankCanvasEditorPageState extends State<StoryBlankCanvasEditorPage> 246 + with StoryMentionEditing<StoryBlankCanvasEditorPage> { 230 247 final _editorKey = GlobalKey<ProImageEditorState>(); 231 248 final bool _useMaterialDesign = 232 249 platformDesignMode == ImageEditorDesignMode.material; ··· 239 256 ImportStateHistory? _initialStateHistory; 240 257 241 258 @override 259 + GlobalKey<ProImageEditorState> get storyEditorKey => _editorKey; 260 + 261 + @override 262 + Size get storyCanvasFallbackSize => StoryImageEditorConfigs.storySize; 263 + 264 + @override 242 265 void initState() { 243 266 super.initState(); 244 267 _initializeEditor(); ··· 269 292 imagePreviewBuilder: () => widget.backgroundImage != null 270 293 ? Image.file(widget.backgroundImage!, fit: BoxFit.cover) 271 294 : const SizedBox.shrink(), 295 + onMention: addStoryMention, 296 + onDone: finishStoryEditing, 272 297 ); 273 298 274 299 if (!mounted) return; ··· 288 313 await file.writeAsBytes(bytes, flush: true); 289 314 290 315 if (mounted) { 291 - Navigator.of( 292 - context, 293 - ).pop(XFile(file.path, mimeType: 'image/png', name: filename)); 316 + Navigator.of(context).pop( 317 + StoryImageEditorResult( 318 + image: XFile(file.path, mimeType: 'image/png', name: filename), 319 + embeds: pendingStoryEmbeds, 320 + ), 321 + ); 294 322 } 295 323 } 296 324
+7 -1
lib/src/core/pro_image_editor/ui/widgets/story_editor_bottom_section.dart
··· 7 7 /// 8 8 /// Contains the toolbar with editing tools. 9 9 class StoryEditorBottomSection extends StatelessWidget { 10 - const StoryEditorBottomSection({required this.editor, super.key}); 10 + const StoryEditorBottomSection({ 11 + required this.editor, 12 + this.onMention, 13 + super.key, 14 + }); 11 15 12 16 final ProImageEditorState editor; 17 + final Future<void> Function()? onMention; 13 18 14 19 @override 15 20 Widget build(BuildContext context) { ··· 18 23 child: SafeArea( 19 24 top: false, 20 25 child: StoryEditorToolbar( 26 + onMention: onMention, 21 27 onPaint: editor.openPaintEditor, 22 28 onText: editor.openTextEditor, 23 29 onFilter: editor.openFilterEditor,
+40 -36
lib/src/core/pro_image_editor/ui/widgets/story_editor_toolbar.dart
··· 6 6 /// Displays horizontal list of editing tools optimized for stories. 7 7 class StoryEditorToolbar extends StatelessWidget { 8 8 const StoryEditorToolbar({ 9 + this.onMention, 9 10 required this.onPaint, 10 11 required this.onText, 11 12 required this.onFilter, ··· 15 16 super.key, 16 17 }); 17 18 19 + final Future<void> Function()? onMention; 18 20 final VoidCallback onPaint; 19 21 final VoidCallback onText; 20 22 final VoidCallback onFilter; ··· 24 26 25 27 @override 26 28 Widget build(BuildContext context) { 27 - return Container( 29 + final items = <Widget>[ 30 + if (onMention != null) 31 + _ToolbarItem( 32 + icon: Icons.alternate_email_rounded, 33 + label: 'Mention', 34 + onTap: () => onMention!.call(), 35 + ), 36 + _ToolbarItem(icon: Icons.brush_rounded, label: 'Draw', onTap: onPaint), 37 + _ToolbarItem( 38 + icon: Icons.text_fields_rounded, 39 + label: 'Text', 40 + onTap: onText, 41 + ), 42 + _ToolbarItem( 43 + icon: Icons.auto_awesome_rounded, 44 + label: 'Filter', 45 + onTap: onFilter, 46 + ), 47 + _ToolbarItem(icon: Icons.blur_on_rounded, label: 'Blur', onTap: onBlur), 48 + _ToolbarItem( 49 + icon: Icons.emoji_emotions_rounded, 50 + label: 'Emoji', 51 + onTap: onEmoji, 52 + ), 53 + _ToolbarItem( 54 + icon: Icons.sticky_note_2_rounded, 55 + label: 'Stickers', 56 + onTap: onStickers, 57 + ), 58 + ]; 59 + 60 + return SizedBox( 28 61 height: 80, 29 - padding: const EdgeInsets.symmetric(horizontal: 16), 30 - child: Row( 31 - mainAxisAlignment: MainAxisAlignment.spaceEvenly, 32 - children: [ 33 - _ToolbarItem( 34 - icon: Icons.brush_rounded, 35 - label: 'Draw', 36 - onTap: onPaint, 37 - ), 38 - _ToolbarItem( 39 - icon: Icons.text_fields_rounded, 40 - label: 'Text', 41 - onTap: onText, 42 - ), 43 - _ToolbarItem( 44 - icon: Icons.auto_awesome_rounded, 45 - label: 'Filter', 46 - onTap: onFilter, 47 - ), 48 - _ToolbarItem( 49 - icon: Icons.blur_on_rounded, 50 - label: 'Blur', 51 - onTap: onBlur, 52 - ), 53 - _ToolbarItem( 54 - icon: Icons.emoji_emotions_rounded, 55 - label: 'Emoji', 56 - onTap: onEmoji, 57 - ), 58 - _ToolbarItem( 59 - icon: Icons.sticky_note_2_rounded, 60 - label: 'Stickers', 61 - onTap: onStickers, 62 - ), 63 - ], 62 + child: ListView.separated( 63 + scrollDirection: Axis.horizontal, 64 + padding: const EdgeInsets.symmetric(horizontal: 16), 65 + itemCount: items.length, 66 + separatorBuilder: (_, _) => const SizedBox(width: 10), 67 + itemBuilder: (context, index) => items[index], 64 68 ), 65 69 ); 66 70 }
+188
lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 4 + import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 5 + import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 6 + 7 + Future<ProfileViewBasic?> showStoryMentionPickerSheet(BuildContext context) { 8 + return showModalBottomSheet<ProfileViewBasic>( 9 + context: context, 10 + isScrollControlled: true, 11 + useSafeArea: true, 12 + backgroundColor: const Color(0xFF0F172A), 13 + shape: const RoundedRectangleBorder( 14 + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), 15 + ), 16 + builder: (_) => const _StoryMentionPickerSheet(), 17 + ); 18 + } 19 + 20 + class _StoryMentionPickerSheet extends ConsumerStatefulWidget { 21 + const _StoryMentionPickerSheet(); 22 + 23 + @override 24 + ConsumerState<_StoryMentionPickerSheet> createState() => 25 + _StoryMentionPickerSheetState(); 26 + } 27 + 28 + class _StoryMentionPickerSheetState 29 + extends ConsumerState<_StoryMentionPickerSheet> { 30 + final TextEditingController _textController = TextEditingController(); 31 + 32 + @override 33 + void dispose() { 34 + _textController.dispose(); 35 + super.dispose(); 36 + } 37 + 38 + @override 39 + Widget build(BuildContext context) { 40 + final typeaheadState = ref.watch(actorTypeaheadProvider); 41 + 42 + return Padding( 43 + padding: EdgeInsets.only( 44 + left: 20, 45 + right: 20, 46 + top: 16, 47 + bottom: MediaQuery.viewInsetsOf(context).bottom + 20, 48 + ), 49 + child: Column( 50 + mainAxisSize: MainAxisSize.min, 51 + children: [ 52 + Container( 53 + width: 42, 54 + height: 4, 55 + decoration: BoxDecoration( 56 + color: const Color(0x334B5563), 57 + borderRadius: BorderRadius.circular(999), 58 + ), 59 + ), 60 + const SizedBox(height: 18), 61 + const Align( 62 + alignment: Alignment.centerLeft, 63 + child: Text( 64 + 'Mention someone', 65 + style: TextStyle( 66 + color: Colors.white, 67 + fontSize: 20, 68 + fontWeight: FontWeight.w700, 69 + ), 70 + ), 71 + ), 72 + const SizedBox(height: 8), 73 + const Align( 74 + alignment: Alignment.centerLeft, 75 + child: Text( 76 + 'Search for an account to place on your story.', 77 + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), 78 + ), 79 + ), 80 + const SizedBox(height: 16), 81 + TextField( 82 + controller: _textController, 83 + autofocus: true, 84 + style: const TextStyle(color: Colors.white), 85 + cursorColor: Colors.white, 86 + decoration: InputDecoration( 87 + hintText: 'Search by handle or display name', 88 + hintStyle: const TextStyle(color: Color(0xFF64748B)), 89 + prefixIcon: const Icon(Icons.search, color: Color(0xFF94A3B8)), 90 + filled: true, 91 + fillColor: const Color(0xFF111827), 92 + enabledBorder: OutlineInputBorder( 93 + borderRadius: BorderRadius.circular(18), 94 + borderSide: const BorderSide(color: Color(0x334B5563)), 95 + ), 96 + focusedBorder: OutlineInputBorder( 97 + borderRadius: BorderRadius.circular(18), 98 + borderSide: const BorderSide(color: Color(0x99FFFFFF)), 99 + ), 100 + ), 101 + onChanged: (value) { 102 + ref.read(actorTypeaheadProvider.notifier).updateQuery(value); 103 + }, 104 + ), 105 + const SizedBox(height: 16), 106 + Flexible( 107 + child: ConstrainedBox( 108 + constraints: const BoxConstraints(maxHeight: 360), 109 + child: _ResultsList(typeaheadState: typeaheadState), 110 + ), 111 + ), 112 + ], 113 + ), 114 + ); 115 + } 116 + } 117 + 118 + class _ResultsList extends StatelessWidget { 119 + const _ResultsList({required this.typeaheadState}); 120 + 121 + final ActorTypeaheadState typeaheadState; 122 + 123 + @override 124 + Widget build(BuildContext context) { 125 + if (typeaheadState.isLoading) { 126 + return const Center( 127 + child: CircularProgressIndicator(color: Colors.white), 128 + ); 129 + } 130 + 131 + if (typeaheadState.error != null) { 132 + return Center( 133 + child: Text( 134 + typeaheadState.error!, 135 + style: const TextStyle(color: Color(0xFFFCA5A5)), 136 + ), 137 + ); 138 + } 139 + 140 + if (typeaheadState.query.isEmpty) { 141 + return const Center( 142 + child: Text( 143 + 'Start typing to find someone.', 144 + style: TextStyle(color: Color(0xFF94A3B8)), 145 + ), 146 + ); 147 + } 148 + 149 + if (typeaheadState.results.isEmpty) { 150 + return const Center( 151 + child: Text( 152 + 'No people found for that search.', 153 + style: TextStyle(color: Color(0xFF94A3B8)), 154 + ), 155 + ); 156 + } 157 + 158 + return ListView.separated( 159 + shrinkWrap: true, 160 + itemCount: typeaheadState.results.length, 161 + separatorBuilder: (_, _) => const Divider(color: Color(0x1FFFFFFF)), 162 + itemBuilder: (context, index) { 163 + final actor = typeaheadState.results[index]; 164 + return ListTile( 165 + contentPadding: EdgeInsets.zero, 166 + leading: CircleAvatar( 167 + backgroundColor: const Color(0x1AFFFFFF), 168 + backgroundImage: actor.avatar != null 169 + ? NetworkImage(actor.avatar.toString()) 170 + : null, 171 + child: actor.avatar == null 172 + ? const Icon(Icons.person_outline, color: Colors.white) 173 + : null, 174 + ), 175 + title: Text( 176 + actor.displayName ?? actor.handle, 177 + style: const TextStyle(color: Colors.white), 178 + ), 179 + subtitle: Text( 180 + '@${actor.handle}', 181 + style: const TextStyle(color: Color(0xFF94A3B8)), 182 + ), 183 + onTap: () => Navigator.of(context).pop(actor), 184 + ); 185 + }, 186 + ); 187 + } 188 + }
+9 -1
lib/src/core/pro_video_editor/models/video_editor_result.dart
··· 1 1 import 'package:atproto/com_atproto_repo_strongref.dart'; 2 2 import 'package:image_picker/image_picker.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 3 4 4 5 /// Result returned from the video editor containing the edited video 5 6 /// and optional metadata about audio used. 6 7 class VideoEditorResult { 7 - const VideoEditorResult({required this.video, this.soundRef}); 8 + const VideoEditorResult({ 9 + required this.video, 10 + this.soundRef, 11 + this.embeds = const [], 12 + }); 8 13 9 14 /// The edited video file. 10 15 final XFile video; 11 16 12 17 /// Reference to the audio track used, if any. 13 18 final RepoStrongRef? soundRef; 19 + 20 + /// Story embeds extracted alongside the exported video. 21 + final List<StoryEmbed> embeds; 14 22 }
+6 -2
lib/src/core/pro_video_editor/pro_video_editor_repository.dart
··· 3 3 import 'package:flutter/widgets.dart'; 4 4 import 'package:image_picker/image_picker.dart'; 5 5 import 'package:pro_video_editor/pro_video_editor.dart'; 6 + import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; 6 7 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 7 8 8 9 /// Abstraction over the pro_video_editor plugin. ··· 52 53 /// tools (text, paint, stickers, emoji, filter, blur - NO crop/rotate). 53 54 /// 54 55 /// Returns `null` if the user cancels without completing the edit. 55 - Future<XFile?> openStoryImageEditor(BuildContext context, XFile source); 56 + Future<StoryImageEditorResult?> openStoryImageEditor( 57 + BuildContext context, 58 + XFile source, 59 + ); 56 60 57 61 /// Opens a blank canvas Story Image Editor (1080x1920). 58 62 /// ··· 60 64 /// This gives more flexibility for positioning the image. 61 65 /// 62 66 /// Returns `null` if the user cancels without completing the edit. 63 - Future<XFile?> openStoryBlankCanvasEditor( 67 + Future<StoryImageEditorResult?> openStoryBlankCanvasEditor( 64 68 BuildContext context, { 65 69 XFile? backgroundImage, 66 70 Color backgroundColor = const Color(0xFF000000),
+6 -2
lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart
··· 6 6 import 'package:path_provider/path_provider.dart'; 7 7 import 'package:pro_image_editor/pro_image_editor.dart'; 8 8 import 'package:pro_video_editor/pro_video_editor.dart'; 9 + import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; 9 10 import 'package:spark/src/core/pro_image_editor/ui/story_image_editor_page.dart'; 10 11 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 11 12 import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; ··· 82 83 } 83 84 84 85 @override 85 - Future<XFile?> openStoryImageEditor(BuildContext context, XFile source) { 86 + Future<StoryImageEditorResult?> openStoryImageEditor( 87 + BuildContext context, 88 + XFile source, 89 + ) { 86 90 return StoryBlankCanvasEditorPage.open( 87 91 context, 88 92 backgroundImage: File(source.path), ··· 90 94 } 91 95 92 96 @override 93 - Future<XFile?> openStoryBlankCanvasEditor( 97 + Future<StoryImageEditorResult?> openStoryBlankCanvasEditor( 94 98 BuildContext context, { 95 99 XFile? backgroundImage, 96 100 Color backgroundColor = const Color(0xFF000000),
+14 -2
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 13 13 import 'package:pro_image_editor/pro_image_editor.dart'; 14 14 import 'package:pro_video_editor/pro_video_editor.dart'; 15 15 import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; 16 + import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; 16 17 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 17 18 import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; 18 19 import 'package:spark/src/core/pro_video_editor/services/audio_waveform_extractor.dart'; ··· 43 44 _VideoEditorGroundedPageState(); 44 45 } 45 46 46 - class _VideoEditorGroundedPageState extends State<VideoEditorGroundedPage> { 47 + class _VideoEditorGroundedPageState extends State<VideoEditorGroundedPage> 48 + with StoryMentionEditing<VideoEditorGroundedPage> { 47 49 static const _storyCanvasSize = Size(1440, 2560); 48 50 49 51 final _editorKey = GlobalKey<ProImageEditorState>(); ··· 104 106 late ProImageEditorConfigs _configs; 105 107 late VideoTimelineState _videoTimelineState; 106 108 List<AudioTrack> _audioTracks = []; 109 + 110 + @override 111 + GlobalKey<ProImageEditorState> get storyEditorKey => _editorKey; 112 + 113 + @override 114 + Size get storyCanvasFallbackSize => _storyCanvasSize; 107 115 108 116 @override 109 117 void initState() { ··· 211 219 onToggleMute: _onToggleMute, 212 220 onAddSound: _showAudioSelectionBottomSheet, 213 221 onToggleFullscreen: _openFullscreenPreview, 222 + onMention: widget.storyMode ? addStoryMention : null, 223 + onDone: widget.storyMode ? finishStoryEditing : null, 214 224 ); 215 225 216 226 // Update clip duration and thumbnails after first frame ··· 618 628 VideoEditorResult( 619 629 video: XFile(_outputPath!, mimeType: 'video/mp4'), 620 630 soundRef: _selectedSoundRef, 631 + embeds: pendingStoryEmbeds, 621 632 ), 622 633 ); 623 634 _outputPath = null; 624 635 _selectedSoundRef = null; 636 + clearPendingStoryEmbeds(); 625 637 } else { 638 + clearPendingStoryEmbeds(); 626 639 Navigator.pop(context); 627 640 } 628 641 } ··· 696 709 await _audioService.pause(); 697 710 }, 698 711 ), 699 - mainEditorCallbacks: const MainEditorCallbacks(), 700 712 stickerEditorCallbacks: StickerEditorCallbacks( 701 713 onSearchChanged: (_) {}, 702 714 ),
+10 -3
lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart
··· 66 66 required VoidCallback onToggleMute, 67 67 required VoidCallback onAddSound, 68 68 required VoidCallback onToggleFullscreen, 69 + Future<void> Function()? onMention, 70 + void Function(ProImageEditorState editor)? onDone, 69 71 bool storyMode = false, 70 72 List<AudioTrack> audioTracks = const [], 71 73 VideoEditorConfigs videoEditorConfigs = const VideoEditorConfigs( ··· 104 106 appBar: (editor, rebuildStream) => null, 105 107 bottomBar: (editor, rebuildStream, key) => ReactiveWidget( 106 108 key: key, 107 - builder: (context) { 109 + builder: (_) { 108 110 if (storyMode) { 109 - return StoryEditorBottomSection(editor: editor); 111 + return StoryEditorBottomSection( 112 + editor: editor, 113 + onMention: onMention, 114 + ); 110 115 } 111 116 112 117 return VideoEditorBottomSection( ··· 148 153 child: storyMode 149 154 ? StoryEditorHeader( 150 155 onBack: editor.closeEditor, 151 - onDone: editor.doneEditing, 156 + onDone: onDone != null 157 + ? () => onDone(editor) 158 + : editor.doneEditing, 152 159 canUndo: editor.canUndo, 153 160 canRedo: editor.canRedo, 154 161 onUndo: editor.undoAction,
+192
lib/src/core/ui/widgets/story_mention_chip.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; 3 + import 'package:spark/src/core/design_system/tokens/colors.dart'; 4 + 5 + const double kStoryMentionInitialHeight = 64; 6 + 7 + class StoryMentionChip extends StatelessWidget { 8 + const StoryMentionChip({ 9 + required this.primaryText, 10 + this.secondaryText, 11 + this.compact = false, 12 + this.fixedHeight, 13 + super.key, 14 + }); 15 + 16 + final String primaryText; 17 + final String? secondaryText; 18 + final bool compact; 19 + final double? fixedHeight; 20 + 21 + @override 22 + Widget build(BuildContext context) { 23 + final child = _MentionText( 24 + primaryText: primaryText, 25 + secondaryText: secondaryText, 26 + compact: compact, 27 + fixedHeight: fixedHeight, 28 + ); 29 + 30 + if (fixedHeight == null) { 31 + return child; 32 + } 33 + 34 + return SizedBox(height: fixedHeight, child: child); 35 + } 36 + } 37 + 38 + Size measureStoryMentionChipSize({ 39 + required String primaryText, 40 + String? secondaryText, 41 + bool compact = false, 42 + double height = kStoryMentionInitialHeight, 43 + }) { 44 + final metrics = _StoryMentionMetrics.fromHeight( 45 + height: height, 46 + compact: compact, 47 + ); 48 + final handleText = primaryText.startsWith('@') 49 + ? primaryText.substring(1) 50 + : primaryText; 51 + final baseStyle = TextStyle( 52 + color: const Color(0xFF111827), 53 + fontWeight: FontWeight.w700, 54 + fontSize: metrics.fontSize, 55 + height: compact ? 1 : 1.05, 56 + ); 57 + 58 + final painter = TextPainter( 59 + text: TextSpan( 60 + style: const TextStyle(leadingDistribution: TextLeadingDistribution.even), 61 + children: [ 62 + TextSpan( 63 + text: '@', 64 + style: baseStyle.copyWith(color: AppColors.primary600), 65 + ), 66 + TextSpan(text: handleText, style: baseStyle), 67 + if (!compact && secondaryText != null && secondaryText.isNotEmpty) 68 + TextSpan( 69 + text: '\n$secondaryText', 70 + style: baseStyle.copyWith( 71 + fontSize: metrics.sublineSize, 72 + fontWeight: FontWeight.w500, 73 + color: AppColors.grey400, 74 + height: 1.15, 75 + ), 76 + ), 77 + ], 78 + ), 79 + textDirection: TextDirection.ltr, 80 + )..layout(); 81 + 82 + return Size(painter.width + metrics.horizontalPadding * 2, height); 83 + } 84 + 85 + class _MentionText extends StatelessWidget { 86 + const _MentionText({ 87 + required this.primaryText, 88 + required this.secondaryText, 89 + required this.compact, 90 + required this.fixedHeight, 91 + }); 92 + 93 + final String primaryText; 94 + final String? secondaryText; 95 + final bool compact; 96 + final double? fixedHeight; 97 + 98 + @override 99 + Widget build(BuildContext context) { 100 + return LayoutBuilder( 101 + builder: (context, constraints) { 102 + final availableHeight = fixedHeight ?? constraints.maxHeight; 103 + final metrics = _StoryMentionMetrics.fromHeight( 104 + height: availableHeight.isFinite 105 + ? availableHeight 106 + : kStoryMentionInitialHeight, 107 + compact: compact, 108 + ); 109 + final handleText = primaryText.startsWith('@') 110 + ? primaryText.substring(1) 111 + : primaryText; 112 + final maxTextWidth = constraints.maxWidth.isFinite 113 + ? (constraints.maxWidth - metrics.horizontalPadding * 2).clamp( 114 + 24.0, 115 + double.infinity, 116 + ) 117 + : double.infinity; 118 + 119 + final baseStyle = TextStyle( 120 + color: const Color(0xFF111827), 121 + fontWeight: FontWeight.w700, 122 + fontSize: metrics.fontSize, 123 + height: compact ? 1 : 1.05, 124 + ); 125 + 126 + return Align( 127 + alignment: Alignment.centerLeft, 128 + child: Padding( 129 + padding: EdgeInsets.symmetric( 130 + horizontal: metrics.horizontalPadding, 131 + ), 132 + child: RoundedBackgroundText.rich( 133 + maxTextWidth: maxTextWidth, 134 + backgroundColor: Colors.white, 135 + leadingDistribution: TextLeadingDistribution.even, 136 + text: TextSpan( 137 + children: [ 138 + TextSpan( 139 + text: '@', 140 + style: baseStyle.copyWith(color: AppColors.primary600), 141 + ), 142 + TextSpan(text: handleText, style: baseStyle), 143 + if (!compact && 144 + secondaryText != null && 145 + secondaryText!.isNotEmpty) 146 + TextSpan( 147 + text: '\n$secondaryText', 148 + style: baseStyle.copyWith( 149 + fontSize: metrics.sublineSize, 150 + fontWeight: FontWeight.w500, 151 + color: AppColors.grey400, 152 + height: 1.15, 153 + ), 154 + ), 155 + ], 156 + ), 157 + ), 158 + ), 159 + ); 160 + }, 161 + ); 162 + } 163 + } 164 + 165 + class _StoryMentionMetrics { 166 + const _StoryMentionMetrics({ 167 + required this.fontSize, 168 + required this.sublineSize, 169 + required this.horizontalPadding, 170 + }); 171 + 172 + final double fontSize; 173 + final double sublineSize; 174 + final double horizontalPadding; 175 + 176 + factory _StoryMentionMetrics.fromHeight({ 177 + required double height, 178 + required bool compact, 179 + }) { 180 + final normalizedHeight = height.clamp(compact ? 28.0 : 36.0, 96.0); 181 + final fontSize = (normalizedHeight * (compact ? 0.42 : 0.32)).clamp( 182 + compact ? 12.0 : 14.0, 183 + compact ? 18.0 : 20.0, 184 + ); 185 + 186 + return _StoryMentionMetrics( 187 + fontSize: fontSize, 188 + sublineSize: (fontSize * 0.62).clamp(10.0, 13.0), 189 + horizontalPadding: compact ? 4 : 6, 190 + ); 191 + } 192 + }
+10 -6
lib/src/features/posting/models/mention_text_editing_controller.dart
··· 29 29 TextStyle? style, 30 30 required bool withComposing, 31 31 }) { 32 - assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid); 32 + assert( 33 + !value.composing.isValid || !withComposing || value.isComposingRangeValid, 34 + ); 33 35 34 36 final theme = Theme.of(context); 35 37 final effectiveStyle = ··· 54 56 55 57 final composingRegionOutOfRange = 56 58 !value.isComposingRangeValid || !withComposing; 57 - final composingStart = composingRegionOutOfRange ? -1 : value.composing.start; 59 + final composingStart = composingRegionOutOfRange 60 + ? -1 61 + : value.composing.start; 58 62 final composingEnd = composingRegionOutOfRange ? -1 : value.composing.end; 59 63 60 64 final boundaries = <int>{0, text.length}; ··· 73 77 final charEnd = TextFormatter.byteIndexToCharIndex(text, mention.byteEnd); 74 78 75 79 // Skip invalid mentions. 76 - if (charStart < 0 || 77 - charEnd > text.length || 78 - charStart >= charEnd) { 80 + if (charStart < 0 || charEnd > text.length || charStart >= charEnd) { 79 81 continue; 80 82 } 81 83 ··· 113 115 segmentStyle = segmentStyle.merge(composingStyle); 114 116 } 115 117 116 - spans.add(TextSpan(text: text.substring(start, end), style: segmentStyle)); 118 + spans.add( 119 + TextSpan(text: text.substring(start, end), style: segmentStyle), 120 + ); 117 121 } 118 122 119 123 return TextSpan(children: spans, style: effectiveStyle);
+1 -1
lib/src/features/posting/providers/camera_provider.dart
··· 44 44 final cameras = await availableCameras(); 45 45 if (cameras.isEmpty) { 46 46 _logger.w('No cameras found on device'); 47 - throw Exception('No cameras found'); 47 + return const CameraState(cameras: [], isInitialized: true); 48 48 } 49 49 50 50 _logger.i('Found ${cameras.length} cameras');
+4
lib/src/features/posting/providers/post_story.dart
··· 12 12 Media media, { 13 13 List<SelfLabel>? selfLabels, 14 14 List<String>? tags, 15 + RepoStrongRef? soundRef, 16 + List<StoryEmbed>? embeds, 15 17 }) async { 16 18 final storyRepository = GetIt.I<StoryRepository>(); 17 19 return await storyRepository.postStory( 18 20 media, 19 21 selfLabels: selfLabels, 20 22 tags: tags, 23 + soundRef: soundRef, 24 + embeds: embeds, 21 25 ); 22 26 }
+3
lib/src/features/posting/providers/video_upload_provider.dart
··· 108 108 bool storyMode = false, 109 109 RepoStrongRef? soundRef, 110 110 List<Facet> facets = const [], 111 + List<StoryEmbed> storyEmbeds = const [], 111 112 }) async { 112 113 final logger = GetIt.I<LogService>().getLogger('Process/Post Video') 113 114 ..d( ··· 148 149 final storyRepository = GetIt.I<StoryRepository>(); 149 150 final res = await storyRepository.postStory( 150 151 Media.video(video: videoBlob), 152 + soundRef: effectiveSoundRef, 153 + embeds: storyEmbeds, 151 154 ); 152 155 logger.i('Story posted: ${res.uri}'); 153 156 return res;
+53 -4
lib/src/features/posting/ui/pages/recording_page.dart
··· 59 59 }); 60 60 } 61 61 62 + bool _hasCameras() { 63 + final cameraAsync = ref.read(cameraProvider); 64 + final cameraState = cameraAsync.value; 65 + return cameraState != null && cameraState.cameras.isNotEmpty; 66 + } 67 + 62 68 bool _isCameraReady() { 63 69 final cameraAsync = ref.read(cameraProvider); 64 70 if (cameraAsync.hasError) return false; 65 71 final cameraState = cameraAsync.value; 66 72 return cameraState != null && 67 73 cameraState.isInitialized && 68 - cameraState.controller != null; 74 + cameraState.controller != null && 75 + cameraState.cameras.isNotEmpty; 69 76 } 70 77 71 78 /// Handle tap on record button. ··· 218 225 final result = await StoryDirectPost.postPhotoStory( 219 226 context, 220 227 ref, 221 - editedImage, 228 + editedImage.image, 229 + embeds: editedImage.embeds, 222 230 ); 223 231 if (result != null && mounted) { 224 232 // Exit the recording flow completely ··· 240 248 // For posts, go to review page 241 249 await context.router.push( 242 250 ImageReviewRoute( 243 - imageFiles: [editedImage], 251 + imageFiles: [editedImage.image], 244 252 storyMode: widget.storyMode, 245 253 ), 246 254 ); ··· 351 359 ref, 352 360 result.video.path, 353 361 soundRef: result.soundRef, 362 + embeds: result.embeds, 354 363 ); 355 364 if (postResult != null && mounted) { 356 365 // Exit the recording flow completely ··· 421 430 }); 422 431 } 423 432 433 + final hasCameras = _hasCameras(); 434 + 424 435 return cameraAsync.when( 425 436 data: (cameraState) { 426 437 if (cameraState.error != null) { ··· 461 472 ); 462 473 } 463 474 464 - if (!cameraState.isInitialized || cameraState.controller == null) { 475 + if (!cameraState.isInitialized) { 465 476 return const Scaffold( 466 477 backgroundColor: Colors.black, 467 478 body: Center(child: CircularProgressIndicator(color: Colors.white)), ··· 473 484 return const Scaffold( 474 485 backgroundColor: Colors.black, 475 486 body: Center(child: CircularProgressIndicator(color: Colors.white)), 487 + ); 488 + } 489 + 490 + // No cameras available - show placeholder with library picker 491 + if (!hasCameras || cameraState.controller == null) { 492 + return RecordingPageTemplate( 493 + cameraPreview: Container( 494 + color: Colors.black, 495 + child: const Center( 496 + child: Column( 497 + mainAxisAlignment: MainAxisAlignment.center, 498 + children: [ 499 + Icon( 500 + Icons.videocam_off_outlined, 501 + color: Colors.white54, 502 + size: 64, 503 + ), 504 + SizedBox(height: 16), 505 + Text( 506 + 'No cameras available', 507 + style: TextStyle(color: Colors.white54, fontSize: 16), 508 + ), 509 + ], 510 + ), 511 + ), 512 + ), 513 + aspectRatio: 9 / 16, 514 + isRecording: false, 515 + elapsedDuration: Duration.zero, 516 + maxDuration: recordingState.maxDuration, 517 + onBack: () => context.router.pop(), 518 + onFlipCamera: null, 519 + canFlipCamera: false, 520 + captureMode: widget.captureMode, 521 + onTap: null, 522 + onRecordStart: null, 523 + onRecordStop: null, 524 + onOpenLibrary: _isProcessing ? null : _openMediaLibraryPicker, 476 525 ); 477 526 } 478 527
+16 -5
lib/src/features/posting/ui/pages/story_post_page.dart
··· 1 1 import 'package:auto_route/auto_route.dart'; 2 + import 'package:atproto/com_atproto_repo_strongref.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:get_it/get_it.dart'; ··· 15 16 /// Shows a loading indicator while uploading and posting. 16 17 @RoutePage() 17 18 class StoryPostPage extends ConsumerStatefulWidget { 18 - const StoryPostPage({this.imageFile, this.videoPath, super.key}) 19 - : assert( 20 - imageFile != null || videoPath != null, 21 - 'Either imageFile or videoPath must be provided', 22 - ); 19 + const StoryPostPage({ 20 + this.imageFile, 21 + this.videoPath, 22 + this.soundRef, 23 + this.embeds = const [], 24 + super.key, 25 + }) : assert( 26 + imageFile != null || videoPath != null, 27 + 'Either imageFile or videoPath must be provided', 28 + ); 23 29 24 30 final XFile? imageFile; 25 31 final String? videoPath; 32 + final RepoStrongRef? soundRef; 33 + final List<StoryEmbed> embeds; 26 34 27 35 @override 28 36 ConsumerState<StoryPostPage> createState() => _StoryPostPageState(); ··· 93 101 final result = await ref.read( 94 102 postStoryProvider( 95 103 Media.image(image: uploadedImage.image, alt: uploadedImage.alt), 104 + embeds: widget.embeds, 96 105 ).future, 97 106 ); 98 107 ··· 110 119 processAndPostVideoProvider( 111 120 videoPath: widget.videoPath!, 112 121 storyMode: true, 122 + soundRef: widget.soundRef, 123 + storyEmbeds: widget.embeds, 113 124 ).future, 114 125 ); 115 126
+6 -2
lib/src/features/posting/utils/story_direct_post.dart
··· 20 20 static Future<RepoStrongRef?> postPhotoStory( 21 21 BuildContext context, 22 22 WidgetRef ref, 23 - XFile imageFile, 24 - ) async { 23 + XFile imageFile, { 24 + List<StoryEmbed> embeds = const [], 25 + }) async { 25 26 // Show loading overlay 26 27 final navigator = Navigator.of(context); 27 28 ··· 50 51 final result = await ref.read( 51 52 postStoryProvider( 52 53 Media.image(image: uploadedImage.image, alt: uploadedImage.alt), 54 + embeds: embeds, 53 55 ).future, 54 56 ); 55 57 ··· 81 83 WidgetRef ref, 82 84 String videoPath, { 83 85 RepoStrongRef? soundRef, 86 + List<StoryEmbed> embeds = const [], 84 87 }) async { 85 88 // Show loading overlay 86 89 final navigator = Navigator.of(context); ··· 98 101 videoPath: videoPath, 99 102 storyMode: true, 100 103 soundRef: soundRef, 104 + storyEmbeds: embeds, 101 105 ).future, 102 106 ); 103 107
+4 -20
lib/src/features/stories/ui/pages/author_stories_page.dart
··· 292 292 _onStoryLoadingStateChanged(index, isLoading), 293 293 onStoryDurationChanged: (duration) => 294 294 _onStoryDurationChanged(index, duration), 295 + onPauseRequested: _pause, 296 + onResumeRequested: _resume, 297 + onPrevious: _previousStory, 298 + onNext: _nextStory, 295 299 ); 296 300 }, 297 301 ), ··· 394 398 ), 395 399 ), 396 400 ], 397 - ), 398 - ), 399 - Positioned( 400 - top: 80, 401 - bottom: 0, 402 - left: 0, 403 - width: MediaQuery.of(context).size.width * 0.3, 404 - child: GestureDetector( 405 - onTap: _previousStory, 406 - child: Container(color: Colors.transparent), 407 - ), 408 - ), 409 - Positioned( 410 - top: 80, 411 - bottom: 0, 412 - right: 0, 413 - width: MediaQuery.of(context).size.width * 0.3, 414 - child: GestureDetector( 415 - onTap: _nextStory, 416 - child: Container(color: Colors.transparent), 417 401 ), 418 402 ), 419 403 ],
+197 -53
lib/src/features/stories/ui/pages/story_page.dart
··· 1 + import 'dart:math' as math; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 2 4 import 'package:cached_network_image/cached_network_image.dart'; 3 5 import 'package:flutter/material.dart'; 6 + import 'package:flutter/widgets.dart' as flutter_widgets show Image; 4 7 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 8 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 9 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 10 + import 'package:spark/src/core/routing/app_router.dart'; 6 11 import 'package:video_player/video_player.dart'; 7 12 8 13 @RoutePage() ··· 12 17 super.key, 13 18 this.onLoadingStateChanged, 14 19 this.onStoryDurationChanged, 20 + this.onPauseRequested, 21 + this.onResumeRequested, 22 + this.onPrevious, 23 + this.onNext, 15 24 }); 16 25 17 26 final StoryView story; 18 27 final ValueChanged<bool>? onLoadingStateChanged; 19 28 final ValueChanged<Duration>? onStoryDurationChanged; 29 + final VoidCallback? onPauseRequested; 30 + final VoidCallback? onResumeRequested; 31 + final VoidCallback? onPrevious; 32 + final VoidCallback? onNext; 20 33 21 34 @override 22 35 ConsumerState<ConsumerStatefulWidget> createState() => _StoryPageState(); ··· 136 149 }; 137 150 } 138 151 152 + Future<void> _handleEmbedTap(StoryMentionEmbedView embed) async { 153 + final router = context.router; 154 + widget.onPauseRequested?.call(); 155 + final videoController = _videoController; 156 + final wasPlaying = videoController?.value.isPlaying ?? false; 157 + if (wasPlaying) { 158 + await videoController?.pause(); 159 + } 160 + 161 + await router.push( 162 + ProfileRoute(did: embed.did, initialProfile: embed.actor), 163 + ); 164 + 165 + if (!mounted) { 166 + return; 167 + } 168 + 169 + widget.onResumeRequested?.call(); 170 + if (wasPlaying) { 171 + await videoController?.play(); 172 + } 173 + } 174 + 139 175 @override 140 176 Widget build(BuildContext context) { 141 - const footerHeight = kBottomNavigationBarHeight + 12; 142 177 const borderRadius = BorderRadius.all(Radius.circular(20)); 143 178 144 179 // Determine the main media widget (video or image) first. 145 180 late final Widget mediaContent; 181 + late final Size sourceSize; 146 182 147 183 if (_isVideoStory(widget.story)) { 148 184 if (_videoController != null && _isVideoInitialized) { 149 185 if (_videoController!.value.isInitialized) { 150 186 final size = _videoController!.value.size; 187 + sourceSize = Size( 188 + size.width > 0 ? size.width : 1440, 189 + size.height > 0 ? size.height : 2560, 190 + ); 151 191 mediaContent = Container( 152 192 color: Colors.black, 153 193 alignment: Alignment.center, ··· 161 201 ), 162 202 ); 163 203 } else { 204 + sourceSize = const Size(1440, 2560); 164 205 mediaContent = const Center( 165 206 child: Icon(Icons.videocam_off, size: 48, color: Colors.white), 166 207 ); 167 208 } 168 209 } else { 210 + sourceSize = const Size(1440, 2560); 169 211 mediaContent = const Center(child: CircularProgressIndicator()); 170 212 } 171 213 } else { 172 214 final imageUrl = _getImageUrl(widget.story); 215 + sourceSize = const Size(1440, 2560); 173 216 174 217 mediaContent = CachedNetworkImage( 175 218 imageUrl: imageUrl, ··· 200 243 _updateLoadingState(); 201 244 } 202 245 }); 203 - return Image(image: imageProvider, fit: BoxFit.cover); 246 + return flutter_widgets.Image(image: imageProvider, fit: BoxFit.cover); 204 247 }, 205 248 ); 206 249 } 207 250 208 251 // Wrap the media in a Stack to overlay gradient shadows for readability. 209 - return Column( 210 - children: [ 211 - Expanded( 212 - child: ClipRRect( 213 - borderRadius: borderRadius, 214 - child: Stack( 215 - fit: StackFit.expand, 216 - children: [ 217 - mediaContent, 218 - // Top shadow overlay 219 - Positioned( 220 - left: 0, 221 - right: 0, 222 - top: 0, 223 - child: IgnorePointer( 224 - child: Container( 225 - height: 120, 226 - decoration: BoxDecoration( 227 - gradient: LinearGradient( 228 - begin: Alignment.topCenter, 229 - end: Alignment.bottomCenter, 230 - colors: [ 231 - Colors.black87.withAlpha(100), 232 - Colors.transparent, 233 - ], 234 - ), 252 + return ClipRRect( 253 + borderRadius: borderRadius, 254 + child: LayoutBuilder( 255 + builder: (context, constraints) { 256 + final destinationRect = _resolveMediaRect( 257 + containerSize: constraints.biggest, 258 + sourceSize: sourceSize, 259 + ); 260 + 261 + return Stack( 262 + fit: StackFit.expand, 263 + children: [ 264 + mediaContent, 265 + Positioned( 266 + top: 80, 267 + bottom: 0, 268 + left: 0, 269 + width: constraints.maxWidth * 0.3, 270 + child: GestureDetector( 271 + behavior: HitTestBehavior.translucent, 272 + onTap: widget.onPrevious, 273 + child: const SizedBox.expand(), 274 + ), 275 + ), 276 + Positioned( 277 + top: 80, 278 + bottom: 0, 279 + right: 0, 280 + width: constraints.maxWidth * 0.3, 281 + child: GestureDetector( 282 + behavior: HitTestBehavior.translucent, 283 + onTap: widget.onNext, 284 + child: const SizedBox.expand(), 285 + ), 286 + ), 287 + ..._buildMentionEmbeds(destinationRect), 288 + // Top shadow overlay 289 + Positioned( 290 + left: 0, 291 + right: 0, 292 + top: 0, 293 + child: IgnorePointer( 294 + child: Container( 295 + height: 120, 296 + decoration: BoxDecoration( 297 + gradient: LinearGradient( 298 + begin: Alignment.topCenter, 299 + end: Alignment.bottomCenter, 300 + colors: [ 301 + Colors.black87.withAlpha(100), 302 + Colors.transparent, 303 + ], 235 304 ), 236 305 ), 237 306 ), 238 307 ), 239 - // Bottom shadow overlay 240 - Positioned( 241 - left: 0, 242 - right: 0, 243 - bottom: 0, 244 - child: IgnorePointer( 245 - child: Container( 246 - height: 120, 247 - decoration: BoxDecoration( 248 - gradient: LinearGradient( 249 - begin: Alignment.bottomCenter, 250 - end: Alignment.topCenter, 251 - colors: [ 252 - Colors.black87.withAlpha(100), 253 - Colors.transparent, 254 - ], 255 - ), 308 + ), 309 + // Bottom shadow overlay 310 + Positioned( 311 + left: 0, 312 + right: 0, 313 + bottom: 0, 314 + child: IgnorePointer( 315 + child: Container( 316 + height: 120, 317 + decoration: BoxDecoration( 318 + gradient: LinearGradient( 319 + begin: Alignment.bottomCenter, 320 + end: Alignment.topCenter, 321 + colors: [ 322 + Colors.black87.withAlpha(100), 323 + Colors.transparent, 324 + ], 256 325 ), 257 326 ), 258 327 ), 259 328 ), 260 - ], 261 - ), 329 + ), 330 + ], 331 + ); 332 + }, 333 + ), 334 + ); 335 + } 336 + 337 + Rect _resolveMediaRect({ 338 + required Size containerSize, 339 + required Size sourceSize, 340 + }) { 341 + final fittedSizes = applyBoxFit(BoxFit.cover, sourceSize, containerSize); 342 + return Alignment.center.inscribe( 343 + fittedSizes.destination, 344 + Offset.zero & containerSize, 345 + ); 346 + } 347 + 348 + Iterable<Widget> _buildMentionEmbeds(Rect mediaRect) { 349 + final mentionEmbeds = _storyMentionEmbeds.toList() 350 + ..sort((a, b) { 351 + final aIndex = a.placement.zIndex ?? 0; 352 + final bIndex = b.placement.zIndex ?? 0; 353 + return aIndex.compareTo(bIndex); 354 + }); 355 + 356 + return mentionEmbeds.map((embed) { 357 + final frame = embed.placement.frame; 358 + final rect = Rect.fromLTWH( 359 + mediaRect.left + mediaRect.width * frame.x / 10000, 360 + mediaRect.top + mediaRect.height * frame.y / 10000, 361 + mediaRect.width * frame.w / 10000, 362 + mediaRect.height * frame.h / 10000, 363 + ); 364 + final rotationDegrees = embed.placement.rotation ?? 0; 365 + 366 + return Positioned( 367 + left: rect.left, 368 + top: rect.top, 369 + width: rect.width, 370 + height: rect.height, 371 + child: Transform.rotate( 372 + angle: rotationDegrees * math.pi / 180, 373 + child: GestureDetector( 374 + behavior: HitTestBehavior.opaque, 375 + onTap: () => _handleEmbedTap(embed), 376 + child: const SizedBox.expand(), 262 377 ), 263 378 ), 264 - const SizedBox( 265 - height: footerHeight, 266 - child: ColoredBox(color: Colors.black), 267 - ), 268 - ], 269 - ); 379 + ); 380 + }); 381 + } 382 + 383 + List<StoryMentionEmbedView> get _storyMentionEmbeds { 384 + if (widget.story.embeds != null && widget.story.embeds!.isNotEmpty) { 385 + final hydratedEmbeds = widget.story.embeds! 386 + .whereType<StoryMentionEmbedView>() 387 + .where((embed) => _isValidMentionEmbed(embed.placement)) 388 + .toList(growable: false); 389 + 390 + if (hydratedEmbeds.isNotEmpty) { 391 + return hydratedEmbeds; 392 + } 393 + } 394 + 395 + final recordEmbeds = widget.story.record.embeds ?? const <StoryEmbed>[]; 396 + return recordEmbeds 397 + .whereType<StoryMentionEmbed>() 398 + .where((embed) { 399 + return _isValidMentionEmbed(embed.placement); 400 + }) 401 + .map((embed) { 402 + return StoryEmbedView.mention( 403 + placement: embed.placement, 404 + did: embed.did, 405 + ); 406 + }) 407 + .whereType<StoryMentionEmbedView>() 408 + .toList(growable: false); 409 + } 410 + 411 + bool _isValidMentionEmbed(StoryEmbedPlacement placement) { 412 + final frame = placement.frame; 413 + return frame.w > 0 && frame.h > 0; 270 414 } 271 415 }
+149
test/core/network/atproto/data/models/story_embed_models_test.dart
··· 1 + import 'package:atproto/core.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/record_models.dart'; 5 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 6 + 7 + void main() { 8 + group('story embed parsing', () { 9 + test( 10 + 'storyEmbedsFromJson parses mention embeds and skips malformed items', 11 + () { 12 + final embeds = storyEmbedsFromJson([ 13 + { 14 + r'$type': 'so.sprk.embed.mention', 15 + 'placement': { 16 + 'frame': {'x': 1200, 'y': 3400, 'w': 2500, 'h': 900}, 17 + 'mediaRef': {'index': 0}, 18 + 'zIndex': 2, 19 + 'rotation': 15, 20 + }, 21 + 'did': 'did:plc:mentioned-user', 22 + }, 23 + { 24 + r'$type': 'so.sprk.embed.mention', 25 + 'placement': {'zIndex': 3}, 26 + 'did': 'did:plc:broken-user', 27 + }, 28 + 'not-a-map', 29 + ]); 30 + 31 + expect(embeds, hasLength(1)); 32 + 33 + final mention = embeds.single as StoryMentionEmbed; 34 + expect(mention.did, 'did:plc:mentioned-user'); 35 + expect(mention.placement.frame.x, 1200); 36 + expect(mention.placement.frame.y, 3400); 37 + expect(mention.placement.frame.w, 2500); 38 + expect(mention.placement.frame.h, 900); 39 + expect(mention.placement.mediaRef?.index, 0); 40 + expect(mention.placement.zIndex, 2); 41 + expect(mention.placement.rotation, 15); 42 + }, 43 + ); 44 + 45 + test( 46 + 'StoryView.fromJson preserves hydrated embeds and skips malformed ones', 47 + () { 48 + final story = StoryView.fromJson({ 49 + 'cid': 'bafyreicid', 50 + 'uri': 'at://did:plc:author/so.sprk.story.post/3lk4example', 51 + 'author': {'did': 'did:plc:author', 'handle': 'author.sprk.so'}, 52 + 'record': { 53 + r'$type': 'so.sprk.story.post', 54 + 'media': { 55 + r'$type': 'so.sprk.media.image', 56 + 'image': { 57 + r'$type': 'blob', 58 + 'mimeType': 'image/jpeg', 59 + 'size': 42, 60 + 'ref': {r'$link': 'bafkreigh2akiscaildc2'}, 61 + }, 62 + }, 63 + 'createdAt': '2026-04-05T12:00:00.000Z', 64 + 'embeds': [ 65 + { 66 + r'$type': 'so.sprk.embed.mention', 67 + 'placement': { 68 + 'frame': {'x': 1000, 'y': 2000, 'w': 3000, 'h': 1000}, 69 + 'zIndex': 1, 70 + }, 71 + 'did': 'did:plc:record-mentioned', 72 + }, 73 + ], 74 + }, 75 + 'indexedAt': '2026-04-05T12:00:01.000Z', 76 + 'embeds': [ 77 + { 78 + r'$type': 'so.sprk.embed.mention#view', 79 + 'placement': { 80 + 'frame': {'x': 1000, 'y': 2000, 'w': 3000, 'h': 1000}, 81 + 'zIndex': 1, 82 + 'rotation': 30, 83 + }, 84 + 'did': 'did:plc:view-mentioned', 85 + 'actor': { 86 + 'did': 'did:plc:view-mentioned', 87 + 'handle': 'mention.sprk.so', 88 + 'displayName': 'Mentioned User', 89 + }, 90 + }, 91 + { 92 + r'$type': 'so.sprk.embed.mention#view', 93 + 'placement': {'zIndex': 99}, 94 + 'did': 'did:plc:broken-view', 95 + }, 96 + ], 97 + }); 98 + 99 + expect(story.record, isA<StoryRecord>()); 100 + expect(story.record.embeds, hasLength(1)); 101 + expect(story.embeds, hasLength(1)); 102 + 103 + final recordMention = story.record.embeds!.single as StoryMentionEmbed; 104 + expect(recordMention.did, 'did:plc:record-mentioned'); 105 + 106 + final viewMention = story.embeds!.single as StoryMentionEmbedView; 107 + expect(viewMention.did, 'did:plc:view-mentioned'); 108 + expect(viewMention.actor?.handle, 'mention.sprk.so'); 109 + expect(viewMention.placement.rotation, 30); 110 + }, 111 + ); 112 + 113 + test('StoryRecord.toJson includes lexicon type on embeds', () { 114 + final record = 115 + Record.story( 116 + media: Media.image( 117 + image: Blob.fromJson({ 118 + r'$type': 'blob', 119 + 'mimeType': 'image/jpeg', 120 + 'size': 42, 121 + 'ref': {r'$link': 'bafkreigh2akiscaildc2'}, 122 + }), 123 + ), 124 + createdAt: DateTime.parse('2026-04-05T12:00:00.000Z'), 125 + embeds: [ 126 + StoryEmbed.mention( 127 + did: 'did:plc:mentioned-user', 128 + placement: const StoryEmbedPlacement( 129 + frame: StoryEmbedFrame( 130 + x: 1000, 131 + y: 2000, 132 + w: 3000, 133 + h: 1000, 134 + ), 135 + ), 136 + ), 137 + ], 138 + ) 139 + as StoryRecord; 140 + 141 + final json = record.toJson(); 142 + final embeds = json['embeds'] as List<dynamic>; 143 + final mention = embeds.single as Map<String, dynamic>; 144 + 145 + expect(mention[r'$type'], 'so.sprk.embed.mention'); 146 + expect(mention['did'], 'did:plc:mentioned-user'); 147 + }); 148 + }); 149 + }
+132
test/core/pro_image_editor/story_mention_layer_test.dart
··· 1 + import 'dart:math' as math; 2 + 3 + import 'package:flutter/widgets.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:pro_image_editor/pro_image_editor.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 7 + import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 8 + import 'package:spark/src/core/pro_image_editor/story_mention_layer.dart'; 9 + import 'package:spark/src/core/ui/widgets/story_mention_chip.dart'; 10 + 11 + void main() { 12 + TestWidgetsFlutterBinding.ensureInitialized(); 13 + 14 + group('story mention widget layers', () { 15 + test('createStoryMentionLayer marks a widget layer as a story mention', () { 16 + final layer = createStoryMentionLayer( 17 + _actor('did:plc:first', 'first.sprk.so'), 18 + ); 19 + 20 + expect(layer, isA<WidgetLayer>()); 21 + expect(isStoryMentionLayer(layer), isTrue); 22 + expect(layer.meta?['did'], 'did:plc:first'); 23 + expect(layer.meta?['handle'], 'first.sprk.so'); 24 + }); 25 + 26 + test( 27 + 'extractStoryMentionEmbeds exports normalized placement from layers', 28 + () { 29 + final baseSize = measureStoryMentionChipSize( 30 + primaryText: '@first.sprk.so', 31 + height: kStoryMentionInitialHeight, 32 + ); 33 + final mention = 34 + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) 35 + ..width = 200 36 + ..offset = const Offset(-100, 50) 37 + ..scale = 1.5 38 + ..rotation = math.pi / 2; 39 + 40 + final embeds = extractStoryMentionEmbeds([ 41 + mention, 42 + ], canvasSize: const Size(1000, 1000)); 43 + 44 + expect(embeds, hasLength(1)); 45 + 46 + final embed = embeds.single as StoryMentionEmbed; 47 + final expectedWidth = 3000; 48 + final expectedHeight = 49 + ((200 * 1.5) * (baseSize.height / baseSize.width) / 1000 * 10000) 50 + .round(); 51 + final renderedHeight = (200 * 1.5) * (baseSize.height / baseSize.width); 52 + final expectedTop = 50 + 500 - renderedHeight / 2; 53 + expect(embed.did, 'did:plc:first'); 54 + expect(embed.placement.frame.x, 2500); 55 + expect(embed.placement.frame.y, ((expectedTop / 1000) * 10000).round()); 56 + expect(embed.placement.frame.w, expectedWidth); 57 + expect(embed.placement.frame.h, expectedHeight); 58 + expect(embed.placement.zIndex, 0); 59 + expect(embed.placement.rotation, 90); 60 + }, 61 + ); 62 + 63 + test('extractStoryMentionEmbeds ignores non-mention widget layers', () { 64 + final mention = createStoryMentionLayer( 65 + _actor('did:plc:first', 'first.sprk.so'), 66 + ); 67 + final background = WidgetLayer( 68 + widget: const SizedBox.shrink(), 69 + meta: const {'kind': 'background'}, 70 + ); 71 + 72 + final embeds = extractStoryMentionEmbeds([ 73 + background, 74 + mention, 75 + ], canvasSize: const Size(1000, 1000)); 76 + final embed = embeds.single as StoryMentionEmbed; 77 + 78 + expect(embeds, hasLength(1)); 79 + expect(embed.did, 'did:plc:first'); 80 + expect(embed.placement.zIndex, 1); 81 + }); 82 + 83 + test('extractStoryMentionEmbeds clips partially off-canvas mentions', () { 84 + final baseSize = measureStoryMentionChipSize( 85 + primaryText: '@first.sprk.so', 86 + height: kStoryMentionInitialHeight, 87 + ); 88 + final mention = 89 + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) 90 + ..width = 200 91 + ..offset = const Offset(-450, 0); 92 + 93 + final embeds = extractStoryMentionEmbeds([ 94 + mention, 95 + ], canvasSize: const Size(1000, 1000)); 96 + 97 + expect(embeds, hasLength(1)); 98 + 99 + final embed = embeds.single as StoryMentionEmbed; 100 + final renderedWidth = 200.0; 101 + final renderedHeight = renderedWidth * (baseSize.height / baseSize.width); 102 + final unclippedLeft = -450 + 500 - renderedWidth / 2; 103 + final unclippedTop = 500 - renderedHeight / 2; 104 + final clippedWidth = renderedWidth - (0 - unclippedLeft); 105 + 106 + expect(embed.placement.frame.x, 0); 107 + expect(embed.placement.frame.y, ((unclippedTop / 1000) * 10000).round()); 108 + expect(embed.placement.frame.w, ((clippedWidth / 1000) * 10000).round()); 109 + expect( 110 + embed.placement.frame.h, 111 + ((renderedHeight / 1000) * 10000).round(), 112 + ); 113 + }); 114 + 115 + test('extractStoryMentionEmbeds drops fully clipped mentions', () { 116 + final mention = 117 + createStoryMentionLayer(_actor('did:plc:first', 'first.sprk.so')) 118 + ..width = 200 119 + ..offset = const Offset(-700, 0); 120 + 121 + final embeds = extractStoryMentionEmbeds([ 122 + mention, 123 + ], canvasSize: const Size(1000, 1000)); 124 + 125 + expect(embeds, isEmpty); 126 + }); 127 + }); 128 + } 129 + 130 + ProfileViewBasic _actor(String did, String handle) { 131 + return ProfileViewBasic(did: did, handle: handle); 132 + }