relatica/lib/screens/editor.dart

650 wiersze
20 KiB
Dart

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<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends State<EditorScreen> {
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 = <ImageEntry>[];
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<ActiveProfileSelector<TimelineManager>>()
.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<ActiveProfileSelector<TimelineManager>>()
.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<void> 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<void> 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<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.value;
final vc = getIt<FriendicaVersionChecker>();
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<ActiveProfileSelector<TimelineManager>>()
.activeEntry
.andThen((tm) => tm.getGroups())
.getValueOrElse(() => []);
groups.sort((g1, g2) => g1.name.compareTo(g2.name));
final groupMenuItems = <DropdownMenuEntry<GroupData>>[];
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<VisibilityType>(
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<GroupData>(
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]);
});
}
}