at main 16 kB view raw
1import 'dart:async'; 2 3import 'package:bluesky_text/bluesky_text.dart'; 4import 'package:flutter/material.dart'; 5import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 7import '../models/profile.dart'; 8import '../providers/actor_search_provider.dart'; 9import '../utils/facet_utils.dart'; 10 11class FacetedTextField extends ConsumerStatefulWidget { 12 final String? label; 13 final TextEditingController controller; 14 final int maxLines; 15 final bool enabled; 16 final TextInputType? keyboardType; 17 final String? hintText; 18 final void Function(String)? onChanged; 19 final Widget? prefixIcon; 20 final Widget? suffixIcon; 21 final List<Map<String, dynamic>>? facets; 22 23 const FacetedTextField({ 24 super.key, 25 this.label, 26 required this.controller, 27 this.maxLines = 1, 28 this.enabled = true, 29 this.keyboardType, 30 this.hintText, 31 this.onChanged, 32 this.prefixIcon, 33 this.suffixIcon, 34 this.facets, 35 }); 36 37 @override 38 ConsumerState<FacetedTextField> createState() => _FacetedTextFieldState(); 39} 40 41class _FacetedTextFieldState extends ConsumerState<FacetedTextField> { 42 // Track which handles have been inserted via overlay selection 43 final Set<String> _insertedHandles = {}; 44 OverlayEntry? _overlayEntry; 45 final GlobalKey _fieldKey = GlobalKey(); 46 List<Profile> _actorResults = []; 47 Timer? _debounceTimer; 48 49 @override 50 void initState() { 51 super.initState(); 52 widget.controller.addListener(_onTextChanged); 53 } 54 55 @override 56 void dispose() { 57 widget.controller.removeListener(_onTextChanged); 58 _debounceTimer?.cancel(); 59 _removeOverlay(); 60 super.dispose(); 61 } 62 63 void _onTextChanged() async { 64 final text = widget.controller.text; 65 final selection = widget.controller.selection; 66 final cursorPos = selection.baseOffset; 67 if (cursorPos < 0) { 68 _removeOverlay(); 69 return; 70 } 71 // If the last character typed is a space, always close overlay 72 if (cursorPos > 0 && text[cursorPos - 1] == ' ') { 73 _removeOverlay(); 74 return; 75 } 76 // Find the @mention match that contains the cursor 77 final regex = RegExp(r'@([\w.]+)'); 78 final matches = regex.allMatches(text); 79 String? query; 80 for (final match in matches) { 81 final start = match.start; 82 final end = match.end; 83 if (cursorPos > start && cursorPos <= end) { 84 query = match.group(1); 85 break; 86 } 87 } 88 if (query != null && query.isNotEmpty) { 89 _debounceTimer?.cancel(); 90 _debounceTimer = Timer(const Duration(milliseconds: 500), () async { 91 final results = await ref.read(actorSearchProvider.notifier).search(query!); 92 if (mounted) { 93 setState(() { 94 _actorResults = results; 95 }); 96 _showOverlay(); 97 } 98 }); 99 return; 100 } 101 _debounceTimer?.cancel(); 102 _removeOverlay(); 103 } 104 105 void _showOverlay() { 106 WidgetsBinding.instance.addPostFrameCallback((_) { 107 _removeOverlay(); 108 final overlay = Overlay.of(context); 109 final caretOffset = _getCaretPosition(); 110 if (caretOffset == null) return; 111 112 // Show only the first 5 results, no scroll, use simple rows 113 final double rowHeight = 44.0; 114 final int maxItems = 5; 115 final resultsToShow = _actorResults.take(maxItems).toList(); 116 final double overlayHeight = resultsToShow.length * rowHeight; 117 final double overlayWidth = 300.0; 118 119 // Get screen size 120 final mediaQuery = MediaQuery.of(context); 121 final screenWidth = mediaQuery.size.width; 122 123 // Default to left of caret, but if it would overflow, switch to right 124 double left = caretOffset.dx; 125 if (left + overlayWidth > screenWidth - 8) { 126 // Try to align right edge of overlay with caret, but don't go off left edge 127 left = (caretOffset.dx - overlayWidth).clamp(8.0, screenWidth - overlayWidth - 8.0); 128 } 129 130 _overlayEntry = OverlayEntry( 131 builder: (context) => Positioned( 132 left: left, 133 top: caretOffset.dy, 134 width: overlayWidth, 135 height: overlayHeight, 136 child: Material( 137 elevation: 4, 138 child: Column( 139 mainAxisSize: MainAxisSize.min, 140 children: resultsToShow.map((actor) { 141 return Material( 142 color: Colors.transparent, 143 child: InkWell( 144 onTap: () => _insertActor(actor.handle), 145 child: Container( 146 height: rowHeight, 147 width: double.infinity, 148 alignment: Alignment.centerLeft, 149 padding: const EdgeInsets.symmetric(horizontal: 12.0), 150 child: Row( 151 children: [ 152 if (actor.avatar != null && actor.avatar!.isNotEmpty) 153 CircleAvatar(radius: 16, backgroundImage: NetworkImage(actor.avatar!)) 154 else 155 CircleAvatar(radius: 16, child: Icon(Icons.person, size: 16)), 156 const SizedBox(width: 12), 157 Expanded( 158 child: Text( 159 actor.displayName ?? actor.handle, 160 style: Theme.of(context).textTheme.bodyMedium, 161 overflow: TextOverflow.ellipsis, 162 ), 163 ), 164 const SizedBox(width: 8), 165 Text( 166 '@${actor.handle}', 167 style: Theme.of( 168 context, 169 ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), 170 overflow: TextOverflow.ellipsis, 171 ), 172 ], 173 ), 174 ), 175 ), 176 ); 177 }).toList(), 178 ), 179 ), 180 ), 181 ); 182 overlay.insert(_overlayEntry!); 183 }); 184 } 185 186 void _removeOverlay() { 187 if (_overlayEntry != null) { 188 _overlayEntry?.remove(); 189 _overlayEntry = null; 190 } 191 } 192 193 Offset? _getCaretPosition() { 194 final renderBox = _fieldKey.currentContext?.findRenderObject() as RenderBox?; 195 if (renderBox == null) return null; 196 197 final controller = widget.controller; 198 final selection = controller.selection; 199 if (!selection.isValid) return null; 200 201 // Get the text up to the caret 202 final text = controller.text.substring(0, selection.baseOffset); 203 final textStyle = 204 Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15) ?? 205 const TextStyle(fontSize: 15); 206 final textPainter = TextPainter( 207 text: TextSpan(text: text, style: textStyle), 208 textDirection: TextDirection.ltr, 209 maxLines: widget.maxLines, 210 ); 211 textPainter.layout(minWidth: 0, maxWidth: renderBox.size.width); 212 213 final caretOffset = textPainter.getOffsetForCaret(TextPosition(offset: text.length), Rect.zero); 214 215 // Convert caret offset to global coordinates 216 final fieldOffset = renderBox.localToGlobal(Offset.zero); 217 // Add vertical padding to position below the caret 218 return fieldOffset + Offset(caretOffset.dx, caretOffset.dy + textPainter.preferredLineHeight); 219 } 220 221 void _insertActor(String actorName) { 222 final text = widget.controller.text; 223 final selection = widget.controller.selection; 224 final cursorPos = selection.baseOffset; 225 // Find the @mention match that contains the cursor (not just before it) 226 final regex = RegExp(r'@([\w.]+)'); 227 final matches = regex.allMatches(text); 228 Match? matchToReplace; 229 for (final match in matches) { 230 if (cursorPos > match.start && cursorPos <= match.end) { 231 matchToReplace = match; 232 break; 233 } 234 } 235 if (matchToReplace != null) { 236 final start = matchToReplace.start; 237 final end = matchToReplace.end; 238 final newText = text.replaceRange(start, end, '@$actorName '); 239 setState(() { 240 _insertedHandles.add(actorName); 241 }); 242 widget.controller.value = TextEditingValue( 243 text: newText, 244 selection: TextSelection.collapsed(offset: start + actorName.length + 2), 245 ); 246 } 247 _removeOverlay(); 248 } 249 250 @override 251 Widget build(BuildContext context) { 252 final theme = Theme.of(context); 253 return Column( 254 crossAxisAlignment: CrossAxisAlignment.start, 255 children: [ 256 if (widget.label != null && widget.label!.isNotEmpty) ...[ 257 Text( 258 widget.label!, 259 style: theme.textTheme.bodyMedium?.copyWith( 260 fontWeight: FontWeight.w500, 261 color: theme.colorScheme.onSurface, 262 ), 263 ), 264 const SizedBox(height: 6), 265 ], 266 Container( 267 decoration: BoxDecoration( 268 color: theme.brightness == Brightness.dark ? Colors.grey[850] : Colors.grey[300], 269 borderRadius: BorderRadius.circular(8), 270 ), 271 child: Focus( 272 child: Builder( 273 builder: (context) { 274 final isFocused = Focus.of(context).hasFocus; 275 return Stack( 276 children: [ 277 _MentionHighlightTextField( 278 key: _fieldKey, 279 controller: widget.controller, 280 maxLines: widget.maxLines, 281 enabled: widget.enabled, 282 keyboardType: widget.keyboardType, 283 onChanged: widget.onChanged, 284 hintText: widget.hintText, 285 prefixIcon: widget.prefixIcon, 286 suffixIcon: widget.suffixIcon, 287 insertedHandles: _insertedHandles, 288 facets: widget.facets, 289 ), 290 // Border overlay 291 Positioned.fill( 292 child: IgnorePointer( 293 child: AnimatedContainer( 294 duration: const Duration(milliseconds: 150), 295 decoration: BoxDecoration( 296 border: Border.all( 297 color: isFocused ? theme.colorScheme.primary : theme.dividerColor, 298 width: isFocused ? 2 : 0, 299 ), 300 borderRadius: BorderRadius.circular(8), 301 ), 302 ), 303 ), 304 ), 305 ], 306 ); 307 }, 308 ), 309 ), 310 ), 311 ], 312 ); 313 } 314} 315 316class _MentionHighlightTextField extends StatefulWidget { 317 final Set<String>? insertedHandles; 318 final TextEditingController controller; 319 final int maxLines; 320 final bool enabled; 321 final TextInputType? keyboardType; 322 final String? hintText; 323 final void Function(String)? onChanged; 324 final Widget? prefixIcon; 325 final Widget? suffixIcon; 326 final List<Map<String, dynamic>>? facets; 327 328 const _MentionHighlightTextField({ 329 super.key, 330 required this.controller, 331 required this.maxLines, 332 required this.enabled, 333 this.keyboardType, 334 this.hintText, 335 this.onChanged, 336 this.prefixIcon, 337 this.suffixIcon, 338 this.insertedHandles, 339 this.facets, 340 }); 341 342 @override 343 State<_MentionHighlightTextField> createState() => _MentionHighlightTextFieldState(); 344} 345 346class _MentionHighlightTextFieldState extends State<_MentionHighlightTextField> { 347 final ScrollController _richTextScrollController = ScrollController(); 348 final ScrollController _textFieldScrollController = ScrollController(); 349 350 void _onMentionTap(String did) { 351 // Show overlay for this mention (simulate as if user is typing @mention) 352 final parent = context.findAncestorStateOfType<_FacetedTextFieldState>(); 353 if (parent != null) { 354 parent._showOverlay(); 355 } 356 } 357 358 List<Map<String, dynamic>> _parsedFacets = []; 359 Timer? _facetDebounce; 360 361 @override 362 void initState() { 363 super.initState(); 364 _parseFacets(); 365 widget.controller.addListener(_parseFacets); 366 367 // Sync scroll controllers 368 _textFieldScrollController.addListener(() { 369 if (_richTextScrollController.hasClients && _textFieldScrollController.hasClients) { 370 _richTextScrollController.jumpTo(_textFieldScrollController.offset); 371 } 372 }); 373 } 374 375 @override 376 void dispose() { 377 widget.controller.removeListener(_parseFacets); 378 _facetDebounce?.cancel(); 379 _richTextScrollController.dispose(); 380 _textFieldScrollController.dispose(); 381 super.dispose(); 382 } 383 384 void _parseFacets() { 385 _facetDebounce?.cancel(); 386 _facetDebounce = Timer(const Duration(milliseconds: 100), () async { 387 final text = widget.controller.text; 388 if (widget.facets != null && widget.facets!.isNotEmpty) { 389 setState(() => _parsedFacets = widget.facets!); 390 } else { 391 try { 392 final blueskyText = BlueskyText(text); 393 final entities = blueskyText.entities; 394 final facets = await entities.toFacets(); 395 if (mounted) setState(() => _parsedFacets = List<Map<String, dynamic>>.from(facets)); 396 } catch (_) { 397 if (mounted) setState(() => _parsedFacets = []); 398 } 399 } 400 }); 401 } 402 403 @override 404 Widget build(BuildContext context) { 405 final theme = Theme.of(context); 406 final text = widget.controller.text; 407 final baseStyle = theme.textTheme.bodyMedium?.copyWith(fontSize: 15); 408 final linkStyle = baseStyle?.copyWith(color: theme.colorScheme.primary); 409 410 // Use the same facet processing logic as FacetedText 411 final spans = FacetUtils.processFacets( 412 text: text, 413 facets: _parsedFacets, 414 defaultStyle: baseStyle, 415 linkStyle: linkStyle, 416 onMentionTap: _onMentionTap, 417 onLinkTap: null, // No link tap in text field 418 onTagTap: null, // No tag tap in text field 419 ); 420 return LayoutBuilder( 421 builder: (context, constraints) { 422 return SizedBox( 423 width: double.infinity, // Make it full width 424 height: widget.maxLines == 1 425 ? null 426 : (baseStyle?.fontSize ?? 15) * 1.4 * widget.maxLines + 427 24, // Line height * maxLines + padding 428 child: Stack( 429 children: [ 430 // RichText for highlight wrapped in SingleChildScrollView 431 SingleChildScrollView( 432 controller: _richTextScrollController, 433 physics: const NeverScrollableScrollPhysics(), // Disable direct interaction 434 child: Padding( 435 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 436 child: RichText( 437 text: TextSpan(children: spans), 438 maxLines: null, // Allow unlimited lines for scrolling 439 overflow: TextOverflow.visible, 440 ), 441 ), 442 ), 443 // Editable TextField for input, but with transparent text so only RichText is visible 444 Positioned.fill( 445 child: TextField( 446 controller: widget.controller, 447 scrollController: _textFieldScrollController, 448 maxLines: null, // Allow unlimited lines for scrolling 449 enabled: widget.enabled, 450 keyboardType: widget.keyboardType, 451 onChanged: widget.onChanged, 452 style: baseStyle?.copyWith(color: const Color(0x01000000)), 453 cursorColor: theme.colorScheme.primary, 454 showCursor: true, 455 enableInteractiveSelection: true, 456 decoration: InputDecoration( 457 hintText: widget.hintText, 458 hintStyle: baseStyle?.copyWith(color: theme.hintColor), 459 border: InputBorder.none, 460 contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 461 isDense: true, 462 prefixIcon: widget.prefixIcon, 463 suffixIcon: widget.suffixIcon, 464 ), 465 ), 466 ), 467 ], 468 ), 469 ); 470 }, 471 ); 472 } 473}