[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.

we out here likin comments (#29)

* comment likn

* Update comment_item.dart

* Update comment_item.dart

* change count on like

* reactive comments

* fix linter errors

* some stuffs

* fixes

* fix input

* Update comment_input.dart

* Update comment_input.dart

* like loading

* is this it?

* Delete settings.json

* update count

* minor styling change

---------

Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

Roscoe Rubin-Rottenberg
daviirodrig
and committed by
GitHub
259cca1b d702380a

+582 -250
+9 -9
lib/main.dart
··· 74 74 create: (context) => ActionsService(context.read<AuthService>()), 75 75 update: (_, authService, previousActionsService) => previousActionsService ?? ActionsService(authService), 76 76 ), 77 - ChangeNotifierProxyProvider<AuthService, CommentsService>( 78 - create: (context) => CommentsService(context.read<AuthService>()), 79 - update: (_, authService, previousCommentsService) => previousCommentsService ?? CommentsService(authService), 77 + ChangeNotifierProxyProvider2<AuthService, ProfileService, CommentsService>( 78 + create: (context) => CommentsService(context.read<AuthService>(), context.read<ProfileService>()), 79 + update: 80 + (_, authService, profileService, previousCommentsService) => 81 + previousCommentsService ?? CommentsService(authService, profileService), 80 82 ), 81 83 ProxyProvider<AuthService, VideoService>( 82 84 create: (context) => VideoService(context.read<AuthService>()), 83 85 update: (_, authService, previousVideoService) => previousVideoService ?? VideoService(authService), 84 86 ), 85 87 ChangeNotifierProxyProvider2<AuthService, SettingsService, LabelerManager>( 86 - create: (context) => LabelerManager( 87 - context.read<AuthService>(), 88 - context.read<SettingsService>(), 89 - ), 90 - update: (_, authService, settingsService, previousLabelerManager) => 91 - previousLabelerManager ?? LabelerManager(authService, settingsService), 88 + create: (context) => LabelerManager(context.read<AuthService>(), context.read<SettingsService>()), 89 + update: 90 + (_, authService, settingsService, previousLabelerManager) => 91 + previousLabelerManager ?? LabelerManager(authService, settingsService), 92 92 ), 93 93 ], 94 94 child: MaterialApp(
+173 -2
lib/models/comment.dart
··· 1 1 import 'package:bluesky/app_bsky_embed_video.dart'; 2 2 import 'package:bluesky/bluesky.dart'; 3 + import 'package:sparksocial/services/identity_service.dart'; 4 + import 'package:sparksocial/services/profile_service.dart'; 3 5 4 6 class Comment { 5 7 final String id; ··· 16 18 final bool hasMedia; 17 19 final String? mediaType; 18 20 final String? mediaUrl; 19 - final String? likeUri; 21 + String? likeUri; 20 22 final bool isSprk; 21 23 final List<Comment> replies; 22 24 final List<String> imageUrls; ··· 185 187 ); 186 188 } 187 189 190 + /// Create a Comment from a Spark record (not a full post object) 191 + static Future<Comment> fromSparkCommentRecord(Map<String, dynamic> record, String uri, ProfileService profileService) async { 192 + // Extract hashtags from text 193 + final text = record['text'] as String? ?? ''; 194 + List<String> hashtags = []; 195 + final matches = RegExp(r'#(\w+)').allMatches(text); 196 + if (matches.isNotEmpty) { 197 + hashtags = matches.map((m) => m.group(1)!).toList(); 198 + } 199 + 200 + // Extract media if available 201 + bool hasMedia = false; 202 + String? mediaType; 203 + String? mediaUrl; 204 + List<String> imageUrls = []; 205 + 206 + if (record['embed'] != null && record['embed'] is Map<String, dynamic>) { 207 + final embedType = record['embed']['\$type'] as String?; 208 + if (embedType == 'so.sprk.embed.images#view') { 209 + hasMedia = true; 210 + mediaType = 'image'; 211 + 212 + // Extract all image URLs 213 + final images = record['embed']['images'] as List<dynamic>?; 214 + if (images != null && images.isNotEmpty) { 215 + mediaUrl = images[0]['thumb'] as String?; 216 + 217 + // Add all fullsize images to imageUrls 218 + for (final image in images) { 219 + final fullsize = image['fullsize'] as String?; 220 + if (fullsize != null) { 221 + imageUrls.add(fullsize); 222 + } 223 + } 224 + } 225 + } else if (embedType == 'so.sprk.embed.video#view') { 226 + hasMedia = true; 227 + mediaType = 'video'; 228 + mediaUrl = record['embed']['playlist'] as String?; 229 + } 230 + } 231 + 232 + // Extract like URI from viewer object if available 233 + String? likeUri; 234 + if (record.containsKey('viewer') && record['viewer'] is Map<String, dynamic>) { 235 + likeUri = (record['viewer'] as Map<String, dynamic>)['like'] as String?; 236 + } 237 + 238 + // Extract authorDid from record or from uri 239 + String authorDid = ''; 240 + final match = RegExp(r'at://([^/]+)/').firstMatch(uri); 241 + if (match != null && match.groupCount >= 1) { 242 + authorDid = match.group(1)!; 243 + } 244 + final identityService = CachedIdentityService(); 245 + final handle = await identityService.resolveDidToHandle(authorDid); 246 + if (handle == null) { 247 + throw Exception('No handle found for author did: $authorDid'); 248 + } 249 + 250 + // Get profile information for avatar 251 + final profile = await profileService.getProfile(authorDid); 252 + final avatarUrl = profile?.avatarUrl; 253 + 254 + return Comment( 255 + id: record['uri'] as String? ?? '', 256 + uri: record['uri'] as String? ?? '', 257 + cid: record['cid'] as String? ?? '', 258 + authorDid: authorDid, 259 + username: profile!.displayName ?? profile.did, 260 + profileImageUrl: avatarUrl, 261 + text: text, 262 + createdAt: formatTimeAgo( 263 + record['indexedAt'] as String? ?? record['createdAt'] as String? ?? DateTime.now().toIso8601String(), 264 + ), 265 + likeCount: record['likeCount'] as int? ?? 0, 266 + replyCount: record['replyCount'] as int? ?? 0, 267 + hashtags: hashtags, 268 + hasMedia: hasMedia, 269 + mediaType: mediaType, 270 + mediaUrl: mediaUrl, 271 + likeUri: likeUri, 272 + isSprk: true, 273 + replies: [], 274 + imageUrls: imageUrls, 275 + ); 276 + } 277 + 278 + /// Create a Comment from a Bluesky record (not a full post object) 279 + static Future<Comment> fromBlueskyCommentRecord(Map<String, dynamic> record, String uri, ProfileService profileService) async { 280 + // Extract hashtags from text 281 + final text = record['text'] as String? ?? ''; 282 + List<String> hashtags = []; 283 + final matches = RegExp(r'#(\w+)').allMatches(text); 284 + if (matches.isNotEmpty) { 285 + hashtags = matches.map((m) => m.group(1)!).toList(); 286 + } 287 + 288 + // Extract media if available 289 + bool hasMedia = false; 290 + String? mediaType; 291 + String? mediaUrl; 292 + List<String> imageUrls = []; 293 + 294 + if (record['embed'] != null && record['embed'] is Map<String, dynamic>) { 295 + final embedType = record['embed']['\$type'] as String?; 296 + if (embedType == 'app.bsky.embed.images#view') { 297 + hasMedia = true; 298 + mediaType = 'image'; 299 + final images = record['embed']['images'] as List<dynamic>?; 300 + if (images != null && images.isNotEmpty) { 301 + mediaUrl = images[0]['thumb'] as String?; 302 + for (final image in images) { 303 + final fullsize = image['fullsize'] as String?; 304 + if (fullsize != null) { 305 + imageUrls.add(fullsize); 306 + } 307 + } 308 + } 309 + } else if (embedType == 'app.bsky.embed.video#view') { 310 + hasMedia = true; 311 + mediaType = 'video'; 312 + mediaUrl = record['embed']['playlist'] as String?; 313 + } 314 + } 315 + 316 + // Extract like URI from viewer object if available 317 + String? likeUri; 318 + if (record.containsKey('viewer') && record['viewer'] is Map<String, dynamic>) { 319 + likeUri = (record['viewer'] as Map<String, dynamic>)['like'] as String?; 320 + } 321 + 322 + // Extract authorDid from record or from uri 323 + String authorDid = ''; 324 + final match = RegExp(r'at://([^/]+)/').firstMatch(uri); 325 + if (match != null && match.groupCount >= 1) { 326 + authorDid = match.group(1)!; 327 + } 328 + 329 + // Get profile information 330 + final profile = await profileService.getProfile(authorDid); 331 + if (profile == null) { 332 + throw Exception('No profile found for author did: $authorDid'); 333 + } 334 + 335 + return Comment( 336 + id: uri, 337 + uri: uri, 338 + cid: record['cid'] as String? ?? '', 339 + authorDid: authorDid, 340 + username: profile.displayName ?? profile.did, 341 + profileImageUrl: profile.avatarUrl, 342 + text: text, 343 + createdAt: formatTimeAgo( 344 + record['indexedAt'] as String? ?? record['createdAt'] as String? ?? DateTime.now().toIso8601String(), 345 + ), 346 + likeCount: record['likeCount'] as int? ?? 0, 347 + replyCount: record['replyCount'] as int? ?? 0, 348 + hashtags: hashtags, 349 + hasMedia: hasMedia, 350 + mediaType: mediaType, 351 + mediaUrl: mediaUrl, 352 + likeUri: likeUri, 353 + isSprk: false, 354 + replies: [], 355 + imageUrls: imageUrls, 356 + ); 357 + } 358 + 188 359 bool get isLiked => likeUri != null; 189 - } 360 + }
+9 -3
lib/services/actions_service.dart
··· 23 23 // Delete a post by its URI 24 24 Future<bool> deletePost(String postUri) async { 25 25 try { 26 - final response = await _client.repo.deleteRecord(uri: AtUri.parse(postUri)); 26 + // Ensure the URI starts with 'at://' 27 + final normalizedUri = postUri.startsWith('at://') ? postUri : 'at://$postUri'; 28 + 29 + final response = await _client.repo.deleteRecord(uri: AtUri.parse(normalizedUri)); 27 30 28 31 if (response.status.code != 200) { 29 32 debugPrint('Failed to delete post: ${response.status.code} ${response.data}'); ··· 75 78 rootCid ??= parentCid; 76 79 rootUri ??= parentUri; 77 80 81 + final isSprk = RegExp(r'^at://[^/]+/so\.sprk\.feed\.post/[^/]+$').hasMatch(parentUri); 82 + final postType = isSprk ? "so.sprk.feed.post" : "app.bsky.feed.post"; 83 + 78 84 final commentRecord = <String, dynamic>{ 79 - "\$type": "so.sprk.feed.post", // Spark feed post type 85 + "\$type": postType, 80 86 "text": text, 81 87 "reply": { 82 88 "root": {"cid": rootCid, "uri": rootUri}, ··· 91 97 } 92 98 93 99 // Use the correct NSID for Spark posts 94 - final response = await _client.repo.createRecord(collection: NSID.parse('so.sprk.feed.post'), record: commentRecord); 100 + final response = await _client.repo.createRecord(collection: NSID.parse(postType), record: commentRecord); 95 101 96 102 // Check response status 97 103 if (response.status.code != 200) {
+31 -1
lib/services/comments_service.dart
··· 5 5 6 6 import '../models/comment.dart'; 7 7 import 'auth_service.dart'; 8 + import 'profile_service.dart'; 8 9 9 10 class CommentsService extends ChangeNotifier { 10 11 final AuthService _authService; 12 + final ProfileService _profileService; 11 13 12 14 bool _isLoading = false; 13 15 String? _error; ··· 17 19 String? get error => _error; 18 20 List<Comment>? get comments => _comments; 19 21 20 - CommentsService(this._authService); 22 + CommentsService(this._authService, this._profileService); 21 23 22 24 /// Fetch comments for a post from Bluesky 23 25 Future<List<Comment>> getBlueskyComments(String postUri) async { ··· 97 99 notifyListeners(); 98 100 throw Exception(_error); 99 101 } 102 + } 103 + 104 + Future<Comment> getSparkComment(String commentUri) async { 105 + if (!_authService.isAuthenticated) { 106 + throw Exception('Not authenticated'); 107 + } 108 + 109 + final sprkClient = SprkClient(_authService); 110 + 111 + final response = await sprkClient.repo.getRecord(uri: AtUri.parse(commentUri)); 112 + 113 + return Comment.fromSparkCommentRecord(response.data.value, commentUri, _profileService); 114 + } 115 + 116 + Future<Comment> getBlueskyComment(String commentUri) async { 117 + if (!_authService.isAuthenticated) { 118 + throw Exception('Not authenticated'); 119 + } 120 + 121 + final bsky = Bluesky.fromSession(_authService.session!); 122 + final response = await bsky.feed.getPostThread(uri: AtUri.parse(commentUri)); 123 + final post = response.data.thread.whenOrNull(record: (rec) => rec.post); 124 + 125 + if (post == null) { 126 + throw Exception('Failed to get comment: Post not found'); 127 + } 128 + 129 + return Comment.fromBlueskyComment(post); 100 130 } 101 131 102 132 /// Extract replies from a Bluesky thread
+44 -62
lib/widgets/comments/comment_input.dart
··· 111 111 const maxImages = 4; 112 112 final currentImageCount = _selectedImages.length; 113 113 if (currentImageCount >= maxImages) { 114 + if (!mounted) return; 114 115 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('You can select up to $maxImages images.'))); 115 116 return; 116 117 } ··· 119 120 final List<XFile> pickedFiles = await _picker.pickMultiImage( 120 121 limit: maxImages - currentImageCount, // Limit selection based on remaining slots 121 122 ); 123 + 124 + if (!mounted) return; 122 125 123 126 if (pickedFiles.isNotEmpty) { 124 127 setState(() { ··· 131 134 } 132 135 } catch (e) { 133 136 debugPrint('Error picking images: $e'); 137 + if (!mounted) return; 134 138 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pick images: ${e.toString()}'))); 135 139 } 136 140 } ··· 151 155 152 156 final text = _textController.text.trim(); 153 157 final imagesToUpload = List<XFile>.from(_selectedImages); // Copy list 158 + final actionsService = Provider.of<ActionsService>(context, listen: false); 154 159 155 160 // Get the target CID and URI for the comment 156 161 final targetCid = widget.parentCid ?? widget.postCid; ··· 161 166 }); 162 167 163 168 try { 164 - final actionsService = Provider.of<ActionsService>(context, listen: false); 165 - 166 169 // Pass text and selected images to the service method 167 170 final response = await actionsService.postComment( 168 171 text, ··· 174 177 altTexts: _altTexts, 175 178 ); 176 179 180 + if (!mounted) return; 181 + 177 182 // Clear text and selected images on success 178 183 _textController.clear(); 179 184 setState(() { ··· 193 198 194 199 ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Comment posted successfully'))); 195 200 } catch (e) { 201 + if (!mounted) return; 196 202 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to post comment: ${e.toString()}'))); 197 203 debugPrint('Error posting comment: $e'); 198 204 } finally { ··· 205 211 } 206 212 207 213 @override 208 - Widget build(BuildContext context) { 214 + Widget build(_) { 209 215 final backgroundColor = widget.isDarkMode ? AppColors.nearBlack : Colors.white; 210 216 final borderColor = widget.isDarkMode ? AppColors.darkPurple : AppColors.lightLavender; 211 217 final textColor = widget.isDarkMode ? AppColors.textLight : AppColors.textPrimary; 212 - final placeholderColor = widget.isDarkMode ? AppColors.textLight.withAlpha(128) : AppColors.textSecondary.withAlpha(179); 213 - final inputBackgroundColor = widget.isDarkMode ? AppColors.deepPurple.withAlpha(128) : AppColors.lightLavender.withAlpha(77); 218 + final placeholderColor = 219 + widget.isDarkMode ? AppColors.textLight.withValues(alpha: 128) : AppColors.textSecondary.withValues(alpha: 179); 220 + final inputBackgroundColor = widget.isDarkMode ? AppColors.deepPurple : AppColors.lightLavender.withValues(alpha: 77); 214 221 215 222 return Container( 216 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), 223 + padding: const EdgeInsets.only( 224 + left: 16, 225 + right: 16, 226 + top: 8, 227 + bottom: 16, 228 + ), 217 229 decoration: BoxDecoration(color: backgroundColor, border: Border(top: BorderSide(color: borderColor, width: 0.5))), 218 230 child: Column( 219 231 crossAxisAlignment: CrossAxisAlignment.start, ··· 222 234 // Emoji Picker is always displayed at the top 223 235 EmojiPicker(onEmojiSelected: _insertEmoji, isDarkMode: widget.isDarkMode), 224 236 225 - const SizedBox(height: 16), 237 + const SizedBox(height: 8), 226 238 227 - if (widget.replyingToUsername != null) 239 + if (widget.replyingToUsername != null) 228 240 Padding( 229 - padding: const EdgeInsets.only(bottom: 12.0), 241 + padding: const EdgeInsets.only(bottom: 8.0), 230 242 child: _buildReplyingToNotice(inputBackgroundColor, borderColor, textColor), 231 243 ), 232 244 233 245 // Updated input row with centered alignment 234 246 Container( 247 + margin: const EdgeInsets.symmetric(horizontal: 8), 235 248 decoration: BoxDecoration( 236 - color: inputBackgroundColor, 237 - borderRadius: BorderRadius.circular(24), 238 - border: Border.all(color: borderColor, width: 0.5), 249 + color: widget.isDarkMode ? const Color(0xFF171619) : const Color(0xFFE8E8EA), 250 + borderRadius: BorderRadius.circular(32), 239 251 ), 240 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 252 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 241 253 child: Row( 242 254 crossAxisAlignment: CrossAxisAlignment.center, 243 255 children: [ 244 - _buildUserAvatar(borderColor), 245 - const SizedBox(width: 8), 256 + _buildUserAvatar(textColor), 257 + const SizedBox(width: 5), 246 258 _buildAttachmentButton(borderColor, textColor), 247 - const SizedBox(width: 8), 259 + const SizedBox(width: 5), 248 260 Expanded(child: _buildTextField(textColor, placeholderColor)), 249 261 ], 250 262 ), 251 263 ), 252 264 253 265 // Selected Images Preview (only show if images are selected) 254 - if (_selectedImages.isNotEmpty) 255 - Padding( 256 - padding: const EdgeInsets.only(top: 12.0), 257 - child: _buildSelectedImagesPreview(borderColor), 258 - ), 266 + if (_selectedImages.isNotEmpty) 267 + Padding(padding: const EdgeInsets.only(top: 8.0), child: _buildSelectedImagesPreview(borderColor)), 259 268 ], 260 269 ), 261 270 ); ··· 283 292 ); 284 293 } 285 294 286 - Widget _buildUserAvatar(Color borderColor) { 295 + Widget _buildUserAvatar(Color textColor) { 287 296 return Container( 288 - width: 32, 289 - height: 32, 290 - decoration: BoxDecoration( 291 - color: AppColors.accent, 292 - shape: BoxShape.circle, 293 - border: Border.all(color: borderColor, width: 1), 294 - boxShadow: [ 295 - BoxShadow( 296 - color: AppColors.accent.withOpacity(0.2), 297 - blurRadius: 4, 298 - spreadRadius: 1, 299 - ), 300 - ], 301 - ), 302 - child: const Center( 303 - child: Text( 304 - 'Y', // Current user's initial 305 - style: TextStyle(color: AppColors.white, fontWeight: FontWeight.bold, fontSize: 14), 306 - ), 307 - ), 297 + width: 28, 298 + height: 28, 299 + decoration: const BoxDecoration(color: Color(0xFF330072), shape: BoxShape.circle), 300 + child: const Center(child: Text('Y', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500, fontSize: 14))), 308 301 ); 309 302 } 310 303 ··· 324 317 hintStyle: TextStyle(color: placeholderColor, fontSize: 14), 325 318 filled: false, 326 319 isDense: true, 327 - contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10), 320 + contentPadding: EdgeInsets.zero, 328 321 border: InputBorder.none, 329 322 enabledBorder: InputBorder.none, 330 323 focusedBorder: InputBorder.none, ··· 334 327 margin: const EdgeInsets.all(8), 335 328 width: 20, 336 329 height: 20, 337 - child: CircularProgressIndicator( 338 - strokeWidth: 2, 339 - valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary), 340 - ), 330 + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary)), 341 331 ) 342 332 : IconButton( 343 333 icon: Icon(FluentIcons.send_24_filled, size: 20, color: _canSubmit ? AppColors.primary : placeholderColor), ··· 347 337 style: TextStyle(color: textColor, fontSize: 14), 348 338 maxLines: 5, 349 339 minLines: 1, 340 + textAlignVertical: TextAlignVertical.center, 350 341 cursorColor: AppColors.primary, 351 342 enabled: !_isPosting, 352 343 ); ··· 358 349 359 350 return IconButton( 360 351 padding: EdgeInsets.zero, 361 - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), 352 + visualDensity: VisualDensity.compact, 353 + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), 362 354 onPressed: enabled ? _pickImages : null, 363 355 tooltip: enabled ? 'Add images (up to 4)' : (_isPosting ? 'Posting...' : 'Maximum images reached'), 364 - icon: Icon( 365 - FluentIcons.image_24_regular, 366 - size: 20, 367 - color: enabled ? AppColors.primary : textColor.withOpacity(0.5), 368 - ), 356 + icon: Icon(FluentIcons.image_24_regular, size: 24, color: AppColors.primary), 369 357 ); 370 358 } 371 359 ··· 391 379 decoration: BoxDecoration( 392 380 borderRadius: BorderRadius.circular(12), 393 381 border: Border.all(color: borderColor, width: 0.5), 394 - boxShadow: [ 395 - BoxShadow( 396 - color: Colors.black.withOpacity(0.1), 397 - blurRadius: 4, 398 - offset: const Offset(0, 2), 399 - ), 400 - ], 382 + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 26), blurRadius: 4, offset: const Offset(0, 2))], 401 383 image: DecorationImage(image: FileImage(File(imageFile.path)), fit: BoxFit.cover), 402 384 ), 403 385 ), ··· 406 388 bottom: 4, 407 389 right: 4, 408 390 child: Material( 409 - color: Colors.black.withOpacity(0.5), 391 + color: Colors.black.withValues(alpha: 128), 410 392 borderRadius: BorderRadius.circular(8), 411 393 child: InkWell( 412 394 onTap: () async { ··· 439 421 top: 4, 440 422 right: 4, 441 423 child: Material( 442 - color: Colors.black.withOpacity(0.5), 424 + color: Colors.black.withValues(alpha: 128), 443 425 shape: const CircleBorder(), 444 426 child: InkWell( 445 427 onTap: () => _removeImage(index),
+65 -24
lib/widgets/comments/comment_item.dart
··· 34 34 final String cid; 35 35 final String? profileImageUrl; 36 36 final String authorDid; 37 - final Function()? onCommentDeleted; 37 + final bool isLiked; 38 + final VoidCallback? onLikePressed; 39 + final VoidCallback? onDeleted; 38 40 39 41 const CommentItem({ 40 42 super.key, ··· 56 58 required this.cid, 57 59 this.profileImageUrl, 58 60 required this.authorDid, 59 - this.onCommentDeleted, 61 + this.isLiked = false, 62 + this.onLikePressed, 63 + this.onDeleted, 60 64 }); 61 65 62 66 @override ··· 69 73 VideoPlayerController? _videoController; 70 74 bool _isVideoInitialized = false; 71 75 bool _isFirstImagePrecached = false; 76 + int _originalCount = 0; 77 + bool _isLikeLoading = false; 72 78 73 79 @override 74 80 void initState() { 75 81 super.initState(); 82 + _isLiked = widget.isLiked; 83 + _originalCount = widget.likeCount; 76 84 if (widget.hasMedia && widget.mediaType == 'video' && widget.mediaUrl != null) { 77 85 _initializeVideoPlayer(); 78 86 } ··· 88 96 } 89 97 90 98 @override 99 + void didUpdateWidget(CommentItem oldWidget) { 100 + super.didUpdateWidget(oldWidget); 101 + if (!_isLikeLoading && oldWidget.isLiked != widget.isLiked) { 102 + setState(() { 103 + _isLiked = widget.isLiked; 104 + }); 105 + } 106 + } 107 + 108 + @override 91 109 void dispose() { 92 110 _videoController?.dispose(); 93 111 super.dispose(); ··· 99 117 } 100 118 101 119 void _initializeVideoPlayer() { 102 - _videoController = VideoPlayerController.network(widget.mediaUrl!) 120 + _videoController = VideoPlayerController.networkUrl(Uri.parse(widget.mediaUrl!)) 103 121 ..initialize().then((_) { 104 122 if (mounted) { 105 123 setState(() { ··· 110 128 } 111 129 112 130 void _toggleLike() { 131 + if (_isLikeLoading) return; 132 + 113 133 setState(() { 134 + _isLikeLoading = true; 114 135 _isLiked = !_isLiked; 115 136 }); 137 + 138 + if (widget.onLikePressed != null) { 139 + widget.onLikePressed!(); 140 + } 141 + 142 + // Reset loading state after a short delay to ensure smooth animation 143 + Future.delayed(const Duration(milliseconds: 300), () { 144 + if (mounted) { 145 + setState(() { 146 + _isLikeLoading = false; 147 + }); 148 + } 149 + }); 116 150 } 117 151 118 152 void _toggleReplies() { ··· 138 172 139 173 showDialog( 140 174 context: context, 141 - barrierColor: Colors.black.withOpacity(0.85), 175 + barrierColor: Colors.black.withValues(alpha: 217), 142 176 builder: (BuildContext context) { 143 177 return Dialog( 144 178 backgroundColor: Colors.transparent, ··· 152 186 child: IconButton( 153 187 icon: const Icon(FluentIcons.dismiss_24_filled, color: Colors.white, size: 30), 154 188 onPressed: () => Navigator.of(context).pop(), 155 - style: IconButton.styleFrom(backgroundColor: Colors.black.withOpacity(0.3)), 189 + style: IconButton.styleFrom(backgroundColor: Colors.black.withValues(alpha: 77)), 156 190 ), 157 191 ), 158 192 ], ··· 164 198 165 199 void _handleReportComment() { 166 200 final modService = ModService(Provider.of<AuthService>(context, listen: false)); 201 + final scaffoldMessenger = ScaffoldMessenger.of(context); 167 202 168 203 showDialog( 169 204 context: context, ··· 181 216 ); 182 217 183 218 if (result) { 184 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Report submitted successfully'))); 219 + scaffoldMessenger.showSnackBar(const SnackBar(content: Text('Report submitted successfully'))); 185 220 } 186 221 } catch (e) { 187 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error submitting report: $e'))); 222 + scaffoldMessenger.showSnackBar(SnackBar(content: Text('Error submitting report: $e'))); 188 223 } 189 224 }, 190 225 ), ··· 193 228 194 229 void _handleDeleteComment() { 195 230 final actionsService = Provider.of<ActionsService>(context, listen: false); 231 + final scaffoldMessenger = ScaffoldMessenger.of(context); 196 232 197 233 // Confirm deletion 198 234 showDialog( ··· 210 246 try { 211 247 final result = await actionsService.deletePost(widget.uri); 212 248 if (result && mounted) { 213 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Comment deleted successfully'))); 214 - // Notify parent to refresh comments 215 - if (widget.onCommentDeleted != null) { 216 - widget.onCommentDeleted!(); 217 - } 249 + scaffoldMessenger.showSnackBar(const SnackBar(content: Text('Comment deleted successfully'))); 250 + widget.onDeleted?.call(); 218 251 } 219 252 } catch (e) { 220 253 if (mounted) { 221 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete comment: $e'))); 254 + scaffoldMessenger.showSnackBar(SnackBar(content: Text('Failed to delete comment: $e'))); 222 255 } 223 256 } 224 257 }, ··· 232 265 @override 233 266 Widget build(BuildContext context) { 234 267 final textColor = widget.isDarkMode ? AppColors.textLight : AppColors.textPrimary; 235 - final secondaryTextColor = widget.isDarkMode ? AppColors.textLight.withAlpha(179) : AppColors.textSecondary; 236 - final dividerColor = widget.isDarkMode ? AppColors.deepPurple.withAlpha(128) : AppColors.lightLavender; 268 + final secondaryTextColor = widget.isDarkMode ? AppColors.textLight.withValues(alpha: 179) : AppColors.textSecondary; 269 + final dividerColor = widget.isDarkMode ? AppColors.deepPurple.withValues(alpha: 128) : AppColors.lightLavender; 237 270 238 271 return Column( 239 272 crossAxisAlignment: CrossAxisAlignment.start, ··· 294 327 _handleDeleteComment(); 295 328 }, 296 329 isCompact: true, 297 - backgroundColor: widget.isDarkMode ? Colors.black.withOpacity(0.2) : Colors.grey.withOpacity(0.1), 330 + backgroundColor: widget.isDarkMode ? Colors.black.withValues(alpha: 51) : Colors.grey.withValues(alpha: 26), 298 331 isProfile: false, 299 332 authorDid: widget.authorDid, 300 333 ), ··· 326 359 } 327 360 328 361 Widget _buildLikeButton(Color secondaryTextColor) { 362 + final displayCount = _isLiked ? _originalCount + 1 : _originalCount; 363 + 329 364 return TextButton( 330 365 style: TextButton.styleFrom( 331 366 padding: EdgeInsets.zero, 332 367 minimumSize: Size.zero, 333 368 tapTargetSize: MaterialTapTargetSize.shrinkWrap, 334 369 ), 335 - onPressed: _toggleLike, 370 + onPressed: _isLikeLoading ? null : _toggleLike, 336 371 child: Row( 337 372 children: [ 338 373 Icon( 339 - _isLiked ? FluentIcons.heart_24_filled : FluentIcons.heart_24_regular, 374 + (_isLikeLoading && !_isLiked) 375 + ? FluentIcons.heart_24_regular 376 + : (_isLikeLoading && _isLiked) 377 + ? FluentIcons.heart_24_filled 378 + : _isLiked 379 + ? FluentIcons.heart_24_filled 380 + : FluentIcons.heart_24_regular, 340 381 size: 16, 341 382 color: _isLiked ? AppColors.red : secondaryTextColor, 342 383 ), 343 384 const SizedBox(width: 4), 344 - Text(widget.likeCount.toString(), style: TextStyle(fontSize: 12, color: secondaryTextColor)), 385 + Text(displayCount.toString(), style: TextStyle(fontSize: 12, color: secondaryTextColor)), 345 386 ], 346 387 ), 347 388 ); ··· 443 484 decoration: BoxDecoration( 444 485 borderRadius: borderRadius, 445 486 border: Border.all(color: widget.isDarkMode ? AppColors.deepPurple : AppColors.lightLavender, width: 0.5), 446 - color: widget.isDarkMode ? AppColors.deepPurple.withAlpha(50) : AppColors.lightLavender.withAlpha(50), 487 + color: widget.isDarkMode ? AppColors.deepPurple.withValues(alpha: 50) : AppColors.lightLavender.withValues(alpha: 50), 447 488 ), 448 489 child: Stack( 449 490 fit: StackFit.expand, ··· 453 494 fit: BoxFit.cover, 454 495 placeholder: 455 496 (context, url) => Container( 456 - color: Colors.grey[850]?.withOpacity(0.5), 497 + color: Colors.grey[850]?.withValues(alpha: 128), 457 498 child: const Center( 458 499 child: SizedBox( 459 500 width: 20, ··· 464 505 ), 465 506 errorWidget: 466 507 (context, url, error) => Container( 467 - color: AppColors.darkPurple.withAlpha(26), 508 + color: AppColors.darkPurple.withValues(alpha: 26), 468 509 child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70)), 469 510 ), 470 511 ), ··· 475 516 right: 4, 476 517 child: Container( 477 518 padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 478 - decoration: BoxDecoration(color: Colors.black.withOpacity(0.7), borderRadius: BorderRadius.circular(10)), 519 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 179), borderRadius: BorderRadius.circular(10)), 479 520 child: Text( 480 521 '+${imageCount - 1}', 481 522 style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), ··· 512 553 Container( 513 554 width: 60, 514 555 height: 60, 515 - decoration: BoxDecoration(color: Colors.black.withAlpha(128), shape: BoxShape.circle), 556 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 128), shape: BoxShape.circle), 516 557 child: const Icon(FluentIcons.play_24_filled, size: 24, color: Colors.white), 517 558 ), 518 559 ],
+189 -42
lib/widgets/comments/comments_tray.dart
··· 4 4 5 5 import '../../models/comment.dart'; 6 6 import '../../services/comments_service.dart'; 7 + import '../../services/actions_service.dart'; 7 8 import '../../utils/app_colors.dart'; 8 9 import 'comment_input.dart'; 9 10 import 'comment_item.dart'; ··· 16 17 required String postCid, 17 18 required int commentCount, 18 19 required Function(int) onClose, 20 + required Function(int) onCountUpdate, 19 21 required bool isDarkMode, 20 22 required bool isSprk, 21 23 }) { ··· 33 35 Navigator.pop(context); 34 36 onClose(updatedCount); 35 37 }, 38 + onCountUpdate: onCountUpdate, 36 39 isDarkMode: isDarkMode, 37 40 isSprk: isSprk, 38 41 ), ··· 44 47 final String postCid; 45 48 final int commentCount; 46 49 final Function(int) onClose; 50 + final Function(int) onCountUpdate; 47 51 final bool isDarkMode; 48 52 final bool isSprk; 49 53 ··· 53 57 required this.postCid, 54 58 required this.commentCount, 55 59 required this.onClose, 60 + required this.onCountUpdate, 56 61 this.isDarkMode = true, 57 62 required this.isSprk, 58 63 }); ··· 75 80 bool _isLoading = false; 76 81 String? _error; 77 82 bool _hasMoreComments = true; 78 - late int _commentCount; 83 + int _commentCount = 0; 79 84 80 85 @override 81 86 void initState() { ··· 96 101 } 97 102 98 103 @override 104 + void didUpdateWidget(CommentsTray oldWidget) { 105 + super.didUpdateWidget(oldWidget); 106 + if (oldWidget.commentCount != widget.commentCount) { 107 + setState(() { 108 + _commentCount = widget.commentCount; 109 + }); 110 + } 111 + } 112 + 113 + @override 99 114 void dispose() { 100 115 _animationController.dispose(); 101 116 _scrollController.removeListener(_scrollListener); ··· 132 147 }); 133 148 134 149 try { 135 - // Get the service but don't listen to it here 136 150 final commentsService = Provider.of<CommentsService>(context, listen: false); 137 151 138 152 final List<Comment> comments; ··· 145 159 if (mounted) { 146 160 setState(() { 147 161 _comments = comments; 162 + _commentCount = comments.length; 148 163 _isLoading = false; 149 - _hasMoreComments = false; // Currently we load all comments at once 164 + _hasMoreComments = false; 150 165 }); 166 + 167 + // Update parent with new count without closing 168 + widget.onCountUpdate(_commentCount); 151 169 } 152 170 } catch (e) { 153 171 if (mounted) { ··· 207 225 } 208 226 209 227 void _onCommentPosted(String commentUri) { 210 - // Increment the comment count 211 228 setState(() { 212 229 _commentCount++; 213 230 }); 214 231 215 232 // Refresh comments after a new comment is posted 216 - _loadComments(); 233 + _addComment(commentUri); 234 + 235 + // Scroll to the bottom after comments are loaded 236 + WidgetsBinding.instance.addPostFrameCallback((_) { 237 + _scrollToBottom(); 238 + }); 239 + } 240 + 241 + Future<void> _handleLike(Comment comment) async { 242 + try { 243 + final actionsService = Provider.of<ActionsService>(context, listen: false); 244 + final wasLiked = comment.isLiked; 245 + final index = _comments?.indexWhere((c) => c.id == comment.id) ?? -1; 246 + if (index == -1) return; 247 + 248 + if (wasLiked) { 249 + final likeUri = comment.likeUri; 250 + if (likeUri == null) { 251 + throw Exception('Cannot unlike comment: like URI is null'); 252 + } 253 + await actionsService.unlikePost(likeUri); 254 + if (mounted) { 255 + setState(() { 256 + _comments![index] = Comment( 257 + id: comment.id, 258 + uri: comment.uri, 259 + cid: comment.cid, 260 + authorDid: comment.authorDid, 261 + username: comment.username, 262 + profileImageUrl: comment.profileImageUrl, 263 + text: comment.text, 264 + createdAt: comment.createdAt, 265 + likeCount: comment.likeCount, 266 + replyCount: comment.replyCount, 267 + hashtags: comment.hashtags, 268 + hasMedia: comment.hasMedia, 269 + mediaType: comment.mediaType, 270 + mediaUrl: comment.mediaUrl, 271 + likeUri: null, 272 + isSprk: comment.isSprk, 273 + replies: comment.replies, 274 + imageUrls: comment.imageUrls, 275 + ); 276 + }); 277 + } 278 + } else { 279 + final response = await actionsService.likePost(comment.cid, comment.uri); 280 + if (mounted) { 281 + setState(() { 282 + _comments![index] = Comment( 283 + id: comment.id, 284 + uri: comment.uri, 285 + cid: comment.cid, 286 + authorDid: comment.authorDid, 287 + username: comment.username, 288 + profileImageUrl: comment.profileImageUrl, 289 + text: comment.text, 290 + createdAt: comment.createdAt, 291 + likeCount: comment.likeCount + 1, 292 + replyCount: comment.replyCount, 293 + hashtags: comment.hashtags, 294 + hasMedia: comment.hasMedia, 295 + mediaType: comment.mediaType, 296 + mediaUrl: comment.mediaUrl, 297 + likeUri: response.data.uri.toString(), 298 + isSprk: comment.isSprk, 299 + replies: comment.replies, 300 + imageUrls: comment.imageUrls, 301 + ); 302 + }); 303 + } 304 + } 305 + } catch (e) { 306 + if (mounted) { 307 + // On error, revert the optimistic update by refreshing the comment 308 + final index = _comments?.indexWhere((c) => c.id == comment.id) ?? -1; 309 + if (index != -1) { 310 + final currentComment = _comments![index]; 311 + setState(() { 312 + _comments![index] = Comment( 313 + id: currentComment.id, 314 + uri: currentComment.uri, 315 + cid: currentComment.cid, 316 + authorDid: currentComment.authorDid, 317 + username: currentComment.username, 318 + profileImageUrl: currentComment.profileImageUrl, 319 + text: currentComment.text, 320 + createdAt: currentComment.createdAt, 321 + likeCount: currentComment.likeCount, 322 + replyCount: currentComment.replyCount, 323 + hashtags: currentComment.hashtags, 324 + hasMedia: currentComment.hasMedia, 325 + mediaType: currentComment.mediaType, 326 + mediaUrl: currentComment.mediaUrl, 327 + likeUri: currentComment.likeUri, 328 + isSprk: currentComment.isSprk, 329 + replies: currentComment.replies, 330 + imageUrls: currentComment.imageUrls, 331 + ); 332 + }); 333 + } 334 + 335 + ScaffoldMessenger.of(context).showSnackBar( 336 + SnackBar(content: Text('Failed to ${comment.isLiked ? 'unlike' : 'like'} comment: $e'), backgroundColor: Colors.red), 337 + ); 338 + } 339 + } 340 + } 341 + 342 + void _handleCommentDeleted(String commentId) { 343 + setState(() { 344 + _comments?.removeWhere((comment) => comment.id == commentId); 345 + _commentCount = (_comments?.length ?? 0); 346 + // Update parent with new count without closing 347 + widget.onCountUpdate(_commentCount); 348 + }); 349 + } 350 + 351 + Future<void> _addComment(String commentUri) async { 352 + final commentsService = Provider.of<CommentsService>(context, listen: false); 353 + if (widget.isSprk) { 354 + final comment = await commentsService.getSparkComment(commentUri); 355 + setState(() { 356 + _comments?.insert(0, comment); 357 + _commentCount = (_comments?.length ?? 0); 358 + // Update parent with new count without closing 359 + widget.onCountUpdate(_commentCount); 360 + }); 361 + } else { 362 + final comment = await commentsService.getBlueskyComment(commentUri); 363 + setState(() { 364 + _comments?.insert(0, comment); 365 + _commentCount = (_comments?.length ?? 0); 366 + // Update parent with new count without closing 367 + widget.onCountUpdate(_commentCount); 368 + }); 369 + } 217 370 } 218 371 219 372 @override ··· 229 382 builder: (context, child) { 230 383 return Transform.translate(offset: Offset(0, height * (1 - _animation.value)), child: child); 231 384 }, 232 - child: Container( 233 - height: height, 234 - decoration: BoxDecoration( 235 - color: backgroundColor, 236 - borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), 237 - border: Border.all(color: borderColor), 238 - ), 239 - child: Column( 240 - children: [ 241 - _buildHeader(borderColor, textColor), 242 - Expanded(child: _buildCommentsList()), 243 - 244 - Padding( 245 - padding: EdgeInsets.only(bottom: keyboardHeight), 246 - child: CommentInput( 247 - videoId: widget.postUri, 248 - replyingToUsername: _replyingToUsername, 249 - replyingToId: _replyingToId, 250 - onCancelReply: _cancelReply, 251 - isDarkMode: widget.isDarkMode, 252 - postCid: widget.postCid, 253 - postUri: widget.postUri, 254 - parentCid: _replyingToCid, 255 - parentUri: _replyingToUri, 256 - onCommentPosted: _onCommentPosted, 385 + child: SafeArea( 386 + child: Container( 387 + height: height, 388 + decoration: BoxDecoration( 389 + color: backgroundColor, 390 + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), 391 + border: Border.all(color: borderColor), 392 + ), 393 + child: Column( 394 + children: [ 395 + _buildHeader(borderColor, textColor), 396 + Expanded(child: _buildCommentsList()), 397 + Padding( 398 + padding: EdgeInsets.only(bottom: keyboardHeight), 399 + child: CommentInput( 400 + videoId: widget.postUri, 401 + replyingToUsername: _replyingToUsername, 402 + replyingToId: _replyingToId, 403 + onCancelReply: _cancelReply, 404 + isDarkMode: widget.isDarkMode, 405 + postCid: widget.postCid, 406 + postUri: widget.postUri, 407 + parentCid: _replyingToCid, 408 + parentUri: _replyingToUri, 409 + onCommentPosted: _onCommentPosted, 410 + ), 257 411 ), 258 - ), 259 - ], 412 + ], 413 + ), 260 414 ), 261 415 ), 262 416 ); ··· 319 473 style: TextStyle(color: widget.isDarkMode ? AppColors.textLight : AppColors.textPrimary, fontSize: 14), 320 474 textAlign: TextAlign.center, 321 475 ), 322 - const SizedBox(height: 16), 323 - ElevatedButton(onPressed: _loadComments, child: const Text('Retry')), 324 476 ], 325 477 ), 326 478 ), ··· 380 532 cid: comment.cid, 381 533 profileImageUrl: comment.profileImageUrl, 382 534 authorDid: comment.authorDid, 383 - onCommentDeleted: () { 384 - // Refresh the comments list after deletion 385 - _loadComments(); 386 - // Update the comment count 387 - setState(() { 388 - _commentCount = _commentCount > 0 ? _commentCount - 1 : 0; 389 - }); 390 - }, 535 + isLiked: comment.isLiked, 536 + onLikePressed: () => _handleLike(comment), 537 + onDeleted: () => _handleCommentDeleted(comment.id), 391 538 ); 392 539 }, 393 540 );
+18 -16
lib/widgets/post/post_item_base.dart
··· 75 75 bool isVisible = true; 76 76 bool showComments = false; 77 77 bool _isDescriptionExpanded = false; 78 - late int _commentCount; 78 + int _commentCount = 0; 79 79 80 80 @override 81 81 void initState() { ··· 157 157 }); 158 158 } 159 159 160 + void _updateCommentCount(int count) { 161 + if (!mounted) return; 162 + setState(() { 163 + _commentCount = count; 164 + }); 165 + } 166 + 160 167 /// Toggle comments tray. 161 168 void toggleComments() { 162 169 // Allow overriding via widget callback first ··· 187 194 commentCount: _commentCount, 188 195 onClose: (updatedCount) { 189 196 if (!mounted) return; 190 - if (updatedCount != _commentCount) { 191 - setState(() { 192 - showComments = false; 193 - _commentCount = updatedCount; // Update local comment count 194 - }); 195 - } else { 196 - setState(() { 197 - showComments = false; 198 - }); 199 - } 197 + setState(() { 198 + showComments = false; 199 + _commentCount = updatedCount; 200 + }); 200 201 201 202 // Resume media only if the item is still visible 202 203 if (isVisible) { 203 204 playMedia(); 204 205 } 205 206 }, 207 + onCountUpdate: _updateCommentCount, 206 208 isDarkMode: isDarkMode, 207 209 isSprk: widget.isSprk, 208 210 ); ··· 295 297 Widget buildSideActionBar() { 296 298 // Determine if we're dealing with an image post based on the widget type 297 299 final bool isImagePost = widget is ImagePostItem; 298 - 300 + 299 301 return Positioned( 300 302 right: 16, 301 303 bottom: 16, 302 304 child: VideoSideActionBar( 303 305 // Consider renaming VideoSideActionBar later 304 306 likeCount: TextFormatter.formatCount(widget.likeCount), 305 - commentCount: TextFormatter.formatCount(_commentCount), // Use local state 307 + commentCount: TextFormatter.formatCount(_commentCount), 306 308 shareCount: TextFormatter.formatCount(widget.shareCount), 307 309 profileImageUrl: widget.profileImageUrl, 308 310 isLiked: widget.isLiked, 309 311 onLikePressed: widget.onLikePressed ?? () {}, 310 - onCommentPressed: toggleComments, // Use the unified method 312 + onCommentPressed: toggleComments, 311 313 onSharePressed: widget.onSharePressed ?? () {}, 312 - onProfilePressed: navigateToProfile, // Use the unified method 314 + onProfilePressed: navigateToProfile, 313 315 postCid: widget.postCid, 314 316 postUri: widget.postUri, 315 317 authorDid: widget.authorDid, 316 318 onPostDeleted: widget.onPostDeleted ?? () {}, 317 - isImage: isImagePost, // Pass whether this is an image post 319 + isImage: isImagePost, 318 320 ), 319 321 ); 320 322 }
+44 -91
lib/widgets/video_side_action_bar.dart
··· 31 31 final String? postUri; 32 32 final String? postCid; 33 33 final String? authorDid; 34 - 34 + 35 35 // Add flag to identify image content 36 36 final bool isImage; 37 37 ··· 54 54 this.postUri, 55 55 this.postCid, 56 56 this.authorDid, 57 - 57 + 58 58 this.isImage = false, 59 59 }); 60 60 ··· 64 64 65 65 class _VideoSideActionBarState extends State<VideoSideActionBar> { 66 66 bool _isLiked = false; 67 + late String _commentCount; 67 68 68 69 @override 69 70 void initState() { 70 71 super.initState(); 71 72 _isLiked = widget.isLiked; 73 + _commentCount = widget.commentCount; 72 74 } 73 75 74 76 @override ··· 79 81 _isLiked = widget.isLiked; 80 82 }); 81 83 } 84 + if (oldWidget.commentCount != widget.commentCount) { 85 + setState(() { 86 + _commentCount = widget.commentCount; 87 + }); 88 + } 82 89 } 83 90 84 91 void _handleLike() { ··· 95 102 ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cannot share this content'))); 96 103 return; 97 104 } 98 - 105 + 99 106 String postUri = widget.postUri!; 100 107 String shareUrl; 101 108 String embedCode = ''; 102 109 bool showEmbed = true; 103 - 110 + 104 111 // Special case for Bluesky posts 105 112 if (postUri.contains('/app.bsky.feed.post/')) { 106 113 // Extract the DID and post ID for Bluesky format 107 114 // Format: at://did:plc:xxx/app.bsky.feed.post/yyy -> https://bsky.app/profile/did:plc:xxx/post/yyy 108 - 115 + 109 116 // Remove 'at://' prefix if present 110 117 if (postUri.startsWith('at://')) { 111 118 postUri = postUri.substring(5); 112 119 } 113 - 120 + 114 121 // Split to get DID and post ID 115 122 final parts = postUri.split('/app.bsky.feed.post/'); 116 123 if (parts.length == 2) { 117 124 final did = parts[0]; 118 125 final postId = parts[1]; 119 - 126 + 120 127 // Format as Bluesky URL 121 128 shareUrl = 'https://bsky.app/profile/$did/post/$postId'; 122 - 129 + 123 130 // Hide embed for Bluesky 124 131 showEmbed = false; 125 132 } else { ··· 133 140 if (postUri.startsWith('at://')) { 134 141 postUri = postUri.substring(5); 135 142 } 136 - 143 + 137 144 // Remove 'so.sprk.feed.post/' from the path if present 138 145 postUri = postUri.replaceAll('so.sprk.feed.post/', ''); 139 - 146 + 140 147 shareUrl = 'https://watch.sprk.so/?uri=$postUri'; 141 148 embedCode = '<iframe src="embed.html?uri=$postUri" width="100%" height="400" frameborder="0" allowfullscreen></iframe>'; 142 149 } 143 - 150 + 144 151 showModalBottomSheet( 145 152 context: context, 146 153 isScrollControlled: true, 147 154 backgroundColor: Colors.transparent, 148 155 builder: (BuildContext context) { 149 - return SharePanel( 150 - shareUrl: shareUrl, 151 - embedCode: embedCode, 152 - showEmbed: showEmbed, 153 - ); 156 + return SharePanel(shareUrl: shareUrl, embedCode: embedCode, showEmbed: showEmbed); 154 157 }, 155 158 ); 156 - 159 + 157 160 if (widget.onSharePressed != null) { 158 161 widget.onSharePressed!(); 159 162 } ··· 259 262 } 260 263 } catch (e) { 261 264 // Use cached messenger (check mounted only if error handling depends on widget state) 262 - if (mounted) { // Keep this check if error handling logic might change based on state 265 + if (mounted) { 266 + // Keep this check if error handling logic might change based on state 263 267 messenger.showSnackBar(SnackBar(content: Text('Error deleting post: $e'))); 264 268 } 265 269 } ··· 277 281 LikeActionButton(count: widget.likeCount, isLiked: _isLiked, onPressed: _handleLike), 278 282 const SizedBox(height: 20), 279 283 280 - CommentActionButton(count: widget.commentCount, onPressed: widget.onCommentPressed), 284 + CommentActionButton(count: _commentCount, onPressed: widget.onCommentPressed), 281 285 const SizedBox(height: 20), 282 286 283 287 // Only show share button for videos, not for images ··· 302 306 final String shareUrl; 303 307 final String embedCode; 304 308 final bool showEmbed; 305 - 306 - const SharePanel({ 307 - super.key, 308 - required this.shareUrl, 309 - required this.embedCode, 310 - this.showEmbed = true, 311 - }); 309 + 310 + const SharePanel({super.key, required this.shareUrl, required this.embedCode, this.showEmbed = true}); 312 311 313 312 @override 314 313 State<SharePanel> createState() => _SharePanelState(); ··· 317 316 class _SharePanelState extends State<SharePanel> { 318 317 bool _copiedLink = false; 319 318 bool _copiedEmbed = false; 320 - 319 + 321 320 void _copyToClipboard(String text, BuildContext context, bool isLink) { 322 321 Clipboard.setData(ClipboardData(text: text)); 323 - 322 + 324 323 // Update state to show copied indicator 325 324 setState(() { 326 325 if (isLink) { ··· 329 328 _copiedEmbed = true; 330 329 } 331 330 }); 332 - 331 + 333 332 // Show a more noticeable snackbar 334 333 ScaffoldMessenger.of(context).showSnackBar( 335 334 SnackBar( ··· 346 345 duration: const Duration(seconds: 2), 347 346 ), 348 347 ); 349 - 348 + 350 349 // Reset the copied state after 2 seconds 351 350 Future.delayed(const Duration(seconds: 2), () { 352 351 if (mounted) { ··· 360 359 } 361 360 }); 362 361 } 363 - 362 + 364 363 @override 365 364 Widget build(BuildContext context) { 366 365 final isDarkMode = Theme.of(context).brightness == Brightness.dark; ··· 368 367 final textColor = isDarkMode ? Colors.white : Colors.black87; 369 368 final fieldBgColor = isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFF5F5F5); 370 369 final dividerColor = isDarkMode ? Colors.white24 : Colors.black12; 371 - 370 + 372 371 return Container( 373 372 decoration: BoxDecoration( 374 373 color: backgroundColor, 375 - borderRadius: const BorderRadius.only( 376 - topLeft: Radius.circular(20), 377 - topRight: Radius.circular(20), 378 - ), 379 - boxShadow: [ 380 - BoxShadow( 381 - color: Colors.black.withAlpha(51), 382 - blurRadius: 10, 383 - spreadRadius: 0, 384 - ), 385 - ], 374 + borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), 375 + boxShadow: [BoxShadow(color: Colors.black.withAlpha(51), blurRadius: 10, spreadRadius: 0)], 386 376 ), 387 377 child: DraggableScrollableSheet( 388 378 initialChildSize: 0.4, ··· 396 386 width: 40, 397 387 height: 4, 398 388 margin: const EdgeInsets.only(top: 12, bottom: 16), 399 - decoration: BoxDecoration( 400 - color: dividerColor, 401 - borderRadius: BorderRadius.circular(10), 402 - ), 389 + decoration: BoxDecoration(color: dividerColor, borderRadius: BorderRadius.circular(10)), 403 390 ), 404 391 Padding( 405 392 padding: const EdgeInsets.symmetric(horizontal: 20), 406 - child: Text( 407 - 'Share Video', 408 - style: TextStyle( 409 - color: textColor, 410 - fontSize: 18, 411 - fontWeight: FontWeight.bold, 412 - ), 413 - ), 393 + child: Text('Share Video', style: TextStyle(color: textColor, fontSize: 18, fontWeight: FontWeight.bold)), 414 394 ), 415 395 Divider(color: dividerColor, height: 30), 416 396 Expanded( ··· 418 398 controller: scrollController, 419 399 padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), 420 400 children: [ 421 - Text( 422 - 'Video link', 423 - style: TextStyle( 424 - color: textColor, 425 - fontSize: 15, 426 - fontWeight: FontWeight.w500, 427 - ), 428 - ), 401 + Text('Video link', style: TextStyle(color: textColor, fontSize: 15, fontWeight: FontWeight.w500)), 429 402 const SizedBox(height: 8), 430 403 _buildCopyField(widget.shareUrl, context, fieldBgColor, textColor, true, _copiedLink), 431 404 if (widget.showEmbed) ...[ 432 405 const SizedBox(height: 24), 433 - Text( 434 - 'Video embed', 435 - style: TextStyle( 436 - color: textColor, 437 - fontSize: 15, 438 - fontWeight: FontWeight.w500, 439 - ), 440 - ), 406 + Text('Video embed', style: TextStyle(color: textColor, fontSize: 15, fontWeight: FontWeight.w500)), 441 407 const SizedBox(height: 8), 442 408 _buildCopyField(widget.embedCode, context, fieldBgColor, textColor, false, _copiedEmbed), 443 409 ], ··· 450 416 ), 451 417 ); 452 418 } 453 - 419 + 454 420 Widget _buildCopyField(String text, BuildContext context, Color bgColor, Color textColor, bool isLink, bool isCopied) { 455 421 final theme = Theme.of(context); 456 422 final accentColor = theme.colorScheme.primary; 457 - 423 + 458 424 return Container( 459 425 decoration: BoxDecoration( 460 426 color: bgColor, ··· 468 434 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 469 435 child: Text( 470 436 text, 471 - style: TextStyle( 472 - fontFamily: 'monospace', 473 - color: textColor.withAlpha(204), 474 - fontSize: 13, 475 - ), 437 + style: TextStyle(fontFamily: 'monospace', color: textColor.withAlpha(204), fontSize: 13), 476 438 overflow: TextOverflow.ellipsis, 477 439 ), 478 440 ), ··· 489 451 transitionBuilder: (Widget child, Animation<double> animation) { 490 452 return ScaleTransition(scale: animation, child: child); 491 453 }, 492 - child: isCopied 493 - ? Icon( 494 - Icons.check_circle, 495 - key: const ValueKey('copied'), 496 - color: Colors.green, 497 - size: 20, 498 - ) 499 - : Icon( 500 - Icons.content_copy_rounded, 501 - key: const ValueKey('copy'), 502 - color: accentColor, 503 - size: 20, 504 - ), 454 + child: 455 + isCopied 456 + ? Icon(Icons.check_circle, key: const ValueKey('copied'), color: Colors.green, size: 20) 457 + : Icon(Icons.content_copy_rounded, key: const ValueKey('copy'), color: accentColor, size: 20), 505 458 ), 506 459 ), 507 460 ),