import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import '../models/timeline_entry.dart'; import '../riverpod_controllers/account_services.dart'; import '../riverpod_controllers/settings_services.dart'; import '../utils/clipboard_utils.dart'; import '../utils/snackbar_builder.dart'; import '../utils/url_opening_utils.dart'; import 'html_text_viewer_control.dart'; import 'media_attachment_viewer_control.dart'; import 'padding.dart'; import 'timeline/link_preview_control.dart'; import 'timeline/status_header_control.dart'; class SearchResultStatusControl extends ConsumerStatefulWidget { static final _logger = Logger('$SearchResultStatusControl'); final TimelineEntry status; final Future Function() goToPostFunction; const SearchResultStatusControl(this.status, this.goToPostFunction, {super.key}); @override ConsumerState createState() => _SearchResultStatusControlState(); } class _SearchResultStatusControlState extends ConsumerState { var showSpoilerControl = true; var showContent = false; var processing = false; TimelineEntry get status => widget.status; bool get isPost => status.parentId.isEmpty; String get label => isPost ? 'post' : 'comment'; String get ucLabel => isPost ? 'Post' : 'Comment'; @override void initState() { super.initState(); showSpoilerControl = ref.read(spoilerHidingSettingProvider); showContent = !showSpoilerControl ? true : widget.status.spoilerText.isEmpty; } Future openStatus() async { if (processing) { return; } setState(() { processing = true; }); buildSnackbar(context, 'Loading $label thread to open'); await widget.goToPostFunction(); setState(() { processing = false; }); } @override Widget build(BuildContext context) { showSpoilerControl = ref.watch(spoilerHidingSettingProvider); SearchResultStatusControl._logger .finest('Building ${widget.status.toShortString()}'); const otherPadding = 8.0; final body = Container( decoration: const BoxDecoration(), child: Padding( padding: const EdgeInsets.all(5.0), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: StatusHeaderControl( entry: widget.status, showIsCommentText: true, ), ), buildMenuControl(context), ], ), const VerticalPadding( height: 5, ), if (showSpoilerControl && status.spoilerText.isNotEmpty) TextButton( onPressed: () { setState(() { showContent = !showContent; }); }, child: Text( 'Content Summary: ${status.spoilerText} (Click to ${showContent ? "Hide" : "Show"})')), if (showContent || !showSpoilerControl) ...[ buildBody(context), const VerticalPadding( height: 5, ), if (status.linkPreviewData != null) LinkPreviewControl(preview: status.linkPreviewData!), buildMediaBar(context), ], const VerticalPadding( height: 5, ), const VerticalPadding( height: 5, ), ], ), ), ); return GestureDetector( onTap: openStatus, child: Padding( padding: const EdgeInsets.only( left: otherPadding, right: otherPadding, top: otherPadding, bottom: otherPadding, ), child: body, ), ); } Widget buildBody(BuildContext context) { return HtmlTextViewerControl( content: widget.status.body, onTapUrl: (url) async => await openUrlStringInSystembrowser(context, url, 'link'), ); } Widget buildMediaBar(BuildContext context) { final items = widget.status.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 (widget.status.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( ref.watch(activeProfileProvider).serverName, 'photo/link', ).toString(); if (widget.status.body.contains(linkPhotoBaseUrl) && items.length == 1) { return const SizedBox(); } return SizedBox( height: 250.0, child: ListView.separated( scrollDirection: Axis.horizontal, itemBuilder: (context, index) { return MediaAttachmentViewerControl( attachments: items, index: index, ); }, separatorBuilder: (context, index) { return const HorizontalPadding(); }, itemCount: items.length)); } Widget buildMenuControl(BuildContext context) { final goToPost = 'Open $ucLabel'; final copyText = 'Copy $ucLabel Text'; const copyUrl = 'Copy URL'; const openExternal = 'Open In Browser'; final options = [ goToPost, copyText, openExternal, copyUrl, ]; return PopupMenuButton(onSelected: (menuOption) async { if (!context.mounted) { return; } if (menuOption == goToPost) { await openStatus(); } else if (menuOption == openExternal) { await openUrlStringInSystembrowser( context, widget.status.externalLink, ucLabel, ); } else if (menuOption == copyUrl) { await copyToClipboard( context: context, text: widget.status.externalLink, message: '$ucLabel link copied to clipboard', ); } else if (menuOption == copyText) { await copyToClipboard( context: context, text: widget.status.body, message: '$ucLabel text copied to clipboard', ); } }, itemBuilder: (context) { return options .map((o) => PopupMenuItem(value: o, child: Text(o))) .toList(); }); } }