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

It's almost 5AM, and here we are. Share button and the both watch screen and embed thing. idk. idc, honestly, I just need to sleep. (#24)

authored by

C3B and committed by
GitHub
f47e4e50 397bf6b8

+344 -58
+9 -6
lib/widgets/action_buttons/action_button.dart
··· 7 7 final Color? color; 8 8 final bool isAnimating; 9 9 final double scale; 10 + final bool showLabel; 10 11 11 12 const ActionButton({ 12 13 super.key, ··· 16 17 this.color, 17 18 this.isAnimating = false, 18 19 this.scale = 1.0, 20 + this.showLabel = true, 19 21 }); 20 22 21 23 @override ··· 53 55 ), 54 56 ], 55 57 ), 56 - Text( 57 - label, 58 - style: const TextStyle( 59 - color: Colors.white, 60 - fontSize: 12, 61 - shadows: <Shadow>[ 58 + if (showLabel) 59 + Text( 60 + label, 61 + style: const TextStyle( 62 + color: Colors.white, 63 + fontSize: 12, 64 + shadows: <Shadow>[ 62 65 Shadow(offset: Offset(0, 0), blurRadius: 20.0, color: Color(0xFF000000)), 63 66 Shadow(offset: Offset(1, 1), blurRadius: 8.0, color: Colors.black87), 64 67 ],
+6 -2
lib/widgets/action_buttons/share_action_button.dart
··· 6 6 final String count; 7 7 final VoidCallback? onPressed; 8 8 9 - const ShareActionButton({super.key, required this.count, this.onPressed}); 9 + const ShareActionButton({ 10 + super.key, 11 + required this.count, 12 + this.onPressed 13 + }); 10 14 11 15 @override 12 16 Widget build(BuildContext context) { 13 - return ActionButton(icon: FluentIcons.share_24_regular, label: count, onPressed: onPressed); 17 + return ActionButton(icon: FluentIcons.share_24_regular, label: count, onPressed: onPressed, showLabel: false); 14 18 } 15 19 }
+5 -2
lib/widgets/post/post_item_base.dart
··· 10 10 import '../dialogs/report_dialog.dart'; 11 11 import '../video_info/video_info_bar.dart'; 12 12 import '../video_side_action_bar.dart'; 13 + import '../image/image_post_item.dart'; 13 14 14 15 /// Base class for post items (Video, Image, etc.) to handle common parameters. 15 16 abstract class PostItemBase extends StatefulWidget { ··· 292 293 } 293 294 294 295 Widget buildSideActionBar() { 296 + // Determine if we're dealing with an image post based on the widget type 297 + final bool isImagePost = widget is ImagePostItem; 298 + 295 299 return Positioned( 296 300 right: 16, 297 301 bottom: 16, ··· 299 303 // Consider renaming VideoSideActionBar later 300 304 likeCount: TextFormatter.formatCount(widget.likeCount), 301 305 commentCount: TextFormatter.formatCount(_commentCount), // Use local state 302 - bookmarkCount: TextFormatter.formatCount(widget.bookmarkCount), 303 306 shareCount: TextFormatter.formatCount(widget.shareCount), 304 307 profileImageUrl: widget.profileImageUrl, 305 308 isLiked: widget.isLiked, 306 309 onLikePressed: widget.onLikePressed ?? () {}, 307 310 onCommentPressed: toggleComments, // Use the unified method 308 - onBookmarkPressed: widget.onBookmarkPressed ?? () {}, 309 311 onSharePressed: widget.onSharePressed ?? () {}, 310 312 onProfilePressed: navigateToProfile, // Use the unified method 311 313 postCid: widget.postCid, 312 314 postUri: widget.postUri, 313 315 authorDid: widget.authorDid, 314 316 onPostDeleted: widget.onPostDeleted ?? () {}, 317 + isImage: isImagePost, // Pass whether this is an image post 315 318 ), 316 319 ); 317 320 }
+324 -48
lib/widgets/video_side_action_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:provider/provider.dart'; 3 + import 'package:sparksocial/widgets/action_buttons/share_action_button.dart'; 4 + import 'package:flutter/services.dart'; 3 5 4 6 import 'action_buttons/comment_action_button.dart'; 5 7 import 'action_buttons/like_action_button.dart'; ··· 9 11 import '../services/auth_service.dart'; 10 12 import '../services/actions_service.dart'; 11 13 import '../widgets/dialogs/report_dialog.dart'; 14 + // import 'action_buttons/bookmark_action_button.dart'; 12 15 13 16 class VideoSideActionBar extends StatefulWidget { 14 17 final VoidCallback? onProfilePressed; 15 18 final VoidCallback? onLikePressed; 16 19 final VoidCallback? onCommentPressed; 17 - final VoidCallback? onBookmarkPressed; 18 20 final VoidCallback? onSharePressed; 19 21 final VoidCallback? onReportPressed; 20 22 final VoidCallback? onPostDeleted; 21 23 22 24 final String likeCount; 23 25 final String commentCount; 24 - final String bookmarkCount; 25 26 final String shareCount; 26 27 final bool isLiked; 27 - final bool isBookmarked; 28 28 final String? profileImageUrl; 29 29 30 30 // Add post info for reporting 31 31 final String? postUri; 32 32 final String? postCid; 33 33 final String? authorDid; 34 + 35 + // Add flag to identify image content 36 + final bool isImage; 34 37 35 38 const VideoSideActionBar({ 36 39 super.key, 37 40 this.onProfilePressed, 38 41 this.onLikePressed, 39 42 this.onCommentPressed, 40 - this.onBookmarkPressed, 41 43 this.onSharePressed, 42 44 this.onReportPressed, 43 45 this.onPostDeleted, 44 46 45 47 this.likeCount = '0', 46 48 this.commentCount = '0', 47 - this.bookmarkCount = '0', 48 49 this.shareCount = '0', 49 50 50 51 this.isLiked = false, 51 - this.isBookmarked = false, 52 52 this.profileImageUrl, 53 53 54 54 this.postUri, 55 55 this.postCid, 56 56 this.authorDid, 57 + 58 + this.isImage = false, 57 59 }); 58 60 59 61 @override ··· 62 64 63 65 class _VideoSideActionBarState extends State<VideoSideActionBar> { 64 66 bool _isLiked = false; 65 - bool _isBookmarked = false; 66 67 67 68 @override 68 69 void initState() { 69 70 super.initState(); 70 71 _isLiked = widget.isLiked; 71 - _isBookmarked = widget.isBookmarked; 72 72 } 73 73 74 74 @override ··· 79 79 _isLiked = widget.isLiked; 80 80 }); 81 81 } 82 - if (oldWidget.isBookmarked != widget.isBookmarked) { 83 - setState(() { 84 - _isBookmarked = widget.isBookmarked; 85 - }); 86 - } 87 82 } 88 83 89 84 void _handleLike() { ··· 95 90 } 96 91 } 97 92 98 - void _handleBookmark() { 99 - setState(() { 100 - _isBookmarked = !_isBookmarked; 101 - }); 102 - if (widget.onBookmarkPressed != null) { 103 - widget.onBookmarkPressed!(); 93 + void _handleShare() { 94 + if (widget.postUri == null) { 95 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cannot share this content'))); 96 + return; 97 + } 98 + 99 + String postUri = widget.postUri!; 100 + String shareUrl; 101 + String embedCode = ''; 102 + bool showEmbed = true; 103 + 104 + // Special case for Bluesky posts 105 + if (postUri.contains('/app.bsky.feed.post/')) { 106 + // Extract the DID and post ID for Bluesky format 107 + // Format: at://did:plc:xxx/app.bsky.feed.post/yyy -> https://bsky.app/profile/did:plc:xxx/post/yyy 108 + 109 + // Remove 'at://' prefix if present 110 + if (postUri.startsWith('at://')) { 111 + postUri = postUri.substring(5); 112 + } 113 + 114 + // Split to get DID and post ID 115 + final parts = postUri.split('/app.bsky.feed.post/'); 116 + if (parts.length == 2) { 117 + final did = parts[0]; 118 + final postId = parts[1]; 119 + 120 + // Format as Bluesky URL 121 + shareUrl = 'https://bsky.app/profile/$did/post/$postId'; 122 + 123 + // Hide embed for Bluesky 124 + showEmbed = false; 125 + } else { 126 + // Fallback if parsing fails 127 + shareUrl = 'https://bsky.app'; 128 + showEmbed = false; 129 + } 130 + } else { 131 + // Standard Spark format 132 + // Remove 'at://' prefix if present 133 + if (postUri.startsWith('at://')) { 134 + postUri = postUri.substring(5); 135 + } 136 + 137 + // Remove 'so.sprk.feed.post/' from the path if present 138 + postUri = postUri.replaceAll('so.sprk.feed.post/', ''); 139 + 140 + shareUrl = 'https://watch.sprk.so/?uri=$postUri'; 141 + embedCode = '<iframe src="embed.html?uri=$postUri" width="100%" height="400" frameborder="0" allowfullscreen></iframe>'; 142 + } 143 + 144 + showModalBottomSheet( 145 + context: context, 146 + isScrollControlled: true, 147 + backgroundColor: Colors.transparent, 148 + builder: (BuildContext context) { 149 + return SharePanel( 150 + shareUrl: shareUrl, 151 + embedCode: embedCode, 152 + showEmbed: showEmbed, 153 + ); 154 + }, 155 + ); 156 + 157 + if (widget.onSharePressed != null) { 158 + widget.onSharePressed!(); 104 159 } 105 160 } 106 161 ··· 119 174 postUri: widget.postUri!, 120 175 postCid: widget.postCid!, 121 176 onSubmit: (subject, reasonType, reason, service) async { 177 + // Cache ScaffoldMessenger before await 178 + final messenger = ScaffoldMessenger.of(context); 122 179 try { 123 180 final result = await modService.createReport( 124 181 subject: subject, ··· 127 184 service: service, 128 185 ); 129 186 187 + // Use cached messenger 130 188 if (result) { 131 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Report submitted successfully'))); 189 + messenger.showSnackBar(const SnackBar(content: Text('Report submitted successfully'))); 132 190 } 133 191 } catch (e) { 134 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error submitting report: $e'))); 192 + // Use cached messenger 193 + messenger.showSnackBar(SnackBar(content: Text('Error submitting report: $e'))); 135 194 } 136 195 }, 137 196 ), ··· 144 203 return; 145 204 } 146 205 206 + // Cache context-dependent objects *before* the first await (showDialog) 207 + final navigator = Navigator.of(context); 208 + final messenger = ScaffoldMessenger.of(context); 209 + final actionsService = Provider.of<ActionsService>(context, listen: false); 210 + 147 211 // Confirm deletion 148 212 final shouldDelete = 149 213 await showDialog<bool>( ··· 167 231 if (!shouldDelete || !mounted) return; 168 232 169 233 try { 170 - final actionsService = Provider.of<ActionsService>(context, listen: false); 171 234 final result = await actionsService.deletePost(widget.postUri!); 172 235 236 + // Check mounted ONLY if further async operations depend on the widget state 237 + if (!mounted) return; 238 + 173 239 if (result) { 174 - if (mounted) { 175 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Post deleted successfully'))); 240 + // Use cached messenger 241 + messenger.showSnackBar(const SnackBar(content: Text('Post deleted successfully'))); 176 242 177 - // Add a delay to ensure the server has processed the deletion 178 - await Future.delayed(const Duration(milliseconds: 800)); 243 + await Future.delayed(const Duration(milliseconds: 800)); 179 244 180 - // Check if widget is still mounted after the delay 181 - if (!mounted) return; 245 + // Check mounted again after delay, before potentially interacting with widget state or navigator 246 + if (!mounted) return; 182 247 183 - // Notify parent that post was deleted 184 - if (widget.onPostDeleted != null) { 185 - widget.onPostDeleted!(); 186 - } 248 + if (widget.onPostDeleted != null) { 249 + widget.onPostDeleted!(); 250 + } 187 251 188 - // Check if we can navigate back (for profile view) 189 - if (Navigator.of(context).canPop()) { 190 - // Pop back to the previous screen with result=true to indicate post was deleted 191 - Navigator.of(context).pop(true); 192 - } 252 + // Use cached navigator 253 + if (navigator.canPop()) { 254 + navigator.pop(true); 193 255 } 194 256 } else { 195 - if (mounted) { 196 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Failed to delete post'))); 197 - } 257 + // Use cached messenger 258 + messenger.showSnackBar(const SnackBar(content: Text('Failed to delete post'))); 198 259 } 199 260 } catch (e) { 200 - if (mounted) { 201 - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error deleting post: $e'))); 261 + // 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 263 + messenger.showSnackBar(SnackBar(content: Text('Error deleting post: $e'))); 202 264 } 203 265 } 204 266 } ··· 218 280 CommentActionButton(count: widget.commentCount, onPressed: widget.onCommentPressed), 219 281 const SizedBox(height: 20), 220 282 221 - // BookmarkActionButton( 222 - // count: widget.bookmarkCount, 223 - // isBookmarked: _isBookmarked, 224 - // onPressed: _handleBookmark, 225 - // key: const ValueKey('bookmark_button'), // Add a stable key 226 - // ), 283 + // Only show share button for videos, not for images 284 + if (!widget.isImage) ...[ 285 + ShareActionButton(count: widget.shareCount, onPressed: _handleShare), 286 + const SizedBox(height: 20), 287 + ], 288 + 227 289 MenuActionButton( 228 290 onPressed: widget.onReportPressed ?? () => _handleReport(context, authService), 229 291 onDeletePressed: () => _handleDelete(context), ··· 235 297 ); 236 298 } 237 299 } 300 + 301 + class SharePanel extends StatefulWidget { 302 + final String shareUrl; 303 + final String embedCode; 304 + final bool showEmbed; 305 + 306 + const SharePanel({ 307 + super.key, 308 + required this.shareUrl, 309 + required this.embedCode, 310 + this.showEmbed = true, 311 + }); 312 + 313 + @override 314 + State<SharePanel> createState() => _SharePanelState(); 315 + } 316 + 317 + class _SharePanelState extends State<SharePanel> { 318 + bool _copiedLink = false; 319 + bool _copiedEmbed = false; 320 + 321 + void _copyToClipboard(String text, BuildContext context, bool isLink) { 322 + Clipboard.setData(ClipboardData(text: text)); 323 + 324 + // Update state to show copied indicator 325 + setState(() { 326 + if (isLink) { 327 + _copiedLink = true; 328 + } else { 329 + _copiedEmbed = true; 330 + } 331 + }); 332 + 333 + // Show a more noticeable snackbar 334 + ScaffoldMessenger.of(context).showSnackBar( 335 + SnackBar( 336 + content: Row( 337 + children: [ 338 + const Icon(Icons.check_circle, color: Colors.white), 339 + const SizedBox(width: 12), 340 + Text(isLink ? 'Video link copied!' : 'Embed code copied!'), 341 + ], 342 + ), 343 + backgroundColor: Colors.green.shade700, 344 + behavior: SnackBarBehavior.floating, 345 + width: MediaQuery.of(context).size.width * 0.9, 346 + duration: const Duration(seconds: 2), 347 + ), 348 + ); 349 + 350 + // Reset the copied state after 2 seconds 351 + Future.delayed(const Duration(seconds: 2), () { 352 + if (mounted) { 353 + setState(() { 354 + if (isLink) { 355 + _copiedLink = false; 356 + } else { 357 + _copiedEmbed = false; 358 + } 359 + }); 360 + } 361 + }); 362 + } 363 + 364 + @override 365 + Widget build(BuildContext context) { 366 + final isDarkMode = Theme.of(context).brightness == Brightness.dark; 367 + final backgroundColor = isDarkMode ? const Color(0xFF1F1F1F) : Colors.white; 368 + final textColor = isDarkMode ? Colors.white : Colors.black87; 369 + final fieldBgColor = isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFF5F5F5); 370 + final dividerColor = isDarkMode ? Colors.white24 : Colors.black12; 371 + 372 + return Container( 373 + decoration: BoxDecoration( 374 + 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 + ], 386 + ), 387 + child: DraggableScrollableSheet( 388 + initialChildSize: 0.4, 389 + minChildSize: 0.3, 390 + maxChildSize: 0.6, 391 + expand: false, 392 + builder: (context, scrollController) { 393 + return Column( 394 + children: [ 395 + Container( 396 + width: 40, 397 + height: 4, 398 + margin: const EdgeInsets.only(top: 12, bottom: 16), 399 + decoration: BoxDecoration( 400 + color: dividerColor, 401 + borderRadius: BorderRadius.circular(10), 402 + ), 403 + ), 404 + Padding( 405 + 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 + ), 414 + ), 415 + Divider(color: dividerColor, height: 30), 416 + Expanded( 417 + child: ListView( 418 + controller: scrollController, 419 + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), 420 + children: [ 421 + Text( 422 + 'Video link', 423 + style: TextStyle( 424 + color: textColor, 425 + fontSize: 15, 426 + fontWeight: FontWeight.w500, 427 + ), 428 + ), 429 + const SizedBox(height: 8), 430 + _buildCopyField(widget.shareUrl, context, fieldBgColor, textColor, true, _copiedLink), 431 + if (widget.showEmbed) ...[ 432 + const SizedBox(height: 24), 433 + Text( 434 + 'Video embed', 435 + style: TextStyle( 436 + color: textColor, 437 + fontSize: 15, 438 + fontWeight: FontWeight.w500, 439 + ), 440 + ), 441 + const SizedBox(height: 8), 442 + _buildCopyField(widget.embedCode, context, fieldBgColor, textColor, false, _copiedEmbed), 443 + ], 444 + ], 445 + ), 446 + ), 447 + ], 448 + ); 449 + }, 450 + ), 451 + ); 452 + } 453 + 454 + Widget _buildCopyField(String text, BuildContext context, Color bgColor, Color textColor, bool isLink, bool isCopied) { 455 + final theme = Theme.of(context); 456 + final accentColor = theme.colorScheme.primary; 457 + 458 + return Container( 459 + decoration: BoxDecoration( 460 + color: bgColor, 461 + borderRadius: BorderRadius.circular(12), 462 + border: Border.all(color: Colors.transparent), 463 + ), 464 + child: Row( 465 + children: [ 466 + Expanded( 467 + child: Padding( 468 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), 469 + child: Text( 470 + text, 471 + style: TextStyle( 472 + fontFamily: 'monospace', 473 + color: textColor.withAlpha(204), 474 + fontSize: 13, 475 + ), 476 + overflow: TextOverflow.ellipsis, 477 + ), 478 + ), 479 + ), 480 + Material( 481 + color: Colors.transparent, 482 + child: InkWell( 483 + onTap: () => _copyToClipboard(text, context, isLink), 484 + borderRadius: BorderRadius.circular(30), 485 + child: Container( 486 + padding: const EdgeInsets.all(12), 487 + child: AnimatedSwitcher( 488 + duration: const Duration(milliseconds: 300), 489 + transitionBuilder: (Widget child, Animation<double> animation) { 490 + return ScaleTransition(scale: animation, child: child); 491 + }, 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 + ), 505 + ), 506 + ), 507 + ), 508 + ), 509 + ], 510 + ), 511 + ); 512 + } 513 + }