Skip to content

Commit

Permalink
wip spoilers
Browse files Browse the repository at this point in the history
TODO widget tests
  • Loading branch information
chrisbobbe committed Feb 8, 2024
1 parent 6ffcc45 commit f1f111a
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 0 deletions.
4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
67 changes: 67 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,49 @@ class QuotationNode extends BlockContentNode {
}
}

class SpoilerNode extends BlockContentNode {
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});

final List<BlockContentNode> header;
final List<BlockContentNode> content;

@override
List<DiagnosticsNode> debugDescribeChildren() {
return [
_SpoilerHeaderDiagnosticableNode(header).toDiagnosticsNode(),
_SpoilerContentDiagnosticableNode(content).toDiagnosticsNode(),
];
}
}

class _SpoilerHeaderDiagnosticableNode extends DiagnosticableTree {
_SpoilerHeaderDiagnosticableNode(this.nodes);

final List<BlockContentNode> nodes;

@override
String toStringShort() => 'spoiler header';

@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}

class _SpoilerContentDiagnosticableNode extends DiagnosticableTree {
_SpoilerContentDiagnosticableNode(this.nodes);

final List<BlockContentNode> nodes;

@override
String toStringShort() => 'spoiler content';

@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}

class CodeBlockNode extends BlockContentNode {
const CodeBlockNode(this.spans, {super.debugHtmlNode});

Expand Down Expand Up @@ -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 = () {
Expand Down Expand Up @@ -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);
}
Expand Down
91 changes: 91 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -230,6 +233,94 @@ class ListItemWidget extends StatelessWidget {
}
}

class Spoiler extends StatefulWidget {
const Spoiler({super.key, required this.node});

final SpoilerNode node;

@override
State<Spoiler> createState() => _SpoilerState();
}

class _SpoilerState extends State<Spoiler> with TickerProviderStateMixin {
bool expanded = false;

late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 400), vsync: this);
late final Animation<double> _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});

Expand Down
52 changes: 52 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,58 @@ void main() {
]);
});

group('parse spoilers', () {
testParse('with default header',
// ```spoiler\nhello world\n```
'<div class="spoiler-block"><div class="spoiler-header">\n'
'</div><div class="spoiler-content" aria-hidden="true">\n'
'<p>hello world</p>\n'
'</div></div>',
[const SpoilerNode(
header: [],
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
)],
);

testParse('with plain custom header',
// ```spoiler hello\nworld\n```
'<div class="spoiler-block"><div class="spoiler-header">\n'
'<p>hello</p>\n'
'</div><div class="spoiler-content" aria-hidden="true">\n'
'<p>world</p>\n'
'</div></div>',
[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```
'<div class="spoiler-block"><div class="spoiler-header">\n'
'<p><strong>bold</strong> <a href="https://chat.zulip.org/">czo</a></p>\n'
'</div><div class="spoiler-content" aria-hidden="true">\n'
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
'</div></div>',
[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"
Expand Down

0 comments on commit f1f111a

Please sign in to comment.