Skip to content

Commit c296615

Browse files
committed
wip spoilers
TODO tests
1 parent 107342f commit c296615

File tree

3 files changed

+142
-0
lines changed

3 files changed

+142
-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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:collection/collection.dart';
12
import 'package:flutter/foundation.dart';
23
import 'package:html/dom.dart' as dom;
34
import 'package:html/parser.dart';
@@ -256,6 +257,13 @@ class QuotationNode extends BlockContentNode {
256257
}
257258
}
258259

260+
class SpoilerNode extends BlockContentNode {
261+
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});
262+
263+
final List<BlockContentNode> header;
264+
final List<BlockContentNode> content;
265+
}
266+
259267
class CodeBlockNode extends BlockContentNode {
260268
const CodeBlockNode(this.spans, {super.debugHtmlNode});
261269

@@ -760,6 +768,36 @@ class _ZulipContentParser {
760768
return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
761769
}
762770

771+
BlockContentNode parseSpoilerNode(dom.Element divElement) {
772+
List<BlockContentNode> title;
773+
List<BlockContentNode> content;
774+
775+
assert(_debugParserContext == _ParserContext.block);
776+
assert(divElement.localName == 'div'
777+
&& divElement.className == 'spoiler-block');
778+
779+
BlockContentNode unimplemented() => UnimplementedBlockContentNode(htmlNode: divElement);
780+
781+
if (divElement.nodes case [
782+
dom.Element(
783+
localName: 'div', className: 'spoiler-header',
784+
nodes: var headerNodes,
785+
),
786+
dom.Element(
787+
localName: 'div', className: 'spoiler-content',
788+
nodes: var contentNodes,
789+
)
790+
]) {
791+
return SpoilerNode(
792+
header: parseBlockContentList(headerNodes),
793+
content: parseBlockContentList(contentNodes),
794+
);
795+
} else {
796+
return unimplemented();
797+
}
798+
799+
}
800+
763801
BlockContentNode parseCodeBlock(dom.Element divElement) {
764802
assert(_debugParserContext == _ParserContext.block);
765803
final mainElement = () {
@@ -930,6 +968,10 @@ class _ZulipContentParser {
930968
parseBlockContentList(element.nodes));
931969
}
932970

971+
if (localName == 'div' && className == 'spoiler-block') {
972+
return parseSpoilerNode(element);
973+
}
974+
933975
if (localName == 'div' && className == 'codehilite') {
934976
return parseCodeBlock(element);
935977
}

lib/widgets/content.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import 'package:flutter/cupertino.dart';
12
import 'package:flutter/gestures.dart';
23
import 'package:flutter/material.dart';
34
import 'package:flutter/services.dart';
5+
import 'package:flutter/widgets.dart';
46
import 'package:html/dom.dart' as dom;
7+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
58

69
import '../api/core.dart';
710
import '../api/model/model.dart';
@@ -78,6 +81,8 @@ class BlockContentList extends StatelessWidget {
7881
return Quotation(node: node);
7982
} else if (node is ListNode) {
8083
return ListNodeWidget(node: node);
84+
} else if (node is SpoilerNode) {
85+
return Spoiler(node: node);
8186
} else if (node is CodeBlockNode) {
8287
return CodeBlock(node: node);
8388
} else if (node is MathBlockNode) {
@@ -228,6 +233,97 @@ class ListItemWidget extends StatelessWidget {
228233
}
229234
}
230235

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

0 commit comments

Comments
 (0)