relatica/lib/controls/timeline/flattened_tree_entry_contro...

339 wiersze
10 KiB
Dart

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/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<FlattenedTreeEntryControl> createState() => _StatusControlState();
}
class _StatusControlState extends State<FlattenedTreeEntryControl> {
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<ActiveProfileSelector<TimelineEntryFilterService>>()
.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: 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();
}
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 goToPost = 'Open Post';
const copyText = 'Copy Post Text';
const copyUrl = 'Copy URL';
const openExternal = 'Open In Browser';
final options = [
if (widget.showStatusOpenButton && !widget.openRemote) goToPost,
if (item.isMine && !item.timelineEntry.youReshared) editStatus,
if (item.isMine) deleteStatus,
copyText,
openExternal,
copyUrl,
];
return PopupMenuButton<String>(onSelected: (menuOption) async {
if (!mounted) {
return;
}
switch (menuOption) {
case goToPost:
context.push(
'/post/view/${item.timelineEntry.id}/${item.timelineEntry.id}');
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;
default:
//do nothing
}
}, itemBuilder: (context) {
return options
.map((o) => PopupMenuItem(value: o, child: Text(o)))
.toList();
});
}
Future<void> deleteEntry() async {
setState(() {
isProcessing = true;
});
final confirm =
await showYesNoDialog(context, 'Delete ${isPost ? "Post" : "Comment"}');
if (confirm == true) {
await getIt<ActiveProfileSelector<TimelineManager>>()
.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(() {});
}
}