[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444import 'package:poptart_lex/com/atproto/moderation/create_report.dart';import 'package:poptart_lex/com/atproto/repo/strong_ref.dart';import 'package:poptart/poptart.dart';import 'package:auto_route/auto_route.dart';import 'package:material_ui/material_ui.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:get_it/get_it.dart';import 'package:spark/src/core/auth/data/repositories/auth_repository.dart';import 'package:spark/src/core/design_system/components/atoms/icons.dart';import 'package:spark/src/core/l10n/app_localizations.dart';import 'package:spark/src/core/moderation/moderated_content.dart';import 'package:spark/src/core/moderation/moderation.dart';import 'package:spark/src/core/network/atproto/data/models/feed_models.dart';import 'package:spark/src/core/routing/app_router.dart';import 'package:spark/src/core/design_system/tokens/colors.dart';import 'package:spark/src/core/ui/widgets/image_content.dart';import 'package:spark/src/core/ui/widgets/options_panel.dart';import 'package:spark/src/core/ui/widgets/report_dialog.dart';import 'package:spark/src/core/design_system/components/atoms/user_avatar.dart';import 'package:spark/src/core/utils/utils.dart';import 'package:spark/src/features/comments/providers/comment_provider.dart';import 'package:spark/src/features/comments/providers/comment_state.dart';import 'package:spark/src/features/comments/providers/comments_page_provider.dart';import 'package:spark/src/features/feed/ui/widgets/post_likes_sheet.dart';
class CommentItem extends ConsumerWidget { const CommentItem({ required this.thread, required this.mainPostUri, super.key, this.isHighlighted = false, });
final ThreadViewPost thread; final AtUri mainPostUri; final bool isHighlighted;
@override Widget build(BuildContext context, WidgetRef ref) { final commentState = ref.watch(commentProvider(thread));
final post = commentState.thread.post; return ModeratedContent( subject: ModerationSubject.content( labels: post.labels ?? const [], authorLabels: post.author.labels ?? const [], subjectDid: post.author.did, ), context: ModerationContext.contentList, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ CommentBody( thread: thread, mainPostUri: mainPostUri, isHighlighted: isHighlighted, ),
if (post.replyCount != null && post.replyCount! > 0) _RepliesSection(commentState),
Container(height: 0.5, color: Theme.of(context).colorScheme.surface), ], ), ); }}
class CommentBody extends ConsumerStatefulWidget { const CommentBody({ required this.thread, required this.mainPostUri, super.key, this.isHighlighted = false, });
final ThreadViewPost thread; final AtUri mainPostUri; final bool isHighlighted;
@override ConsumerState<CommentBody> createState() => _CommentBodyState();}
class _CommentBodyState extends ConsumerState<CommentBody> { late CommentState commentState; late final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 'CommentBody', );
@override void initState() { super.initState(); }
void _navigateToProfile() { final author = commentState.thread.post.author; final isBskyPost = widget.mainPostUri.collection.toString().startsWith( 'app.bsky', ); context.router.push( ProfileRoute(did: author.did, initialProfile: author, bsky: isBskyPost), ); }
void _handleReportComment() { showDialog<void>( context: context, builder: (context) => ReportDialog( subject: UModerationCreateReportInputSubject.repoStrongRef( data: RepoStrongRef( uri: commentState.thread.post.uri, cid: commentState.thread.post.cid, ), ), ), ); }
void _handleDeleteComment() { final l10n = AppLocalizations.of(context); showDialog<void>( context: context, builder: (context) => AlertDialog( title: Text(l10n.dialogDeleteComment), content: Text(l10n.dialogDeleteCommentConfirm), actions: [ TextButton( onPressed: () => context.router.maybePop(), child: Text(l10n.buttonCancel), ), TextButton( onPressed: () async { try { await ref .read( commentsPageProvider(postUri: widget.mainPostUri) .notifier, ) .deleteComment(commentState.thread.post.uri.toString()); if (context.mounted) { await context.router.maybePop(); } } catch (e) { _logger.e('Error deleting comment', error: e); } }, child: Text(l10n.buttonDelete), ), ], ), ); }
@override Widget build(BuildContext context) { commentState = ref.watch(commentProvider(widget.thread)); const double thumbnailSize = 120;
final borderRadius = BorderRadius.circular(8);
final imageUrls = commentState.thread.post.imageUrls;
return ColoredBox( color: widget.isHighlighted ? AppColors.pink.withValues(alpha: 0.15) : Colors.transparent, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: _navigateToProfile, child: _Avatar(thread: widget.thread), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: GestureDetector( onTap: _navigateToProfile, child: Row( children: [ Text( commentState.thread.post.author.handle, style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context) .textTheme .bodyLarge ?.color, ), ), const SizedBox(width: 8), Text( _formatDate( commentState.thread.post.indexedAt .toLocal() .toString(), ), style: TextStyle( fontSize: 12, color: Theme.of(context) .textTheme .bodyMedium ?.color, ), ), ], ), ), ), Builder( builder: (context) { final authRepository = GetIt.instance<AuthRepository>(); final userDid = authRepository.did; final isCurrentUserAuthor = userDid == commentState.thread.post.author.did; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final iconColor = isDark ? AppColors.white : AppColors.black;
return GestureDetector( onTap: () => OptionsPanel.show( context: context, onReport: _handleReportComment, onDelete: isCurrentUserAuthor ? _handleDeleteComment : null, ), child: SizedBox( width: 28, height: 28, child: AppIcon( AppIconData.moreHoriz, color: iconColor, size: 16, ), ), ); }, ), ], ), const SizedBox(height: 4),
if (commentState.thread.post.displayText.isNotEmpty) Text( commentState.thread.post.displayText, style: Theme.of(context).textTheme.bodyMedium, ),
if (imageUrls.isNotEmpty) ...[ const SizedBox(height: 8), ImageContent( imageUrls: imageUrls, borderRadius: borderRadius, thumbnailSize: thumbnailSize, ), ],
const SizedBox(height: 8), _ActionButtons( ref: ref, commentState: commentState, thread: widget.thread, secondaryTextColor: Theme.of(context) .textTheme .bodyMedium! .color!, ), ], ), ), ], ), ), ], ), ); }
String _formatDate(String dateTimeString) { final dateTime = DateTime.parse(dateTimeString); final now = DateTime.now(); final difference = now.difference(dateTime);
if (difference.inDays > 365) { return '${(difference.inDays / 365).floor()}y'; } else if (difference.inDays > 30) { return '${(difference.inDays / 30).floor()}mo'; } else if (difference.inDays > 0) { return '${difference.inDays}d'; } else if (difference.inHours > 0) { return '${difference.inHours}h'; } else if (difference.inMinutes > 0) { return '${difference.inMinutes}m'; } else { return 'now'; } }}
class _RepliesSection extends StatelessWidget { const _RepliesSection(this.commentState); final CommentState commentState;
@override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return GestureDetector( onTap: () => context.router.push( RepliesRoute(postUri: commentState.thread.post.uri.toString()), ), child: Container( margin: const EdgeInsets.only(left: 64), padding: const EdgeInsets.only(top: 4, bottom: 8), decoration: BoxDecoration( border: Border( left: BorderSide(color: Theme.of(context).colorScheme.surface), ), ), child: Text( l10n.messageShowReplies(commentState.thread.post.replyCount ?? 0), ), ), ); }}
class _ActionButtons extends StatelessWidget { const _ActionButtons({ required this.ref, required this.commentState, required this.thread, required this.secondaryTextColor, });
final WidgetRef ref; final CommentState commentState; final ThreadViewPost thread; final Color secondaryTextColor;
@override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final notifier = ref.read(commentProvider(thread).notifier); return Row( children: [ TextButton( style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, splashFactory: NoSplash.splashFactory, ), onPressed: notifier.toggleLike, child: Row( children: [ if (commentState.isLiked) AppIcons.likeFilled(size: 16, color: AppColors.pink) else AppIcons.like(size: 16, color: secondaryTextColor), const SizedBox(width: 4), GestureDetector( behavior: HitTestBehavior.opaque, onTap: commentState.likeCount == 0 ? null : () { final post = commentState.thread.post; showPostLikesSheet( context: context, uri: post.uri.toString(), cid: post.cid, ); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( commentState.likeCount.toString(), style: TextStyle(fontSize: 12, color: secondaryTextColor), ), ), ), ], ), ), const SizedBox(width: 16),
TextButton( style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: () { context.router.push( RepliesRoute(postUri: commentState.thread.post.uri.toString()), ); }, child: Text( l10n.labelReply, style: TextStyle(fontSize: 12, color: secondaryTextColor), ), ), ], ); }}
class _Avatar extends StatelessWidget { const _Avatar({required this.thread});
final ThreadViewPost thread;
@override Widget build(BuildContext context) { final author = thread.post.author; return ModeratedProfileAvatar( labels: author.labels ?? const [], subjectDid: author.did, child: UserAvatar( imageUrl: author.avatar.toString(), username: author.handle, size: 36, ), ); }}