diff --git a/LICENSE b/LICENSE index c7990a936..df0a63536 100644 --- a/LICENSE +++ b/LICENSE @@ -662,4 +662,5 @@ For more information on this, and how to apply and follow the GNU AGPL, see Portions of this software are copyright of their respective authors and released under the MIT license: -- marquee_widget.dart, Copyright (c) 2018 Marcel Garus \ No newline at end of file +- marquee_widget.dart, Copyright (c) 2018 Marcel Garus +- conditional_parent_widget.dart, Copyright (c) 2023 ltOgt \ No newline at end of file diff --git a/lib/comment/view/create_comment_page.dart b/lib/comment/view/create_comment_page.dart index fd57568c1..561fa5558 100644 --- a/lib/comment/view/create_comment_page.dart +++ b/lib/comment/view/create_comment_page.dart @@ -26,6 +26,7 @@ import 'package:thunder/shared/input_dialogs.dart'; import 'package:thunder/shared/language_selector.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/user/widgets/user_indicator.dart'; +import 'package:thunder/utils/colors.dart'; import 'package:thunder/utils/instance.dart'; import 'package:thunder/utils/media/image.dart'; @@ -93,6 +94,9 @@ class _CreateCommentPageState extends State { /// Used for restoring and saving drafts SharedPreferences? sharedPreferences; + /// Whether to view source for posts or comments + bool viewSource = false; + @override void initState() { super.initState(); @@ -276,7 +280,7 @@ class _CreateCommentPageState extends State { child: Container( padding: const EdgeInsets.only(top: 6.0, bottom: 12.0), decoration: BoxDecoration( - color: theme.dividerColor.withOpacity(0.25), + color: getBackgroundColor(context), borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), child: PostSubview( @@ -284,10 +288,12 @@ class _CreateCommentPageState extends State { postViewMedia: widget.postViewMedia!, crossPosts: const [], moderators: const [], - viewSource: false, + viewSource: viewSource, + onViewSourceToggled: () => setState(() => viewSource = !viewSource), showQuickPostActionBar: false, showExpandableButton: false, selectable: true, + showReplyEditorButtons: true, ), ), ), @@ -296,7 +302,7 @@ class _CreateCommentPageState extends State { padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 16.0), child: Container( decoration: BoxDecoration( - color: theme.dividerColor.withOpacity(0.25), + color: getBackgroundColor(context), borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), child: CommentContent( @@ -310,10 +316,11 @@ class _CreateCommentPageState extends State { isUserLoggedIn: true, isOwnComment: false, isHidden: false, - viewSource: false, - onViewSourceToggled: () {}, + viewSource: viewSource, + onViewSourceToggled: () => setState(() => viewSource = !viewSource), disableActions: true, selectable: true, + showReplyEditorButtons: true, ), ), ), @@ -343,7 +350,7 @@ class _CreateCommentPageState extends State { width: double.infinity, padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant, + color: getBackgroundColor(context), borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), child: CommonMarkdownBody(body: _bodyTextController.text, isComment: true), diff --git a/lib/community/pages/create_post_page.dart b/lib/community/pages/create_post_page.dart index a12b4b50a..69083ad07 100644 --- a/lib/community/pages/create_post_page.dart +++ b/lib/community/pages/create_post_page.dart @@ -38,6 +38,7 @@ import 'package:thunder/shared/link_preview_card.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/user/utils/restore_user.dart'; import 'package:thunder/user/widgets/user_selector.dart'; +import 'package:thunder/utils/colors.dart'; import 'package:thunder/utils/debounce.dart'; import 'package:thunder/utils/instance.dart'; import 'package:thunder/utils/media/image.dart'; @@ -515,26 +516,29 @@ class _CreatePostPageState extends State { ], ), const SizedBox(height: 10), - showPreview - ? Container( - constraints: const BoxConstraints(minWidth: double.infinity), - decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.all(12), - child: SingleChildScrollView( - child: CommonMarkdownBody( - body: _bodyTextController.text, - isComment: true, - ), - ), - ) - : MarkdownTextInputField( - controller: _bodyTextController, - focusNode: _bodyFocusNode, - label: l10n.postBody, - minLines: 8, - maxLines: null, - textStyle: theme.textTheme.bodyLarge, - ), + AnimatedCrossFade( + firstChild: Container( + margin: const EdgeInsets.only(top: 8.0), + width: double.infinity, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: getBackgroundColor(context), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: CommonMarkdownBody(body: _bodyTextController.text, isComment: true), + ), + secondChild: MarkdownTextInputField( + controller: _bodyTextController, + focusNode: _bodyFocusNode, + label: l10n.postBody, + minLines: 8, + maxLines: null, + textStyle: theme.textTheme.bodyLarge, + ), + crossFadeState: showPreview ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 120), + excludeBottomFocus: false, + ), ]), ), ), diff --git a/lib/post/widgets/post_view.dart b/lib/post/widgets/post_view.dart index 6859a8ae9..af6f1a462 100644 --- a/lib/post/widgets/post_view.dart +++ b/lib/post/widgets/post_view.dart @@ -1,4 +1,7 @@ // Flutter imports +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -34,8 +37,11 @@ import 'package:thunder/shared/avatars/user_avatar.dart'; import 'package:thunder/shared/chips/community_chip.dart'; import 'package:thunder/shared/chips/user_chip.dart'; import 'package:thunder/shared/common_markdown_body.dart'; +import 'package:thunder/shared/conditional_parent_widget.dart'; import 'package:thunder/shared/cross_posts.dart'; +import 'package:thunder/shared/divider.dart'; import 'package:thunder/shared/media_view.dart'; +import 'package:thunder/shared/reply_to_preview_actions.dart'; import 'package:thunder/shared/text/scalable_text.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; @@ -46,9 +52,11 @@ class PostSubview extends StatefulWidget { final List? moderators; final List? crossPosts; final bool viewSource; + final void Function()? onViewSourceToggled; final bool showQuickPostActionBar; final bool showExpandableButton; final bool selectable; + final bool showReplyEditorButtons; const PostSubview({ super.key, @@ -58,9 +66,11 @@ class PostSubview extends StatefulWidget { required this.moderators, required this.crossPosts, required this.viewSource, + this.onViewSourceToggled, this.showQuickPostActionBar = true, this.showExpandableButton = true, this.selectable = false, + this.showReplyEditorButtons = false, }); @override @@ -70,6 +80,7 @@ class PostSubview extends StatefulWidget { class _PostSubviewState extends State with SingleTickerProviderStateMixin { final ExpandableController expandableController = ExpandableController(initialExpanded: true); late PostViewMedia postViewMedia; + final FocusNode _selectableRegionFocusNode = FocusNode(); @override void initState() { @@ -173,16 +184,32 @@ class _PostSubviewState extends State with SingleTickerProviderStat ), expanded: Padding( padding: const EdgeInsets.only(top: 8.0), - child: widget.viewSource - ? ScalableText( - post.body ?? '', - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - fontScale: thunderState.contentFontSizeScale, - ) - : CommonMarkdownBody( - body: post.body ?? '', - isSelectableText: widget.selectable, - ), + child: ConditionalParentWidget( + condition: widget.selectable, + parentBuilder: (child) { + return SelectableRegion( + focusNode: _selectableRegionFocusNode, + // See comments on [SelectableTextModal] regarding the next two properties + selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, + contextMenuBuilder: (context, selectableRegionState) { + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors, + ); + }, + child: child, + ); + }, + child: widget.viewSource + ? ScalableText( + post.body ?? '', + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + fontScale: thunderState.contentFontSizeScale, + ) + : CommonMarkdownBody( + body: post.body ?? '', + ), + ), ), ), const SizedBox(height: 16.0), @@ -228,6 +255,14 @@ class _PostSubviewState extends State with SingleTickerProviderStat hasBeenEdited: postViewMedia.postView.post.updated != null ? true : false, url: postViewMedia.media.firstOrNull != null ? postViewMedia.media.first.originalUrl : null, ), + if (widget.showReplyEditorButtons && widget.postViewMedia.postView.post.body?.isNotEmpty == true) ...[ + const ThunderDivider(sliver: false, padding: false), + ReplyToPreviewActions( + onViewSourceToggled: widget.onViewSourceToggled, + viewSource: widget.viewSource, + text: widget.postViewMedia.postView.post.body!, + ), + ], ], ), ), @@ -311,7 +346,7 @@ class _PostSubviewState extends State with SingleTickerProviderStat }, ), ), - ] + ], ], ), ), diff --git a/lib/settings/pages/appearance_settings_page.dart b/lib/settings/pages/appearance_settings_page.dart index a3ef49500..8f824960a 100644 --- a/lib/settings/pages/appearance_settings_page.dart +++ b/lib/settings/pages/appearance_settings_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/shared/divider.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/constants.dart'; @@ -43,15 +44,7 @@ class AppearanceSettingsPage extends StatelessWidget { ], ), ), - SliverToBoxAdapter( - child: Divider( - indent: 32.0, - height: 32.0, - endIndent: 32.0, - thickness: 2.0, - color: theme.dividerColor.withOpacity(0.6), - ), - ), + const ThunderDivider(sliver: true), SliverList( delegate: SliverChildListDelegate( [ diff --git a/lib/settings/pages/debug_settings_page.dart b/lib/settings/pages/debug_settings_page.dart index 4acde0d5d..d12ac0814 100644 --- a/lib/settings/pages/debug_settings_page.dart +++ b/lib/settings/pages/debug_settings_page.dart @@ -20,6 +20,7 @@ import 'package:thunder/notification/shared/android_notification.dart'; import 'package:thunder/notification/utils/local_notifications.dart'; import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/divider.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/settings/widgets/settings_list_tile.dart'; @@ -167,15 +168,7 @@ class _DebugSettingsPageState extends State { }, ), ), - SliverToBoxAdapter( - child: Divider( - indent: 32.0, - height: 32.0, - endIndent: 32.0, - thickness: 2.0, - color: theme.dividerColor.withOpacity(0.6), - ), - ), + const ThunderDivider(sliver: true), SliverToBoxAdapter( child: FutureBuilder( future: getExtendedImageCacheSize(), @@ -382,15 +375,7 @@ class _DebugSettingsPageState extends State { ), ], ], - SliverToBoxAdapter( - child: Divider( - indent: 32.0, - height: 32.0, - endIndent: 32.0, - thickness: 2.0, - color: theme.dividerColor.withOpacity(0.6), - ), - ), + const ThunderDivider(sliver: true), SliverToBoxAdapter( child: SettingsListTile( icon: Icons.edit_notifications_rounded, diff --git a/lib/shared/comment_content.dart b/lib/shared/comment_content.dart index 94f48bbb5..45c741e8f 100644 --- a/lib/shared/comment_content.dart +++ b/lib/shared/comment_content.dart @@ -1,8 +1,15 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:thunder/comment/utils/comment.dart'; import 'package:thunder/shared/common_markdown_body.dart'; +import 'package:thunder/shared/conditional_parent_widget.dart'; +import 'package:thunder/shared/divider.dart'; +import 'package:thunder/shared/reply_to_preview_actions.dart'; import 'package:thunder/shared/text/scalable_text.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; @@ -29,6 +36,7 @@ class CommentContent extends StatefulWidget { final bool viewSource; final void Function() onViewSourceToggled; final bool selectable; + final bool showReplyEditorButtons; const CommentContent({ super.key, @@ -49,6 +57,7 @@ class CommentContent extends StatefulWidget { required this.viewSource, required this.onViewSourceToggled, this.selectable = false, + this.showReplyEditorButtons = false, }); @override @@ -70,6 +79,8 @@ class _CommentContentState extends State with SingleTickerProvid curve: Curves.fastOutSlowIn, )); + final FocusNode _selectableRegionFocusNode = FocusNode(); + @override Widget build(BuildContext context) { final ThunderState state = context.read().state; @@ -110,17 +121,33 @@ class _CommentContentState extends State with SingleTickerProvid children: [ Padding( padding: EdgeInsets.only(top: 0, right: 8.0, left: 8.0, bottom: (state.showCommentButtonActions && widget.isUserLoggedIn && !widget.disableActions) ? 0.0 : 8.0), - child: widget.viewSource - ? ScalableText( - cleanCommentContent(widget.comment.comment), - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - fontScale: state.contentFontSizeScale, - ) - : CommonMarkdownBody( - body: cleanCommentContent(widget.comment.comment), - isComment: true, - isSelectableText: widget.selectable, - ), + child: ConditionalParentWidget( + condition: widget.selectable, + parentBuilder: (child) { + return SelectableRegion( + focusNode: _selectableRegionFocusNode, + // See comments on [SelectableTextModal] regarding the next two properties + selectionControls: Platform.isIOS ? cupertinoTextSelectionHandleControls : materialTextSelectionHandleControls, + contextMenuBuilder: (context, selectableRegionState) { + return AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors, + ); + }, + child: child, + ); + }, + child: widget.viewSource + ? ScalableText( + cleanCommentContent(widget.comment.comment), + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + fontScale: state.contentFontSizeScale, + ) + : CommonMarkdownBody( + body: cleanCommentContent(widget.comment.comment), + isComment: true, + ), + ), ), if (state.showCommentButtonActions && widget.isUserLoggedIn && !widget.disableActions) Padding( @@ -140,6 +167,20 @@ class _CommentContentState extends State with SingleTickerProvid ], ), ), + if (widget.showReplyEditorButtons && widget.comment.comment.content.isNotEmpty == true) ...[ + const Padding( + padding: EdgeInsets.only(left: 8.0, right: 8.0), + child: ThunderDivider(sliver: false, padding: false), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), + child: ReplyToPreviewActions( + onViewSourceToggled: widget.onViewSourceToggled, + viewSource: widget.viewSource, + text: cleanCommentContent(widget.comment.comment), + ), + ), + ], ], ), ); diff --git a/lib/shared/common_markdown_body.dart b/lib/shared/common_markdown_body.dart index ce0af74fc..03f4d3b2f 100644 --- a/lib/shared/common_markdown_body.dart +++ b/lib/shared/common_markdown_body.dart @@ -25,9 +25,6 @@ class CommonMarkdownBody extends StatelessWidget { /// Whether to hide the markdown content. This is mainly used for spoiler markdown final bool hideContent; - /// Whether the text is selectable - defaults to false - final bool isSelectableText; - /// Indicates if the given markdown is a comment. Depending on the markdown content, different text scaling may be applied /// TODO: This should be converted to an enum of possible markdown content (e.g., post, comment, general, metadata, etc.) to allow for more fined-tuned scaling of text final bool? isComment; @@ -38,7 +35,6 @@ class CommonMarkdownBody extends StatelessWidget { super.key, required this.body, this.hideContent = false, - this.isSelectableText = false, this.isComment, this.imageMaxWidth, }); @@ -151,7 +147,6 @@ class CommonMarkdownBody extends StatelessWidget { }, ); }, - selectable: isSelectableText, onTapLink: (text, url, title) => handleLinkTap(context, state, text, url), onLongPressLink: (text, url, title) => handleLinkLongPress(context, state, text, url), styleSheet: hideContent diff --git a/lib/shared/conditional_parent_widget.dart b/lib/shared/conditional_parent_widget.dart new file mode 100644 index 000000000..658394803 --- /dev/null +++ b/lib/shared/conditional_parent_widget.dart @@ -0,0 +1,80 @@ +import 'package:flutter/widgets.dart'; + +typedef ParentBuilder = Widget Function(Widget child); + +/// {@template conditionalParent} +/// Conditionally wrap a subtree with a parent widget without breaking the code tree. +/// +/// - [condition] controls how/whether the [child] is wrapped. +/// - [child] the subtree that should always be build. +/// - [parentBuilder] build this parent with the subtree [child] if [condition] is `true`. +/// - [parentBuilderElse] build this parent with the subtree [child] if [condition] is `false`. +/// return [child] if [condition] is `false` and [parentBuilderElse] is null. +/// +/// ___________ +/// Tree will look like: +/// ```dart +/// return SomeWidget( +/// child: SomeOtherWidget( +/// child: ConditionalParentWidget( +/// condition: shouldIncludeParent, +/// parentBuilder: (Widget child) => SomeParentWidget(child: child), +/// child: Widget1( +/// child: Widget2( +/// child: Widget3(), +/// ), +/// ), +/// ), +/// ), +/// ); +/// ``` +/// +/// ___________ +/// Instead of: +/// ```dart +/// Widget child = Widget1( +/// child: Widget2( +/// child: Widget3(), +/// ), +/// ); +/// +/// return SomeWidget( +/// child: SomeOtherWidget( +/// child: shouldIncludeParent +/// ? SomeParentWidget(child: child) +/// : child +/// ), +/// ); +/// ``` +/// {@endtemplate} +class ConditionalParentWidget extends StatelessWidget { + /// {@macro conditionalParent} + const ConditionalParentWidget({ + super.key, + required this.condition, + required this.parentBuilder, + this.parentBuilderElse, + required this.child, + }); + + /// The [condition] which controls how/whether the [child] is wrapped. + final bool condition; + + /// The [child] which should be conditionally wrapped. + final Widget child; + + /// Builder to wrap [child] when [condition] is `true`. + final ParentBuilder? parentBuilder; + + /// Optional builder to wrap [child] when [condition] is `false`. + /// + /// [child] is returned directly when this is `null`. + final ParentBuilder? parentBuilderElse; + + @override + Widget build(BuildContext context) { + return condition // + ? parentBuilder?.call(child) ?? child + : parentBuilderElse?.call(child) ?? child; + } +} diff --git a/lib/shared/divider.dart b/lib/shared/divider.dart new file mode 100644 index 000000000..74ca3b80e --- /dev/null +++ b/lib/shared/divider.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:thunder/shared/conditional_parent_widget.dart'; + +class ThunderDivider extends StatelessWidget { + /// Whether to wrap the returned widget in a [SliverToBoxAdapter] + final bool sliver; + + /// Whether to apply padding around the divider + final bool padding; + + const ThunderDivider({super.key, required this.sliver, this.padding = true}); + + @override + Widget build(BuildContext context) => ConditionalParentWidget( + condition: sliver, + parentBuilder: (Widget child) => SliverToBoxAdapter(child: child), + child: Divider( + indent: padding ? 32.0 : 0, + height: padding ? 32.0 : 16, + endIndent: padding ? 32.0 : 0, + thickness: 2.0, + color: Theme.of(context).dividerColor.withOpacity(0.6), + ), + ); +} diff --git a/lib/shared/reply_to_preview_actions.dart b/lib/shared/reply_to_preview_actions.dart new file mode 100644 index 000000000..973196a02 --- /dev/null +++ b/lib/shared/reply_to_preview_actions.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/shared/snackbar.dart'; + +/// Defines a widget which provides action buttons for the preview of a post or comment when replying +class ReplyToPreviewActions extends StatelessWidget { + final void Function()? onViewSourceToggled; + final bool viewSource; + final String text; + + const ReplyToPreviewActions({ + super.key, + required this.onViewSourceToggled, + required this.viewSource, + required this.text, + }); + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + + return Material( + color: Colors.transparent, + child: Row( + children: [ + InkWell( + onTap: onViewSourceToggled, + borderRadius: BorderRadius.circular(10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + const Icon(Icons.edit_document, size: 15), + const SizedBox(width: 5), + Text(viewSource ? l10n.viewOriginal : l10n.viewSource), + const SizedBox(width: 5), + ], + ), + ), + const SizedBox(width: 12.0), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: text)).then((_) { + showSnackbar(AppLocalizations.of(context)!.copiedToClipboard); + }); + }, + borderRadius: BorderRadius.circular(10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + const Icon(Icons.copy_rounded, size: 15), + const SizedBox(width: 5), + Text(l10n.copyText), + const SizedBox(width: 5), + ], + ), + ), + ], + ), + ); + } +}