[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: mention indicators

+421 -174
+16
lib/src/core/design_system/components/molecules/input_field.dart
··· 3 3 4 4 class InputField extends StatelessWidget { 5 5 final TextEditingController? controller; 6 + final FocusNode? focusNode; 6 7 final String hintText; 7 8 final List<Widget>? leadingWidgets; 8 9 final List<Widget>? actionWidgets; ··· 11 12 final TextInputAction? textInputAction; 12 13 final int? maxLines; 13 14 final int? minLines; 15 + final bool enabled; 14 16 15 17 const InputField._({ 16 18 required this.controller, 19 + required this.focusNode, 17 20 required this.hintText, 18 21 required this.leadingWidgets, 19 22 required this.actionWidgets, ··· 22 25 required this.textInputAction, 23 26 required this.maxLines, 24 27 required this.minLines, 28 + required this.enabled, 25 29 super.key, 26 30 }); 27 31 28 32 const InputField.comment({ 29 33 Key? key, 30 34 TextEditingController? controller, 35 + FocusNode? focusNode, 31 36 String hintText = '', 32 37 List<Widget>? leadingWidgets, 33 38 List<Widget>? actionWidgets, 34 39 }) : this._( 35 40 key: key, 36 41 controller: controller, 42 + focusNode: focusNode, 37 43 hintText: hintText, 38 44 leadingWidgets: leadingWidgets, 39 45 actionWidgets: actionWidgets, ··· 42 48 textInputAction: null, 43 49 maxLines: null, 44 50 minLines: null, 51 + enabled: true, 45 52 ); 46 53 47 54 const InputField.chat({ 48 55 Key? key, 49 56 TextEditingController? controller, 57 + FocusNode? focusNode, 50 58 String hintText = '', 51 59 List<Widget>? leadingWidgets, 52 60 VoidCallback? onSendMessage, 53 61 }) : this._( 54 62 key: key, 55 63 controller: controller, 64 + focusNode: focusNode, 56 65 hintText: hintText, 57 66 leadingWidgets: leadingWidgets, 58 67 onSendMessage: onSendMessage, ··· 61 70 textInputAction: null, 62 71 maxLines: null, 63 72 minLines: null, 73 + enabled: true, 64 74 ); 65 75 66 76 const InputField.search({ 67 77 Key? key, 68 78 TextEditingController? controller, 79 + FocusNode? focusNode, 69 80 String hintText = '', 70 81 List<Widget>? leadingWidgets, 71 82 List<Widget>? actionWidgets, ··· 73 84 TextInputAction? textInputAction, 74 85 int? maxLines, 75 86 int? minLines, 87 + bool enabled = true, 76 88 }) : this._( 77 89 key: key, 78 90 controller: controller, 91 + focusNode: focusNode, 79 92 hintText: hintText, 80 93 leadingWidgets: leadingWidgets, 81 94 actionWidgets: actionWidgets, ··· 84 97 textInputAction: textInputAction, 85 98 maxLines: maxLines, 86 99 minLines: minLines, 100 + enabled: enabled, 87 101 ); 88 102 89 103 @override ··· 118 132 119 133 return TextField( 120 134 controller: controller, 135 + focusNode: focusNode, 121 136 onSubmitted: onSubmitted, 122 137 textInputAction: textInputAction, 123 138 maxLines: maxLines, 124 139 minLines: minLines, 140 + enabled: enabled, 125 141 decoration: InputDecoration( 126 142 hintText: hintText, 127 143 prefixIcon: leading,
+2
lib/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart
··· 452 452 required DateTime createdAt, 453 453 required RecordReplyRef reply, 454 454 UFeedPostEmbed? embed, 455 + List<RichtextFacet>? facets, 455 456 }) { 456 457 return FeedPostRecord( 457 458 text: text, 458 459 createdAt: createdAt, 459 460 reply: ReplyRef(root: reply.root, parent: reply.parent), 460 461 embed: embed, 462 + facets: facets, 461 463 ); 462 464 } 463 465
+1
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 132 132 AtUri? rootUri, 133 133 List<XFile>? imageFiles, 134 134 Map<String, String>? altTexts, 135 + List<Facet> facets = const [], 135 136 }); 136 137 137 138 /// Post a new feed item with images
+26 -1
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 852 852 AtUri? rootUri, 853 853 List<XFile>? imageFiles, 854 854 Map<String, String>? altTexts, 855 + List<Facet> facets = const [], 855 856 }) async { 856 857 _logger.d('Posting comment to parent: $parentUri'); 857 858 ··· 902 903 } 903 904 904 905 final sprkRecord = ReplyRecord( 905 - caption: CaptionRef(text: text, facets: []), 906 + caption: CaptionRef(text: text, facets: facets), 906 907 reply: RecordReplyRef( 907 908 root: RepoStrongRef(uri: effectiveRootUri, cid: effectiveRootCid), 908 909 parent: RepoStrongRef(uri: parentUri, cid: parentCid), ··· 927 928 ? bskyFeedAdapter.convertJsonToBskyEmbed(mediaJson) 928 929 : null; 929 930 931 + // Convert Spark mention facets to Bluesky mention facets 932 + final bskyFacets = <RichtextFacet>[]; 933 + for (final facet in facets) { 934 + for (final feature in facet.features) { 935 + feature.map( 936 + mention: (m) { 937 + bskyFacets.add( 938 + bskyFeedAdapter.createMentionFacet( 939 + did: m.did, 940 + byteStart: facet.index.byteStart, 941 + byteEnd: facet.index.byteEnd, 942 + ), 943 + ); 944 + }, 945 + link: (_) {}, 946 + tag: (_) {}, 947 + bskyMention: (_) {}, 948 + bskyLink: (_) {}, 949 + bskyTag: (_) {}, 950 + ); 951 + } 952 + } 953 + 930 954 final bskyRecord = bskyFeedAdapter.createCommentRecord( 931 955 text: text, 932 956 createdAt: DateTime.now().toUtc(), ··· 935 959 parent: RepoStrongRef(uri: parentUri, cid: parentCid), 936 960 ), 937 961 embed: bskyMedia, 962 + facets: bskyFacets.isNotEmpty ? bskyFacets : null, 938 963 ); 939 964 recordJson = bskyRecord.toJson(); 940 965 collection = NSID.parse('app.bsky.feed.post');
+82 -38
lib/src/features/comments/providers/comment_input_provider.dart
··· 3 3 import 'package:get_it/get_it.dart'; 4 4 import 'package:image_picker/image_picker.dart'; 5 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 + import 'package:spark/src/core/utils/text_formatter.dart'; 6 8 import 'package:spark/src/core/utils/logging/logging.dart'; 7 9 import 'package:spark/src/features/comments/providers/comment_input_state.dart'; 8 10 import 'package:spark/src/features/comments/providers/comments_page_provider.dart'; 11 + import 'package:spark/src/features/posting/models/mention.dart'; 12 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 9 13 10 14 part 'comment_input_provider.g.dart'; 11 15 12 16 @riverpod 13 17 class CommentInput extends _$CommentInput { 14 18 @override 15 - CommentInputState build( 16 - TextEditingController textController, 17 - ImagePicker imagePicker, 18 - ) { 19 + CommentInputState build(ImagePicker imagePicker, {String initialText = ''}) { 20 + final mentionController = MentionController(text: initialText); 21 + final mentionTextController = mentionController.textController; 22 + mentionTextController.addListener(updateCanSubmit); 23 + 19 24 ref.onDispose(() { 20 - textController.dispose(); 25 + mentionTextController.removeListener(updateCanSubmit); 26 + mentionController.dispose(); 21 27 }); 22 - textController.addListener(updateCanSubmit); 28 + 23 29 return CommentInputState( 24 - textController: textController, 30 + textController: mentionTextController, 25 31 imagePicker: imagePicker, 32 + mentionController: mentionController, 33 + canSubmit: mentionTextController.text.trim().isNotEmpty, 26 34 ); 27 35 } 28 36 ··· 45 53 void insertEmoji(String emoji) { 46 54 if (state.isPosting) return; 47 55 48 - final currentText = state.textController.text; 49 - final selection = state.textController.selection; 50 - 51 - if (selection.baseOffset < 0) { 52 - state.textController.text = currentText + emoji; 53 - state.textController.selection = TextSelection.collapsed( 54 - offset: currentText.length + emoji.length, 55 - ); 56 - } else { 57 - state.textController.text = currentText.replaceRange( 58 - selection.start, 59 - selection.end, 60 - emoji, 61 - ); 62 - } 63 - 64 - final newText = currentText.replaceRange( 65 - selection.start, 66 - selection.end, 67 - emoji, 68 - ); 69 - state.textController.value = TextEditingValue( 56 + final controller = state.textController; 57 + final currentText = controller.text; 58 + final selection = controller.selection; 59 + final hasSelection = selection.start >= 0 && selection.end >= 0; 60 + final start = hasSelection ? selection.start : currentText.length; 61 + final end = hasSelection ? selection.end : currentText.length; 62 + final newText = currentText.replaceRange(start, end, emoji); 63 + controller.value = TextEditingValue( 70 64 text: newText, 71 - selection: TextSelection.collapsed( 72 - offset: selection.baseOffset + emoji.length, 73 - ), 65 + selection: TextSelection.collapsed(offset: start + emoji.length), 74 66 ); 75 67 } 76 68 ··· 99 91 100 92 void removeImage(int index) { 101 93 if (state.isPosting) return; 102 - final removed = state.selectedImages.removeAt(index); 103 - state.altTexts.remove(removed.path); 94 + final selectedImages = List<XFile>.from(state.selectedImages); 95 + final removed = selectedImages.removeAt(index); 96 + final altTexts = Map<String, String>.from(state.altTexts) 97 + ..remove(removed.path); 98 + state = state.copyWith(selectedImages: selectedImages, altTexts: altTexts); 104 99 updateCanSubmit(); 105 100 } 106 101 ··· 111 106 required String? rootCid, 112 107 required String? rootUri, 113 108 }) async { 114 - if (!state.canSubmit || state.isPosting) return; 109 + final hasContent = 110 + state.textController.text.trim().isNotEmpty || 111 + state.selectedImages.isNotEmpty; 112 + if (!hasContent || state.isPosting) return; 115 113 final trayNotifier = ref.read( 116 114 commentsPageProvider(postUri: AtUri.parse(parentUri)).notifier, 117 115 ); 118 - final text = state.textController.text.trim(); 116 + final rawText = state.textController.text; 117 + final text = rawText.trim(); 119 118 final imagesToUpload = List<XFile>.from(state.selectedImages); 120 119 121 120 state = state.copyWith(isPosting: true); 122 121 123 122 try { 123 + final facets = _buildTrimmedMentionFacets( 124 + rawText: rawText, 125 + trimmedText: text, 126 + ); 124 127 await trayNotifier.postComment( 125 128 text, 126 129 parentCid, ··· 129 132 rootUri: rootUri, 130 133 imageFiles: imagesToUpload, 131 134 altTexts: state.altTexts, 135 + facets: facets, 132 136 ); 133 137 134 - state.textController.clear(); 135 - updateCanSubmit(); 138 + state.mentionController.clear(); 136 139 state = state.copyWith( 140 + canSubmit: false, 137 141 isPosting: false, 138 142 selectedImages: [], 139 143 altTexts: {}, ··· 151 155 152 156 void updateAltText(String imagePath, String altText) { 153 157 state = state.copyWith(altTexts: {...state.altTexts, imagePath: altText}); 158 + } 159 + 160 + List<Facet> _buildTrimmedMentionFacets({ 161 + required String rawText, 162 + required String trimmedText, 163 + }) { 164 + if (trimmedText.isEmpty || state.mentionController.mentions.isEmpty) { 165 + return const []; 166 + } 167 + 168 + if (rawText == trimmedText) { 169 + return state.mentionController.buildFacets(); 170 + } 171 + 172 + final leadingTrimmedText = rawText.trimLeft(); 173 + final leadingTrimmedChars = rawText.length - leadingTrimmedText.length; 174 + final leadingTrimmedBytes = TextFormatter.charIndexToByteIndex( 175 + rawText, 176 + leadingTrimmedChars, 177 + ); 178 + final trimmedByteLength = TextFormatter.byteLength(trimmedText); 179 + final trimmedEndByte = leadingTrimmedBytes + trimmedByteLength; 180 + 181 + final adjustedMentions = state.mentionController.mentions 182 + .where( 183 + (mention) => 184 + mention.byteStart >= leadingTrimmedBytes && 185 + mention.byteEnd <= trimmedEndByte, 186 + ) 187 + .map( 188 + (mention) => Mention( 189 + handle: mention.handle, 190 + did: mention.did, 191 + byteStart: mention.byteStart - leadingTrimmedBytes, 192 + byteEnd: mention.byteEnd - leadingTrimmedBytes, 193 + ), 194 + ) 195 + .toList(growable: false); 196 + 197 + return TextFormatter.buildMentionFacets(adjustedMentions); 154 198 } 155 199 }
+2
lib/src/features/comments/providers/comment_input_state.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:freezed_annotation/freezed_annotation.dart'; 3 3 import 'package:image_picker/image_picker.dart'; 4 + import 'package:spark/src/features/posting/models/mention_controller.dart'; 4 5 5 6 part 'comment_input_state.freezed.dart'; 6 7 ··· 9 10 const factory CommentInputState({ 10 11 required TextEditingController textController, 11 12 required ImagePicker imagePicker, 13 + required MentionController mentionController, 12 14 @Default(false) bool canSubmit, 13 15 @Default(false) bool isPosting, 14 16 @Default([]) List<XFile> selectedImages,
+2
lib/src/features/comments/providers/comments_page_provider.dart
··· 47 47 String? rootUri, 48 48 List<XFile>? imageFiles, 49 49 Map<String, String>? altTexts, 50 + List<Facet> facets = const [], 50 51 }) async { 51 52 // We need the current state to determine if the post is a sprk or bsky post 52 53 final currentState = state.value; ··· 62 63 rootUri: rootUri != null ? AtUri.parse(rootUri) : null, 63 64 imageFiles: imageFiles, 64 65 altTexts: altTexts, 66 + facets: facets, 65 67 ); 66 68 67 69 // Short delay to account for server-side replication lag.
+85 -115
lib/src/features/comments/ui/widgets/comment_input.dart
··· 14 14 import 'package:spark/src/features/comments/providers/comment_input_provider.dart'; 15 15 import 'package:spark/src/features/comments/providers/comment_input_state.dart'; 16 16 import 'package:spark/src/features/comments/ui/widgets/emoji_picker.dart'; 17 + import 'package:spark/src/features/posting/ui/widgets/mention_input_field.dart'; 17 18 import 'package:spark/src/features/profile/providers/profile_provider.dart'; 18 19 19 20 class CommentInputWidget extends ConsumerStatefulWidget { ··· 42 43 } 43 44 44 45 class _CommentInputState extends ConsumerState<CommentInputWidget> { 45 - final textController = TextEditingController(); 46 46 final imagePicker = ImagePicker(); 47 47 static const int _maxChars = AppConstants.replyMaxChars; 48 48 49 49 @override 50 - void initState() { 51 - super.initState(); 52 - textController.addListener(() { 53 - if (mounted) setState(() {}); 54 - }); 55 - } 56 - 57 - @override 58 - void dispose() { 59 - textController.dispose(); 60 - super.dispose(); 61 - } 62 - 63 - @override 64 50 Widget build(BuildContext context) { 65 - final state = ref.watch(commentInputProvider(textController, imagePicker)); 66 - final notifier = ref.read( 67 - commentInputProvider(textController, imagePicker).notifier, 68 - ); 51 + final state = ref.watch(commentInputProvider(imagePicker)); 52 + final notifier = ref.read(commentInputProvider(imagePicker).notifier); 69 53 final authState = ref.watch(authProvider); 70 54 final userDid = authState.did ?? ''; 71 55 final userHandle = authState.handle ?? ''; 56 + final inputController = state.mentionController.textController; 72 57 return Container( 73 58 padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16), 74 59 ··· 107 92 size: 28, 108 93 ), 109 94 const SizedBox(width: 8), 110 - _AttachmentButton( 111 - state: state, 112 - notifier: notifier, 113 - context: context, 114 - borderColor: Theme.of(context).colorScheme.outline, 115 - textColor: Theme.of(context).colorScheme.onSurface, 116 - ), 117 - const SizedBox(width: 5), 118 95 Expanded( 119 - child: _TextField( 120 - widget: widget, 121 - state: state, 122 - context: context, 123 - notifier: notifier, 124 - textColor: Theme.of(context).colorScheme.onSurface, 125 - placeholderColor: Theme.of( 126 - context, 127 - ).colorScheme.onSurface.withValues(alpha: 128), 128 - isOverLimit: textController.text.runes.length > _maxChars, 96 + child: MentionInputField( 97 + controller: state.mentionController, 98 + onMentionsChanged: (_) {}, 99 + hintText: 'Add a comment...', 100 + maxChars: _maxChars, 101 + maxLines: 5, 102 + minLines: 1, 103 + focusNode: widget.focusNode, 104 + enabled: !state.isPosting, 129 105 ), 130 106 ), 107 + const SizedBox(width: 5), 108 + _AttachmentButton(state: state, notifier: notifier), 109 + ValueListenableBuilder<TextEditingValue>( 110 + valueListenable: inputController, 111 + builder: (context, value, child) { 112 + final showSendButton = state.canSubmit || state.isPosting; 113 + if (!showSendButton) { 114 + return const SizedBox.shrink(); 115 + } 116 + 117 + return Padding( 118 + padding: const EdgeInsets.only(left: 4), 119 + child: _SendButton( 120 + state: state, 121 + widget: widget, 122 + notifier: notifier, 123 + isOverLimit: value.text.runes.length > _maxChars, 124 + ), 125 + ); 126 + }, 127 + ), 131 128 ], 132 129 ), 133 130 ), ··· 140 137 ), 141 138 142 139 // Character counter (show when approaching limit) 143 - _CharacterCounter(controller: textController, maxChars: _maxChars), 140 + ValueListenableBuilder<TextEditingValue>( 141 + valueListenable: inputController, 142 + builder: (context, _, child) => _CharacterCounter( 143 + controller: inputController, 144 + maxChars: _maxChars, 145 + ), 146 + ), 144 147 ], 145 148 ), 146 149 ); ··· 181 184 } 182 185 } 183 186 184 - class _TextField extends StatelessWidget { 185 - const _TextField({ 186 - required this.widget, 187 + class _SendButton extends StatelessWidget { 188 + const _SendButton({ 187 189 required this.state, 188 - required this.context, 190 + required this.widget, 189 191 required this.notifier, 190 - required this.textColor, 191 - required this.placeholderColor, 192 192 required this.isOverLimit, 193 193 }); 194 194 195 - final CommentInputWidget widget; 196 195 final CommentInputState state; 197 - final BuildContext context; 196 + final CommentInputWidget widget; 198 197 final CommentInput notifier; 199 - final Color textColor; 200 - final Color placeholderColor; 201 198 final bool isOverLimit; 202 199 203 200 @override 204 201 Widget build(BuildContext context) { 205 - const hint = 'Add a comment...'; 202 + final canSend = state.canSubmit && !isOverLimit && !state.isPosting; 203 + final placeholderColor = Theme.of( 204 + context, 205 + ).colorScheme.onSurface.withValues(alpha: 128); 206 206 207 - return TextField( 208 - controller: state.textController, 209 - focusNode: widget.focusNode, 210 - decoration: InputDecoration( 211 - hintText: hint, 212 - hintStyle: TextStyle(color: placeholderColor, fontSize: 14), 213 - filled: false, 214 - isDense: true, 215 - contentPadding: EdgeInsets.zero, 216 - border: InputBorder.none, 217 - enabledBorder: InputBorder.none, 218 - focusedBorder: InputBorder.none, 219 - suffixIcon: state.isPosting 220 - ? Container( 221 - margin: const EdgeInsets.all(8), 222 - width: 20, 223 - height: 20, 224 - child: CircularProgressIndicator( 225 - strokeWidth: 2, 226 - valueColor: AlwaysStoppedAnimation<Color>( 227 - Theme.of(context).colorScheme.primary, 228 - ), 229 - ), 230 - ) 231 - : IconButton( 232 - icon: Icon( 233 - FluentIcons.send_24_filled, 234 - size: 20, 235 - color: state.canSubmit && !isOverLimit 236 - ? Theme.of(context).colorScheme.primary 237 - : placeholderColor, 238 - ), 239 - onPressed: () { 240 - if (state.canSubmit && !isOverLimit) { 241 - HapticFeedback.mediumImpact(); 242 - // Use reply info if available, otherwise use main post info 243 - final parentCid = widget.postCid; 244 - final parentUri = widget.postUri; 245 - final rootCid = widget.rootCid; 246 - final rootUri = widget.rootUri; 207 + if (state.isPosting) { 208 + return const Padding( 209 + padding: EdgeInsets.all(8), 210 + child: SizedBox( 211 + width: 20, 212 + height: 20, 213 + child: CircularProgressIndicator(strokeWidth: 2), 214 + ), 215 + ); 216 + } 247 217 248 - notifier.submitComment( 249 - parentCid: parentCid, 250 - parentUri: parentUri, 251 - isSprk: widget.isSprk, 252 - rootCid: rootCid, 253 - rootUri: rootUri, 254 - ); 255 - } 256 - }, 257 - ), 218 + return IconButton( 219 + icon: Icon( 220 + FluentIcons.send_24_filled, 221 + size: 20, 222 + color: canSend 223 + ? Theme.of(context).colorScheme.primary 224 + : placeholderColor, 258 225 ), 259 - style: TextStyle(color: textColor, fontSize: 14), 260 - maxLines: 5, 261 - minLines: 1, 262 - textAlignVertical: TextAlignVertical.center, 263 - cursorColor: Theme.of(context).colorScheme.primary, 264 - enabled: !state.isPosting, 226 + onPressed: canSend 227 + ? () { 228 + HapticFeedback.mediumImpact(); 229 + // Use reply info if available, otherwise use main post info 230 + final parentCid = widget.postCid; 231 + final parentUri = widget.postUri; 232 + final rootCid = widget.rootCid; 233 + final rootUri = widget.rootUri; 234 + 235 + notifier.submitComment( 236 + parentCid: parentCid, 237 + parentUri: parentUri, 238 + isSprk: widget.isSprk, 239 + rootCid: rootCid, 240 + rootUri: rootUri, 241 + ); 242 + } 243 + : null, 265 244 ); 266 245 } 267 246 } 268 247 269 248 class _AttachmentButton extends StatelessWidget { 270 - const _AttachmentButton({ 271 - required this.state, 272 - required this.notifier, 273 - required this.context, 274 - required this.borderColor, 275 - required this.textColor, 276 - }); 249 + const _AttachmentButton({required this.state, required this.notifier}); 277 250 278 251 final CommentInputState state; 279 252 final CommentInput notifier; 280 - final BuildContext context; 281 - final Color borderColor; 282 - final Color textColor; 283 253 284 254 @override 285 255 Widget build(BuildContext context) {
+14 -3
lib/src/features/posting/models/mention_controller.dart
··· 2 2 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 3 3 import 'package:spark/src/core/utils/text_formatter.dart'; 4 4 import 'package:spark/src/features/posting/models/mention.dart'; 5 + import 'package:spark/src/features/posting/models/mention_text_editing_controller.dart'; 5 6 6 7 class MentionController extends ChangeNotifier { 7 8 MentionController({String text = ''}) 8 - : _textController = TextEditingController(text: text); 9 + : _textController = MentionTextEditingController(text: text); 9 10 10 - final TextEditingController _textController; 11 + final MentionTextEditingController _textController; 11 12 final List<Mention> _mentions = []; 12 13 13 - TextEditingController get textController => _textController; 14 + MentionTextEditingController get textController => _textController; 14 15 String get text => _textController.text; 15 16 List<Mention> get mentions => List.unmodifiable(_mentions); 16 17 17 18 void addMention(Mention mention) { 18 19 _mentions.add(mention); 20 + _syncMentionsToController(); 19 21 notifyListeners(); 20 22 } 21 23 22 24 void removeMention(Mention mention) { 23 25 _mentions.remove(mention); 26 + _syncMentionsToController(); 24 27 notifyListeners(); 25 28 } 26 29 27 30 void clearMentions() { 28 31 _mentions.clear(); 32 + _syncMentionsToController(); 29 33 notifyListeners(); 30 34 } 31 35 ··· 33 37 _mentions 34 38 ..clear() 35 39 ..addAll(mentions); 40 + _syncMentionsToController(); 36 41 notifyListeners(); 37 42 } 38 43 44 + /// Syncs the mentions list to the text controller for visual highlighting. 45 + void _syncMentionsToController() { 46 + _textController.mentions = List.unmodifiable(_mentions); 47 + } 48 + 39 49 void clear() { 40 50 _textController.clear(); 41 51 _mentions.clear(); 52 + _syncMentionsToController(); 42 53 notifyListeners(); 43 54 } 44 55
+135
lib/src/features/posting/models/mention_text_editing_controller.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:spark/src/core/ui/foundation/colors.dart'; 3 + import 'package:spark/src/core/utils/text_formatter.dart'; 4 + import 'package:spark/src/features/posting/models/mention.dart'; 5 + 6 + /// A custom [TextEditingController] that styles @mentions in pink 7 + /// while displaying regular text in the default style. 8 + class MentionTextEditingController extends TextEditingController { 9 + MentionTextEditingController({super.text}); 10 + 11 + /// The list of mentions to highlight. Should be kept in sync with the 12 + /// [MentionController]. 13 + List<Mention> _mentions = const []; 14 + 15 + List<Mention> get mentions => _mentions; 16 + 17 + set mentions(List<Mention> value) { 18 + if (_sameMentions(_mentions, value)) { 19 + return; 20 + } 21 + 22 + _mentions = List<Mention>.unmodifiable(value); 23 + notifyListeners(); 24 + } 25 + 26 + @override 27 + TextSpan buildTextSpan({ 28 + required BuildContext context, 29 + TextStyle? style, 30 + required bool withComposing, 31 + }) { 32 + assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid); 33 + 34 + final theme = Theme.of(context); 35 + final effectiveStyle = 36 + style ?? TextStyle(color: theme.colorScheme.onSurface, fontSize: 16); 37 + 38 + final mentionStyle = effectiveStyle.copyWith( 39 + color: AppColors.pink, 40 + fontWeight: FontWeight.w600, 41 + ); 42 + final composingStyle = effectiveStyle.merge( 43 + const TextStyle(decoration: TextDecoration.underline), 44 + ); 45 + 46 + final text = this.text; 47 + if (_mentions.isEmpty || text.isEmpty) { 48 + return super.buildTextSpan( 49 + context: context, 50 + style: effectiveStyle, 51 + withComposing: withComposing, 52 + ); 53 + } 54 + 55 + final composingRegionOutOfRange = 56 + !value.isComposingRangeValid || !withComposing; 57 + final composingStart = composingRegionOutOfRange ? -1 : value.composing.start; 58 + final composingEnd = composingRegionOutOfRange ? -1 : value.composing.end; 59 + 60 + final boundaries = <int>{0, text.length}; 61 + final mentionRanges = <({int start, int end})>[]; 62 + 63 + // Sort mentions by byte start to process them in order. 64 + final sortedMentions = List<Mention>.from(_mentions) 65 + ..sort((a, b) => a.byteStart.compareTo(b.byteStart)); 66 + 67 + for (final mention in sortedMentions) { 68 + // Convert byte indices to character indices. 69 + final charStart = TextFormatter.byteIndexToCharIndex( 70 + text, 71 + mention.byteStart, 72 + ); 73 + final charEnd = TextFormatter.byteIndexToCharIndex(text, mention.byteEnd); 74 + 75 + // Skip invalid mentions. 76 + if (charStart < 0 || 77 + charEnd > text.length || 78 + charStart >= charEnd) { 79 + continue; 80 + } 81 + 82 + boundaries 83 + ..add(charStart) 84 + ..add(charEnd); 85 + mentionRanges.add((start: charStart, end: charEnd)); 86 + } 87 + 88 + if (!composingRegionOutOfRange) { 89 + boundaries 90 + ..add(composingStart) 91 + ..add(composingEnd); 92 + } 93 + 94 + final sortedBoundaries = boundaries.toList()..sort(); 95 + final spans = <InlineSpan>[]; 96 + 97 + for (var i = 0; i < sortedBoundaries.length - 1; i++) { 98 + final start = sortedBoundaries[i]; 99 + final end = sortedBoundaries[i + 1]; 100 + 101 + if (start >= end) continue; 102 + 103 + final isMention = mentionRanges.any( 104 + (range) => start >= range.start && end <= range.end, 105 + ); 106 + final isComposing = 107 + !composingRegionOutOfRange && 108 + start >= composingStart && 109 + end <= composingEnd; 110 + 111 + var segmentStyle = isMention ? mentionStyle : effectiveStyle; 112 + if (isComposing) { 113 + segmentStyle = segmentStyle.merge(composingStyle); 114 + } 115 + 116 + spans.add(TextSpan(text: text.substring(start, end), style: segmentStyle)); 117 + } 118 + 119 + return TextSpan(children: spans, style: effectiveStyle); 120 + } 121 + 122 + bool _sameMentions(List<Mention> current, List<Mention> next) { 123 + if (current.length != next.length) { 124 + return false; 125 + } 126 + 127 + for (var i = 0; i < current.length; i++) { 128 + if (current[i] != next[i]) { 129 + return false; 130 + } 131 + } 132 + 133 + return true; 134 + } 135 + }
+56 -17
lib/src/features/posting/ui/widgets/mention_input_field.dart
··· 15 15 this.maxChars = AppConstants.postDescriptionMaxChars, 16 16 this.maxLines = 5, 17 17 this.minLines = 1, 18 + this.focusNode, 19 + this.enabled = true, 18 20 super.key, 19 21 }); 20 22 ··· 24 26 final int maxChars; 25 27 final int maxLines; 26 28 final int minLines; 29 + final FocusNode? focusNode; 30 + final bool enabled; 27 31 28 32 @override 29 33 ConsumerState<MentionInputField> createState() => _MentionInputFieldState(); ··· 40 44 super.initState(); 41 45 _previousText = widget.controller.text; 42 46 widget.controller.textController.addListener(_onTextChanged); 47 + } 48 + 49 + @override 50 + void didUpdateWidget(covariant MentionInputField oldWidget) { 51 + super.didUpdateWidget(oldWidget); 52 + 53 + if (oldWidget.controller != widget.controller) { 54 + oldWidget.controller.textController.removeListener(_onTextChanged); 55 + _previousText = widget.controller.text; 56 + widget.controller.textController.addListener(_onTextChanged); 57 + } 58 + 59 + if (!widget.enabled && oldWidget.enabled != widget.enabled) { 60 + _hideSuggestions(clearTypeahead: true); 61 + } 43 62 } 44 63 45 64 @override ··· 56 75 _previousText = text; 57 76 } 58 77 78 + if (!widget.enabled) { 79 + _hideSuggestions(clearTypeahead: true); 80 + return; 81 + } 82 + 59 83 final cursorPosition = widget.controller.textController.selection.start; 60 84 if (cursorPosition >= 0) { 61 85 _detectMentionQuery(text, cursorPosition); 62 86 } 63 87 } 64 88 89 + void _hideSuggestions({required bool clearTypeahead}) { 90 + final hadSuggestions = _showSuggestions || _queryStartIndex != null; 91 + 92 + if (clearTypeahead) { 93 + ref.read(actorTypeaheadProvider.notifier).clear(); 94 + } 95 + 96 + if (!hadSuggestions) { 97 + return; 98 + } 99 + 100 + if (mounted) { 101 + setState(() { 102 + _showSuggestions = false; 103 + _queryStartIndex = null; 104 + }); 105 + return; 106 + } 107 + 108 + _showSuggestions = false; 109 + _queryStartIndex = null; 110 + } 111 + 65 112 void _syncMentions({ 66 113 required String previousText, 67 114 required String currentText, ··· 183 230 184 231 void _detectMentionQuery(String text, int cursorPosition) { 185 232 if (cursorPosition <= 0 || cursorPosition > text.length) { 186 - setState(() { 187 - _showSuggestions = false; 188 - _queryStartIndex = null; 189 - }); 190 - ref.read(actorTypeaheadProvider.notifier).clear(); 233 + _hideSuggestions(clearTypeahead: true); 191 234 return; 192 235 } 193 236 ··· 202 245 } 203 246 204 247 if (atIndex == -1) { 205 - setState(() { 206 - _showSuggestions = false; 207 - _queryStartIndex = null; 208 - }); 209 - ref.read(actorTypeaheadProvider.notifier).clear(); 248 + _hideSuggestions(clearTypeahead: true); 210 249 return; 211 250 } 212 251 213 252 if (atIndex > 0) { 214 253 final prevChar = text[atIndex - 1]; 215 254 if (prevChar != ' ' && prevChar != '\n') { 216 - setState(() { 217 - _showSuggestions = false; 218 - _queryStartIndex = null; 219 - }); 220 - ref.read(actorTypeaheadProvider.notifier).clear(); 255 + _hideSuggestions(clearTypeahead: true); 221 256 return; 222 257 } 223 258 } ··· 300 335 children: [ 301 336 InputField.search( 302 337 controller: widget.controller.textController, 338 + focusNode: widget.focusNode ?? _focusNode, 303 339 hintText: widget.hintText, 304 340 maxLines: widget.maxLines, 305 341 minLines: widget.minLines, 342 + enabled: widget.enabled, 306 343 ), 307 - if (_showSuggestions && typeaheadState.results.isNotEmpty) 344 + if (widget.enabled && 345 + _showSuggestions && 346 + typeaheadState.results.isNotEmpty) 308 347 Container( 309 348 constraints: const BoxConstraints(maxHeight: 200), 310 349 decoration: BoxDecoration( ··· 347 386 }, 348 387 ), 349 388 ), 350 - if (_showSuggestions && typeaheadState.isLoading) 389 + if (widget.enabled && _showSuggestions && typeaheadState.isLoading) 351 390 const Padding( 352 391 padding: EdgeInsets.all(16), 353 392 child: SizedBox(