import 'package:flutter/material.dart' hide Visibility; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../controls/autocomplete/hashtag_autocomplete_options.dart'; import '../controls/autocomplete/mention_autocomplete_options.dart'; import '../controls/entry_media_attachments/gallery_selector_control.dart'; import '../controls/entry_media_attachments/media_uploads_control.dart'; import '../controls/login_aware_cached_network_image.dart'; import '../controls/padding.dart'; import '../controls/standard_appbar.dart'; import '../controls/timeline/status_header_control.dart'; import '../globals.dart'; import '../models/group_data.dart'; import '../models/image_entry.dart'; import '../models/link_preview_data.dart'; import '../models/media_attachment_uploads/new_entry_media_items.dart'; import '../models/timeline_entry.dart'; import '../models/visibility.dart'; import '../serializers/friendica/link_preview_friendica_extensions.dart'; import '../services/feature_version_checker.dart'; import '../services/timeline_manager.dart'; import '../utils/active_profile_selector.dart'; import '../utils/html_to_edit_text_helper.dart'; import '../utils/opengraph_preview_grabber.dart'; import '../utils/snackbar_builder.dart'; import '../utils/string_utils.dart'; class EditorScreen extends StatefulWidget { final String id; final String parentId; final bool forEditing; const EditorScreen( {super.key, this.id = '', this.parentId = '', required this.forEditing}); @override State createState() => _EditorScreenState(); } class _EditorScreenState extends State { static final _logger = Logger('$EditorScreen'); final contentController = TextEditingController(); final spoilerController = TextEditingController(); final localEntryTemporaryId = const Uuid().v4(); TimelineEntry? parentEntry; final linkPreviewController = TextEditingController(); LinkPreviewData? linkPreviewData; final newMediaItems = NewEntryMediaItems(); final existingMediaItems = []; final focusNode = FocusNode(); Visibility visibility = Visibility.public(); GroupData? currentGroup; var isSubmitting = false; bool get isComment => widget.parentId.isNotEmpty; String get statusType => widget.parentId.isEmpty ? 'Post' : 'Comment'; String get localEntryId => widget.id.isNotEmpty ? widget.id : localEntryTemporaryId; bool loaded = false; @override void initState() { if (isComment) { final manager = context .read>() .activeEntry .value; manager.getEntryById(widget.parentId).match(onSuccess: (entry) { spoilerController.text = entry.spoilerText; parentEntry = entry; visibility = entry.visibility; }, onError: (error) { _logger.finest('Error trying to get parent entry: $error'); }); } if (widget.forEditing) { restoreStatusData(); } else { loaded = true; } } void restoreStatusData() async { _logger.fine('Attempting to load status for editing'); loaded = false; final result = await getIt>() .activeEntry .andThenAsync((manager) async => await manager.getEntryById(widget.id)); result.match(onSuccess: (entry) { _logger.fine('Loading status ${widget.id} information into fields'); contentController.text = htmlToSimpleText(entry.body); spoilerController.text = entry.spoilerText; existingMediaItems .addAll(entry.mediaAttachments.map((e) => e.toImageEntry())); if (entry.linkPreviewData?.link.isNotEmpty ?? false) { restoreLinkPreviewData(entry.linkPreviewData!); } visibility = entry.visibility; setState(() { loaded = true; }); }, onError: (error) { if (context.mounted) { buildSnackbar( context, 'Error getting post for editing: $error', ); } _logger.severe('Error getting post for editing: $error'); }); } void restoreLinkPreviewData(LinkPreviewData preview) { linkPreviewController.text = preview.link; linkPreviewData = preview; Future.delayed(const Duration(seconds: 1), () async { final updatedPreview = await getLinkPreview(preview.link); if (updatedPreview.isSuccess) { linkPreviewData = linkPreviewData?.copy( availableImageUrls: updatedPreview.value.availableImageUrls); setState(() {}); } }); } String get bodyText => '${contentController.text} ${linkPreviewData?.toBodyAttachment() ?? ''}'; bool get isEmptyPost => bodyText.isEmpty && existingMediaItems.isEmpty && newMediaItems.attachments.isEmpty; Future createStatus( BuildContext context, TimelineManager manager) async { if (isEmptyPost) { buildSnackbar(context, "Can't submit an empty $statusType"); return; } setState(() { isSubmitting = true; }); final result = await manager.createNewStatus( bodyText, spoilerText: spoilerController.text, inReplyToId: widget.parentId, newMediaItems: newMediaItems, existingMediaItems: existingMediaItems, visibility: visibility, ); setState(() { isSubmitting = false; }); if (result.isFailure) { buildSnackbar(context, 'Error posting: ${result.error}'); return; } if (mounted && context.canPop()) { context.pop(); } } Future editStatus(BuildContext context, TimelineManager manager) async { if (isEmptyPost) { buildSnackbar(context, "Can't submit an empty $statusType"); return; } setState(() { isSubmitting = true; }); final result = await manager.editStatus( widget.id, bodyText, spoilerText: spoilerController.text, inReplyToId: widget.parentId, newMediaItems: newMediaItems, existingMediaItems: existingMediaItems, newMediaItemVisibility: visibility, ); setState(() { isSubmitting = false; }); if (result.isFailure) { buildSnackbar(context, 'Error Updating $statusType: ${result.error}'); return; } if (mounted && context.canPop()) { context.pop(); } } @override Widget build(BuildContext context) { _logger.finest('Build editor $isComment $parentEntry'); final manager = context .read>() .activeEntry .value; final vc = getIt(); final canEdit = vc.canUseFeature(RelaticaFeatures.statusEditing); final canSpoilerText = vc.canUseFeature(RelaticaFeatures.postSpoilerText) || widget.parentId.isNotEmpty; late final body; if (widget.forEditing && !canEdit) { body = Center( child: Column( children: [ Text(vc.versionErrorString(RelaticaFeatures.statusEditing)), const VerticalPadding(), ElevatedButton( onPressed: () => context.pop(), child: const Text('Back')), ], ), ); } else { final mainBody = Padding( padding: const EdgeInsets.all(8.0), child: Container( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ if (isComment && parentEntry != null) buildCommentPreview(context, parentEntry!), TextFormField( readOnly: isSubmitting, enabled: !isSubmitting && canSpoilerText, controller: spoilerController, decoration: InputDecoration( labelText: canSpoilerText ? '$statusType Spoiler Text (optional)' : 'Your server doesnt support $statusType Spoiler Text', border: OutlineInputBorder( borderSide: const BorderSide(), borderRadius: BorderRadius.circular(5.0), ), ), ), const VerticalPadding(), buildVisibilitySelector(context), const VerticalPadding(), buildContentField(context), const VerticalPadding(), buildLinkWithPreview(context), const VerticalPadding(), GallerySelectorControl( entries: existingMediaItems, visibilityFilter: visibility, ), const VerticalPadding(), MediaUploadsControl( entryMediaItems: newMediaItems, ), buildButtonBar(context, manager), ], ), ), ), ); if (widget.forEditing && !loaded) { body = buildBusyBody(context, mainBody, 'Loading status'); } else if (isSubmitting) { body = buildBusyBody(context, mainBody, 'Submitting $statusType'); } else { body = mainBody; } } return Scaffold( appBar: StandardAppBar.build( context, widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType', withDrawer: true), body: body, ); } Widget buildContentField(BuildContext context) { return MultiTriggerAutocomplete( textEditingController: contentController, focusNode: focusNode, optionsAlignment: OptionsAlignment.bottomEnd, autocompleteTriggers: [ AutocompleteTrigger( trigger: '@', optionsViewBuilder: (context, autocompleteQuery, controller) { return MentionAutocompleteOptions( query: autocompleteQuery.query, onMentionUserTap: (user) { final autocomplete = MultiTriggerAutocomplete.of(context); return autocomplete.acceptAutocompleteOption(user.handle); }, ); }, ), AutocompleteTrigger( trigger: '#', optionsViewBuilder: (context, autocompleteQuery, controller) { return HashtagAutocompleteOptions( query: autocompleteQuery.query, onHashtagTap: (hashtag) { final autocomplete = MultiTriggerAutocomplete.of(context); return autocomplete.acceptAutocompleteOption(hashtag); }, ); }, ), ], fieldViewBuilder: (context, controller, focusNode) => TextFormField( focusNode: focusNode, readOnly: isSubmitting, enabled: !isSubmitting, maxLines: 10, controller: controller, decoration: InputDecoration( labelText: '$statusType Content', alignLabelWithHint: true, border: OutlineInputBorder( borderSide: const BorderSide(), borderRadius: BorderRadius.circular(5.0), ), ), ), ); } Widget buildCommentPreview(BuildContext context, TimelineEntry entry) { _logger.finest('Build preview'); return Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Comment for status: ', style: Theme.of(context).textTheme.bodyLarge, ), Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ StatusHeaderControl( entry: entry, ), const VerticalPadding(height: 3), if (entry.spoilerText.isNotEmpty) ...[ Text( 'Content Summary: ${entry.spoilerText}', style: Theme.of(context).textTheme.bodyLarge, ), const VerticalPadding(height: 3) ], HtmlWidget(entry.body), ], ), ), ), const VerticalPadding(), ], ); } Widget buildButtonBar(BuildContext context, TimelineManager manager) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!widget.forEditing) ElevatedButton( onPressed: isSubmitting ? null : () => createStatus(context, manager), child: const Text('Submit'), ), if (widget.forEditing) ElevatedButton( onPressed: isSubmitting ? null : () => editStatus(context, manager), child: const Text('Edit'), ), const HorizontalPadding(), ElevatedButton( onPressed: isSubmitting ? null : () { context.pop(); }, child: const Text('Cancel'), ), ], ); } Widget buildBusyBody(BuildContext context, Widget mainBody, String status) { return Stack( children: [ mainBody, Card( color: Theme.of(context).canvasColor.withOpacity(0.8), child: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const VerticalPadding(), Text(status), ], ), ), ), ), ], ); } Widget buildLinkWithPreview(BuildContext context) { return Column( children: [ Row( children: [ Expanded( child: TextField( controller: linkPreviewController, decoration: InputDecoration( labelText: 'Link with preview (optional)', border: OutlineInputBorder( borderSide: const BorderSide(), borderRadius: BorderRadius.circular(5.0), ), ), ), ), IconButton( onPressed: () async { final newPreviewResult = await getLinkPreview(linkPreviewController.text); newPreviewResult.match( onSuccess: (preview) => setState(() { linkPreviewData = preview; }), onError: (error) { if (mounted) { buildSnackbar( context, 'Error building link preview: $error'); } }); }, icon: Icon(Icons.refresh), ), ], ), const VerticalPadding(), if (linkPreviewData != null) buildPreviewCard(linkPreviewData!), ], ); } Widget buildPreviewCard(LinkPreviewData preview) { return Row( children: [ buildPreviewImageSelector(preview), Expanded( child: ListTile( title: Text(preview.title), subtitle: Text(preview.description.truncate(length: 128)), trailing: IconButton( onPressed: () { setState(() { linkPreviewController.text = ''; linkPreviewData = null; }); }, icon: Icon(Icons.delete), ), ), ), ], ); } Widget buildPreviewImageSelector(LinkPreviewData preview) { const width = 128.0; const height = 128.0; if (preview.selectedImageUrl.isEmpty && preview.availableImageUrls.isEmpty) { return Container( width: width, height: height, color: Colors.grey, ); } final currentImage = Container( width: width, height: height, child: LoginAwareCachedNetworkImage(imageUrl: preview.selectedImageUrl)); if (preview.availableImageUrls.length < 2) { return currentImage; } return Column( children: [ currentImage, // TODO Add in when Friendica no longer stomps on image previews // Row( // mainAxisAlignment: MainAxisAlignment.spaceEvenly, // children: [ // IconButton( // onPressed: () => updateLinkPreviewThumbnail(preview, -1), // icon: Icon(size: iconSize, Icons.arrow_back_ios), // ), // IconButton( // onPressed: () => updateLinkPreviewThumbnail(preview, 1), // icon: Icon(size: iconSize, Icons.arrow_forward_ios), // ), // ], // ) ], ); } Widget buildVisibilitySelector(BuildContext context) { if (widget.forEditing || widget.parentId.isNotEmpty) { return Row( children: [ const Text('Visibility:'), const HorizontalPadding(), visibility.type == VisibilityType.public ? const Icon(Icons.public) : const Icon(Icons.lock), ], ); } final groups = context .watch>() .activeEntry .andThen((tm) => tm.getGroups()) .getValueOrElse(() => []); groups.sort((g1, g2) => g1.name.compareTo(g2.name)); final groupMenuItems = >[]; groupMenuItems.add(DropdownMenuEntry( value: GroupData.followersPseudoGroup, label: GroupData.followersPseudoGroup.name)); groupMenuItems.add(DropdownMenuEntry( value: GroupData('', ''), label: '-', enabled: false)); groupMenuItems.addAll(groups.map((g) => DropdownMenuEntry( value: g, label: g.name, ))); if (!groups.contains(currentGroup)) { currentGroup = null; } return Row( children: [ const Text('Visibility:'), const HorizontalPadding(), DropdownMenu( initialSelection: visibility.type, enabled: !widget.forEditing, onSelected: (value) { setState(() { if (value == VisibilityType.public) { visibility = Visibility.public(); return; } if (value == VisibilityType.private && currentGroup == null) { visibility = Visibility.private(); return; } visibility = Visibility( type: VisibilityType.private, allowedGroupIds: [currentGroup!.id], ); }); }, dropdownMenuEntries: VisibilityType.values .map((v) => DropdownMenuEntry( value: v, label: v.toLabel(), )) .toList(), ), const HorizontalPadding(), if (visibility.type == VisibilityType.private) DropdownMenu( enabled: !widget.forEditing, initialSelection: currentGroup, onSelected: (value) { setState(() { currentGroup = value; visibility = Visibility( type: VisibilityType.private, allowedGroupIds: currentGroup == null ? [] : [currentGroup!.id], ); }); }, dropdownMenuEntries: groupMenuItems, ), ], ); } void updateLinkPreviewThumbnail(LinkPreviewData preview, int increment) { var currentIndex = preview.availableImageUrls.indexOf(preview.selectedImageUrl) + increment; if (currentIndex < 0) { currentIndex = preview.availableImageUrls.length - 1; } if (currentIndex > preview.availableImageUrls.length - 1) { currentIndex = 0; } setState(() { linkPreviewData = preview.copy( selectedImageUrl: preview.availableImageUrls[currentIndex]); }); } }