From f1f111aa0bb48f6933ed1a4282791dc505bb7b07 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 29 Jan 2024 17:00:37 -0800 Subject: [PATCH] wip spoilers TODO widget tests --- assets/l10n/app_en.arb | 4 ++ lib/model/content.dart | 67 ++++++++++++++++++++++++++ lib/widgets/content.dart | 91 ++++++++++++++++++++++++++++++++++++ test/model/content_test.dart | 52 +++++++++++++++++++++ 4 files changed, 214 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index edb01af23dc..95b924fa18c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -366,6 +366,10 @@ "@serverUrlValidationErrorUnsupportedScheme": { "description": "Error message when URL has an unsupported scheme." }, + "spoilerDefaultHeaderText": "Spoiler", + "@spoilerDefaultHeaderText": { + "description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )." + }, "markAllAsReadLabel": "Mark all messages as read", "@markAllAsReadLabel": { "description": "Button text to mark messages as read." diff --git a/lib/model/content.dart b/lib/model/content.dart index 925a3e64f32..f8bec4941e5 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -256,6 +256,49 @@ class QuotationNode extends BlockContentNode { } } +class SpoilerNode extends BlockContentNode { + const SpoilerNode({super.debugHtmlNode, required this.header, required this.content}); + + final List header; + final List content; + + @override + List debugDescribeChildren() { + return [ + _SpoilerHeaderDiagnosticableNode(header).toDiagnosticsNode(), + _SpoilerContentDiagnosticableNode(content).toDiagnosticsNode(), + ]; + } +} + +class _SpoilerHeaderDiagnosticableNode extends DiagnosticableTree { + _SpoilerHeaderDiagnosticableNode(this.nodes); + + final List nodes; + + @override + String toStringShort() => 'spoiler header'; + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + +class _SpoilerContentDiagnosticableNode extends DiagnosticableTree { + _SpoilerContentDiagnosticableNode(this.nodes); + + final List nodes; + + @override + String toStringShort() => 'spoiler content'; + + @override + List debugDescribeChildren() { + return nodes.map((node) => node.toDiagnosticsNode()).toList(); + } +} + class CodeBlockNode extends BlockContentNode { const CodeBlockNode(this.spans, {super.debugHtmlNode}); @@ -796,6 +839,26 @@ class _ZulipContentParser { return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode); } + BlockContentNode parseSpoilerNode(dom.Element divElement) { + assert(_debugParserContext == _ParserContext.block); + assert(divElement.localName == 'div' + && divElement.className == 'spoiler-block'); + + if (divElement.nodes case [ + dom.Element( + localName: 'div', className: 'spoiler-header', nodes: var headerNodes), + dom.Element( + localName: 'div', className: 'spoiler-content', nodes: var contentNodes), + ]) { + return SpoilerNode( + header: parseBlockContentList(headerNodes), + content: parseBlockContentList(contentNodes), + ); + } else { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + } + BlockContentNode parseCodeBlock(dom.Element divElement) { assert(_debugParserContext == _ParserContext.block); final mainElement = () { @@ -964,6 +1027,10 @@ class _ZulipContentParser { parseBlockContentList(element.nodes)); } + if (localName == 'div' && className == 'spoiler-block') { + return parseSpoilerNode(element); + } + if (localName == 'div' && className == 'codehilite') { return parseCodeBlock(element); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 365153d25b5..106ce469bcc 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:html/dom.dart' as dom; import 'package:intl/intl.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/core.dart'; import '../api/model/model.dart'; @@ -80,6 +81,8 @@ class BlockContentList extends StatelessWidget { return Quotation(node: node); } else if (node is ListNode) { return ListNodeWidget(node: node); + } else if (node is SpoilerNode) { + return Spoiler(node: node); } else if (node is CodeBlockNode) { return CodeBlock(node: node); } else if (node is MathBlockNode) { @@ -230,6 +233,94 @@ class ListItemWidget extends StatelessWidget { } } +class Spoiler extends StatefulWidget { + const Spoiler({super.key, required this.node}); + + final SpoilerNode node; + + @override + State createState() => _SpoilerState(); +} + +class _SpoilerState extends State with TickerProviderStateMixin { + bool expanded = false; + + late final AnimationController _controller = AnimationController( + duration: const Duration(milliseconds: 400), vsync: this); + late final Animation _animation = CurvedAnimation( + parent: _controller, curve: Curves.easeInOut); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTap() { + setState(() { + if (!expanded) { + _controller.forward(); + expanded = true; + } else { + _controller.reverse(); + expanded = false; + } + }); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final header = widget.node.header; + final effectiveHeader = header.isNotEmpty + ? header + : [ParagraphNode(links: null, + nodes: [TextNode(zulipLocalizations.spoilerDefaultHeaderText)])]; + return Padding( + padding: const EdgeInsets.fromLTRB(0, 5, 0, 15), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xff808080)), + borderRadius: BorderRadius.circular(10), + ), + child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(10, 2, 8, 2), + child: Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleTap, + child: Padding( + padding: const EdgeInsets.all(5), + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + child: DefaultTextStyle.merge( + style: weightVariableTextStyle(context, wght: 700), + child: BlockContentList( + nodes: effectiveHeader))), + RotationTransition( + turns: _animation.drive(Tween(begin: 0, end: 0.5)), + child: const Icon(color: Color(0xffd4d4d4), size: 25, + Icons.expand_more)), + ]))), + FadeTransition( + opacity: _animation, + child: const SizedBox(height: 0, width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(width: 1, color: Color(0xff808080))))))), + SizeTransition( + sizeFactor: _animation, + axis: Axis.vertical, + axisAlignment: -1, + child: Padding( + padding: const EdgeInsets.all(5), + child: BlockContentList(nodes: widget.node.content))), + ])))); + } +} + + class MessageImage extends StatelessWidget { const MessageImage({super.key, required this.node}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index bdfe8dbfdab..1365afd2ee8 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -324,6 +324,58 @@ void main() { ]); }); + group('parse spoilers', () { + testParse('with default header', + // ```spoiler\nhello world\n``` + '
\n' + '
', + [const SpoilerNode( + header: [], + content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])], + )], + ); + + testParse('with plain custom header', + // ```spoiler hello\nworld\n``` + '
\n' + '

hello

\n' + '
', + [const SpoilerNode( + header: [ParagraphNode(links: null, nodes: [TextNode('hello')])], + content: [ParagraphNode(links: null, nodes: [TextNode('world')])], + )], + ); + + testParse('with rich header and content', + // ```spoiler **bold** [czo](https://chat.zulip.org/)\n*italic* [zulip](https://zulip.com/)\n``` + '
\n' + '

bold czo

\n' + '
', + [const SpoilerNode( + header: [ParagraphNode( + links: [LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')])], + nodes: [ + StrongNode(nodes: [TextNode('bold')]), + TextNode(' '), + LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')]) + ])], + content: [ParagraphNode( + links: [LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')])], + nodes: [ + EmphasisNode(nodes: [TextNode('italic')]), + TextNode(' '), + LinkNode(url: 'https://zulip.com/', nodes: [TextNode('zulip')]) + ])], + )], + ); + }); + group('track links inside block-inline containers', () { testParse('multiple links in paragraph', // "before[text](/there)mid[other](/else)after"