Skip to content

Commit f1f111a

Browse files
committed
wip spoilers
TODO widget tests
1 parent 6ffcc45 commit f1f111a

File tree

4 files changed

+214
-0
lines changed

4 files changed

+214
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@
366366
"@serverUrlValidationErrorUnsupportedScheme": {
367367
"description": "Error message when URL has an unsupported scheme."
368368
},
369+
"spoilerDefaultHeaderText": "Spoiler",
370+
"@spoilerDefaultHeaderText": {
371+
"description": "The default header text in a spoiler block ( https://zulip.com/help/spoilers )."
372+
},
369373
"markAllAsReadLabel": "Mark all messages as read",
370374
"@markAllAsReadLabel": {
371375
"description": "Button text to mark messages as read."

lib/model/content.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,49 @@ class QuotationNode extends BlockContentNode {
256256
}
257257
}
258258

259+
class SpoilerNode extends BlockContentNode {
260+
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});
261+
262+
final List<BlockContentNode> header;
263+
final List<BlockContentNode> content;
264+
265+
@override
266+
List<DiagnosticsNode> debugDescribeChildren() {
267+
return [
268+
_SpoilerHeaderDiagnosticableNode(header).toDiagnosticsNode(),
269+
_SpoilerContentDiagnosticableNode(content).toDiagnosticsNode(),
270+
];
271+
}
272+
}
273+
274+
class _SpoilerHeaderDiagnosticableNode extends DiagnosticableTree {
275+
_SpoilerHeaderDiagnosticableNode(this.nodes);
276+
277+
final List<BlockContentNode> nodes;
278+
279+
@override
280+
String toStringShort() => 'spoiler header';
281+
282+
@override
283+
List<DiagnosticsNode> debugDescribeChildren() {
284+
return nodes.map((node) => node.toDiagnosticsNode()).toList();
285+
}
286+
}
287+
288+
class _SpoilerContentDiagnosticableNode extends DiagnosticableTree {
289+
_SpoilerContentDiagnosticableNode(this.nodes);
290+
291+
final List<BlockContentNode> nodes;
292+
293+
@override
294+
String toStringShort() => 'spoiler content';
295+
296+
@override
297+
List<DiagnosticsNode> debugDescribeChildren() {
298+
return nodes.map((node) => node.toDiagnosticsNode()).toList();
299+
}
300+
}
301+
259302
class CodeBlockNode extends BlockContentNode {
260303
const CodeBlockNode(this.spans, {super.debugHtmlNode});
261304

@@ -796,6 +839,26 @@ class _ZulipContentParser {
796839
return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
797840
}
798841

842+
BlockContentNode parseSpoilerNode(dom.Element divElement) {
843+
assert(_debugParserContext == _ParserContext.block);
844+
assert(divElement.localName == 'div'
845+
&& divElement.className == 'spoiler-block');
846+
847+
if (divElement.nodes case [
848+
dom.Element(
849+
localName: 'div', className: 'spoiler-header', nodes: var headerNodes),
850+
dom.Element(
851+
localName: 'div', className: 'spoiler-content', nodes: var contentNodes),
852+
]) {
853+
return SpoilerNode(
854+
header: parseBlockContentList(headerNodes),
855+
content: parseBlockContentList(contentNodes),
856+
);
857+
} else {
858+
return UnimplementedBlockContentNode(htmlNode: divElement);
859+
}
860+
}
861+
799862
BlockContentNode parseCodeBlock(dom.Element divElement) {
800863
assert(_debugParserContext == _ParserContext.block);
801864
final mainElement = () {
@@ -964,6 +1027,10 @@ class _ZulipContentParser {
9641027
parseBlockContentList(element.nodes));
9651028
}
9661029

1030+
if (localName == 'div' && className == 'spoiler-block') {
1031+
return parseSpoilerNode(element);
1032+
}
1033+
9671034
if (localName == 'div' && className == 'codehilite') {
9681035
return parseCodeBlock(element);
9691036
}

lib/widgets/content.dart

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
44
import 'package:html/dom.dart' as dom;
55
import 'package:intl/intl.dart';
6+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
67

78
import '../api/core.dart';
89
import '../api/model/model.dart';
@@ -80,6 +81,8 @@ class BlockContentList extends StatelessWidget {
8081
return Quotation(node: node);
8182
} else if (node is ListNode) {
8283
return ListNodeWidget(node: node);
84+
} else if (node is SpoilerNode) {
85+
return Spoiler(node: node);
8386
} else if (node is CodeBlockNode) {
8487
return CodeBlock(node: node);
8588
} else if (node is MathBlockNode) {
@@ -230,6 +233,94 @@ class ListItemWidget extends StatelessWidget {
230233
}
231234
}
232235

236+
class Spoiler extends StatefulWidget {
237+
const Spoiler({super.key, required this.node});
238+
239+
final SpoilerNode node;
240+
241+
@override
242+
State<Spoiler> createState() => _SpoilerState();
243+
}
244+
245+
class _SpoilerState extends State<Spoiler> with TickerProviderStateMixin {
246+
bool expanded = false;
247+
248+
late final AnimationController _controller = AnimationController(
249+
duration: const Duration(milliseconds: 400), vsync: this);
250+
late final Animation<double> _animation = CurvedAnimation(
251+
parent: _controller, curve: Curves.easeInOut);
252+
253+
@override
254+
void dispose() {
255+
_controller.dispose();
256+
super.dispose();
257+
}
258+
259+
void _handleTap() {
260+
setState(() {
261+
if (!expanded) {
262+
_controller.forward();
263+
expanded = true;
264+
} else {
265+
_controller.reverse();
266+
expanded = false;
267+
}
268+
});
269+
}
270+
271+
@override
272+
Widget build(BuildContext context) {
273+
final zulipLocalizations = ZulipLocalizations.of(context);
274+
final header = widget.node.header;
275+
final effectiveHeader = header.isNotEmpty
276+
? header
277+
: [ParagraphNode(links: null,
278+
nodes: [TextNode(zulipLocalizations.spoilerDefaultHeaderText)])];
279+
return Padding(
280+
padding: const EdgeInsets.fromLTRB(0, 5, 0, 15),
281+
child: DecoratedBox(
282+
decoration: BoxDecoration(
283+
border: Border.all(color: const Color(0xff808080)),
284+
borderRadius: BorderRadius.circular(10),
285+
),
286+
child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(10, 2, 8, 2),
287+
child: Column(
288+
children: [
289+
GestureDetector(
290+
behavior: HitTestBehavior.translucent,
291+
onTap: _handleTap,
292+
child: Padding(
293+
padding: const EdgeInsets.all(5),
294+
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
295+
Expanded(
296+
child: DefaultTextStyle.merge(
297+
style: weightVariableTextStyle(context, wght: 700),
298+
child: BlockContentList(
299+
nodes: effectiveHeader))),
300+
RotationTransition(
301+
turns: _animation.drive(Tween(begin: 0, end: 0.5)),
302+
child: const Icon(color: Color(0xffd4d4d4), size: 25,
303+
Icons.expand_more)),
304+
]))),
305+
FadeTransition(
306+
opacity: _animation,
307+
child: const SizedBox(height: 0, width: double.infinity,
308+
child: DecoratedBox(
309+
decoration: BoxDecoration(
310+
border: Border(
311+
bottom: BorderSide(width: 1, color: Color(0xff808080))))))),
312+
SizeTransition(
313+
sizeFactor: _animation,
314+
axis: Axis.vertical,
315+
axisAlignment: -1,
316+
child: Padding(
317+
padding: const EdgeInsets.all(5),
318+
child: BlockContentList(nodes: widget.node.content))),
319+
]))));
320+
}
321+
}
322+
323+
233324
class MessageImage extends StatelessWidget {
234325
const MessageImage({super.key, required this.node});
235326

test/model/content_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,58 @@ void main() {
324324
]);
325325
});
326326

