import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:relatica/utils/snackbar_builder.dart'; import 'package:result_monad/result_monad.dart'; import '../../globals.dart'; import '../../models/filters/timeline_entry_filter.dart'; import '../../models/flattened_tree_item.dart'; import '../../models/timeline_entry.dart'; import '../../services/auth_service.dart'; import '../../services/timeline_entry_filter_service.dart'; import '../../services/timeline_manager.dart'; import '../../utils/active_profile_selector.dart'; import '../../utils/clipboard_utils.dart'; import '../../utils/filter_runner.dart'; import '../../utils/html_to_edit_text_helper.dart'; import '../../utils/responsive_sizes_calculator.dart'; import '../../utils/url_opening_utils.dart'; import '../html_text_viewer_control.dart'; import '../media_attachment_viewer_control.dart'; import '../padding.dart'; import 'interactions_bar_control.dart'; import 'link_preview_control.dart'; import 'status_header_control.dart'; class FlattenedTreeEntryControl extends StatefulWidget { final FlattenedTreeItem originalItem; final bool openRemote; final bool showStatusOpenButton; const FlattenedTreeEntryControl( {super.key, required this.originalItem, required this.openRemote, required this.showStatusOpenButton}); @override State createState() => _StatusControlState(); } class _StatusControlState extends State { static final _logger = Logger('$FlattenedTreeEntryControl'); var showContent = true; var showFilteredPost = false; var showComments = false; var isProcessing = false; FlattenedTreeItem get item => widget.originalItem; TimelineEntry get entry => item.timelineEntry; bool get isPost => entry.parentId.isEmpty; bool get hasComments => entry.engagementSummary.repliesCount > 0; var filteringInfo = FilterResult.show; @override void initState() { super.initState(); showContent = entry.spoilerText.isEmpty; showComments = isPost ? false : true; } @override Widget build(BuildContext context) { _logger.finest('Building ${entry.toShortString()}'); final filterService = context .watch>() .activeEntry .value; filteringInfo = filterService.checkTimelineEntry(entry); const otherPadding = 8.0; final leftPadding = otherPadding + (widget.originalItem.level * 15.0); final color = widget.originalItem.level.isOdd ? Theme.of(context).secondaryHeaderColor : Theme.of(context).dialogBackgroundColor; if (filteringInfo.isFiltered && filteringInfo.action == TimelineEntryFilterAction.hide) { return kReleaseMode ? const SizedBox() : Container( height: 10, color: Colors.red, ); } late final Widget body; if (filteringInfo.isFiltered && !showFilteredPost) { body = buildHiddenBody(context, filteringInfo); } else { body = buildMainWidgetBody(context); } final bodyCard = Container( decoration: BoxDecoration( color: color, border: Border.all(width: 0.5), borderRadius: BorderRadius.circular(5.0), boxShadow: [ BoxShadow( color: Theme.of(context).dividerColor, blurRadius: 2, offset: const Offset(4, 4), spreadRadius: 0.1, blurStyle: BlurStyle.normal, ) ], ), child: body, ); return Padding( padding: EdgeInsets.only( left: leftPadding, right: otherPadding, top: otherPadding, bottom: otherPadding, ), child: GestureDetector( onTap: () { if (widget.showStatusOpenButton) { _goToPostView(); } }, child: bodyCard), ); } Widget buildMainWidgetBody(BuildContext context) { return Padding( padding: const EdgeInsets.all(5.0), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: StatusHeaderControl( entry: entry, ), ), if (filteringInfo.isFiltered) IconButton( onPressed: () => setState(() { showFilteredPost = false; }), icon: const Icon(Icons.hide_source)), buildMenuControl(context), ], ), const VerticalPadding( height: 5, ), if (entry.spoilerText.isNotEmpty) TextButton( onPressed: () { setState(() { showContent = !showContent; }); }, child: Text( 'Content Summary: ${entry.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')), if (showContent) ...[ buildContentField(context), const VerticalPadding( height: 5, ), if (entry.linkPreviewData != null) LinkPreviewControl(preview: entry.linkPreviewData!), buildMediaBar(context), ], const VerticalPadding( height: 5, ), InteractionsBarControl( entry: entry, isMine: item.isMine, showOpenControl: widget.showStatusOpenButton, ), const VerticalPadding( height: 5, ), ], ), ); } Widget buildHiddenBody(BuildContext context, FilterResult result) { return Padding( padding: const EdgeInsets.all(5.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (result.isFiltered && result.action == TimelineEntryFilterAction.warn) TextButton( onPressed: () { setState(() { showFilteredPost = true; }); }, child: Text( '${result.trippingFilterName} filtered post. Click to show'), ), ], ), ); } Widget buildContentField(BuildContext context) { return HtmlTextViewerControl( content: entry.body, onTapUrl: (url) async => await openUrlStringInSystembrowser(context, url, 'link'), ); } Widget buildMediaBar(BuildContext context) { final items = entry.mediaAttachments; if (items.isEmpty) { return const SizedBox(); } // A Link Preview with only one media attachment will have a duplicate image // even though it points to different resources server side. So we don't // want to render it twice. if (entry.linkPreviewData != null && items.length == 1) { return const SizedBox(); } // A Diaspora reshare will have an HTML-built card with a link preview image // to the same image as what would be in the single attachment but at a // different link. So we don't want it to render twice. final linkPhotoBaseUrl = Uri.https( context.read().currentProfile.serverName, 'photo/link', ).toString(); if (entry.body.contains(linkPhotoBaseUrl) && items.length == 1) { return const SizedBox(); } return SizedBox( height: ResponsiveSizesCalculator(context).maxThumbnailHeight, child: ListView.separated( scrollDirection: Axis.horizontal, itemBuilder: (context, index) { return MediaAttachmentViewerControl( attachments: items, index: index, width: items.length > 1 ? ResponsiveSizesCalculator(context).maxThumbnailWidth : ResponsiveSizesCalculator(context).viewPortalWidth, height: ResponsiveSizesCalculator(context).maxThumbnailHeight, ); }, separatorBuilder: (context, index) { return const HorizontalPadding(); }, itemCount: items.length)); } Widget buildMenuControl(BuildContext context) { const editStatus = 'Edit'; const deleteStatus = 'Delete'; const divider = 'Divider'; const goToPost = 'Open Post'; const copyText = 'Copy Post Text'; const copyUrl = 'Copy URL'; const openExternal = 'Open In Browser'; const showLikers = 'Show Likers'; const showResharers = 'Show Reshares'; final options = [ if (widget.showStatusOpenButton && !widget.openRemote) goToPost, if (item.isMine && !item.timelineEntry.youReshared) editStatus, if (item.isMine) deleteStatus, divider, showLikers, showResharers, divider, openExternal, copyUrl, copyText, ]; if (options.first == divider) { options.removeAt(0); } final entry = widget.originalItem.timelineEntry; return PopupMenuButton(onSelected: (menuOption) async { if (!mounted) { return; } switch (menuOption) { case goToPost: _goToPostView(); break; case editStatus: if (item.timelineEntry.parentId.isEmpty) { context.push('/post/edit/${item.timelineEntry.id}'); } else { context.push('/comment/edit/${item.timelineEntry.id}'); } break; case deleteStatus: deleteEntry(); break; case openExternal: await openUrlStringInSystembrowser( context, item.timelineEntry.externalLink, 'Post', ); break; case copyUrl: await copyToClipboard( context: context, text: item.timelineEntry.externalLink, message: 'Post link copied to clipboard', ); break; case copyText: await copyToClipboard( context: context, text: htmlToSimpleText(item.timelineEntry.body), message: 'Post link copied to clipboard', ); break; case showLikers: await context.push('/likes/${entry.id}'); break; case showResharers: await context.push('/reshares/${entry.id}'); break; default: //do nothing } }, itemBuilder: (context) { return options .map( (o) => PopupMenuItem( value: o, enabled: switch (o) { divider => false, showResharers => entry.engagementSummary.rebloggedCount > 0, showLikers => entry.engagementSummary.favoritesCount > 0, _ => true, }, child: o == divider ? const Divider() : Text(o), ), ) .toList(); }); } Future deleteEntry() async { setState(() { isProcessing = true; }); final confirm = await showYesNoDialog(context, 'Delete ${isPost ? "Post" : "Comment"}'); if (confirm == true) { await getIt>() .activeEntry .transformAsync( (tm) async => await tm.deleteEntryById(item.timelineEntry.id)) .match(onSuccess: (_) { isProcessing = false; if (context.canPop()) { context.pop(); } }, onError: (e) { isProcessing = false; buildSnackbar( context, 'Error deleting ${isPost ? "Post" : "Comment"}: $e', ); }); } setState(() {}); } void _goToPostView() { context .push('/post/view/${item.timelineEntry.id}/${item.timelineEntry.id}'); } }