diff --git a/packages/flutter_markdown/.cirrus.yml b/packages/flutter_markdown/.cirrus.yml
new file mode 100644
index 000000000000..49fe2845d91b
--- /dev/null
+++ b/packages/flutter_markdown/.cirrus.yml
@@ -0,0 +1,18 @@
+container:
+ image: cirrusci/flutter:latest
+
+test_task:
+ pub_cache:
+ folder: ~/.pub-cache
+ upgrade_script:
+ - flutter channel master
+ - flutter upgrade
+ test_script: flutter test
+
+analyze_task:
+ pub_cache:
+ folder: ~/.pub-cache
+ upgrade_script:
+ - flutter channel master
+ - flutter upgrade
+ analyze_script: flutter analyze
\ No newline at end of file
diff --git a/packages/flutter_markdown/.gitignore b/packages/flutter_markdown/.gitignore
new file mode 100644
index 000000000000..5246badb9d97
--- /dev/null
+++ b/packages/flutter_markdown/.gitignore
@@ -0,0 +1,15 @@
+.DS_Store
+.atom/
+.idea
+.packages
+.dart_tool/
+.pub/
+ios/.generated/
+ios/Flutter/Generated.xcconfig
+ios/Runner/GeneratedPluginRegistrant.*
+packages
+pubspec.lock
+
+# Visual Studio Code related
+.vscode/
+.history/
diff --git a/packages/flutter_markdown/.travis.yml b/packages/flutter_markdown/.travis.yml
new file mode 100644
index 000000000000..191da0afb91f
--- /dev/null
+++ b/packages/flutter_markdown/.travis.yml
@@ -0,0 +1,7 @@
+os: linux
+before_script:
+ - git clone https://github.com/flutter/flutter.git --depth 1
+ - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH
+ - flutter doctor
+script:
+ - flutter test
diff --git a/packages/flutter_markdown/CHANGELOG.md b/packages/flutter_markdown/CHANGELOG.md
new file mode 100644
index 000000000000..195659ea8401
--- /dev/null
+++ b/packages/flutter_markdown/CHANGELOG.md
@@ -0,0 +1,43 @@
+## 0.2.0
+
+* Updated environment sdk constraints to make the package
+ Dart 2 compatible. As a result, usage of this version and higher
+ requires a Dart 2 SDK.
+
+## 0.1.6
+
+* Updated `markdown` dependency.
+
+## 0.1.5
+
+* Add `mockito` as a dev dependency. Eliminate use of `package:http`, which
+ is no longer part of Flutter.
+
+## 0.1.4
+
+* Add `li` style to bullets
+
+## 0.1.3
+
+* Add `path` and `http` as declared dependencies in `pubspec.yaml`
+
+## 0.1.2
+
+* Add support for horizontal rules.
+* Fix the `onTap` callback on images nested in hyperlinks
+
+## 0.1.1
+
+* Add support for local file paths in image links. Make sure to set the
+ `imageDirectory` property to specify the base directory containing the image
+ files.
+
+## 0.1.0
+
+* Roll the dependency on `markdown` to 1.0.0
+* Add a test and example for image links
+* Fix the `onTap` callback on hyperlinks
+
+## 0.0.9
+
+* First published version
diff --git a/packages/flutter_markdown/LICENSE b/packages/flutter_markdown/LICENSE
new file mode 100644
index 000000000000..e7892520aaa9
--- /dev/null
+++ b/packages/flutter_markdown/LICENSE
@@ -0,0 +1,27 @@
+// Copyright 2017 Google, Inc. All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following disclaimer
+// in the documentation and/or other materials provided with the
+// distribution.
+// * Neither the name of Google Inc. nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/flutter_markdown/README.md b/packages/flutter_markdown/README.md
new file mode 100644
index 000000000000..05bb2662cb6b
--- /dev/null
+++ b/packages/flutter_markdown/README.md
@@ -0,0 +1,37 @@
+# Flutter Markdown
+[](https://pub.dartlang.org/packages/flutter_markdown)
+[](https://travis-ci.org/flutter/flutter_markdown)
+
+
+A markdown renderer for Flutter. It supports the
+[original format](https://daringfireball.net/projects/markdown/), but no inline
+html.
+
+## Getting Started
+
+Using the Markdown widget is simple, just pass in the source markdown as a
+string:
+
+ new Markdown(data: markdownSource);
+
+If you do not want the padding or scrolling behavior, use the MarkdownBody
+instead:
+
+ new MarkdownBody(data: markdownSource);
+
+By default, Markdown uses the formatting from the current material design theme,
+but it's possible to create your own custom styling. Use the MarkdownStyle class
+to pass in your own style. If you don't want to use Markdown outside of material
+design, use the MarkdownRaw class.
+
+## Image support
+
+The `Img` tag only supports the following image locations. It specifically
+does not support image locations referring to bundled assets.
+
+* From the network: Use a URL prefixed by either `http://` or `https://`.
+
+* From local files on the device: Use an absolute path to the file, for example by
+ concatenating the file name with the path returned by a known storage location,
+ such as those provided by the [`path_provider`](https://pub.dartlang.org/packages/path_provider)
+ plugin.
diff --git a/packages/flutter_markdown/example/example.dart b/packages/flutter_markdown/example/example.dart
new file mode 100644
index 000000000000..8d3be041dfc4
--- /dev/null
+++ b/packages/flutter_markdown/example/example.dart
@@ -0,0 +1,57 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_markdown/flutter_markdown.dart';
+
+const String _markdownData = """# Markdown Example
+Markdown allows you to easily include formatted text, images, and even formatted Dart code in your app.
+
+## Styling
+Style text as _italic_, __bold__, or `inline code`.
+
+- Use bulleted lists
+- To better clarify
+- Your points
+
+## Links
+You can use [hyperlinks](hyperlink) in markdown
+
+## Images
+
+You can include images:
+
+
+
+## Markdown widget
+
+This is an example of how to create your own Markdown widget:
+
+ new Markdown(data: 'Hello _world_!');
+
+## Code blocks
+Formatted Dart code looks really pretty too:
+
+```
+void main() {
+ runApp(new MaterialApp(
+ home: new Scaffold(
+ body: new Markdown(data: markdownData)
+ )
+ ));
+}
+```
+
+Enjoy!
+""";
+
+void main() {
+ runApp(new MaterialApp(
+ title: "Markdown Demo",
+ home: new Scaffold(
+ appBar: new AppBar(title: const Text('Markdown Demo')),
+ body: const Markdown(data: _markdownData)
+ )
+ ));
+}
diff --git a/packages/flutter_markdown/flutter_markdown.iml b/packages/flutter_markdown/flutter_markdown.iml
new file mode 100644
index 000000000000..4f0181093da8
--- /dev/null
+++ b/packages/flutter_markdown/flutter_markdown.iml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/flutter_markdown/lib/flutter_markdown.dart b/packages/flutter_markdown/lib/flutter_markdown.dart
new file mode 100644
index 000000000000..8d7ed6ea0826
--- /dev/null
+++ b/packages/flutter_markdown/lib/flutter_markdown.dart
@@ -0,0 +1,10 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// A library to render markdown formatted text.
+library flutter_markdown;
+
+export 'src/builder.dart';
+export 'src/style_sheet.dart';
+export 'src/widget.dart';
diff --git a/packages/flutter_markdown/lib/src/builder.dart b/packages/flutter_markdown/lib/src/builder.dart
new file mode 100644
index 000000000000..059bbadbe841
--- /dev/null
+++ b/packages/flutter_markdown/lib/src/builder.dart
@@ -0,0 +1,350 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/widgets.dart';
+import 'package:markdown/markdown.dart' as md;
+import 'package:path/path.dart' as p;
+
+import 'style_sheet.dart';
+
+final Set _kBlockTags = new Set.from([
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'li',
+ 'blockquote',
+ 'pre',
+ 'ol',
+ 'ul',
+ 'hr',
+]);
+
+const List _kListTags = const ['ul', 'ol'];
+
+bool _isBlockTag(String tag) => _kBlockTags.contains(tag);
+bool _isListTag(String tag) => _kListTags.contains(tag);
+
+class _BlockElement {
+ _BlockElement(this.tag);
+
+ final String tag;
+ final List children = [];
+
+ int nextListIndex = 0;
+}
+
+/// A collection of widgets that should be placed adjacent to (inline with)
+/// other inline elements in the same parent block.
+///
+/// Inline elements can be textual (a/em/strong) represented by [RichText]
+/// widgets or images (img) represented by [Image.network] widgets.
+///
+/// Inline elements can be nested within other inline elements, inheriting their
+/// parent's style along with the style of the block they are in.
+///
+/// When laying out inline widgets, first, any adjacent RichText widgets are
+/// merged, then, all inline widgets are enclosed in a parent [Wrap] widget.
+class _InlineElement {
+ _InlineElement(this.tag, {this.style});
+
+ final String tag;
+
+ /// Created by merging the style defined for this element's [tag] in the
+ /// delegate's [MarkdownStyleSheet] with the style of its parent.
+ final TextStyle style;
+
+ final List children = [];
+}
+
+/// A delegate used by [MarkdownBuilder] to control the widgets it creates.
+abstract class MarkdownBuilderDelegate {
+ /// Returns a gesture recognizer to use for an `a` element with the given
+ /// `href` attribute.
+ GestureRecognizer createLink(String href);
+
+ /// Returns formatted text to use to display the given contents of a `pre`
+ /// element.
+ ///
+ /// The `styleSheet` is the value of [MarkdownBuilder.styleSheet].
+ TextSpan formatText(MarkdownStyleSheet styleSheet, String code);
+}
+
+/// Builds a [Widget] tree from parsed Markdown.
+///
+/// See also:
+///
+/// * [Markdown], which is a widget that parses and displays Markdown.
+class MarkdownBuilder implements md.NodeVisitor {
+ /// Creates an object that builds a [Widget] tree from parsed Markdown.
+ MarkdownBuilder({ this.delegate, this.styleSheet, this.imageDirectory });
+
+ /// A delegate that controls how link and `pre` elements behave.
+ final MarkdownBuilderDelegate delegate;
+
+ /// Defines which [TextStyle] objects to use for each type of element.
+ final MarkdownStyleSheet styleSheet;
+
+ /// The base directory holding images referenced by Img tags with local file paths.
+ final Directory imageDirectory;
+
+ final List _listIndents = [];
+ final List<_BlockElement> _blocks = <_BlockElement>[];
+ final List<_InlineElement> _inlines = <_InlineElement>[];
+ final List _linkHandlers = [];
+
+
+ /// Returns widgets that display the given Markdown nodes.
+ ///
+ /// The returned widgets are typically used as children in a [ListView].
+ List build(List nodes) {
+ _listIndents.clear();
+ _blocks.clear();
+ _inlines.clear();
+ _linkHandlers.clear();
+
+ _blocks.add(new _BlockElement(null));
+
+ for (md.Node node in nodes) {
+ assert(_blocks.length == 1);
+ node.accept(this);
+ }
+
+ assert(_inlines.isEmpty);
+ return _blocks.single.children;
+ }
+
+ @override
+ void visitText(md.Text text) {
+ if (_blocks.last.tag == null) // Don't allow text directly under the root.
+ return;
+
+ _addParentInlineIfNeeded(_blocks.last.tag);
+
+ final TextSpan span = _blocks.last.tag == 'pre'
+ ? delegate.formatText(styleSheet, text.text)
+ : new TextSpan(
+ style: _inlines.last.style,
+ text: text.text,
+ recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null,
+ );
+
+ _inlines.last.children.add(new RichText(text: span));
+ }
+
+ @override
+ bool visitElementBefore(md.Element element) {
+ final String tag = element.tag;
+ if (_isBlockTag(tag)) {
+ _addAnonymousBlockIfNeeded(styleSheet.styles[tag]);
+ if (_isListTag(tag))
+ _listIndents.add(tag);
+ _blocks.add(new _BlockElement(tag));
+ } else {
+ _addParentInlineIfNeeded(_blocks.last.tag);
+
+ TextStyle parentStyle = _inlines.last.style;
+ _inlines.add(new _InlineElement(
+ tag,
+ style: parentStyle.merge(styleSheet.styles[tag]),
+ ));
+ }
+
+ if (tag == 'a') {
+ _linkHandlers.add(delegate.createLink(element.attributes['href']));
+ }
+
+ return true;
+ }
+
+ @override
+ void visitElementAfter(md.Element element) {
+ final String tag = element.tag;
+
+ if (_isBlockTag(tag)) {
+ _addAnonymousBlockIfNeeded(styleSheet.styles[tag]);
+
+ final _BlockElement current = _blocks.removeLast();
+ Widget child;
+
+ if (current.children.isNotEmpty) {
+ child = new Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: current.children,
+ );
+ } else {
+ child = const SizedBox();
+ }
+
+ if (_isListTag(tag)) {
+ assert(_listIndents.isNotEmpty);
+ _listIndents.removeLast();
+ } else if (tag == 'li') {
+ if (_listIndents.isNotEmpty) {
+ child = new Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ new SizedBox(
+ width: styleSheet.listIndent,
+ child: _buildBullet(_listIndents.last),
+ ),
+ new Expanded(child: child)
+ ],
+ );
+ }
+ } else if (tag == 'blockquote') {
+ child = new DecoratedBox(
+ decoration: styleSheet.blockquoteDecoration,
+ child: new Padding(
+ padding: new EdgeInsets.all(styleSheet.blockquotePadding),
+ child: child,
+ ),
+ );
+ } else if (tag == 'pre') {
+ child = new DecoratedBox(
+ decoration: styleSheet.codeblockDecoration,
+ child: new Padding(
+ padding: new EdgeInsets.all(styleSheet.codeblockPadding),
+ child: child,
+ ),
+ );
+ } else if (tag == 'hr') {
+ child = new DecoratedBox(
+ decoration: styleSheet.horizontalRuleDecoration,
+ child: child,
+ );
+ }
+
+ _addBlockChild(child);
+ } else {
+ final _InlineElement current = _inlines.removeLast();
+ final _InlineElement parent = _inlines.last;
+
+ if (tag == 'img') {
+ // create an image widget for this image
+ current.children.add(_buildImage(element.attributes['src']));
+ } else if (tag == 'a') {
+ _linkHandlers.removeLast();
+ }
+
+ if (current.children.isNotEmpty) {
+ parent.children.addAll(current.children);
+ }
+ }
+ }
+
+ Widget _buildImage(String src) {
+ final List parts = src.split('#');
+ if (parts.isEmpty)
+ return const SizedBox();
+
+ final String path = parts.first;
+ double width;
+ double height;
+ if (parts.length == 2) {
+ final List dimensions = parts.last.split('x');
+ if (dimensions.length == 2) {
+ width = double.parse(dimensions[0]);
+ height = double.parse(dimensions[1]);
+ }
+ }
+
+ Uri uri = Uri.parse(path);
+ Widget child;
+ if (uri.scheme == 'http' || uri.scheme == 'https') {
+ child = new Image.network(uri.toString(), width: width, height: height);
+ } else if (uri.scheme == 'data') {
+ child = _handleDataSchemeUri(uri, width, height);
+ } else {
+ String filePath = (imageDirectory == null
+ ? uri.toFilePath()
+ : p.join(imageDirectory.path, uri.toFilePath()));
+ child = new Image.file(new File(filePath), width: width, height: height);
+ }
+
+ if (_linkHandlers.isNotEmpty) {
+ TapGestureRecognizer recognizer = _linkHandlers.last;
+ return new GestureDetector(child: child, onTap: recognizer.onTap);
+ } else {
+ return child;
+ }
+ }
+
+ Widget _handleDataSchemeUri(Uri uri, final double width, final double height) {
+ final String mimeType = uri.data.mimeType;
+ if (mimeType.startsWith('image/')) {
+ return new Image.memory(uri.data.contentAsBytes(), width: width, height: height);
+ } else if (mimeType.startsWith('text/')) {
+ return new Text(uri.data.contentAsString());
+ }
+ return const SizedBox();
+ }
+
+ Widget _buildBullet(String listTag) {
+ if (listTag == 'ul')
+ return new Text('•', textAlign: TextAlign.center, style: styleSheet.styles['li']);
+
+ final int index = _blocks.last.nextListIndex;
+ return new Padding(
+ padding: const EdgeInsets.only(right: 5.0),
+ child: new Text('${index + 1}.', textAlign: TextAlign.right, style: styleSheet.styles['li']),
+ );
+ }
+
+ void _addParentInlineIfNeeded(String tag) {
+ if (_inlines.isEmpty) {
+ _inlines.add(new _InlineElement(
+ tag,
+ style: styleSheet.styles[tag],
+ ));
+ }
+ }
+
+ void _addBlockChild(Widget child) {
+ final _BlockElement parent = _blocks.last;
+ if (parent.children.isNotEmpty)
+ parent.children.add(new SizedBox(height: styleSheet.blockSpacing));
+ parent.children.add(child);
+ parent.nextListIndex += 1;
+ }
+
+ void _addAnonymousBlockIfNeeded(TextStyle style) {
+ if (_inlines.isEmpty) {
+ return;
+ }
+
+ final _InlineElement inline = _inlines.single;
+ if (inline.children.isNotEmpty) {
+ List mergedInlines = _mergeInlineChildren(inline);
+ final Wrap wrap = new Wrap(children: mergedInlines);
+ _addBlockChild(wrap);
+ _inlines.clear();
+ }
+ }
+
+ /// Merges adjacent [TextSpan] children of the given [_InlineElement]
+ List _mergeInlineChildren(_InlineElement inline) {
+ List mergedTexts = [];
+ for (Widget child in inline.children) {
+ if (mergedTexts.isNotEmpty && mergedTexts.last is RichText && child is RichText) {
+ RichText previous = mergedTexts.removeLast();
+ List children = previous.text.children != null
+ ? new List.from(previous.text.children)
+ : [previous.text];
+ children.add(child.text);
+ TextSpan mergedSpan = new TextSpan(children: children);
+ mergedTexts.add(new RichText(text: mergedSpan));
+ } else {
+ mergedTexts.add(child);
+ }
+ }
+ return mergedTexts;
+ }
+}
diff --git a/packages/flutter_markdown/lib/src/style_sheet.dart b/packages/flutter_markdown/lib/src/style_sheet.dart
new file mode 100644
index 000000000000..53e38db666f1
--- /dev/null
+++ b/packages/flutter_markdown/lib/src/style_sheet.dart
@@ -0,0 +1,299 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+/// Defines which [TextStyle] objects to use for which Markdown elements.
+class MarkdownStyleSheet {
+ /// Creates an explicit mapping of [TextStyle] objects to Markdown elements.
+ MarkdownStyleSheet({
+ this.a,
+ this.p,
+ this.code,
+ this.h1,
+ this.h2,
+ this.h3,
+ this.h4,
+ this.h5,
+ this.h6,
+ this.em,
+ this.strong,
+ this.blockquote,
+ this.img,
+ this.blockSpacing,
+ this.listIndent,
+ this.blockquotePadding,
+ this.blockquoteDecoration,
+ this.codeblockPadding,
+ this.codeblockDecoration,
+ this.horizontalRuleDecoration
+ }) : _styles = {
+ 'a': a,
+ 'p': p,
+ 'li': p,
+ 'code': code,
+ 'pre': p,
+ 'h1': h1,
+ 'h2': h2,
+ 'h3': h3,
+ 'h4': h4,
+ 'h5': h5,
+ 'h6': h6,
+ 'em': em,
+ 'strong': strong,
+ 'blockquote': blockquote,
+ 'img': img,
+ };
+
+ /// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [ThemeData].
+ factory MarkdownStyleSheet.fromTheme(ThemeData theme) {
+ assert(theme?.textTheme?.body1?.fontSize != null);
+ return new MarkdownStyleSheet(
+ a: const TextStyle(color: Colors.blue),
+ p: theme.textTheme.body1,
+ code: new TextStyle(
+ color: Colors.grey.shade700,
+ fontFamily: "monospace",
+ fontSize: theme.textTheme.body1.fontSize * 0.85
+ ),
+ h1: theme.textTheme.headline,
+ h2: theme.textTheme.title,
+ h3: theme.textTheme.subhead,
+ h4: theme.textTheme.body2,
+ h5: theme.textTheme.body2,
+ h6: theme.textTheme.body2,
+ em: const TextStyle(fontStyle: FontStyle.italic),
+ strong: const TextStyle(fontWeight: FontWeight.bold),
+ blockquote: theme.textTheme.body1,
+ img: theme.textTheme.body1,
+ blockSpacing: 8.0,
+ listIndent: 32.0,
+ blockquotePadding: 8.0,
+ blockquoteDecoration: new BoxDecoration(
+ color: Colors.blue.shade100,
+ borderRadius: new BorderRadius.circular(2.0)
+ ),
+ codeblockPadding: 8.0,
+ codeblockDecoration: new BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: new BorderRadius.circular(2.0)
+ ),
+ horizontalRuleDecoration: new BoxDecoration(
+ border: new Border(
+ top: new BorderSide(width: 5.0, color: Colors.grey.shade300)
+ ),
+ ),
+ );
+ }
+
+ /// Creates a [MarkdownStyle] from the [TextStyle]s in the provided [ThemeData].
+ ///
+ /// This constructor uses larger fonts for the headings than in
+ /// [MarkdownStyle.fromTheme].
+ factory MarkdownStyleSheet.largeFromTheme(ThemeData theme) {
+ return new MarkdownStyleSheet(
+ a: const TextStyle(color: Colors.blue),
+ p: theme.textTheme.body1,
+ code: new TextStyle(
+ color: Colors.grey.shade700,
+ fontFamily: "monospace",
+ fontSize: theme.textTheme.body1.fontSize * 0.85
+ ),
+ h1: theme.textTheme.display3,
+ h2: theme.textTheme.display2,
+ h3: theme.textTheme.display1,
+ h4: theme.textTheme.headline,
+ h5: theme.textTheme.title,
+ h6: theme.textTheme.subhead,
+ em: const TextStyle(fontStyle: FontStyle.italic),
+ strong: const TextStyle(fontWeight: FontWeight.bold),
+ blockquote: theme.textTheme.body1,
+ img: theme.textTheme.body1,
+ blockSpacing: 8.0,
+ listIndent: 32.0,
+ blockquotePadding: 8.0,
+ blockquoteDecoration: new BoxDecoration(
+ color: Colors.blue.shade100,
+ borderRadius: new BorderRadius.circular(2.0)
+ ),
+ codeblockPadding: 8.0,
+ codeblockDecoration: new BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: new BorderRadius.circular(2.0)
+ ),
+ horizontalRuleDecoration: new BoxDecoration(
+ border: new Border(
+ top: new BorderSide(width: 5.0, color: Colors.grey.shade300)
+ ),
+ ),
+ );
+ }
+
+ /// Creates a new [MarkdownStyleSheet] based on the current style, with the
+ /// provided parameters overridden.
+ MarkdownStyleSheet copyWith({
+ TextStyle a,
+ TextStyle p,
+ TextStyle code,
+ TextStyle h1,
+ TextStyle h2,
+ TextStyle h3,
+ TextStyle h4,
+ TextStyle h5,
+ TextStyle h6,
+ TextStyle em,
+ TextStyle strong,
+ TextStyle blockquote,
+ TextStyle img,
+ double blockSpacing,
+ double listIndent,
+ double blockquotePadding,
+ Decoration blockquoteDecoration,
+ double codeblockPadding,
+ Decoration codeblockDecoration,
+ Decoration horizontalRuleDecoration
+ }) {
+ return new MarkdownStyleSheet(
+ a: a ?? this.a,
+ p: p ?? this.p,
+ code: code ?? this.code,
+ h1: h1 ?? this.h1,
+ h2: h2 ?? this.h2,
+ h3: h3 ?? this.h3,
+ h4: h4 ?? this.h4,
+ h5: h5 ?? this.h5,
+ h6: h6 ?? this.h6,
+ em: em ?? this.em,
+ strong: strong ?? this.strong,
+ blockquote: blockquote ?? this.blockquote,
+ img: img ?? this.img,
+ blockSpacing: blockSpacing ?? this.blockSpacing,
+ listIndent: listIndent ?? this.listIndent,
+ blockquotePadding: blockquotePadding ?? this.blockquotePadding,
+ blockquoteDecoration: blockquoteDecoration ?? this.blockquoteDecoration,
+ codeblockPadding: codeblockPadding ?? this.codeblockPadding,
+ codeblockDecoration: codeblockDecoration ?? this.codeblockDecoration,
+ horizontalRuleDecoration: horizontalRuleDecoration ?? this.horizontalRuleDecoration,
+ );
+ }
+
+ /// The [TextStyle] to use for `a` elements.
+ final TextStyle a;
+
+ /// The [TextStyle] to use for `p` elements.
+ final TextStyle p;
+
+ /// The [TextStyle] to use for `code` elements.
+ final TextStyle code;
+
+ /// The [TextStyle] to use for `h1` elements.
+ final TextStyle h1;
+
+ /// The [TextStyle] to use for `h2` elements.
+ final TextStyle h2;
+
+ /// The [TextStyle] to use for `h3` elements.
+ final TextStyle h3;
+
+ /// The [TextStyle] to use for `h4` elements.
+ final TextStyle h4;
+
+ /// The [TextStyle] to use for `h5` elements.
+ final TextStyle h5;
+
+ /// The [TextStyle] to use for `h6` elements.
+ final TextStyle h6;
+
+ /// The [TextStyle] to use for `em` elements.
+ final TextStyle em;
+
+ /// The [TextStyle] to use for `strong` elements.
+ final TextStyle strong;
+
+ /// The [TextStyle] to use for `blockquote` elements.
+ final TextStyle blockquote;
+
+ /// The [TextStyle] to use for `img` elements.
+ final TextStyle img;
+
+ /// The amount of vertical space to use between block-level elements.
+ final double blockSpacing;
+
+ /// The amount of horizontal space to indent list items.
+ final double listIndent;
+
+ /// The padding to use for `blockquote` elements.
+ final double blockquotePadding;
+
+ /// The decoration to use behind `blockquote` elements.
+ final Decoration blockquoteDecoration;
+
+ /// The padding to use for `pre` elements.
+ final double codeblockPadding;
+
+ /// The decoration to use behind for `pre` elements.
+ final Decoration codeblockDecoration;
+
+ /// The decoration to use for `hr` elements.
+ final Decoration horizontalRuleDecoration;
+
+ /// A [Map] from element name to the corresponding [TextStyle] object.
+ Map get styles => _styles;
+ Map _styles;
+
+ @override
+ bool operator ==(dynamic other) {
+ if (identical(this, other))
+ return true;
+ if (other.runtimeType != MarkdownStyleSheet)
+ return false;
+ final MarkdownStyleSheet typedOther = other;
+ return typedOther.a == a
+ && typedOther.p == p
+ && typedOther.code == code
+ && typedOther.h1 == h1
+ && typedOther.h2 == h2
+ && typedOther.h3 == h3
+ && typedOther.h4 == h4
+ && typedOther.h5 == h5
+ && typedOther.h6 == h6
+ && typedOther.em == em
+ && typedOther.strong == strong
+ && typedOther.blockquote == blockquote
+ && typedOther.img == img
+ && typedOther.blockSpacing == blockSpacing
+ && typedOther.listIndent == listIndent
+ && typedOther.blockquotePadding == blockquotePadding
+ && typedOther.blockquoteDecoration == blockquoteDecoration
+ && typedOther.codeblockPadding == codeblockPadding
+ && typedOther.codeblockDecoration == codeblockDecoration
+ && typedOther.horizontalRuleDecoration == horizontalRuleDecoration;
+ }
+
+ @override
+ int get hashCode {
+ return hashValues(
+ a,
+ p,
+ code,
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ em,
+ strong,
+ blockquote,
+ img,
+ blockSpacing,
+ listIndent,
+ blockquotePadding,
+ blockquoteDecoration,
+ codeblockPadding,
+ codeblockDecoration,
+ horizontalRuleDecoration,
+ );
+ }
+}
diff --git a/packages/flutter_markdown/lib/src/widget.dart b/packages/flutter_markdown/lib/src/widget.dart
new file mode 100644
index 000000000000..39a15c6b267e
--- /dev/null
+++ b/packages/flutter_markdown/lib/src/widget.dart
@@ -0,0 +1,224 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:markdown/markdown.dart' as md;
+import 'package:meta/meta.dart';
+
+import 'builder.dart';
+import 'style_sheet.dart';
+
+/// Signature for callbacks used by [MarkdownWidget] when the user taps a link.
+///
+/// Used by [MarkdownWidget.onTapLink].
+typedef void MarkdownTapLinkCallback(String href);
+
+/// Creates a format [TextSpan] given a string.
+///
+/// Used by [MarkdownWidget] to highlight the contents of `pre` elements.
+abstract class SyntaxHighlighter { // ignore: one_member_abstracts
+ /// Returns the formated [TextSpan] for the given string.
+ TextSpan format(String source);
+}
+
+/// A base class for widgets that parse and display Markdown.
+///
+/// Supports all standard Markdown from the original
+/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+///
+/// See also:
+///
+/// * [Markdown], which is a scrolling container of Markdown.
+/// * [MarkdownBody], which is a non-scrolling container of Markdown.
+/// *
+abstract class MarkdownWidget extends StatefulWidget {
+ /// Creates a widget that parses and displays Markdown.
+ ///
+ /// The [data] argument must not be null.
+ const MarkdownWidget({
+ Key key,
+ @required this.data,
+ this.styleSheet,
+ this.syntaxHighlighter,
+ this.onTapLink,
+ this.imageDirectory,
+ }) : assert(data != null),
+ super(key: key);
+
+ /// The Markdown to display.
+ final String data;
+
+ /// The styles to use when displaying the Markdown.
+ ///
+ /// If null, the styles are inferred from the current [Theme].
+ final MarkdownStyleSheet styleSheet;
+
+ /// The syntax highlighter used to color text in `pre` elements.
+ ///
+ /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements.
+ final SyntaxHighlighter syntaxHighlighter;
+
+ /// Called when the user taps a link.
+ final MarkdownTapLinkCallback onTapLink;
+
+ /// The base directory holding images referenced by Img tags with local file paths.
+ final Directory imageDirectory;
+
+ /// Subclasses should override this function to display the given children,
+ /// which are the parsed representation of [data].
+ @protected
+ Widget build(BuildContext context, List children);
+
+ @override
+ _MarkdownWidgetState createState() => new _MarkdownWidgetState();
+}
+
+class _MarkdownWidgetState extends State implements MarkdownBuilderDelegate {
+ List _children;
+ final List _recognizers = [];
+
+ @override
+ void didChangeDependencies() {
+ _parseMarkdown();
+ super.didChangeDependencies();
+ }
+
+ @override
+ void didUpdateWidget(MarkdownWidget oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.data != oldWidget.data
+ || widget.styleSheet != oldWidget.styleSheet)
+ _parseMarkdown();
+ }
+
+ @override
+ void dispose() {
+ _disposeRecognizers();
+ super.dispose();
+ }
+
+ void _parseMarkdown() {
+ final MarkdownStyleSheet styleSheet = widget.styleSheet ?? new MarkdownStyleSheet.fromTheme(Theme.of(context));
+
+ _disposeRecognizers();
+
+ // TODO: This can be optimized by doing the split and removing \r at the same time
+ final List lines = widget.data.replaceAll('\r\n', '\n').split('\n');
+ final md.Document document = new md.Document(encodeHtml: false);
+ final MarkdownBuilder builder = new MarkdownBuilder(
+ delegate: this,
+ styleSheet: styleSheet,
+ imageDirectory: widget.imageDirectory,
+ );
+ _children = builder.build(document.parseLines(lines));
+ }
+
+ void _disposeRecognizers() {
+ if (_recognizers.isEmpty)
+ return;
+ final List localRecognizers = new List.from(_recognizers);
+ _recognizers.clear();
+ for (GestureRecognizer recognizer in localRecognizers)
+ recognizer.dispose();
+ }
+
+ @override
+ GestureRecognizer createLink(String href) {
+ final TapGestureRecognizer recognizer = new TapGestureRecognizer()
+ ..onTap = () {
+ if (widget.onTapLink != null)
+ widget.onTapLink(href);
+ };
+ _recognizers.add(recognizer);
+ return recognizer;
+ }
+
+ @override
+ TextSpan formatText(MarkdownStyleSheet styleSheet, String code) {
+ if (widget.syntaxHighlighter != null)
+ return widget.syntaxHighlighter.format(code);
+ return new TextSpan(style: styleSheet.code, text: code);
+ }
+
+ @override
+ Widget build(BuildContext context) => widget.build(context, _children);
+}
+
+/// A non-scrolling widget that parses and displays Markdown.
+///
+/// Supports all standard Markdown from the original
+/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+///
+/// See also:
+///
+/// * [Markdown], which is a scrolling container of Markdown.
+/// *
+class MarkdownBody extends MarkdownWidget {
+ /// Creates a non-scrolling widget that parses and displays Markdown.
+ const MarkdownBody({
+ Key key,
+ String data,
+ MarkdownStyleSheet styleSheet,
+ SyntaxHighlighter syntaxHighlighter,
+ MarkdownTapLinkCallback onTapLink,
+ Directory imageDirectory,
+ }) : super(
+ key: key,
+ data: data,
+ styleSheet: styleSheet,
+ syntaxHighlighter: syntaxHighlighter,
+ onTapLink: onTapLink,
+ imageDirectory: imageDirectory,
+ );
+
+ @override
+ Widget build(BuildContext context, List children) {
+ if (children.length == 1)
+ return children.single;
+ return new Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: children,
+ );
+ }
+}
+
+/// A scrolling widget that parses and displays Markdown.
+///
+/// Supports all standard Markdown from the original
+/// [Markdown specification](https://daringfireball.net/projects/markdown/).
+///
+/// See also:
+///
+/// * [MarkdownBody], which is a non-scrolling container of Markdown.
+/// *
+class Markdown extends MarkdownWidget {
+ /// Creates a scrolling widget that parses and displays Markdown.
+ const Markdown({
+ Key key,
+ String data,
+ MarkdownStyleSheet styleSheet,
+ SyntaxHighlighter syntaxHighlighter,
+ MarkdownTapLinkCallback onTapLink,
+ Directory imageDirectory,
+ this.padding: const EdgeInsets.all(16.0),
+ }) : super(
+ key: key,
+ data: data,
+ styleSheet: styleSheet,
+ syntaxHighlighter: syntaxHighlighter,
+ onTapLink: onTapLink,
+ imageDirectory: imageDirectory,
+ );
+
+ /// The amount of space by which to inset the children.
+ final EdgeInsets padding;
+
+ @override
+ Widget build(BuildContext context, List children) {
+ return new ListView(padding: padding, children: children);
+ }
+}
diff --git a/packages/flutter_markdown/pubspec.yaml b/packages/flutter_markdown/pubspec.yaml
new file mode 100644
index 000000000000..d4e656528a14
--- /dev/null
+++ b/packages/flutter_markdown/pubspec.yaml
@@ -0,0 +1,21 @@
+name: flutter_markdown
+author: Flutter Authors
+description: A Markdown renderer for Flutter.
+homepage: https://github.com/flutter/flutter_markdown
+version: 0.2.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+ markdown: ^2.0.0
+ meta: ^1.0.5
+ string_scanner: ^1.0.0
+ path: ^1.5.1
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ mockito: ^3.0.0
+
+environment:
+ sdk: '>=2.0.0-dev.58.0 <3.0.0'
diff --git a/packages/flutter_markdown/test/flutter_markdown_test.dart b/packages/flutter_markdown/test/flutter_markdown_test.dart
new file mode 100644
index 000000000000..ca341f622d03
--- /dev/null
+++ b/packages/flutter_markdown/test/flutter_markdown_test.dart
@@ -0,0 +1,538 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io' as io;
+
+import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
+import 'package:mockito/mockito.dart';
+
+void main() {
+ TextTheme textTheme = new Typography(platform: TargetPlatform.android)
+ .black
+ .merge(new TextTheme(body1: new TextStyle(fontSize: 12.0)));
+
+ testWidgets('Simple string', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: 'Hello')));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectWidgetTypes(
+ widgets, [Directionality, MarkdownBody, Column, Wrap, RichText]);
+ _expectTextStrings(widgets, ['Hello']);
+ });
+
+ testWidgets('Header', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '# Header')));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectWidgetTypes(
+ widgets, [Directionality, MarkdownBody, Column, Wrap, RichText]);
+ _expectTextStrings(widgets, ['Header']);
+ });
+
+ testWidgets('Empty string', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '')));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectWidgetTypes(widgets, [Directionality, MarkdownBody, Column]);
+ });
+
+ testWidgets('Ordered list', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(
+ const MarkdownBody(data: '1. Item 1\n1. Item 2\n2. Item 3'),
+ ));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectTextStrings(widgets, [
+ '1.',
+ 'Item 1',
+ '2.',
+ 'Item 2',
+ '3.',
+ 'Item 3',
+ ]);
+ });
+
+ testWidgets('Unordered list', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ _boilerplate(const MarkdownBody(data: '- Item 1\n- Item 2\n- Item 3')),
+ );
+
+ final Iterable widgets = tester.allWidgets;
+ _expectTextStrings(widgets, [
+ '•',
+ 'Item 1',
+ '•',
+ 'Item 2',
+ '•',
+ 'Item 3',
+ ]);
+ });
+
+ testWidgets('Horizontal Rule', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '-----')));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectWidgetTypes(
+ widgets, [Directionality, MarkdownBody, DecoratedBox, SizedBox]);
+ });
+
+ testWidgets('Scrollable wrapping', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(data: '')));
+
+ final List widgets = tester.allWidgets.toList();
+ _expectWidgetTypes(widgets.take(3), [
+ Directionality,
+ Markdown,
+ ListView,
+ ]);
+ _expectWidgetTypes(widgets.reversed.take(2).toList().reversed, [
+ SliverPadding,
+ SliverList,
+ ]);
+ });
+
+ group('Links', () {
+ testWidgets('should be tappable', (WidgetTester tester) async {
+ String tapResult;
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[Link Text](href)',
+ onTapLink: (value) => tapResult = value,
+ )));
+
+ final RichText textWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final TextSpan span = textWidget.text;
+
+ (span.recognizer as TapGestureRecognizer).onTap();
+
+ expect(span.children, null);
+ expect(span.recognizer.runtimeType, equals(TapGestureRecognizer));
+ expect(tapResult, 'href');
+ });
+
+ testWidgets('should work with nested elements', (WidgetTester tester) async {
+ final List tapResults = [];
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[Link `with nested code` Text](href)',
+ onTapLink: (value) => tapResults.add(value),
+ )));
+
+ final RichText textWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final TextSpan span = textWidget.text;
+
+ final List gestureRecognizerTypes = [];
+ span.visitTextSpan((TextSpan textSpan) {
+ TapGestureRecognizer recognizer = textSpan.recognizer;
+ gestureRecognizerTypes.add(recognizer.runtimeType);
+ recognizer.onTap();
+ return true;
+ });
+
+ expect(span.children.length, 3);
+ expect(gestureRecognizerTypes.length, 3);
+ expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer));
+ expect(tapResults.length, 3);
+ expect(tapResults, everyElement('href'));
+ });
+
+ testWidgets('should work next to other links', (WidgetTester tester) async {
+ final List tapResults = [];
+
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[First Link](firstHref) and [Second Link](secondHref)',
+ onTapLink: (value) => tapResults.add(value),
+ )));
+
+ final RichText textWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final TextSpan span = textWidget.text;
+
+ final List gestureRecognizerTypes = [];
+ span.visitTextSpan((TextSpan textSpan) {
+ TapGestureRecognizer recognizer = textSpan.recognizer;
+ gestureRecognizerTypes.add(recognizer.runtimeType);
+ recognizer?.onTap();
+ return true;
+ });
+
+ expect(span.children.length, 3);
+ expect(gestureRecognizerTypes,
+ orderedEquals([TapGestureRecognizer, Null, TapGestureRecognizer]));
+ expect(tapResults, orderedEquals(['firstHref', 'secondHref']));
+ });
+ });
+
+ group('Images', () {
+ setUp(() {
+ // Only needs to be done once since the HttpClient generated by this
+ // override is cached as a static singleton.
+ io.HttpOverrides.global = new TestHttpOverrides();
+ });
+
+ testWidgets('should not interrupt styling', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(
+ data:'_textbefore  textafter_',
+ )));
+
+ final RichText firstTextWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ final NetworkImage networkImage = image.image;
+ final RichText secondTextWidget =
+ tester.allWidgets.lastWhere((Widget widget) => widget is RichText);
+
+ expect(firstTextWidget.text.text, 'textbefore ');
+ expect(firstTextWidget.text.style.fontStyle, FontStyle.italic);
+ expect(networkImage.url,'http://img');
+ expect(secondTextWidget.text.text, ' textafter');
+ expect(secondTextWidget.text.style.fontStyle, FontStyle.italic);
+ });
+
+ testWidgets('should work with a link', (WidgetTester tester) async {
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: '')));
+
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ final NetworkImage networkImage = image.image;
+ expect(networkImage.url, 'https://img');
+ expect(image.width, 50);
+ expect(image.height, 50);
+ });
+
+ testWidgets('local files should be files', (WidgetTester tester) async {
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: '')));
+
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ expect(image.image is FileImage, isTrue);
+ });
+
+ testWidgets('should work with local image files', (WidgetTester tester) async {
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: '')));
+
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ final FileImage fileImage = image.image;
+ expect(fileImage.file.path, 'img.png');
+ expect(image.width, 50);
+ expect(image.height, 50);
+ });
+
+ testWidgets('should show properly next to text', (WidgetTester tester) async {
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: 'Hello ')));
+
+ final RichText richText =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ TextSpan textSpan = richText.text;
+ expect(textSpan.text, 'Hello ');
+ expect(textSpan.style, isNotNull);
+ });
+
+ testWidgets('should work when nested in a link', (WidgetTester tester) async {
+ final List tapResults = [];
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[](href)',
+ onTapLink: (value) => tapResults.add(value),
+ )));
+
+ final GestureDetector detector =
+ tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector);
+
+ detector.onTap();
+
+ expect(tapResults.length, 1);
+ expect(tapResults, everyElement('href'));
+ });
+
+ testWidgets('should work when nested in a link with text', (WidgetTester tester) async {
+ final List tapResults = [];
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[Text before  text after](href)',
+ onTapLink: (value) => tapResults.add(value),
+ )));
+
+ final GestureDetector detector =
+ tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector);
+ detector.onTap();
+
+ final RichText firstTextWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final TextSpan firstSpan = firstTextWidget.text;
+ (firstSpan.recognizer as TapGestureRecognizer).onTap();
+
+ final RichText lastTextWidget =
+ tester.allWidgets.lastWhere((Widget widget) => widget is RichText);
+ final TextSpan lastSpan = lastTextWidget.text;
+ (lastSpan.recognizer as TapGestureRecognizer).onTap();
+
+ expect(firstSpan.children, null);
+ expect(firstSpan.text, 'Text before ');
+ expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer));
+
+ expect(lastSpan.children, null);
+ expect(lastSpan.text, ' text after');
+ expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer));
+
+ expect(tapResults.length, 3);
+ expect(tapResults, everyElement('href'));
+ });
+
+ testWidgets('should work alongside different links', (WidgetTester tester) async {
+ final List tapResults = [];
+ await tester.pumpWidget(_boilerplate(new Markdown(
+ data: '[Link before](firstHref)[](imageHref)[link after](secondHref)',
+ onTapLink: (value) => tapResults.add(value),
+ )));
+
+ final RichText firstTextWidget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is RichText);
+ final TextSpan firstSpan = firstTextWidget.text;
+ (firstSpan.recognizer as TapGestureRecognizer).onTap();
+
+ final GestureDetector detector =
+ tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector);
+ detector.onTap();
+
+ final RichText lastTextWidget =
+ tester.allWidgets.lastWhere((Widget widget) => widget is RichText);
+ final TextSpan lastSpan = lastTextWidget.text;
+ (lastSpan.recognizer as TapGestureRecognizer).onTap();
+
+ expect(firstSpan.children, null);
+ expect(firstSpan.text, 'Link before');
+ expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer));
+
+ expect(lastSpan.children, null);
+ expect(lastSpan.text, 'link after');
+ expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer));
+
+ expect(tapResults.length, 3);
+ expect(tapResults, ['firstHref', 'imageHref', 'secondHref']);
+ });
+ });
+
+ group('uri data scheme', () {
+ testWidgets('should work with image in uri data scheme', (WidgetTester tester) async {
+ const String imageData = '';
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: imageData)));
+
+ final Image image =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Image);
+ expect(image.image.runtimeType, MemoryImage);
+ });
+
+ testWidgets('should work with base64 text in uri data scheme', (WidgetTester tester) async {
+ const String imageData = '';
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: imageData)));
+
+ final Text widget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Text);
+ expect(widget.runtimeType, Text);
+ expect(widget.data, 'Flutter');
+ });
+
+ testWidgets('should work with text in uri data scheme', (WidgetTester tester) async {
+ const String imageData = '';
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: imageData)));
+
+ final Text widget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Text);
+ expect(widget.runtimeType, Text);
+ expect(widget.data, 'Hello, Flutter');
+ });
+
+ testWidgets('should work with empty uri data scheme', (WidgetTester tester) async {
+ const String imageData = '';
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: imageData)));
+
+ final Text widget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is Text);
+ expect(widget.runtimeType, Text);
+ expect(widget.data, '');
+ });
+
+ testWidgets('should work with unsupported mime types of uri data scheme', (WidgetTester tester) async {
+ const String imageData = '';
+ await tester
+ .pumpWidget(_boilerplate(const Markdown(data: imageData)));
+
+ final SizedBox widget =
+ tester.allWidgets.firstWhere((Widget widget) => widget is SizedBox);
+ expect(widget.runtimeType, SizedBox);
+ });
+ });
+
+ testWidgets('HTML tag ignored ', (WidgetTester tester) async {
+ final List mdData = [
+ 'Line 1\nHTML content
\nLine 2',
+ 'Line 1\n<\nLine 2'
+ ];
+
+ for (String mdLine in mdData) {
+ await tester.pumpWidget(_boilerplate(new MarkdownBody(data: mdLine)));
+
+ final Iterable widgets = tester.allWidgets;
+ _expectTextStrings(widgets, ['Line 1', 'Line 2']);
+ }
+ });
+
+ group('Parser does not convert', () {
+ testWidgets('& to & when parsing', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(data: '&')));
+ _expectTextStrings(tester.allWidgets, ['&']);
+ });
+
+ testWidgets('< to < when parsing', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(data: '<')));
+ _expectTextStrings(tester.allWidgets, ['<']);
+ });
+
+ testWidgets('existing HTML entities when parsing', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(data: '& © < {')));
+ _expectTextStrings(tester.allWidgets, ['& © < {']);
+ });
+ });
+
+ testWidgets('Changing config - data', (WidgetTester tester) async {
+ await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1')));
+ _expectTextStrings(tester.allWidgets, ['Data1']);
+
+ final String stateBefore = _dumpRenderView();
+ await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1')));
+ final String stateAfter = _dumpRenderView();
+ expect(stateBefore, equals(stateAfter));
+
+ await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data2')));
+ _expectTextStrings(tester.allWidgets, ['Data2']);
+ });
+
+ testWidgets('Changing config - style', (WidgetTester tester) async {
+ final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme);
+
+ final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme);
+ final MarkdownStyleSheet style2 =
+ new MarkdownStyleSheet.largeFromTheme(theme);
+ expect(style1, isNot(style2));
+
+ await tester.pumpWidget(
+ _boilerplate(new Markdown(data: '# Test', styleSheet: style1)),
+ );
+ final RichText text1 = tester.widget(find.byType(RichText));
+ await tester.pumpWidget(
+ _boilerplate(new Markdown(data: '# Test', styleSheet: style2)),
+ );
+ final RichText text2 = tester.widget(find.byType(RichText));
+
+ expect(text1.text, isNot(text2.text));
+ });
+
+ testWidgets('Style equality', (WidgetTester tester) async {
+ final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme);
+
+ final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme);
+ final MarkdownStyleSheet style2 = new MarkdownStyleSheet.fromTheme(theme);
+ expect(style1, equals(style2));
+ expect(style1.hashCode, equals(style2.hashCode));
+ });
+}
+
+void _expectWidgetTypes(Iterable widgets, List expected) {
+ final List actual = widgets.map((Widget w) => w.runtimeType).toList();
+ expect(actual, expected);
+}
+
+void _expectTextStrings(Iterable widgets, List strings) {
+ int currentString = 0;
+ for (Widget widget in widgets) {
+ if (widget is RichText) {
+ final TextSpan span = widget.text;
+ final String text = _extractTextFromTextSpan(span);
+ expect(text, equals(strings[currentString]));
+ currentString += 1;
+ }
+ }
+}
+
+String _extractTextFromTextSpan(TextSpan span) {
+ String text = span.text ?? '';
+ if (span.children != null) {
+ for (TextSpan child in span.children) {
+ text += _extractTextFromTextSpan(child);
+ }
+ }
+ return text;
+}
+
+String _dumpRenderView() {
+ return WidgetsBinding.instance.renderViewElement.toStringDeep().replaceAll(
+ new RegExp(r'SliverChildListDelegate#\d+', multiLine: true),
+ 'SliverChildListDelegate');
+}
+
+/// Wraps a widget with a left-to-right [Directionality] for tests.
+Widget _boilerplate(Widget child) {
+ return new Directionality(
+ textDirection: TextDirection.ltr,
+ child: child,
+ );
+}
+
+class MockHttpClient extends Mock implements io.HttpClient {}
+class MockHttpClientRequest extends Mock implements io.HttpClientRequest {}
+class MockHttpClientResponse extends Mock implements io.HttpClientResponse {}
+class MockHttpHeaders extends Mock implements io.HttpHeaders {}
+
+class TestHttpOverrides extends io.HttpOverrides {
+ io.HttpClient createHttpClient(io.SecurityContext context) {
+ return createMockImageHttpClient(context);
+ }
+}
+
+// Returns a mock HTTP client that responds with an image to all requests.
+MockHttpClient createMockImageHttpClient(io.SecurityContext _) {
+ final MockHttpClient client = new MockHttpClient();
+ final MockHttpClientRequest request = new MockHttpClientRequest();
+ final MockHttpClientResponse response = new MockHttpClientResponse();
+ final MockHttpHeaders headers = new MockHttpHeaders();
+
+ when(client.getUrl(any)).thenAnswer((_) => new Future.value(request));
+ when(request.headers).thenReturn(headers);
+ when(request.close()).thenAnswer((_) => new Future.value(response));
+ when(response.contentLength).thenReturn(_transparentImage.length);
+ when(response.statusCode).thenReturn(io.HttpStatus.ok);
+ when(response.listen(any)).thenAnswer((Invocation invocation) {
+ final void Function(List) onData = invocation.positionalArguments[0];
+ final void Function() onDone = invocation.namedArguments[#onDone];
+ final void Function(Object, [StackTrace]) onError = invocation.namedArguments[#onError];
+ final bool cancelOnError = invocation.namedArguments[#cancelOnError];
+
+ return new Stream>.fromIterable(>[_transparentImage])
+ .listen(onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError);
+ });
+
+ return client;
+}
+
+const List _transparentImage = const [
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49,
+ 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
+ 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44,
+ 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D,
+ 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+];