327+
group('parse spoilers', () {
328+
testParse('with default header',
329+
// ```spoiler\nhello world\n```
330+
'<div class="spoiler-block"><div class="spoiler-header">\n'
331+
'</div><div class="spoiler-content" aria-hidden="true">\n'
332+
'<p>hello world</p>\n'
333+
'</div></div>',
334+
[const SpoilerNode(
335+
header: [],
336+
content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
337+
)],
338+
);
339+
340+
testParse('with plain custom header',
341+
// ```spoiler hello\nworld\n```
342+
'<div class="spoiler-block"><div class="spoiler-header">\n'
343+
'<p>hello</p>\n'
344+
'</div><div class="spoiler-content" aria-hidden="true">\n'
345+
'<p>world</p>\n'
346+
'</div></div>',
347+
[const SpoilerNode(
348+
header: [ParagraphNode(links: null, nodes: [TextNode('hello')])],
349+
content: [ParagraphNode(links: null, nodes: [TextNode('world')])],
350+
)],
351+
);
352+
353+
testParse('with rich header and content',
354+
// ```spoiler **bold** [czo](https://chat.zulip.org/)\n*italic* [zulip](https://zulip.com/)\n```
355+
'<div class="spoiler-block"><div class="spoiler-header">\n'
356+
'<p><strong>bold</strong> <a href="https://chat.zulip.org/">czo</a></p>\n'
357+
'</div><div class="spoiler-content" aria-hidden="true">\n'
358+
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
359+
'</div></div>',
360+
[const SpoilerNode(
361+
header: [ParagraphNode(
362+
links: [LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')])],
363+
nodes: [
364+
StrongNode(nodes: [TextNode('bold')]),
365+
TextNode(' '),
366+
LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')])
367+
])],
368+
content: [ParagraphNode(
369+
links: [LinkNode(url: 'https://chat.zulip.org/', nodes: [TextNode('czo')])],
370+
nodes: [
371+
EmphasisNode(nodes: [TextNode('italic')]),
372+
TextNode(' '),
373+
LinkNode(url: 'https://zulip.com/', nodes: [TextNode('zulip')])
374+
])],
375+
)],
376+
);
377+
});
378+
327379
group('track links inside block-inline containers', () {
328380
testParse('multiple links in paragraph',
329381
// "before[text](/there)mid[other](/else)after"

0 commit comments

Comments
 (0)