feat: implement faceted text widget for enhanced comment and gallery descriptions feat: add polling functionality for gallery thread comments feat: enable comment creation with facets and reply functionality feat: update gallery and comment models to include facets refactor: improve error handling and loading states in comments page refactor: enhance UI with theme colors and styles in various screens fix: adjust background color for image loading errors

+133 -4
lib/api.dart
··· 1 1 import 'dart:convert'; 2 2 import 'dart:io'; 3 3 4 + import 'package:at_uri/at_uri.dart'; 4 5 import 'package:grain/app_logger.dart'; 5 6 import 'package:grain/dpop_client.dart'; 6 7 import 'package:grain/main.dart'; ··· 9 10 import 'package:mime/mime.dart'; 10 11 11 12 import './auth.dart'; 13 + import 'models/comment.dart'; 12 14 import 'models/gallery.dart'; 13 15 import 'models/notification.dart' as grain; 14 16 import 'models/profile.dart'; ··· 130 132 } 131 133 } 132 134 133 - Future<Map<String, dynamic>> getGalleryThread({required String uri}) async { 135 + Future<GalleryThread?> getGalleryThread({required String uri}) async { 134 136 appLogger.i('Fetching gallery thread for uri: $uri'); 135 137 final response = await http.get( 136 138 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 137 139 headers: {'Content-Type': 'application/json'}, 138 140 ); 139 141 if (response.statusCode != 200) { 140 - appLogger.w('Failed to fetch gallery thread: ${response.statusCode} ${response.body}'); 141 - return {}; 142 + appLogger.w('Failed to fetch gallery thread: \\${response.statusCode} \\${response.body}'); 143 + return null; 142 144 } 143 - return jsonDecode(response.body) as Map<String, dynamic>; 145 + final json = jsonDecode(response.body) as Map<String, dynamic>; 146 + final gallery = Gallery.fromJson(json['gallery']); 147 + final comments = (json['comments'] as List<dynamic>? ?? []) 148 + .map((c) => Comment.fromJson(c as Map<String, dynamic>)) 149 + .toList(); 150 + return GalleryThread(gallery: gallery, comments: comments); 144 151 } 145 152 146 153 Future<List<grain.Notification>> getNotifications() async { ··· 259 266 return null; 260 267 } 261 268 269 + /// Polls the gallery thread until the number of comments matches [expectedCount] or timeout. 270 + /// Returns the thread map if successful, or null if timeout. 271 + Future<GalleryThread?> pollGalleryThreadComments({ 272 + required String galleryUri, 273 + required int expectedCount, 274 + Duration pollDelay = const Duration(seconds: 2), 275 + int maxAttempts = 20, 276 + }) async { 277 + int attempts = 0; 278 + GalleryThread? thread; 279 + while (attempts < maxAttempts) { 280 + thread = await getGalleryThread(uri: galleryUri); 281 + if (thread != null && thread.comments.length == expectedCount) { 282 + appLogger.i('Gallery thread $galleryUri has expected number of comments: $expectedCount'); 283 + return thread; 284 + } 285 + await Future.delayed(pollDelay); 286 + attempts++; 287 + } 288 + appLogger.w( 289 + 'Gallery thread $galleryUri did not reach expected comments count ($expectedCount) after polling.', 290 + ); 291 + return null; 292 + } 293 + 262 294 /// Uploads a blob (file) to the atproto uploadBlob endpoint using DPoP authentication. 263 295 /// Returns the blob reference map on success, or null on failure. 264 296 Future<Map<String, dynamic>?> uploadBlob(File file) async { ··· 386 418 appLogger.i('Created gallery item result: $result'); 387 419 return result['uri'] as String?; 388 420 } 421 + 422 + Future<String?> createComment({ 423 + required String text, 424 + List<Map<String, dynamic>>? facets, 425 + required String subject, 426 + Map<String, dynamic>? focus, 427 + String? replyTo, 428 + }) async { 429 + final session = await auth.getValidSession(); 430 + if (session == null) { 431 + appLogger.w('No valid session for createComment'); 432 + return null; 433 + } 434 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 435 + final issuer = session.issuer; 436 + final did = session.subject; 437 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 438 + final record = { 439 + 'collection': 'social.grain.comment', 440 + 'repo': did, 441 + 'record': { 442 + 'text': text, 443 + if (facets != null) 'facets': facets, 444 + 'subject': subject, 445 + if (focus != null) 'focus': focus, 446 + if (replyTo != null) 'replyTo': replyTo, 447 + 'createdAt': DateTime.now().toUtc().toIso8601String(), 448 + }, 449 + }; 450 + appLogger.i('Creating comment: $record'); 451 + final response = await dpopClient.send( 452 + method: 'POST', 453 + url: url, 454 + accessToken: session.accessToken, 455 + headers: {'Content-Type': 'application/json'}, 456 + body: jsonEncode(record), 457 + ); 458 + if (response.statusCode != 200 && response.statusCode != 201) { 459 + appLogger.w('Failed to create comment: \\${response.statusCode} \\${response.body}'); 460 + return null; 461 + } 462 + final result = jsonDecode(response.body) as Map<String, dynamic>; 463 + appLogger.i('Created comment result: $result'); 464 + return result['uri'] as String?; 465 + } 466 + 467 + /// Deletes a record by its URI using DPoP authentication. 468 + /// Returns true on success, false on failure. 469 + Future<bool> deleteRecord(String uri) async { 470 + final session = await auth.getValidSession(); 471 + if (session == null) { 472 + appLogger.w('No valid session for deleteRecord'); 473 + return false; 474 + } 475 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 476 + final issuer = session.issuer; 477 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.deleteRecord'); 478 + final repo = session.subject; 479 + if (repo.isEmpty) { 480 + appLogger.w('No repo (DID) available from session for deleteRecord'); 481 + return false; 482 + } 483 + String? collection; 484 + String? rkey; 485 + try { 486 + final atUri = AtUri.parse(uri); 487 + collection = atUri.collection.toString(); 488 + rkey = atUri.rkey; 489 + } catch (e) { 490 + appLogger.w('Failed to parse collection from uri: $uri'); 491 + } 492 + if (collection == null || collection.isEmpty) { 493 + appLogger.w('No collection found in uri: $uri'); 494 + return false; 495 + } 496 + final payload = {'uri': uri, 'repo': repo, 'collection': collection, 'rkey': rkey}; 497 + appLogger.i('Deleting record: $payload'); 498 + final response = await dpopClient.send( 499 + method: 'POST', 500 + url: url, 501 + accessToken: session.accessToken, 502 + headers: {'Content-Type': 'application/json'}, 503 + body: jsonEncode(payload), 504 + ); 505 + if (response.statusCode != 200 && response.statusCode != 204) { 506 + appLogger.w('Failed to delete record: \\${response.statusCode} \\${response.body}'); 507 + return false; 508 + } 509 + appLogger.i('Deleted record $uri'); 510 + return true; 511 + } 389 512 } 390 513 391 514 final apiService = ApiService(); 515 + 516 + class GalleryThread { 517 + final Gallery gallery; 518 + final List<Comment> comments; 519 + GalleryThread({required this.gallery, required this.comments}); 520 + }
+1 -1
lib/app_theme.dart
··· 53 53 dividerColor: Colors.grey[900], 54 54 colorScheme: ColorScheme.dark( 55 55 primary: primaryColor, 56 - surface: Colors.black, 56 + surface: Colors.grey[900]!, 57 57 onSurface: Colors.white, 58 58 onSurfaceVariant: Colors.white70, 59 59 onPrimary: Colors.white,
+9 -3
lib/models/comment.dart
··· 8 8 final String? replyTo; 9 9 final String? createdAt; 10 10 final GalleryPhoto? focus; 11 + final List<Map<String, dynamic>>? facets; 11 12 12 13 Comment({ 13 14 required this.uri, ··· 17 18 this.replyTo, 18 19 this.createdAt, 19 20 this.focus, 21 + this.facets, 20 22 }); 21 23 22 24 factory Comment.fromJson(Map<String, dynamic> json) { 25 + final record = json['record'] as Map<String, dynamic>? ?? {}; 23 26 return Comment( 24 27 uri: json['uri'] ?? '', 25 28 cid: json['cid'] ?? '', 26 29 author: json['author'] ?? {}, 27 - text: json['text'] ?? '', 28 - replyTo: json['replyTo'], 29 - createdAt: json['createdAt'], 30 + text: json['text'] ?? record['text'] ?? '', 31 + replyTo: json['replyTo'] ?? record['replyTo'], 32 + createdAt: json['createdAt'] ?? record['createdAt'], 30 33 focus: json['focus'] != null ? GalleryPhoto.fromJson(json['focus']) : null, 34 + facets: 35 + (json['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList() ?? 36 + (record['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList(), 31 37 ); 32 38 } 33 39 }
+6 -2
lib/models/gallery.dart
··· 11 11 final int? favCount; 12 12 final int? commentCount; 13 13 final Map<String, dynamic>? viewer; 14 + final List<Map<String, dynamic>>? facets; 14 15 15 16 Gallery({ 16 17 required this.uri, ··· 23 24 this.favCount, 24 25 this.commentCount, 25 26 this.viewer, 27 + this.facets, 26 28 }); 27 29 28 30 factory Gallery.fromJson(Map<String, dynamic> json) { 31 + final record = json['record'] as Map<String, dynamic>? ?? {}; 29 32 return Gallery( 30 33 uri: json['uri'] ?? '', 31 34 cid: json['cid'] ?? '', 32 - title: json['record']?['title'] ?? '', 33 - description: json['record']?['description'] ?? '', 35 + title: record['title'] ?? '', 36 + description: record['description'] ?? '', 34 37 items: (json['items'] as List<dynamic>? ?? []) 35 38 .map((item) => GalleryPhoto.fromJson(item as Map<String, dynamic>)) 36 39 .toList(), ··· 39 42 favCount: json['favCount'], 40 43 commentCount: json['commentCount'], 41 44 viewer: json['viewer'], 45 + facets: (record['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList(), 42 46 ); 43 47 } 44 48 }
+424 -64
lib/screens/comments_page.dart
··· 1 + import 'package:bluesky_text/bluesky_text.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:grain/api.dart'; 3 4 import 'package:grain/models/comment.dart'; 4 5 import 'package:grain/models/gallery.dart'; 6 + import 'package:grain/screens/profile_page.dart'; 5 7 import 'package:grain/utils.dart'; 6 8 import 'package:grain/widgets/app_image.dart'; 9 + import 'package:grain/widgets/faceted_text.dart'; 7 10 import 'package:grain/widgets/gallery_photo_view.dart'; 8 11 9 12 class CommentsPage extends StatefulWidget { ··· 20 23 Gallery? _gallery; 21 24 List<Comment> _comments = []; 22 25 GalleryPhoto? _selectedPhoto; 26 + bool _showInputBar = false; 27 + final TextEditingController _replyController = TextEditingController(); 28 + final FocusNode _replyFocusNode = FocusNode(); 29 + String? _replyTo; 23 30 24 31 @override 25 32 void initState() { ··· 27 34 _fetchThread(); 28 35 } 29 36 37 + @override 38 + void dispose() { 39 + _replyController.dispose(); 40 + _replyFocusNode.dispose(); 41 + super.dispose(); 42 + } 43 + 30 44 Future<void> _fetchThread() async { 31 45 setState(() { 32 46 _loading = true; 33 47 _error = false; 34 48 }); 35 49 try { 36 - final data = await apiService.getGalleryThread(uri: widget.galleryUri); 50 + final thread = await apiService.getGalleryThread(uri: widget.galleryUri); 37 51 setState(() { 38 - _gallery = Gallery.fromJson(data['gallery']); 39 - _comments = (data['comments'] as List<dynamic>? ?? []) 40 - .map((c) => Comment.fromJson(c as Map<String, dynamic>)) 41 - .toList(); 52 + _gallery = thread?.gallery; 53 + _comments = thread?.comments ?? []; 42 54 _loading = false; 43 55 }); 44 56 } catch (e) { ··· 49 61 } 50 62 } 51 63 64 + void _showReplyBar({String? replyTo, String? mention}) { 65 + setState(() { 66 + _showInputBar = true; 67 + _replyTo = replyTo; 68 + }); 69 + if (mention != null && mention.isNotEmpty) { 70 + _replyController.text = mention; 71 + _replyController.selection = TextSelection.fromPosition( 72 + TextPosition(offset: _replyController.text.length), 73 + ); 74 + } else { 75 + _replyController.clear(); 76 + } 77 + Future.delayed(const Duration(milliseconds: 100), () { 78 + if (mounted) FocusScope.of(context).requestFocus(_replyFocusNode); 79 + }); 80 + } 81 + 82 + void _hideReplyBar() { 83 + setState(() { 84 + _showInputBar = false; 85 + _replyController.clear(); 86 + _replyTo = null; 87 + }); 88 + FocusScope.of(context).unfocus(); 89 + } 90 + 91 + Future<void> handleDeleteComment(Comment comment) async { 92 + final confirmed = await showDialog<bool>( 93 + context: context, 94 + builder: (ctx) => AlertDialog( 95 + title: const Text('Delete Comment'), 96 + content: const Text('Are you sure you want to delete this comment?'), 97 + actions: [ 98 + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')), 99 + TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete')), 100 + ], 101 + ), 102 + ); 103 + if (confirmed != true) return; 104 + final scaffold = ScaffoldMessenger.of(context); 105 + scaffold.removeCurrentSnackBar(); 106 + scaffold.showSnackBar(const SnackBar(content: Text('Deleting comment...'))); 107 + final deleted = await apiService.deleteRecord(comment.uri); 108 + if (!deleted) { 109 + scaffold.removeCurrentSnackBar(); 110 + scaffold.showSnackBar(const SnackBar(content: Text('Failed to delete comment.'))); 111 + return; 112 + } 113 + final expectedCount = _comments.length - 1; 114 + final thread = await apiService.pollGalleryThreadComments( 115 + galleryUri: widget.galleryUri, 116 + expectedCount: expectedCount, 117 + ); 118 + if (thread != null) { 119 + setState(() { 120 + _gallery = thread.gallery; 121 + _comments = thread.comments; 122 + }); 123 + } else { 124 + await _fetchThread(); 125 + } 126 + scaffold.removeCurrentSnackBar(); 127 + scaffold.showSnackBar(const SnackBar(content: Text('Comment deleted.'))); 128 + } 129 + 130 + // Extract facets using the async BlueskyText/entities/toFacets pattern 131 + Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 132 + final blueskyText = BlueskyText(text); 133 + final entities = blueskyText.entities; 134 + final facets = await entities.toFacets(); 135 + return List<Map<String, dynamic>>.from(facets); 136 + } 137 + 52 138 @override 53 139 Widget build(BuildContext context) { 54 140 final theme = Theme.of(context); ··· 65 151 ), 66 152 title: Text('Comments', style: theme.appBarTheme.titleTextStyle), 67 153 ), 68 - body: _loading 69 - ? Center( 70 - child: CircularProgressIndicator( 71 - strokeWidth: 2, 72 - color: theme.colorScheme.primary, 154 + body: GestureDetector( 155 + behavior: HitTestBehavior.translucent, 156 + onTap: () { 157 + if (_showInputBar) _hideReplyBar(); 158 + }, 159 + child: _loading 160 + ? Center( 161 + child: CircularProgressIndicator( 162 + strokeWidth: 2, 163 + color: theme.colorScheme.primary, 164 + ), 165 + ) 166 + : _error 167 + ? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium)) 168 + : ListView( 169 + padding: const EdgeInsets.fromLTRB(12, 12, 12, 100), 170 + children: [ 171 + if (_gallery != null) 172 + Text(_gallery!.title, style: theme.textTheme.titleMedium), 173 + const SizedBox(height: 12), 174 + _CommentsList( 175 + comments: _comments, 176 + onPhotoTap: (photo) { 177 + setState(() { 178 + _selectedPhoto = photo; 179 + }); 180 + }, 181 + onReply: (replyTo, {mention}) => 182 + _showReplyBar(replyTo: replyTo, mention: mention), 183 + onDelete: handleDeleteComment, 184 + ), 185 + ], 186 + ), 187 + ), 188 + bottomNavigationBar: _showInputBar 189 + ? AnimatedPadding( 190 + duration: const Duration(milliseconds: 150), 191 + curve: Curves.easeOut, 192 + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), 193 + child: Builder( 194 + builder: (context) { 195 + final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; 196 + return Padding( 197 + padding: EdgeInsets.only(bottom: keyboardOpen ? 0 : 12), 198 + child: Column( 199 + mainAxisSize: MainAxisSize.min, 200 + children: [ 201 + Divider(height: 1, thickness: 1, color: theme.dividerColor), 202 + Container( 203 + color: theme.colorScheme.surfaceContainer, 204 + child: Row( 205 + crossAxisAlignment: CrossAxisAlignment.end, 206 + children: [ 207 + Expanded( 208 + child: Container( 209 + decoration: BoxDecoration( 210 + color: theme.colorScheme.surfaceContainerHighest 211 + .withOpacity(0.95), 212 + borderRadius: BorderRadius.circular(18), 213 + ), 214 + padding: const EdgeInsets.symmetric( 215 + horizontal: 18, 216 + vertical: 12, 217 + ), 218 + child: ConstrainedBox( 219 + constraints: const BoxConstraints( 220 + minHeight: 40, 221 + maxHeight: 120, 222 + ), 223 + child: Scrollbar( 224 + child: TextField( 225 + controller: _replyController, 226 + focusNode: _replyFocusNode, 227 + autofocus: true, 228 + minLines: 1, 229 + maxLines: 5, 230 + textInputAction: TextInputAction.newline, 231 + decoration: const InputDecoration( 232 + hintText: 'Write a reply...', 233 + border: InputBorder.none, 234 + isCollapsed: true, 235 + ), 236 + style: theme.textTheme.bodyLarge, 237 + onSubmitted: (value) async { 238 + if (value.trim().isEmpty) return; 239 + final text = value.trim(); 240 + final facets = await _extractFacets(text); 241 + final uri = await apiService.createComment( 242 + text: text, 243 + subject: widget.galleryUri, 244 + replyTo: _replyTo, 245 + facets: facets, 246 + ); 247 + if (uri != null) { 248 + final thread = await apiService 249 + .pollGalleryThreadComments( 250 + galleryUri: widget.galleryUri, 251 + expectedCount: _comments.length + 1, 252 + ); 253 + if (thread != null) { 254 + setState(() { 255 + _gallery = thread.gallery; 256 + _comments = thread.comments; 257 + }); 258 + } else { 259 + await _fetchThread(); 260 + } 261 + } 262 + _hideReplyBar(); 263 + }, 264 + ), 265 + ), 266 + ), 267 + ), 268 + ), 269 + const SizedBox(width: 8), 270 + Container( 271 + margin: const EdgeInsets.only(right: 10, bottom: 8), 272 + decoration: BoxDecoration( 273 + color: theme.colorScheme.primary, 274 + borderRadius: BorderRadius.circular(16), 275 + ), 276 + child: IconButton( 277 + icon: Icon(Icons.send, color: theme.colorScheme.onPrimary), 278 + onPressed: () async { 279 + final value = _replyController.text.trim(); 280 + if (value.isEmpty) return; 281 + final facets = await _extractFacets(value); 282 + final uri = await apiService.createComment( 283 + text: value, 284 + subject: widget.galleryUri, 285 + replyTo: _replyTo, 286 + facets: facets, 287 + ); 288 + if (uri != null) { 289 + final thread = await apiService.pollGalleryThreadComments( 290 + galleryUri: widget.galleryUri, 291 + expectedCount: _comments.length + 1, 292 + ); 293 + if (thread != null) { 294 + setState(() { 295 + _gallery = thread.gallery; 296 + _comments = thread.comments; 297 + }); 298 + } else { 299 + await _fetchThread(); 300 + } 301 + } 302 + _hideReplyBar(); 303 + }, 304 + ), 305 + ), 306 + ], 307 + ), 308 + ), 309 + ], 310 + ), 311 + ); 312 + }, 73 313 ), 74 314 ) 75 - : _error 76 - ? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium)) 77 - : ListView( 78 - padding: const EdgeInsets.all(12), 79 - children: [ 80 - if (_gallery != null) Text(_gallery!.title, style: theme.textTheme.titleMedium), 81 - const SizedBox(height: 12), 82 - _CommentsList( 83 - comments: _comments, 84 - onPhotoTap: (photo) { 85 - setState(() { 86 - _selectedPhoto = photo; 87 - }); 88 - }, 315 + : Container( 316 + color: theme.colorScheme.surface, 317 + child: SafeArea( 318 + child: GestureDetector( 319 + onTap: _showReplyBar, 320 + child: Container( 321 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), 322 + child: Container( 323 + height: 44, 324 + decoration: BoxDecoration( 325 + color: theme.colorScheme.surfaceContainerHighest, 326 + borderRadius: BorderRadius.circular(22), 327 + ), 328 + child: Row( 329 + children: [ 330 + Icon(Icons.reply, color: theme.iconTheme.color, size: 20), 331 + const SizedBox(width: 8), 332 + Text( 333 + 'Add a reply...', 334 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), 335 + ), 336 + ], 337 + ), 338 + ), 339 + ), 89 340 ), 90 - ], 341 + ), 91 342 ), 343 + // Show photo view overlay if needed 344 + extendBody: true, 345 + extendBodyBehindAppBar: false, 92 346 ), 93 347 if (_selectedPhoto != null) 94 348 Positioned.fill( ··· 106 360 class _CommentsList extends StatelessWidget { 107 361 final List<Comment> comments; 108 362 final void Function(GalleryPhoto photo) onPhotoTap; 109 - const _CommentsList({required this.comments, required this.onPhotoTap}); 363 + final void Function(String replyTo, {String? mention}) onReply; 364 + final void Function(Comment comment) onDelete; 365 + const _CommentsList({ 366 + required this.comments, 367 + required this.onPhotoTap, 368 + required this.onReply, 369 + required this.onDelete, 370 + }); 110 371 111 372 Map<String, List<Comment>> _groupReplies(List<Comment> comments) { 112 373 final repliesByParent = <String, List<Comment>>{}; ··· 122 383 return comments.where((c) => c.replyTo == null).toList(); 123 384 } 124 385 125 - Widget _buildCommentTree(Comment comment, Map<String, List<Comment>> repliesByParent, int depth) { 386 + /// Returns the top-level parent for a comment (itself if already top-level) 387 + Comment _findTopLevelParent(Comment comment, Map<String, Comment> byUri) { 388 + var current = comment; 389 + while (current.replyTo != null && byUri[current.replyTo!] != null) { 390 + final parent = byUri[current.replyTo!]; 391 + if (parent == null) break; 392 + if (parent.replyTo == null) return parent; 393 + current = parent; 394 + } 395 + return current.replyTo == null ? current : byUri[current.replyTo!] ?? current; 396 + } 397 + 398 + Widget _buildCommentTree( 399 + Comment comment, 400 + Map<String, List<Comment>> repliesByParent, 401 + int depth, 402 + Map<String, Comment> byUri, 403 + ) { 126 404 return Padding( 127 405 padding: EdgeInsets.only(left: depth * 18.0), 128 406 child: Column( 129 407 crossAxisAlignment: CrossAxisAlignment.start, 130 408 children: [ 131 - _CommentTile(comment: comment, onPhotoTap: onPhotoTap), 409 + _CommentTile( 410 + comment: comment, 411 + onPhotoTap: onPhotoTap, 412 + onReply: (replyTo, {mention}) { 413 + // Only two levels: replyTo should always be the top-level parent 414 + final parent = _findTopLevelParent(comment, byUri); 415 + onReply(parent.uri, mention: mention); 416 + }, 417 + onDelete: onDelete, 418 + ), 132 419 if (repliesByParent[comment.uri] != null) 133 420 ...repliesByParent[comment.uri]!.map( 134 - (reply) => _buildCommentTree(reply, repliesByParent, depth + 1), 421 + (reply) => _buildCommentTree(reply, repliesByParent, depth + 1, byUri), 135 422 ), 136 423 ], 137 424 ), ··· 143 430 final theme = Theme.of(context); 144 431 final repliesByParent = _groupReplies(comments); 145 432 final topLevel = _topLevel(comments); 433 + final byUri = {for (final c in comments) c.uri: c}; 146 434 if (comments.isEmpty) { 147 435 return Padding( 148 436 padding: const EdgeInsets.symmetric(vertical: 32), ··· 156 444 } 157 445 return Column( 158 446 crossAxisAlignment: CrossAxisAlignment.start, 159 - children: [for (final comment in topLevel) _buildCommentTree(comment, repliesByParent, 0)], 447 + children: [ 448 + for (final comment in topLevel) _buildCommentTree(comment, repliesByParent, 0, byUri), 449 + ], 160 450 ); 161 451 } 162 452 } ··· 164 454 class _CommentTile extends StatelessWidget { 165 455 final Comment comment; 166 456 final void Function(GalleryPhoto photo)? onPhotoTap; 167 - const _CommentTile({required this.comment, this.onPhotoTap}); 457 + final void Function(String replyTo, {String? mention})? onReply; 458 + final void Function(Comment comment)? onDelete; 459 + const _CommentTile({required this.comment, this.onPhotoTap, this.onReply, this.onDelete}); 168 460 169 461 @override 170 462 Widget build(BuildContext context) { ··· 194 486 author['displayName'] ?? '@${author['handle'] ?? ''}', 195 487 style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), 196 488 ), 197 - Text(comment.text, style: theme.textTheme.bodyMedium), 198 - if (comment.focus != null) ...[ 199 - const SizedBox(height: 8), 200 - Align( 201 - alignment: Alignment.centerLeft, 202 - child: ConstrainedBox( 203 - constraints: const BoxConstraints(maxWidth: 180, maxHeight: 180), 204 - child: AspectRatio( 205 - aspectRatio: (comment.focus!.width > 0 && comment.focus!.height > 0) 206 - ? comment.focus!.width / comment.focus!.height 207 - : 1.0, 208 - child: ClipRRect( 209 - borderRadius: BorderRadius.circular(8), 210 - child: GestureDetector( 211 - onTap: onPhotoTap != null 212 - ? () => onPhotoTap!( 213 - GalleryPhoto( 214 - uri: comment.focus!.uri, 215 - cid: comment.focus!.cid, 216 - thumb: comment.focus!.thumb, 217 - fullsize: comment.focus!.fullsize, 218 - alt: comment.focus!.alt, 219 - width: comment.focus!.width, 220 - height: comment.focus!.height, 221 - ), 222 - ) 223 - : null, 224 - child: AppImage( 225 - url: comment.focus!.thumb.isNotEmpty 226 - ? comment.focus!.thumb 227 - : comment.focus!.fullsize, 228 - fit: BoxFit.cover, 489 + FacetedText( 490 + text: comment.text, 491 + facets: comment.facets, 492 + style: theme.textTheme.bodyMedium, 493 + linkStyle: theme.textTheme.bodyMedium?.copyWith( 494 + color: theme.colorScheme.primary, 495 + fontWeight: FontWeight.w600, 496 + ), 497 + onMentionTap: (did) { 498 + Navigator.of( 499 + context, 500 + ).push(MaterialPageRoute(builder: (context) => ProfilePage(did: did))); 501 + }, 502 + onLinkTap: (url) { 503 + // Navigator.of( 504 + // context, 505 + // ).push(MaterialPageRoute(builder: (context) => WebViewPage(url: url))); 506 + }, 507 + onTagTap: (tag) { 508 + // TODO: Implement hashtag navigation 509 + }, 510 + ), 511 + if (comment.focus != null && 512 + (comment.focus!.thumb.isNotEmpty || comment.focus!.fullsize.isNotEmpty)) 513 + Padding( 514 + padding: const EdgeInsets.only(top: 8.0), 515 + child: Align( 516 + alignment: Alignment.centerLeft, 517 + child: ConstrainedBox( 518 + constraints: const BoxConstraints(maxWidth: 180, maxHeight: 180), 519 + child: AspectRatio( 520 + aspectRatio: (comment.focus!.width > 0 && comment.focus!.height > 0) 521 + ? comment.focus!.width / comment.focus!.height 522 + : 1.0, 523 + child: ClipRRect( 524 + borderRadius: BorderRadius.circular(8), 525 + child: GestureDetector( 526 + onTap: onPhotoTap != null 527 + ? () => onPhotoTap!( 528 + GalleryPhoto( 529 + uri: comment.focus!.uri, 530 + cid: comment.focus!.cid, 531 + thumb: comment.focus!.thumb, 532 + fullsize: comment.focus!.fullsize, 533 + alt: comment.focus!.alt, 534 + width: comment.focus!.width, 535 + height: comment.focus!.height, 536 + ), 537 + ) 538 + : null, 539 + child: AppImage( 540 + url: comment.focus!.thumb.isNotEmpty 541 + ? comment.focus!.thumb 542 + : comment.focus!.fullsize, 543 + fit: BoxFit.cover, 544 + ), 229 545 ), 230 546 ), 231 547 ), 232 548 ), 233 549 ), 234 550 ), 235 - ], 236 551 if (comment.createdAt != null) 237 552 Text( 238 553 formatRelativeTime(comment.createdAt!), 239 554 style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), 240 555 ), 556 + const SizedBox(height: 4), // Add vertical spacing above the buttons 557 + Row( 558 + children: [ 559 + if (comment.replyTo == null) 560 + TextButton( 561 + style: TextButton.styleFrom( 562 + padding: EdgeInsets.zero, 563 + minimumSize: Size(0, 0), 564 + tapTargetSize: MaterialTapTargetSize.shrinkWrap, 565 + ), 566 + onPressed: () { 567 + final handle = comment.author['handle'] ?? ''; 568 + final mention = handle.isNotEmpty ? '@$handle ' : ''; 569 + if (onReply != null) onReply!(comment.uri, mention: mention); 570 + }, 571 + child: Text( 572 + 'Reply', 573 + style: theme.textTheme.bodyMedium?.copyWith( 574 + fontWeight: FontWeight.w600, 575 + decoration: TextDecoration.none, 576 + ), 577 + ), 578 + ), 579 + if (comment.author['did'] == (apiService.currentUser?.did ?? '')) ...[ 580 + const SizedBox(width: 16), 581 + TextButton( 582 + style: TextButton.styleFrom( 583 + padding: EdgeInsets.zero, 584 + minimumSize: Size(0, 0), 585 + tapTargetSize: MaterialTapTargetSize.shrinkWrap, 586 + ), 587 + onPressed: () { 588 + if (onDelete != null) onDelete!(comment); 589 + }, 590 + child: Text( 591 + 'Delete', 592 + style: theme.textTheme.bodyMedium?.copyWith( 593 + fontWeight: FontWeight.w600, 594 + decoration: TextDecoration.none, 595 + ), 596 + ), 597 + ), 598 + ], 599 + ], 600 + ), 241 601 ], 242 602 ), 243 603 ),
+21 -2
lib/screens/gallery_page.dart
··· 5 5 import 'package:grain/screens/create_gallery_page.dart'; 6 6 import 'package:grain/screens/profile_page.dart'; 7 7 import 'package:grain/widgets/app_image.dart'; 8 + import 'package:grain/widgets/faceted_text.dart'; 8 9 import 'package:grain/widgets/gallery_action_buttons.dart'; 9 10 import 'package:grain/widgets/gallery_photo_view.dart'; 10 11 import 'package:grain/widgets/justified_gallery_view.dart'; ··· 214 215 if (gallery.description.isNotEmpty) 215 216 Padding( 216 217 padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8), 217 - child: Text( 218 - gallery.description, 218 + child: FacetedText( 219 + text: gallery.description, 220 + facets: gallery.facets, 219 221 style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface), 222 + linkStyle: theme.textTheme.bodyMedium?.copyWith( 223 + color: theme.colorScheme.primary, 224 + fontWeight: FontWeight.w600, 225 + ), 226 + onMentionTap: (did) { 227 + Navigator.of(context).push( 228 + MaterialPageRoute( 229 + builder: (context) => ProfilePage(did: did, showAppBar: true), 230 + ), 231 + ); 232 + }, 233 + onLinkTap: (url) { 234 + // TODO: Implement or use your WebViewPage 235 + }, 236 + onTagTap: (tag) { 237 + // TODO: Implement hashtag navigation 238 + }, 220 239 ), 221 240 ), 222 241 if (isLoggedIn)
+76 -3
lib/screens/profile_page.dart
··· 1 + import 'package:bluesky_text/bluesky_text.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:grain/api.dart'; 3 4 import 'package:grain/app_theme.dart'; 4 5 import 'package:grain/models/gallery.dart'; 5 6 import 'package:grain/widgets/app_image.dart'; 7 + import 'package:grain/widgets/faceted_text.dart'; 6 8 7 9 import 'gallery_page.dart'; 8 10 ··· 24 26 TabController? _tabController; 25 27 bool _favsLoading = false; 26 28 bool _galleriesLoading = false; 29 + List<Map<String, dynamic>>? _descriptionFacets; 30 + 31 + Future<List<Map<String, dynamic>>> _extractFacets(String text) async { 32 + final blueskyText = BlueskyText(text); 33 + final entities = blueskyText.entities; 34 + final facets = await entities.toFacets(); 35 + return List<Map<String, dynamic>>.from(facets); 36 + } 27 37 28 38 @override 29 39 void initState() { ··· 94 104 } 95 105 final profile = await apiService.fetchProfile(did: did); 96 106 final galleries = await apiService.fetchActorGalleries(did: did); 107 + List<Map<String, dynamic>>? descriptionFacets; 108 + if ((profile?.description ?? '').isNotEmpty) { 109 + try { 110 + final desc = profile != null ? profile.description : ''; 111 + descriptionFacets = await _extractFacets(desc); 112 + } catch (_) { 113 + descriptionFacets = null; 114 + } 115 + } 97 116 if (mounted) { 98 117 setState(() { 99 118 _profile = profile; 100 119 _galleries = galleries; 120 + _descriptionFacets = descriptionFacets; 101 121 _loading = false; 102 122 }); 103 123 } ··· 211 231 ), 212 232 if ((profile.description ?? '').isNotEmpty) ...[ 213 233 const SizedBox(height: 16), 214 - Text(profile.description, textAlign: TextAlign.left), 234 + FacetedText( 235 + text: profile.description, 236 + facets: _descriptionFacets, 237 + onMentionTap: (didOrHandle) { 238 + Navigator.of(context).push( 239 + MaterialPageRoute( 240 + builder: (context) => 241 + ProfilePage(did: didOrHandle, showAppBar: true), 242 + ), 243 + ); 244 + }, 245 + onLinkTap: (url) { 246 + // TODO: Implement WebViewPage navigation 247 + }, 248 + onTagTap: (tag) { 249 + // TODO: Implement hashtag navigation 250 + }, 251 + linkStyle: TextStyle( 252 + color: Theme.of(context).colorScheme.primary, 253 + fontWeight: FontWeight.w600, 254 + ), 255 + ), 215 256 ], 216 257 const SizedBox(height: 24), 217 258 ], ··· 254 295 ), 255 296 ) 256 297 : _galleries.isEmpty 257 - ? const Center(child: Text('No galleries yet')) 298 + ? GridView.builder( 299 + shrinkWrap: true, 300 + physics: const NeverScrollableScrollPhysics(), 301 + padding: EdgeInsets.zero, 302 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 303 + crossAxisCount: 3, 304 + childAspectRatio: 3 / 4, 305 + crossAxisSpacing: 2, 306 + mainAxisSpacing: 2, 307 + ), 308 + itemCount: 12, // Enough to fill the screen 309 + itemBuilder: (context, index) { 310 + return Container(color: theme.colorScheme.surfaceContainerHighest); 311 + }, 312 + ) 258 313 : GridView.builder( 314 + shrinkWrap: true, 315 + physics: const NeverScrollableScrollPhysics(), 259 316 padding: EdgeInsets.zero, 260 317 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 261 318 crossAxisCount: 3, ··· 316 373 ), 317 374 ) 318 375 : _favs.isEmpty 319 - ? const Center(child: Text('No favorites yet')) 376 + ? GridView.builder( 377 + shrinkWrap: true, 378 + physics: const NeverScrollableScrollPhysics(), 379 + padding: EdgeInsets.zero, 380 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 381 + crossAxisCount: 3, 382 + childAspectRatio: 3 / 4, 383 + crossAxisSpacing: 2, 384 + mainAxisSpacing: 2, 385 + ), 386 + itemCount: 12, // Enough to fill the screen 387 + itemBuilder: (context, index) { 388 + return Container(color: theme.colorScheme.surfaceContainerHighest); 389 + }, 390 + ) 320 391 : GridView.builder( 392 + shrinkWrap: true, 393 + physics: const NeverScrollableScrollPhysics(), 321 394 padding: EdgeInsets.zero, 322 395 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 323 396 crossAxisCount: 3,
+3 -6
lib/widgets/app_image.dart
··· 24 24 @override 25 25 Widget build(BuildContext context) { 26 26 final theme = Theme.of(context); 27 - final Color bgColor = theme.brightness == Brightness.dark 28 - ? Colors.grey[900]! 29 - : Colors.grey[100]!; 30 27 if (url == null || url!.isEmpty) { 31 28 return errorWidget ?? 32 29 Container( 33 30 width: width, 34 31 height: height, 35 - color: bgColor, 32 + color: theme.colorScheme.surface, 36 33 child: const Icon(Icons.broken_image, color: Colors.grey), 37 34 ); 38 35 } ··· 48 45 Container( 49 46 width: width, 50 47 height: height, 51 - color: bgColor, 48 + color: theme.colorScheme.surface, 52 49 // child: const Center( 53 50 // child: CircularProgressIndicator( 54 51 // strokeWidth: 2, ··· 61 58 Container( 62 59 width: width, 63 60 height: height, 64 - color: bgColor, 61 + color: theme.colorScheme.surface, 65 62 child: const Icon(Icons.broken_image, color: Colors.grey), 66 63 ), 67 64 );
+102
lib/widgets/faceted_text.dart
··· 1 + import 'package:flutter/gestures.dart'; 2 + import 'package:flutter/material.dart'; 3 + 4 + class FacetedText extends StatelessWidget { 5 + final String text; 6 + final List<Map<String, dynamic>>? facets; 7 + final TextStyle? style; 8 + final TextStyle? linkStyle; 9 + final void Function(String did)? onMentionTap; 10 + final void Function(String url)? onLinkTap; 11 + final void Function(String tag)? onTagTap; 12 + 13 + const FacetedText({ 14 + super.key, 15 + required this.text, 16 + this.facets, 17 + this.style, 18 + this.linkStyle, 19 + this.onMentionTap, 20 + this.onLinkTap, 21 + this.onTagTap, 22 + }); 23 + 24 + @override 25 + Widget build(BuildContext context) { 26 + final theme = Theme.of(context); 27 + final defaultStyle = style ?? theme.textTheme.bodyMedium; 28 + final defaultLinkStyle = 29 + linkStyle ?? 30 + defaultStyle?.copyWith( 31 + color: theme.colorScheme.primary, 32 + fontWeight: FontWeight.w600, 33 + decoration: TextDecoration.underline, 34 + ); 35 + if (facets == null || facets!.isEmpty) { 36 + return Text(text, style: defaultStyle); 37 + } 38 + // Build a list of all ranges (start, end, type, data) 39 + final List<_FacetRange> ranges = facets!.map((facet) { 40 + final feature = facet['features']?[0] ?? {}; 41 + final type = feature['\$type'] ?? feature['type']; 42 + return _FacetRange( 43 + start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0, 44 + end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0, 45 + type: type, 46 + data: feature, 47 + ); 48 + }).toList(); 49 + ranges.sort((a, b) => a.start.compareTo(b.start)); 50 + int pos = 0; 51 + final spans = <TextSpan>[]; 52 + for (final range in ranges) { 53 + if (range.start > pos) { 54 + spans.add(TextSpan(text: text.substring(pos, range.start), style: defaultStyle)); 55 + } 56 + final content = text.substring(range.start, range.end); 57 + if (range.type?.contains('mention') == true && range.data['did'] != null) { 58 + spans.add( 59 + TextSpan( 60 + text: content, 61 + style: defaultLinkStyle, 62 + recognizer: TapGestureRecognizer() 63 + ..onTap = onMentionTap != null ? () => onMentionTap!(range.data['did']) : null, 64 + ), 65 + ); 66 + } else if (range.type?.contains('link') == true && range.data['uri'] != null) { 67 + spans.add( 68 + TextSpan( 69 + text: content, 70 + style: defaultLinkStyle, 71 + recognizer: TapGestureRecognizer() 72 + ..onTap = onLinkTap != null ? () => onLinkTap!(range.data['uri']) : null, 73 + ), 74 + ); 75 + } else if (range.type?.contains('tag') == true && range.data['tag'] != null) { 76 + spans.add( 77 + TextSpan( 78 + text: '#${range.data['tag']}', 79 + style: defaultLinkStyle, 80 + recognizer: TapGestureRecognizer() 81 + ..onTap = onTagTap != null ? () => onTagTap!(range.data['tag']) : null, 82 + ), 83 + ); 84 + } else { 85 + spans.add(TextSpan(text: content, style: defaultStyle)); 86 + } 87 + pos = range.end; 88 + } 89 + if (pos < text.length) { 90 + spans.add(TextSpan(text: text.substring(pos), style: defaultStyle)); 91 + } 92 + return RichText(text: TextSpan(children: spans)); 93 + } 94 + } 95 + 96 + class _FacetRange { 97 + final int start; 98 + final int end; 99 + final String? type; 100 + final Map<String, dynamic> data; 101 + _FacetRange({required this.start, required this.end, required this.type, required this.data}); 102 + }
+8
pubspec.lock
··· 41 41 url: "https://pub.dev" 42 42 source: hosted 43 43 version: "0.4.0" 44 + bluesky_text: 45 + dependency: "direct main" 46 + description: 47 + name: bluesky_text 48 + sha256: a10835d17e9cfc1739ea09a5c6fc86c287aa59074c60961601f7a0cf663b1ed4 49 + url: "https://pub.dev" 50 + source: hosted 51 + version: "0.7.2" 44 52 boolean_selector: 45 53 dependency: transitive 46 54 description:
+1
pubspec.yaml
··· 54 54 xrpc: ^0.6.1 55 55 mime: ^1.0.6 56 56 image: ^4.5.4 57 + bluesky_text: ^0.7.2 57 58 58 59 dev_dependencies: 59 60 flutter_test: