Grain flutter app
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}