add new dependency

This commit is contained in:
otsmr 2026-04-22 17:15:35 +02:00
parent 24d048b4ab
commit 4e6d3eb9b7
40 changed files with 14690 additions and 1 deletions

View file

@ -1,6 +1,7 @@
adaptive_number: ea9178fdd4d82ac45cf0ec966ac870dae661124f adaptive_number: ea9178fdd4d82ac45cf0ec966ac870dae661124f
dots_indicator: 508f5883ac79bdbc10254092de3f28f571d261cd dots_indicator: 508f5883ac79bdbc10254092de3f28f571d261cd
ed25519_edwards: 7353ba759ea9f4646cbf481c2ef949625c8ce4cf ed25519_edwards: 7353ba759ea9f4646cbf481c2ef949625c8ce4cf
flutter_markdown_plus: dc1185c933fbf9dba559ef6c91586ff1503be3ee
flutter_sharing_intent: aa1672f547d6579585fa27df0b28ffa2a2544aaa flutter_sharing_intent: aa1672f547d6579585fa27df0b28ffa2a2544aaa
hand_signature: 1beedb164d093643365b0832277c377353c7464f hand_signature: 1beedb164d093643365b0832277c377353c7464f
hashlib: bc9c2f8dd7bbc72f47ccab0ce1111d40259c49bc hashlib: bc9c2f8dd7bbc72f47ccab0ce1111d40259c49bc

View file

@ -55,4 +55,8 @@ restart_app:
git: https://github.com/gabrimatic/restart_app git: https://github.com/gabrimatic/restart_app
no_screenshot: no_screenshot:
git: https://github.com/FlutterPlaza/no_screenshot.git git: https://github.com/FlutterPlaza/no_screenshot.git
flutter_markdown_plus:
git: https://github.com/foresightmobile/flutter_markdown_plus.git

View file

@ -0,0 +1,25 @@
Copyright 2013 The Flutter Authors. 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.

View file

@ -0,0 +1,10 @@
// Copyright 2013 The Flutter 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_plus;
export 'src/builder.dart';
export 'src/style_sheet.dart';
export 'src/widget.dart';

View file

@ -0,0 +1,119 @@
// Copyright 2013 The Flutter 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/cupertino.dart' show CupertinoTheme;
import 'package:flutter/material.dart' show Theme;
import 'package:flutter/widgets.dart';
import 'style_sheet.dart';
import 'widget.dart';
/// Type for a function that creates image widgets.
typedef ImageBuilder = Widget Function(Uri uri, String? imageDirectory, double? width, double? height);
/// A default image builder handling http/https, resource, and file URLs.
// ignore: prefer_function_declarations_over_variables
final ImageBuilder kDefaultImageBuilder = (
Uri uri,
String? imageDirectory,
double? width,
double? height,
) {
if (uri.scheme == 'http' || uri.scheme == 'https') {
return Image.network(
uri.toString(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else if (uri.scheme == 'data') {
return _handleDataSchemeUri(uri, width, height);
} else if (uri.scheme == 'resource') {
return Image.asset(
uri.path,
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else {
final Uri fileUri = imageDirectory != null ? Uri.parse(imageDirectory + uri.toString()) : uri;
if (fileUri.scheme == 'http' || fileUri.scheme == 'https') {
return Image.network(
fileUri.toString(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else {
try {
return Image.file(
File.fromUri(fileUri),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} catch (error, stackTrace) {
// Handle any invalid file URI's.
return Builder(
builder: (BuildContext context) {
return kDefaultImageErrorWidgetBuilder(context, error, stackTrace);
},
);
}
}
}
};
/// A default error widget builder for handling image errors.
// ignore: prefer_function_declarations_over_variables
final ImageErrorWidgetBuilder kDefaultImageErrorWidgetBuilder = (
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return const SizedBox();
};
/// A default style sheet generator.
final MarkdownStyleSheet Function(BuildContext, MarkdownStyleSheetBaseTheme?)
// ignore: prefer_function_declarations_over_variables
kFallbackStyle = (
BuildContext context,
MarkdownStyleSheetBaseTheme? baseTheme,
) {
MarkdownStyleSheet result;
switch (baseTheme) {
case MarkdownStyleSheetBaseTheme.platform:
result = (Platform.isIOS || Platform.isMacOS)
? MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context))
: MarkdownStyleSheet.fromTheme(Theme.of(context));
case MarkdownStyleSheetBaseTheme.cupertino:
result = MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context));
case MarkdownStyleSheetBaseTheme.material:
// ignore: no_default_cases
default:
result = MarkdownStyleSheet.fromTheme(Theme.of(context));
}
return result.copyWith(
textScaler: MediaQuery.textScalerOf(context),
);
};
Widget _handleDataSchemeUri(Uri uri, final double? width, final double? height) {
final String mimeType = uri.data!.mimeType;
if (mimeType.startsWith('image/')) {
return Image.memory(
uri.data!.contentAsBytes(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else if (mimeType.startsWith('text/')) {
return Text(uri.data!.contentAsString());
}
return const SizedBox();
}

View file

@ -0,0 +1,124 @@
// Copyright 2013 The Flutter 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:js_interop';
import 'package:flutter/cupertino.dart' show CupertinoTheme;
import 'package:flutter/material.dart' show Theme;
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p;
import 'style_sheet.dart';
import 'widget.dart';
/// Type for a function that creates image widgets.
typedef ImageBuilder = Widget Function(Uri uri, String? imageDirectory, double? width, double? height);
/// A default image builder handling http/https, resource, data, and file URLs.
// ignore: prefer_function_declarations_over_variables
final ImageBuilder kDefaultImageBuilder = (
Uri uri,
String? imageDirectory,
double? width,
double? height,
) {
if (uri.scheme == 'http' || uri.scheme == 'https') {
return Image.network(
uri.toString(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else if (uri.scheme == 'data') {
return _handleDataSchemeUri(uri, width, height);
} else if (uri.scheme == 'resource') {
return Image.asset(
uri.path,
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else {
final Uri fileUri;
if (imageDirectory != null) {
try {
fileUri = Uri.parse(p.join(imageDirectory, uri.toString()));
} catch (error, stackTrace) {
// Handle any invalid file URI's.
return Builder(
builder: (BuildContext context) {
return kDefaultImageErrorWidgetBuilder(context, error, stackTrace);
},
);
}
} else {
fileUri = uri;
}
if (fileUri.scheme == 'http' || fileUri.scheme == 'https') {
return Image.network(
fileUri.toString(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else {
final String src = p.join(p.current, fileUri.toString());
return Image.network(
src,
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
}
}
};
/// A default error widget builder for handling image errors.
// ignore: prefer_function_declarations_over_variables
final ImageErrorWidgetBuilder kDefaultImageErrorWidgetBuilder = (
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return const SizedBox();
};
/// A default style sheet generator.
final MarkdownStyleSheet Function(BuildContext, MarkdownStyleSheetBaseTheme?)
// ignore: prefer_function_declarations_over_variables
kFallbackStyle = (
BuildContext context,
MarkdownStyleSheetBaseTheme? baseTheme,
) {
final MarkdownStyleSheet result = switch (baseTheme) {
MarkdownStyleSheetBaseTheme.platform when _userAgent.toDart.contains('Mac OS X') =>
MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)),
MarkdownStyleSheetBaseTheme.cupertino => MarkdownStyleSheet.fromCupertinoTheme(CupertinoTheme.of(context)),
_ => MarkdownStyleSheet.fromTheme(Theme.of(context)),
};
return result.copyWith(
textScaler: MediaQuery.textScalerOf(context),
);
};
Widget _handleDataSchemeUri(Uri uri, final double? width, final double? height) {
final String mimeType = uri.data!.mimeType;
if (mimeType.startsWith('image/')) {
return Image.memory(
uri.data!.contentAsBytes(),
width: width,
height: height,
errorBuilder: kDefaultImageErrorWidgetBuilder,
);
} else if (mimeType.startsWith('text/')) {
return Text(uri.data!.contentAsString());
}
return const SizedBox();
}
@JS('window.navigator.userAgent')
external JSString get _userAgent;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,856 @@
// Copyright 2013 The Flutter 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/cupertino.dart';
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.pPadding,
this.code,
this.h1,
this.h1Padding,
this.h2,
this.h2Padding,
this.h3,
this.h3Padding,
this.h4,
this.h4Padding,
this.h5,
this.h5Padding,
this.h6,
this.h6Padding,
this.em,
this.strong,
this.del,
this.blockquote,
this.img,
this.checkbox,
this.blockSpacing,
this.listIndent,
this.listBullet,
this.listBulletPadding,
this.tableHead,
this.tableBody,
this.tableHeadAlign,
this.tablePadding,
this.tableBorder,
this.tableColumnWidth,
this.tableScrollbarThumbVisibility,
this.tableCellsPadding,
this.tableCellsDecoration,
this.tableHeadCellsPadding,
this.tableHeadCellsDecoration,
this.tableVerticalAlignment = TableCellVerticalAlignment.middle,
this.blockquotePadding,
this.blockquoteDecoration,
this.codeblockPadding,
this.codeblockDecoration,
this.horizontalRuleDecoration,
this.textAlign = WrapAlignment.start,
this.h1Align = WrapAlignment.start,
this.h2Align = WrapAlignment.start,
this.h3Align = WrapAlignment.start,
this.h4Align = WrapAlignment.start,
this.h5Align = WrapAlignment.start,
this.h6Align = WrapAlignment.start,
this.unorderedListAlign = WrapAlignment.start,
this.orderedListAlign = WrapAlignment.start,
this.blockquoteAlign = WrapAlignment.start,
this.codeblockAlign = WrapAlignment.start,
this.superscriptFontFeatureTag,
@Deprecated('Use textScaler instead.') this.textScaleFactor,
TextScaler? textScaler,
}) : assert(
textScaler == null || textScaleFactor == null,
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
),
textScaler = textScaler ??
// Internally, only textScaler is used, so convert the scale factor
// to a linear scaler.
(textScaleFactor == null ? null : TextScaler.linear(textScaleFactor)),
_styles = <String, TextStyle?>{
'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,
'del': del,
'blockquote': blockquote,
'img': img,
'table': p,
'th': tableHead,
'tr': tableBody,
'td': tableBody,
};
/// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [ThemeData].
factory MarkdownStyleSheet.fromTheme(ThemeData theme) {
assert(theme.textTheme.bodyMedium?.fontSize != null);
return MarkdownStyleSheet(
a: const TextStyle(color: Colors.blue),
p: theme.textTheme.bodyMedium,
pPadding: EdgeInsets.zero,
code: theme.textTheme.bodyMedium!.copyWith(
backgroundColor: theme.cardTheme.color,
fontFamily: 'monospace',
fontSize: theme.textTheme.bodyMedium!.fontSize! * 0.85,
),
h1: theme.textTheme.headlineSmall,
h1Padding: EdgeInsets.zero,
h2: theme.textTheme.titleLarge,
h2Padding: EdgeInsets.zero,
h3: theme.textTheme.titleMedium,
h3Padding: EdgeInsets.zero,
h4: theme.textTheme.bodyLarge,
h4Padding: EdgeInsets.zero,
h5: theme.textTheme.bodyLarge,
h5Padding: EdgeInsets.zero,
h6: theme.textTheme.bodyLarge,
h6Padding: EdgeInsets.zero,
em: const TextStyle(fontStyle: FontStyle.italic),
strong: const TextStyle(fontWeight: FontWeight.bold),
del: const TextStyle(decoration: TextDecoration.lineThrough),
blockquote: theme.textTheme.bodyMedium,
img: theme.textTheme.bodyMedium,
checkbox: theme.textTheme.bodyMedium!.copyWith(
color: theme.primaryColor,
),
blockSpacing: 8.0,
listIndent: 24.0,
listBullet: theme.textTheme.bodyMedium,
listBulletPadding: const EdgeInsets.only(right: 4),
tableHead: const TextStyle(fontWeight: FontWeight.w600),
tableBody: theme.textTheme.bodyMedium,
tableHeadAlign: TextAlign.center,
tablePadding: const EdgeInsets.only(bottom: 4.0),
tableBorder: TableBorder.all(
color: theme.dividerColor,
),
tableColumnWidth: const FlexColumnWidth(),
tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
tableCellsDecoration: const BoxDecoration(),
blockquotePadding: const EdgeInsets.all(8.0),
blockquoteDecoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(2.0),
),
codeblockPadding: const EdgeInsets.all(8.0),
codeblockDecoration: BoxDecoration(
color: theme.cardTheme.color ?? theme.cardColor,
borderRadius: BorderRadius.circular(2.0),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 5.0,
color: theme.dividerColor,
),
),
),
);
}
/// Creates a [MarkdownStyleSheet] from the [TextStyle]s in the provided [CupertinoThemeData].
factory MarkdownStyleSheet.fromCupertinoTheme(CupertinoThemeData theme) {
assert(theme.textTheme.textStyle.fontSize != null);
return MarkdownStyleSheet(
a: theme.textTheme.textStyle.copyWith(
color: theme.brightness == Brightness.dark ? CupertinoColors.link.darkColor : CupertinoColors.link.color,
),
p: theme.textTheme.textStyle,
pPadding: EdgeInsets.zero,
code: theme.textTheme.textStyle.copyWith(
fontFamily: 'monospace',
fontSize: theme.textTheme.textStyle.fontSize! * 0.85,
),
h1: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
fontSize: theme.textTheme.textStyle.fontSize! + 10,
),
h1Padding: EdgeInsets.zero,
h2: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
fontSize: theme.textTheme.textStyle.fontSize! + 8,
),
h2Padding: EdgeInsets.zero,
h3: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
fontSize: theme.textTheme.textStyle.fontSize! + 6,
),
h3Padding: EdgeInsets.zero,
h4: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
fontSize: theme.textTheme.textStyle.fontSize! + 4,
),
h4Padding: EdgeInsets.zero,
h5: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
fontSize: theme.textTheme.textStyle.fontSize! + 2,
),
h5Padding: EdgeInsets.zero,
h6: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w500,
),
h6Padding: EdgeInsets.zero,
em: theme.textTheme.textStyle.copyWith(
fontStyle: FontStyle.italic,
),
strong: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.bold,
),
del: theme.textTheme.textStyle.copyWith(
decoration: TextDecoration.lineThrough,
),
blockquote: theme.textTheme.textStyle,
img: theme.textTheme.textStyle,
checkbox: theme.textTheme.textStyle.copyWith(
color: theme.primaryColor,
),
blockSpacing: 8,
listIndent: 24,
listBullet: theme.textTheme.textStyle,
listBulletPadding: const EdgeInsets.only(right: 4),
tableHead: theme.textTheme.textStyle.copyWith(
fontWeight: FontWeight.w600,
),
tableBody: theme.textTheme.textStyle,
tableHeadAlign: TextAlign.center,
tablePadding: const EdgeInsets.only(bottom: 8),
tableBorder: TableBorder.all(color: CupertinoColors.separator, width: 0),
tableColumnWidth: const FlexColumnWidth(),
tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
tableCellsDecoration: BoxDecoration(
color: theme.brightness == Brightness.dark
? CupertinoColors.systemGrey6.darkColor
: CupertinoColors.systemGrey6.color,
),
blockquotePadding: const EdgeInsets.all(16),
blockquoteDecoration: BoxDecoration(
color: theme.brightness == Brightness.dark
? CupertinoColors.systemGrey6.darkColor
: CupertinoColors.systemGrey6.color,
border: Border(
left: BorderSide(
color: theme.brightness == Brightness.dark
? CupertinoColors.systemGrey4.darkColor
: CupertinoColors.systemGrey4.color,
width: 4,
),
),
),
codeblockPadding: const EdgeInsets.all(8),
codeblockDecoration: BoxDecoration(
color: theme.brightness == Brightness.dark
? CupertinoColors.systemGrey6.darkColor
: CupertinoColors.systemGrey6.color,
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
color: theme.brightness == Brightness.dark
? CupertinoColors.systemGrey4.darkColor
: CupertinoColors.systemGrey4.color,
),
),
),
);
}
/// 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 MarkdownStyleSheet(
a: const TextStyle(color: Colors.blue),
p: theme.textTheme.bodyMedium,
pPadding: EdgeInsets.zero,
code: theme.textTheme.bodyMedium!.copyWith(
backgroundColor: theme.cardTheme.color,
fontFamily: 'monospace',
fontSize: theme.textTheme.bodyMedium!.fontSize! * 0.85,
),
h1: theme.textTheme.displayMedium,
h1Padding: EdgeInsets.zero,
h2: theme.textTheme.displaySmall,
h2Padding: EdgeInsets.zero,
h3: theme.textTheme.headlineMedium,
h3Padding: EdgeInsets.zero,
h4: theme.textTheme.headlineSmall,
h4Padding: EdgeInsets.zero,
h5: theme.textTheme.titleLarge,
h5Padding: EdgeInsets.zero,
h6: theme.textTheme.titleMedium,
h6Padding: EdgeInsets.zero,
em: const TextStyle(fontStyle: FontStyle.italic),
strong: const TextStyle(fontWeight: FontWeight.bold),
del: const TextStyle(decoration: TextDecoration.lineThrough),
blockquote: theme.textTheme.bodyMedium,
img: theme.textTheme.bodyMedium,
checkbox: theme.textTheme.bodyMedium!.copyWith(
color: theme.primaryColor,
),
blockSpacing: 8.0,
listIndent: 24.0,
listBullet: theme.textTheme.bodyMedium,
listBulletPadding: const EdgeInsets.only(right: 4),
tableHead: const TextStyle(fontWeight: FontWeight.w600),
tableBody: theme.textTheme.bodyMedium,
tableHeadAlign: TextAlign.center,
tablePadding: const EdgeInsets.only(bottom: 4.0),
tableBorder: TableBorder.all(
color: theme.dividerColor,
),
tableColumnWidth: const FlexColumnWidth(),
tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
tableCellsDecoration: const BoxDecoration(),
blockquotePadding: const EdgeInsets.all(8.0),
blockquoteDecoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(2.0),
),
codeblockPadding: const EdgeInsets.all(8.0),
codeblockDecoration: BoxDecoration(
color: theme.cardTheme.color ?? theme.cardColor,
borderRadius: BorderRadius.circular(2.0),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 5.0,
color: theme.dividerColor,
),
),
),
);
}
/// Creates a [MarkdownStyleSheet] based on the current style, with the
/// provided parameters overridden.
MarkdownStyleSheet copyWith({
TextStyle? a,
TextStyle? p,
EdgeInsets? pPadding,
TextStyle? code,
TextStyle? h1,
EdgeInsets? h1Padding,
TextStyle? h2,
EdgeInsets? h2Padding,
TextStyle? h3,
EdgeInsets? h3Padding,
TextStyle? h4,
EdgeInsets? h4Padding,
TextStyle? h5,
EdgeInsets? h5Padding,
TextStyle? h6,
EdgeInsets? h6Padding,
TextStyle? em,
TextStyle? strong,
TextStyle? del,
TextStyle? blockquote,
TextStyle? img,
TextStyle? checkbox,
double? blockSpacing,
double? listIndent,
TextStyle? listBullet,
EdgeInsets? listBulletPadding,
TextStyle? tableHead,
TextStyle? tableBody,
TextAlign? tableHeadAlign,
EdgeInsets? tablePadding,
TableBorder? tableBorder,
TableColumnWidth? tableColumnWidth,
bool? tableScrollbarThumbVisibility,
EdgeInsets? tableCellsPadding,
Decoration? tableCellsDecoration,
EdgeInsets? tableHeadCellsPadding,
Decoration? tableHeadCellsDecoration,
TableCellVerticalAlignment? tableVerticalAlignment,
EdgeInsets? blockquotePadding,
Decoration? blockquoteDecoration,
EdgeInsets? codeblockPadding,
Decoration? codeblockDecoration,
Decoration? horizontalRuleDecoration,
WrapAlignment? textAlign,
WrapAlignment? h1Align,
WrapAlignment? h2Align,
WrapAlignment? h3Align,
WrapAlignment? h4Align,
WrapAlignment? h5Align,
WrapAlignment? h6Align,
WrapAlignment? unorderedListAlign,
WrapAlignment? orderedListAlign,
WrapAlignment? blockquoteAlign,
WrapAlignment? codeblockAlign,
String? superscriptFontFeatureTag,
@Deprecated('Use textScaler instead.') double? textScaleFactor,
TextScaler? textScaler,
}) {
assert(
textScaler == null || textScaleFactor == null,
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
);
// If either of textScaler or textScaleFactor is non-null, pass null for the
// other instead of the previous value, since only one is allowed.
final TextScaler? newTextScaler = textScaler ?? (textScaleFactor == null ? this.textScaler : null);
final double? nextTextScaleFactor = textScaleFactor ?? (textScaler == null ? this.textScaleFactor : null);
return MarkdownStyleSheet(
a: a ?? this.a,
p: p ?? this.p,
pPadding: pPadding ?? this.pPadding,
code: code ?? this.code,
h1: h1 ?? this.h1,
h1Padding: h1Padding ?? this.h1Padding,
h2: h2 ?? this.h2,
h2Padding: h2Padding ?? this.h2Padding,
h3: h3 ?? this.h3,
h3Padding: h3Padding ?? this.h3Padding,
h4: h4 ?? this.h4,
h4Padding: h4Padding ?? this.h4Padding,
h5: h5 ?? this.h5,
h5Padding: h5Padding ?? this.h5Padding,
h6: h6 ?? this.h6,
h6Padding: h6Padding ?? this.h6Padding,
em: em ?? this.em,
strong: strong ?? this.strong,
del: del ?? this.del,
blockquote: blockquote ?? this.blockquote,
img: img ?? this.img,
checkbox: checkbox ?? this.checkbox,
blockSpacing: blockSpacing ?? this.blockSpacing,
listIndent: listIndent ?? this.listIndent,
listBullet: listBullet ?? this.listBullet,
listBulletPadding: listBulletPadding ?? this.listBulletPadding,
tableHead: tableHead ?? this.tableHead,
tableBody: tableBody ?? this.tableBody,
tableHeadAlign: tableHeadAlign ?? this.tableHeadAlign,
tablePadding: tablePadding ?? this.tablePadding,
tableBorder: tableBorder ?? this.tableBorder,
tableColumnWidth: tableColumnWidth ?? this.tableColumnWidth,
tableScrollbarThumbVisibility: tableScrollbarThumbVisibility,
tableCellsPadding: tableCellsPadding ?? this.tableCellsPadding,
tableCellsDecoration: tableCellsDecoration ?? this.tableCellsDecoration,
tableHeadCellsPadding: tableHeadCellsPadding ?? this.tableHeadCellsPadding,
tableHeadCellsDecoration: tableHeadCellsDecoration ?? this.tableHeadCellsDecoration,
tableVerticalAlignment: tableVerticalAlignment ?? this.tableVerticalAlignment,
blockquotePadding: blockquotePadding ?? this.blockquotePadding,
blockquoteDecoration: blockquoteDecoration ?? this.blockquoteDecoration,
codeblockPadding: codeblockPadding ?? this.codeblockPadding,
codeblockDecoration: codeblockDecoration ?? this.codeblockDecoration,
horizontalRuleDecoration: horizontalRuleDecoration ?? this.horizontalRuleDecoration,
textAlign: textAlign ?? this.textAlign,
h1Align: h1Align ?? this.h1Align,
h2Align: h2Align ?? this.h2Align,
h3Align: h3Align ?? this.h3Align,
h4Align: h4Align ?? this.h4Align,
h5Align: h5Align ?? this.h5Align,
h6Align: h6Align ?? this.h6Align,
unorderedListAlign: unorderedListAlign ?? this.unorderedListAlign,
orderedListAlign: orderedListAlign ?? this.orderedListAlign,
blockquoteAlign: blockquoteAlign ?? this.blockquoteAlign,
codeblockAlign: codeblockAlign ?? this.codeblockAlign,
superscriptFontFeatureTag: superscriptFontFeatureTag ?? this.superscriptFontFeatureTag,
textScaler: newTextScaler,
textScaleFactor: nextTextScaleFactor,
);
}
/// Returns a new text style that is a combination of this style and the given
/// [other] style.
MarkdownStyleSheet merge(MarkdownStyleSheet? other) {
if (other == null) {
return this;
}
return copyWith(
a: a!.merge(other.a),
p: p!.merge(other.p),
pPadding: other.pPadding,
code: code!.merge(other.code),
h1: h1!.merge(other.h1),
h1Padding: other.h1Padding,
h2: h2!.merge(other.h2),
h2Padding: other.h2Padding,
h3: h3!.merge(other.h3),
h3Padding: other.h3Padding,
h4: h4!.merge(other.h4),
h4Padding: other.h4Padding,
h5: h5!.merge(other.h5),
h5Padding: other.h5Padding,
h6: h6!.merge(other.h6),
h6Padding: other.h6Padding,
em: em!.merge(other.em),
strong: strong!.merge(other.strong),
del: del!.merge(other.del),
blockquote: blockquote!.merge(other.blockquote),
img: img!.merge(other.img),
checkbox: checkbox!.merge(other.checkbox),
blockSpacing: other.blockSpacing,
listIndent: other.listIndent,
listBullet: listBullet!.merge(other.listBullet),
listBulletPadding: other.listBulletPadding,
tableHead: tableHead!.merge(other.tableHead),
tableBody: tableBody!.merge(other.tableBody),
tableHeadAlign: other.tableHeadAlign,
tablePadding: other.tablePadding,
tableBorder: other.tableBorder,
tableColumnWidth: other.tableColumnWidth,
tableScrollbarThumbVisibility: other.tableScrollbarThumbVisibility,
tableCellsPadding: other.tableCellsPadding,
tableCellsDecoration: other.tableCellsDecoration,
tableHeadCellsPadding: other.tableHeadCellsPadding,
tableHeadCellsDecoration: other.tableHeadCellsDecoration,
tableVerticalAlignment: other.tableVerticalAlignment,
blockquotePadding: other.blockquotePadding,
blockquoteDecoration: other.blockquoteDecoration,
codeblockPadding: other.codeblockPadding,
codeblockDecoration: other.codeblockDecoration,
horizontalRuleDecoration: other.horizontalRuleDecoration,
textAlign: other.textAlign,
h1Align: other.h1Align,
h2Align: other.h2Align,
h3Align: other.h3Align,
h4Align: other.h4Align,
h5Align: other.h5Align,
h6Align: other.h6Align,
unorderedListAlign: other.unorderedListAlign,
orderedListAlign: other.orderedListAlign,
blockquoteAlign: other.blockquoteAlign,
codeblockAlign: other.codeblockAlign,
textScaleFactor: other.textScaleFactor,
superscriptFontFeatureTag: other.superscriptFontFeatureTag,
// Only one of textScaler and textScaleFactor can be passed. If
// other.textScaleFactor is non-null, then the sheet was created with a
// textScaleFactor and the textScaler was derived from that, so should be
// ignored so that the textScaleFactor continues to be set.
textScaler: other.textScaleFactor == null ? other.textScaler : null,
);
}
/// The [TextStyle] to use for `a` elements.
final TextStyle? a;
/// The [TextStyle] to use for `p` elements.
final TextStyle? p;
/// The padding to use for `p` elements.
final EdgeInsets? pPadding;
/// The [TextStyle] to use for `code` elements.
final TextStyle? code;
/// The [TextStyle] to use for `h1` elements.
final TextStyle? h1;
/// The padding to use for `h1` elements.
final EdgeInsets? h1Padding;
/// The [TextStyle] to use for `h2` elements.
final TextStyle? h2;
/// The padding to use for `h2` elements.
final EdgeInsets? h2Padding;
/// The [TextStyle] to use for `h3` elements.
final TextStyle? h3;
/// The padding to use for `h3` elements.
final EdgeInsets? h3Padding;
/// The [TextStyle] to use for `h4` elements.
final TextStyle? h4;
/// The padding to use for `h4` elements.
final EdgeInsets? h4Padding;
/// The [TextStyle] to use for `h5` elements.
final TextStyle? h5;
/// The padding to use for `h5` elements.
final EdgeInsets? h5Padding;
/// The [TextStyle] to use for `h6` elements.
final TextStyle? h6;
/// The padding to use for `h6` elements.
final EdgeInsets? h6Padding;
/// 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 `del` elements.
final TextStyle? del;
/// The [TextStyle] to use for `blockquote` elements.
final TextStyle? blockquote;
/// The [TextStyle] to use for `img` elements.
final TextStyle? img;
/// The [TextStyle] to use for `input` elements.
final TextStyle? checkbox;
/// 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 [TextStyle] to use for bullets.
final TextStyle? listBullet;
/// The padding to use for bullets.
final EdgeInsets? listBulletPadding;
/// The [TextStyle] to use for `th` elements.
final TextStyle? tableHead;
/// The [TextStyle] to use for `td` elements.
final TextStyle? tableBody;
/// The [TextAlign] to use for `th` elements.
final TextAlign? tableHeadAlign;
/// The padding to use for `table` elements.
final EdgeInsets? tablePadding;
/// The [TableBorder] to use for `table` elements.
final TableBorder? tableBorder;
/// The [TableColumnWidth] to use for `th` and `td` elements.
final TableColumnWidth? tableColumnWidth;
/// The scrollbar thumbVisibility when the table is scrollable.
final bool? tableScrollbarThumbVisibility;
/// The padding to use for `th` and `td` elements.
final EdgeInsets? tableCellsPadding;
/// The decoration to use for `th` and `td` elements.
final Decoration? tableCellsDecoration;
/// The padding to use for `th` elements.
///
/// If null, defaults to [tableCellsPadding].
final EdgeInsets? tableHeadCellsPadding;
/// The decoration to use for `th` elements.
///
/// If null, defaults to [tableCellsDecoration].
final Decoration? tableHeadCellsDecoration;
/// The [TableCellVerticalAlignment] to use for `th` and `td` elements.
final TableCellVerticalAlignment tableVerticalAlignment;
/// The padding to use for `blockquote` elements.
final EdgeInsets? blockquotePadding;
/// The decoration to use behind `blockquote` elements.
final Decoration? blockquoteDecoration;
/// The padding to use for `pre` elements.
final EdgeInsets? codeblockPadding;
/// The decoration to use behind for `pre` elements.
final Decoration? codeblockDecoration;
/// The decoration to use for `hr` elements.
final Decoration? horizontalRuleDecoration;
/// The [WrapAlignment] to use for normal text. Defaults to start.
final WrapAlignment textAlign;
/// The [WrapAlignment] to use for h1 text. Defaults to start.
final WrapAlignment h1Align;
/// The [WrapAlignment] to use for h2 text. Defaults to start.
final WrapAlignment h2Align;
/// The [WrapAlignment] to use for h3 text. Defaults to start.
final WrapAlignment h3Align;
/// The [WrapAlignment] to use for h4 text. Defaults to start.
final WrapAlignment h4Align;
/// The [WrapAlignment] to use for h5 text. Defaults to start.
final WrapAlignment h5Align;
/// The [WrapAlignment] to use for h6 text. Defaults to start.
final WrapAlignment h6Align;
/// The [WrapAlignment] to use for an unordered list. Defaults to start.
final WrapAlignment unorderedListAlign;
/// The [WrapAlignment] to use for an ordered list. Defaults to start.
final WrapAlignment orderedListAlign;
/// The [WrapAlignment] to use for a blockquote. Defaults to start.
final WrapAlignment blockquoteAlign;
/// The [WrapAlignment] to use for a code block. Defaults to start.
final WrapAlignment codeblockAlign;
/// The text scaler to use in textual elements.
final TextScaler? textScaler;
/// The text scale factor to use in textual elements.
///
/// This will be non-null only if the sheet was created with the deprecated
/// [textScaleFactor] instead of [textScaler].
@Deprecated('Use textScaler instead.')
final double? textScaleFactor;
/// Custom font feature tag for font which does not support `sups'
/// feature to create superscript in footnotes.
final String? superscriptFontFeatureTag;
/// A [Map] from element name to the corresponding [TextStyle] object.
Map<String, TextStyle?> get styles => _styles;
Map<String, TextStyle?> _styles;
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != MarkdownStyleSheet) {
return false;
}
return other is MarkdownStyleSheet &&
other.a == a &&
other.p == p &&
other.pPadding == pPadding &&
other.code == code &&
other.h1 == h1 &&
other.h1Padding == h1Padding &&
other.h2 == h2 &&
other.h2Padding == h2Padding &&
other.h3 == h3 &&
other.h3Padding == h3Padding &&
other.h4 == h4 &&
other.h4Padding == h4Padding &&
other.h5 == h5 &&
other.h5Padding == h5Padding &&
other.h6 == h6 &&
other.h6Padding == h6Padding &&
other.em == em &&
other.strong == strong &&
other.del == del &&
other.blockquote == blockquote &&
other.img == img &&
other.checkbox == checkbox &&
other.blockSpacing == blockSpacing &&
other.listIndent == listIndent &&
other.listBullet == listBullet &&
other.listBulletPadding == listBulletPadding &&
other.tableHead == tableHead &&
other.tableBody == tableBody &&
other.tableHeadAlign == tableHeadAlign &&
other.tablePadding == tablePadding &&
other.tableBorder == tableBorder &&
other.tableColumnWidth == tableColumnWidth &&
other.tableCellsPadding == tableCellsPadding &&
other.tableCellsDecoration == tableCellsDecoration &&
other.tableHeadCellsPadding == tableHeadCellsPadding &&
other.tableHeadCellsDecoration == tableHeadCellsDecoration &&
other.tableVerticalAlignment == tableVerticalAlignment &&
other.blockquotePadding == blockquotePadding &&
other.blockquoteDecoration == blockquoteDecoration &&
other.codeblockPadding == codeblockPadding &&
other.codeblockDecoration == codeblockDecoration &&
other.horizontalRuleDecoration == horizontalRuleDecoration &&
other.textAlign == textAlign &&
other.h1Align == h1Align &&
other.h2Align == h2Align &&
other.h3Align == h3Align &&
other.h4Align == h4Align &&
other.h5Align == h5Align &&
other.h6Align == h6Align &&
other.unorderedListAlign == unorderedListAlign &&
other.orderedListAlign == orderedListAlign &&
other.blockquoteAlign == blockquoteAlign &&
other.codeblockAlign == codeblockAlign &&
other.superscriptFontFeatureTag == superscriptFontFeatureTag &&
other.textScaler == textScaler;
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode {
return Object.hashAll(<Object?>[
a,
p,
pPadding,
code,
h1,
h1Padding,
h2,
h2Padding,
h3,
h3Padding,
h4,
h4Padding,
h5,
h5Padding,
h6,
h6Padding,
em,
strong,
del,
blockquote,
img,
checkbox,
blockSpacing,
listIndent,
listBullet,
listBulletPadding,
tableHead,
tableBody,
tableHeadAlign,
tablePadding,
tableBorder,
tableColumnWidth,
tableCellsPadding,
tableCellsDecoration,
tableHeadCellsPadding,
tableHeadCellsDecoration,
tableVerticalAlignment,
blockquotePadding,
blockquoteDecoration,
codeblockPadding,
codeblockDecoration,
horizontalRuleDecoration,
textAlign,
h1Align,
h2Align,
h3Align,
h4Align,
h5Align,
h6Align,
unorderedListAlign,
orderedListAlign,
blockquoteAlign,
codeblockAlign,
textScaler,
textScaleFactor,
superscriptFontFeatureTag,
]);
}
}

View file

@ -0,0 +1,589 @@
// Copyright 2013 The Flutter 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:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:markdown/markdown.dart' as md;
import '../flutter_markdown_plus.dart';
import '_functions_io.dart' if (dart.library.js_interop) '_functions_web.dart';
/// Signature for callbacks used by [MarkdownWidget] when
/// [MarkdownWidget.selectable] is set to true and the user changes selection.
///
/// The callback will return the entire block of text available for selection,
/// along with the current [selection] and the [cause] of the selection change.
/// This is a wrapper of [SelectionChangedCallback] with additional context
/// [text] for the caller to process.
///
/// Used by [MarkdownWidget.onSelectionChanged]
typedef MarkdownOnSelectionChangedCallback = void Function(
String? text, TextSelection selection, SelectionChangedCause? cause);
/// Signature for callbacks used by [MarkdownWidget] when the user taps a link.
/// The callback will return the link text, destination, and title from the
/// Markdown link tag in the document.
///
/// Used by [MarkdownWidget.onTapLink].
typedef MarkdownTapLinkCallback = void Function(String text, String? href, String title);
/// Signature for custom image widget.
///
/// Used by [MarkdownWidget.imageBuilder]
typedef MarkdownImageBuilder = Widget Function(Uri uri, String? title, String? alt);
/// Signature for custom checkbox widget.
///
/// Used by [MarkdownWidget.checkboxBuilder]
typedef MarkdownCheckboxBuilder = Widget Function(bool value);
/// Signature for custom bullet widget.
///
/// Used by [MarkdownWidget.bulletBuilder]
typedef MarkdownBulletBuilder = Widget Function(
MarkdownBulletParameters parameters,
);
/// An parameters of [MarkdownBulletBuilder].
///
/// Used by [MarkdownWidget.bulletBuilder]
class MarkdownBulletParameters {
/// Creates a new instance of [MarkdownBulletParameters].
const MarkdownBulletParameters({
required this.index,
required this.style,
required this.nestLevel,
});
/// The index of the bullet on that nesting level.
final int index;
/// The style of the bullet.
final BulletStyle style;
/// The nest level of the bullet.
final int nestLevel;
}
/// Enumeration sent to the user when calling [MarkdownBulletBuilder]
///
/// Use this to differentiate the bullet styling when building your own.
enum BulletStyle {
/// An ordered list.
orderedList,
/// An unordered list.
unorderedList,
}
/// 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 formatted [TextSpan] for the given string.
TextSpan format(String source);
}
/// An interface for an element builder.
abstract class MarkdownElementBuilder {
/// For block syntax has to return true.
///
/// By default returns false.
bool isBlockElement() => false;
/// Called when an Element has been reached, before its children have been
/// visited.
void visitElementBefore(md.Element element) {}
/// Called when a text node has been reached.
///
/// If [MarkdownWidget.styleSheet] has a style of this tag, will passing
/// to [preferredStyle].
///
/// If you needn't build a widget, return null.
Widget? visitText(md.Text text, TextStyle? preferredStyle) => null;
/// Called when an Element has been reached, after its children have been
/// visited.
///
/// If [MarkdownWidget.styleSheet] has a style with this tag, it will be
/// passed as [preferredStyle].
///
/// If parent element has [TextStyle] set, it will be passed as
/// [parentStyle].
///
/// If a widget build isn't needed, return null.
Widget? visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
return visitElementAfter(element, preferredStyle);
}
/// Called when an Element has been reached, after its children have been
/// visited.
///
/// If [MarkdownWidget.styleSheet] has a style of this tag, will passing
/// to [preferredStyle].
///
/// If you needn't build a widget, return null.
@Deprecated('Use visitElementAfterWithContext() instead.')
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) => null;
}
/// Enum to specify which theme being used when creating [MarkdownStyleSheet]
///
/// [material] - create MarkdownStyleSheet based on MaterialTheme
/// [cupertino] - create MarkdownStyleSheet based on CupertinoTheme
/// [platform] - create MarkdownStyleSheet based on the Platform where the
/// is running on. Material on Android and Cupertino on iOS
enum MarkdownStyleSheetBaseTheme {
/// Creates a MarkdownStyleSheet based on MaterialTheme.
material,
/// Creates a MarkdownStyleSheet based on CupertinoTheme.
cupertino,
/// Creates a MarkdownStyleSheet whose theme is based on the current platform.
platform,
}
/// Enumeration of alignment strategies for the cross axis of list items.
enum MarkdownListItemCrossAxisAlignment {
/// Uses [CrossAxisAlignment.baseline] for the row the bullet and the list
/// item are placed in.
///
/// This alignment will ensure that the bullet always lines up with
/// the list text on the baseline.
///
/// However, note that this alignment does not support intrinsic height
/// measurements because [RenderFlex] does not support it for
/// [CrossAxisAlignment.baseline].
/// See https://github.com/flutter/flutter_markdown_plus/issues/311 for cases,
/// where this might be a problem for you.
///
/// See also:
/// * [start], which allows for intrinsic height measurements.
baseline,
/// Uses [CrossAxisAlignment.start] for the row the bullet and the list item
/// are placed in.
///
/// This alignment will ensure that intrinsic height measurements work.
///
/// However, note that this alignment might not line up the bullet with the
/// list text in the way you would expect in certain scenarios.
/// See https://github.com/flutter/flutter_markdown_plus/issues/169 for example
/// cases that do not produce expected results.
///
/// See also:
/// * [baseline], which will position the bullet and list item on the
/// baseline.
start,
}
/// A base class for widgets that parse and display Markdown.
///
/// Supports all standard Markdown from the original
/// [Markdown specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
abstract class MarkdownWidget extends StatefulWidget {
/// Creates a widget that parses and displays Markdown.
///
/// The [data] argument must not be null.
const MarkdownWidget({
super.key,
required this.data,
this.selectable = false,
this.styleSheet,
this.styleSheetTheme = MarkdownStyleSheetBaseTheme.material,
this.syntaxHighlighter,
this.onSelectionChanged,
this.onTapLink,
this.onTapText,
this.imageDirectory,
this.blockSyntaxes,
this.inlineSyntaxes,
this.extensionSet,
this.imageBuilder,
this.checkboxBuilder,
this.bulletBuilder,
this.builders = const <String, MarkdownElementBuilder>{},
this.paddingBuilders = const <String, MarkdownPaddingBuilder>{},
this.fitContent = false,
this.listItemCrossAxisAlignment = MarkdownListItemCrossAxisAlignment.baseline,
this.softLineBreak = false,
});
/// The Markdown to display.
final String data;
/// If true, the text is selectable.
///
/// Defaults to false.
final bool selectable;
/// The styles to use when displaying the Markdown.
///
/// If null, the styles are inferred from the current [Theme].
final MarkdownStyleSheet? styleSheet;
/// Setting to specify base theme for MarkdownStyleSheet
///
/// Default to [MarkdownStyleSheetBaseTheme.material]
final MarkdownStyleSheetBaseTheme? styleSheetTheme;
/// 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;
/// Called when the user changes selection when [selectable] is set to true.
final MarkdownOnSelectionChangedCallback? onSelectionChanged;
/// Default tap handler used when [selectable] is set to true
final VoidCallback? onTapText;
/// The base directory holding images referenced by Img tags with local or network file paths.
final String? imageDirectory;
/// Collection of custom block syntax types to be used parsing the Markdown data.
final List<md.BlockSyntax>? blockSyntaxes;
/// Collection of custom inline syntax types to be used parsing the Markdown data.
final List<md.InlineSyntax>? inlineSyntaxes;
/// Markdown syntax extension set
///
/// Defaults to [md.ExtensionSet.gitHubFlavored]
final md.ExtensionSet? extensionSet;
/// Call when build an image widget.
final MarkdownImageBuilder? imageBuilder;
/// Call when build a checkbox widget.
final MarkdownCheckboxBuilder? checkboxBuilder;
/// Called when building a bullet
final MarkdownBulletBuilder? bulletBuilder;
/// Render certain tags, usually used with [extensionSet]
///
/// For example, we will add support for `sub` tag:
///
/// ```dart
/// builders: {
/// 'sub': SubscriptBuilder(),
/// }
/// ```
///
/// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder].
final Map<String, MarkdownElementBuilder> builders;
/// Add padding for different tags (use only for block elements and img)
///
/// For example, we will add padding for `img` tag:
///
/// ```dart
/// paddingBuilders: {
/// 'img': ImgPaddingBuilder(),
/// }
/// ```
///
/// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder].
final Map<String, MarkdownPaddingBuilder> paddingBuilders;
/// Whether to allow the widget to fit the child content.
final bool fitContent;
/// Controls the cross axis alignment for the bullet and list item content
/// in lists.
///
/// Defaults to [MarkdownListItemCrossAxisAlignment.baseline], which
/// does not allow for intrinsic height measurements.
final MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment;
/// The soft line break is used to identify the spaces at the end of aline of
/// text and the leading spaces in the immediately following the line of text.
///
/// Default these spaces are removed in accordance with the Markdown
/// specification on soft line breaks when lines of text are joined.
final bool softLineBreak;
/// Subclasses should override this function to display the given children,
/// which are the parsed representation of [data].
@protected
Widget build(BuildContext context, List<Widget>? children);
@override
State<MarkdownWidget> createState() => _MarkdownWidgetState();
}
class _MarkdownWidgetState extends State<MarkdownWidget> implements MarkdownBuilderDelegate {
List<Widget>? _children;
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
@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 fallbackStyleSheet = kFallbackStyle(context, widget.styleSheetTheme);
final MarkdownStyleSheet styleSheet = fallbackStyleSheet.merge(widget.styleSheet);
_disposeRecognizers();
final md.Document document = md.Document(
blockSyntaxes: widget.blockSyntaxes,
inlineSyntaxes: widget.inlineSyntaxes,
extensionSet: widget.extensionSet ?? md.ExtensionSet.gitHubFlavored,
encodeHtml: false,
);
// Parse the source Markdown data into nodes of an Abstract Syntax Tree.
final List<String> lines = const LineSplitter().convert(widget.data);
final List<md.Node> astNodes = document.parseLines(lines);
// Configure a Markdown widget builder to traverse the AST nodes and
// create a widget tree based on the elements.
final MarkdownBuilder builder = MarkdownBuilder(
delegate: this,
selectable: widget.selectable,
styleSheet: styleSheet,
imageDirectory: widget.imageDirectory,
imageBuilder: widget.imageBuilder,
checkboxBuilder: widget.checkboxBuilder,
bulletBuilder: widget.bulletBuilder,
builders: widget.builders,
paddingBuilders: widget.paddingBuilders,
fitContent: widget.fitContent,
listItemCrossAxisAlignment: widget.listItemCrossAxisAlignment,
onSelectionChanged: widget.onSelectionChanged,
onTapText: widget.onTapText,
softLineBreak: widget.softLineBreak,
);
_children = builder.build(astNodes);
}
void _disposeRecognizers() {
if (_recognizers.isEmpty) {
return;
}
final List<GestureRecognizer> localRecognizers = List<GestureRecognizer>.from(_recognizers);
_recognizers.clear();
for (final GestureRecognizer recognizer in localRecognizers) {
recognizer.dispose();
}
}
@override
GestureRecognizer createLink(String text, String? href, String title) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
if (widget.onTapLink != null) {
widget.onTapLink!(text, href, title);
}
};
_recognizers.add(recognizer);
return recognizer;
}
@override
TextSpan formatText(MarkdownStyleSheet styleSheet, String code) {
code = code.replaceAll(RegExp(r'\n$'), '');
if (widget.syntaxHighlighter != null) {
return widget.syntaxHighlighter!.format(code);
}
return 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 GitHub Flavored Markdown from the
/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [Markdown], which is a scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
class MarkdownBody extends MarkdownWidget {
/// Creates a non-scrolling widget that parses and displays Markdown.
const MarkdownBody({
super.key,
required super.data,
super.selectable,
super.styleSheet,
super.styleSheetTheme = null,
super.syntaxHighlighter,
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.imageDirectory,
super.blockSyntaxes,
super.inlineSyntaxes,
super.extensionSet,
super.imageBuilder,
super.checkboxBuilder,
super.bulletBuilder,
super.builders,
super.paddingBuilders,
super.listItemCrossAxisAlignment,
this.shrinkWrap = true,
super.fitContent = true,
super.softLineBreak,
});
/// If [shrinkWrap] is `true`, [MarkdownBody] will take the minimum height
/// that wraps its content. Otherwise, [MarkdownBody] will expand to the
/// maximum allowed height.
final bool shrinkWrap;
@override
Widget build(BuildContext context, List<Widget>? children) {
if (children!.length == 1 && shrinkWrap) {
return children.single;
}
return Column(
mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
crossAxisAlignment: fitContent ? CrossAxisAlignment.start : CrossAxisAlignment.stretch,
children: children,
);
}
}
/// A scrolling widget that parses and displays Markdown.
///
/// Supports all GitHub Flavored Markdown from the
/// [specification](https://github.github.com/gfm/).
///
/// See also:
///
/// * [MarkdownBody], which is a non-scrolling container of Markdown.
/// * <https://github.github.com/gfm/>
class Markdown extends MarkdownWidget {
/// Creates a scrolling widget that parses and displays Markdown.
const Markdown({
super.key,
required super.data,
super.selectable,
super.styleSheet,
super.styleSheetTheme = null,
super.syntaxHighlighter,
super.onSelectionChanged,
super.onTapLink,
super.onTapText,
super.imageDirectory,
super.blockSyntaxes,
super.inlineSyntaxes,
super.extensionSet,
super.imageBuilder,
super.checkboxBuilder,
super.bulletBuilder,
super.builders,
super.paddingBuilders,
super.listItemCrossAxisAlignment,
this.padding = const EdgeInsets.all(16.0),
this.controller,
this.physics,
this.shrinkWrap = false,
super.softLineBreak,
});
/// The amount of space by which to inset the children.
final EdgeInsets padding;
/// An object that can be used to control the position to which this scroll view is scrolled.
///
/// See also: [ScrollView.controller]
final ScrollController? controller;
/// How the scroll view should respond to user input.
///
/// See also: [ScrollView.physics]
final ScrollPhysics? physics;
/// Whether the extent of the scroll view in the scroll direction should be
/// determined by the contents being viewed.
///
/// See also: [ScrollView.shrinkWrap]
final bool shrinkWrap;
@override
Widget build(BuildContext context, List<Widget>? children) {
return ListView(
padding: padding,
controller: controller,
physics: physics,
shrinkWrap: shrinkWrap,
children: children!,
);
}
}
/// Parse [task list items](https://github.github.com/gfm/#task-list-items-extension-).
///
/// This class is no longer used as Markdown now supports checkbox syntax natively.
@Deprecated('Use [OrderedListWithCheckBoxSyntax] or [UnorderedListWithCheckBoxSyntax]')
class TaskListSyntax extends md.InlineSyntax {
/// Creates a new instance.
@Deprecated('Use [OrderedListWithCheckBoxSyntax] or [UnorderedListWithCheckBoxSyntax]')
TaskListSyntax() : super(_pattern);
static const String _pattern = r'^ *\[([ xX])\] +';
@override
bool onMatch(md.InlineParser parser, Match match) {
final md.Element el = md.Element.withTag('input');
el.attributes['type'] = 'checkbox';
el.attributes['disabled'] = 'true';
el.attributes['checked'] = '${match[1]!.trim().isNotEmpty}';
parser.addNode(el);
return true;
}
}
/// An interface for an padding builder for element.
abstract class MarkdownPaddingBuilder {
/// Called when an Element has been reached, before its children have been
/// visited.
void visitElementBefore(md.Element element) {}
/// Called when a widget node has been rendering and need tag padding.
EdgeInsets getPadding() => EdgeInsets.zero;
}

View file

@ -0,0 +1,32 @@
name: flutter_markdown_plus
description: A Markdown renderer for Flutter. Create rich text output,
including text styles, tables, links, and more, from plain text data
formatted with simple Markdown tags.
repository: https://github.com/foresightmobile/flutter_markdown_plus
issue_tracker: https://github.com/foresightmobile/flutter_markdown_plus/issues
version: 1.0.7
environment:
sdk: ^3.4.0
flutter: ">=3.27.1"
dependencies:
flutter:
sdk: flutter
markdown: ^7.3.0
meta: ^1.16.0
path: ^1.9.1
dev_dependencies:
flutter_test:
sdk: flutter
leak_tracker_flutter_testing: any
mockito: ^5.5.0
standard_message_codec: ^0.0.1+4
topics:
- markdown
- widgets
formatter:
page_width: 120

View file

@ -0,0 +1,45 @@
// Copyright 2013 The Flutter 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 'blockquote_test.dart' as blockquote_test;
import 'custom_syntax_test.dart' as custome_syntax_test;
import 'emphasis_test.dart' as emphasis_test;
import 'footnote_test.dart' as footnote_test;
import 'header_test.dart' as header_test;
import 'horizontal_rule_test.dart' as horizontal_rule_test;
import 'html_test.dart' as html_test;
import 'image_test.dart' as image_test;
import 'line_break_test.dart' as line_break_test;
import 'link_test.dart' as link_test;
import 'list_test.dart' as list_test;
import 'scrollable_test.dart' as scrollable_test;
import 'selection_area_compatibility_test.dart' as selection_area_test;
import 'style_sheet_test.dart' as style_sheet_test;
import 'table_test.dart' as table_test;
import 'text_alignment_test.dart' as text_alignment_test;
import 'text_scaler_test.dart' as text_scaler;
import 'text_test.dart' as text_test;
import 'uri_test.dart' as uri_test;
void main() {
blockquote_test.defineTests();
custome_syntax_test.defineTests();
emphasis_test.defineTests();
footnote_test.defineTests();
header_test.defineTests();
horizontal_rule_test.defineTests();
html_test.defineTests();
image_test.defineTests();
line_break_test.defineTests();
link_test.defineTests();
list_test.defineTests();
scrollable_test.defineTests();
selection_area_test.defineTests();
style_sheet_test.defineTests();
table_test.defineTests();
text_test.defineTests();
text_alignment_test.defineTests();
text_scaler.defineTests();
uri_test.defineTests();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,106 @@
// Copyright 2013 The Flutter 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_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Blockquote', () {
testWidgets(
'simple one word blockquote',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: '> quote'),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['quote']);
},
);
testWidgets(
'soft wrapping in blockquote',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: '> soft\n> wrap'),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['soft wrap']);
},
);
testWidgets(
'should work with styling',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(
textTheme: textTheme,
);
final MarkdownStyleSheet styleSheet = MarkdownStyleSheet.fromTheme(
theme,
);
const String data =
'> this is a link: [Markdown guide](https://www.markdownguide.org) and this is **bold** and *italic*';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
styleSheet: styleSheet,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final DecoratedBox blockQuoteContainer = tester.widget(
find.byType(DecoratedBox),
);
final Text quoteText = tester.widget(find.byType(Text));
final List<TextSpan> styledTextParts = (quoteText.textSpan! as TextSpan).children!.cast<TextSpan>();
expectTextStrings(
widgets,
<String>['this is a link: Markdown guide and this is bold and italic'],
);
expect(
(blockQuoteContainer.decoration as BoxDecoration).color,
(styleSheet.blockquoteDecoration as BoxDecoration?)!.color,
);
expect(
(blockQuoteContainer.decoration as BoxDecoration).borderRadius,
(styleSheet.blockquoteDecoration as BoxDecoration?)!.borderRadius,
);
/// this is a link
expect(styledTextParts[0].text, 'this is a link: ');
expect(
styledTextParts[0].style!.color,
theme.textTheme.bodyMedium!.color,
);
/// Markdown guide
expect(styledTextParts[1].text, 'Markdown guide');
expect(styledTextParts[1].style!.color, styleSheet.blockquote!.color);
/// and this is
expect(
styledTextParts[2].style!.color,
theme.textTheme.bodyMedium!.color,
);
/// bold
expect(styledTextParts[2].text, ' and this is bold and italic');
expect(styledTextParts[2].style!.fontWeight, FontWeight.w400);
},
);
});
}

View file

@ -0,0 +1,445 @@
// Copyright 2013 The Flutter 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:markdown/markdown.dart' as md;
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Custom Syntax', () {
testWidgets(
'Subscript',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'H_2O',
extensionSet: md.ExtensionSet.none,
inlineSyntaxes: <md.InlineSyntax>[SubscriptSyntax()],
builders: <String, MarkdownElementBuilder>{
'sub': SubscriptBuilder(),
},
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['H₂O']);
},
);
testWidgets(
'Custom block element',
(WidgetTester tester) async {
const String blockContent = 'note block';
await tester.pumpWidget(
boilerplate(
Markdown(
data: '[!NOTE] $blockContent',
extensionSet: md.ExtensionSet.none,
blockSyntaxes: <md.BlockSyntax>[NoteSyntax()],
builders: <String, MarkdownElementBuilder>{
'note': NoteBuilder(),
},
),
),
);
final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox;
expect(container.color, Colors.red);
expect(container.child, isInstanceOf<Text>());
expect((container.child! as Text).data, blockContent);
},
);
testWidgets(
'Block with custom tag',
(WidgetTester tester) async {
const String textBefore = 'Before ';
const String textAfter = ' After';
const String blockContent = 'Custom content rendered in a ColoredBox';
await tester.pumpWidget(
boilerplate(
Markdown(
data: '$textBefore\n{{custom}}\n$blockContent\n{{/custom}}\n$textAfter',
extensionSet: md.ExtensionSet.none,
blockSyntaxes: <md.BlockSyntax>[CustomTagBlockSyntax()],
builders: <String, MarkdownElementBuilder>{
'custom': CustomTagBlockBuilder(),
},
),
),
);
final ColoredBox container = tester.widgetList(find.byType(ColoredBox)).first as ColoredBox;
expect(container.color, Colors.red);
expect(container.child, isInstanceOf<Text>());
expect((container.child! as Text).data, blockContent);
},
);
testWidgets(
'link for wikistyle',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'This is a [[wiki link]]',
extensionSet: md.ExtensionSet.none,
inlineSyntaxes: <md.InlineSyntax>[WikilinkSyntax()],
builders: <String, MarkdownElementBuilder>{
'wikilink': WikilinkBuilder(),
},
),
),
);
final Text textWidget = tester.widget(find.byType(Text));
final TextSpan span = (textWidget.textSpan! as TextSpan).children![1] as TextSpan;
expect(span.children, null);
expect(span.recognizer.runtimeType, equals(TapGestureRecognizer));
},
);
testWidgets(
'WidgetSpan in Text.rich is handled correctly',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'container is a widget that allows to customize its child',
extensionSet: md.ExtensionSet.none,
inlineSyntaxes: <md.InlineSyntax>[ContainerSyntax()],
builders: <String, MarkdownElementBuilder>{
'container': ContainerBuilder(),
},
),
),
);
final Text textWidget = tester.widget(find.byType(Text));
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
final WidgetSpan widgetSpan = textSpan.children![0] as WidgetSpan;
expect(widgetSpan.child, isInstanceOf<Container>());
},
);
testWidgets(
'visitElementAfterWithContext is handled correctly',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
Markdown(
data: r'# This is a header with some \color1{color} in it',
extensionSet: md.ExtensionSet.none,
inlineSyntaxes: <md.InlineSyntax>[InlineTextColorSyntax()],
builders: <String, MarkdownElementBuilder>{
'inlineTextColor': InlineTextColorElementBuilder(),
},
),
),
);
final Text textWidget = tester.widget(find.byType(Text));
final TextSpan rootSpan = textWidget.textSpan! as TextSpan;
final TextSpan firstSpan = rootSpan.children![0] as TextSpan;
final TextSpan secondSpan = rootSpan.children![1] as TextSpan;
final TextSpan thirdSpan = rootSpan.children![2] as TextSpan;
expect(secondSpan.style!.color, Colors.red);
expect(secondSpan.style!.fontSize, firstSpan.style!.fontSize);
expect(secondSpan.style!.fontSize, thirdSpan.style!.fontSize);
},
);
});
testWidgets(
'TextSpan and WidgetSpan as children in Text.rich are handled correctly',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'this test replaces a string with a container',
extensionSet: md.ExtensionSet.none,
inlineSyntaxes: <md.InlineSyntax>[ContainerSyntax()],
builders: <String, MarkdownElementBuilder>{
'container': ContainerBuilder2(),
},
),
),
);
final Text textWidget = tester.widget(find.byType(Text));
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
final TextSpan start = textSpan.children![0] as TextSpan;
expect(start.text, 'this test replaces a string with a ');
final TextSpan foo = textSpan.children![1] as TextSpan;
expect(foo.text, 'foo');
final WidgetSpan widgetSpan = textSpan.children![2] as WidgetSpan;
expect(widgetSpan.child, isInstanceOf<Container>());
},
);
testWidgets(
'Custom rendering of tags without children',
(WidgetTester tester) async {
const String data = '![alt](/assets/images/logo.png)';
await tester.pumpWidget(
boilerplate(
Markdown(
data: data,
builders: <String, MarkdownElementBuilder>{
'img': ImgBuilder(),
},
),
),
);
final Finder imageFinder = find.byType(Image);
expect(imageFinder, findsNothing);
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text textWidget = tester.widget(find.byType(Text));
expect(textWidget.data, 'foo');
},
);
}
class SubscriptSyntax extends md.InlineSyntax {
SubscriptSyntax() : super(_pattern);
static const String _pattern = r'_([0-9]+)';
@override
bool onMatch(md.InlineParser parser, Match match) {
parser.addNode(md.Element.text('sub', match[1]!));
return true;
}
}
class SubscriptBuilder extends MarkdownElementBuilder {
static const List<String> _subscripts = <String>['', '', '', '', '', '', '', '', '', ''];
@override
Widget visitElementAfter(md.Element element, _) {
// We don't currently have a way to control the vertical alignment of text spans.
// See https://github.com/flutter/flutter/issues/10906#issuecomment-385723664
final String textContent = element.textContent;
String text = '';
for (int i = 0; i < textContent.length; i++) {
text += _subscripts[int.parse(textContent[i])];
}
return Text.rich(TextSpan(text: text));
}
}
class WikilinkSyntax extends md.InlineSyntax {
WikilinkSyntax() : super(_pattern);
static const String _pattern = r'\[\[(.*?)\]\]';
@override
bool onMatch(md.InlineParser parser, Match match) {
final String link = match[1]!;
final md.Element el = md.Element('wikilink', <md.Element>[md.Element.text('span', link)])
..attributes['href'] = link.replaceAll(' ', '_');
parser.addNode(el);
return true;
}
}
class WikilinkBuilder extends MarkdownElementBuilder {
@override
Widget visitElementAfter(md.Element element, _) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()..onTap = () {};
addTearDown(recognizer.dispose);
return Text.rich(
TextSpan(text: element.textContent, recognizer: recognizer),
);
}
}
class ContainerSyntax extends md.InlineSyntax {
ContainerSyntax() : super(_pattern);
static const String _pattern = 'container';
@override
bool onMatch(md.InlineParser parser, Match match) {
parser.addNode(
md.Element.text('container', ''),
);
return true;
}
}
class ContainerBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(md.Element element, _) {
return Text.rich(
TextSpan(
children: <InlineSpan>[
WidgetSpan(
child: Container(),
),
],
),
);
}
}
class ContainerBuilder2 extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(md.Element element, _) {
return Text.rich(
TextSpan(
children: <InlineSpan>[
const TextSpan(text: 'foo'),
WidgetSpan(
child: Container(),
),
],
),
);
}
}
// Note: The implementation of inline span is incomplete, it does not handle
// bold, italic, ... text with a colored block.
// This would not work: `\color1{Text with *bold* text}`
class InlineTextColorSyntax extends md.InlineSyntax {
InlineTextColorSyntax() : super(r'\\color([1-9]){(.*?)}');
@override
bool onMatch(md.InlineParser parser, Match match) {
final String colorId = match.group(1)!;
final String textContent = match.group(2)!;
final md.Element node = md.Element.text(
'inlineTextColor',
textContent,
)..attributes['color'] = colorId;
parser.addNode(node);
parser.addNode(
md.Text(''),
);
return true;
}
}
class InlineTextColorElementBuilder extends MarkdownElementBuilder {
@override
Widget visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
final String innerText = element.textContent;
final String color = element.attributes['color'] ?? '';
final Map<String, Color> contentColors = <String, Color>{
'1': Colors.red,
'2': Colors.green,
'3': Colors.blue,
};
final Color? contentColor = contentColors[color];
return Text.rich(
TextSpan(
text: innerText,
style: parentStyle?.copyWith(color: contentColor),
),
);
}
}
class ImgBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
return Text('foo', style: preferredStyle);
}
}
class NoteBuilder extends MarkdownElementBuilder {
@override
Widget? visitText(md.Text text, TextStyle? preferredStyle) {
return ColoredBox(color: Colors.red, child: Text(text.text, style: preferredStyle));
}
@override
bool isBlockElement() {
return true;
}
}
class NoteSyntax extends md.BlockSyntax {
@override
md.Node? parse(md.BlockParser parser) {
final md.Line line = parser.current;
parser.advance();
return md.Element('note', <md.Node>[md.Text(line.content.substring(8))]);
}
@override
RegExp get pattern => RegExp(r'^\[!NOTE] ');
}
class CustomTagBlockBuilder extends MarkdownElementBuilder {
@override
bool isBlockElement() => true;
@override
Widget visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
if (element.tag == 'custom') {
final String content = element.attributes['content']!;
return ColoredBox(color: Colors.red, child: Text(content, style: preferredStyle));
}
return const SizedBox.shrink();
}
}
class CustomTagBlockSyntax extends md.BlockSyntax {
@override
bool canParse(md.BlockParser parser) {
return parser.current.content.startsWith('{{custom}}');
}
@override
RegExp get pattern => RegExp(r'\{\{custom\}\}([\s\S]*?)\{\{/custom\}\}');
@override
md.Node parse(md.BlockParser parser) {
parser.advance();
final StringBuffer buffer = StringBuffer();
while (!parser.current.content.startsWith('{{/custom}}') && !parser.isDone) {
buffer.writeln(parser.current.content);
parser.advance();
}
if (!parser.isDone) {
parser.advance();
}
final String content = buffer.toString().trim();
final md.Element element = md.Element.empty('custom');
element.attributes['content'] = content;
return element;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
// Copyright 2013 The Flutter 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 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
LeakTesting.enable();
LeakTracking.warnForUnsupportedPlatforms = false;
await testMain();
}

View file

@ -0,0 +1,281 @@
// Copyright 2013 The Flutter 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/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group(
'structure',
() {
testWidgets(
'footnote is detected and handle correctly',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1',
'1.',
'Bar ↩',
]);
},
);
testWidgets(
'footnote is detected and handle correctly for selectable markdown',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
selectable: true,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1',
'1.',
'Bar ↩',
]);
},
);
testWidgets(
'ignore footnotes without description',
(WidgetTester tester) async {
const String data = 'Foo[^1] Bar[^2]\n[^1]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1 Bar[^2]',
'1.',
'Bar ↩',
]);
},
);
testWidgets(
'ignore superscripts and footnotes order',
(WidgetTester tester) async {
const String data = '[^2]: Bar \n [^1]: Foo \n Foo[^f] Bar[^b]';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'Foo1 Bar2',
'1.',
'Foo ↩',
'2.',
'Bar ↩',
]);
},
);
testWidgets(
'handle two digits superscript',
(WidgetTester tester) async {
const String data = '''
1[^1] 2[^2] 3[^3] 4[^4] 5[^5] 6[^6] 7[^7] 8[^8] 9[^9] 10[^10]
[^1]:1
[^2]:2
[^3]:3
[^4]:4
[^5]:5
[^6]:6
[^7]:7
[^8]:8
[^9]:9
[^10]:10
''';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'11 22 33 44 55 66 77 88 99 1010',
'1.',
'1 ↩',
'2.',
'2 ↩',
'3.',
'3 ↩',
'4.',
'4 ↩',
'5.',
'5 ↩',
'6.',
'6 ↩',
'7.',
'7 ↩',
'8.',
'8 ↩',
'9.',
'9 ↩',
'10.',
'10 ↩',
]);
},
);
},
);
group(
'superscript textstyle replacing',
() {
testWidgets(
'superscript has correct default fontfeature',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text;
final TextSpan span = text.textSpan! as TextSpan;
final List<InlineSpan>? children = span.children;
expect(children, isNotNull);
expect(children!.length, 2);
expect(children[1].style, isNotNull);
expect(children[1].style!.fontFeatures?.length, 1);
expect(children[1].style!.fontFeatures?.first.feature, 'sups');
},
);
testWidgets(
'superscript has correct custom fontfeature',
(WidgetTester tester) async {
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
styleSheet: MarkdownStyleSheet(superscriptFontFeatureTag: 'numr'),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text;
final TextSpan span = text.textSpan! as TextSpan;
final List<InlineSpan>? children = span.children;
expect(children, isNotNull);
expect(children!.length, 2);
expect(children[1].style, isNotNull);
expect(children[1].style!.fontFeatures?.length, 2);
expect(children[1].style!.fontFeatures?[1].feature, 'numr');
},
);
testWidgets(
'superscript index has the same font style like text',
(WidgetTester tester) async {
const String data = '# Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text;
final TextSpan span = text.textSpan! as TextSpan;
final List<InlineSpan>? children = span.children;
expect(children![0].style, isNotNull);
expect(children[1].style!.fontSize, children[0].style!.fontSize);
expect(children[1].style!.fontFamily, children[0].style!.fontFamily);
expect(children[1].style!.fontStyle, children[0].style!.fontStyle);
expect(children[1].style!.fontSize, children[0].style!.fontSize);
},
);
testWidgets(
'link is correctly copied to new superscript index',
(WidgetTester tester) async {
final List<MarkdownLink> linkTapResults = <MarkdownLink>[];
const String data = 'Foo[^a]\n[^a]: Bar';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
onTapLink: (String text, String? href, String title) =>
linkTapResults.add(MarkdownLink(text, href, title)),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Text text = widgets.firstWhere((Widget widget) => widget is Text) as Text;
final TextSpan span = text.textSpan! as TextSpan;
final List<Type> gestureRecognizerTypes = <Type>[];
span.visitChildren((InlineSpan inlineSpan) {
if (inlineSpan is TextSpan) {
final TapGestureRecognizer? recognizer = inlineSpan.recognizer as TapGestureRecognizer?;
gestureRecognizerTypes.add(recognizer?.runtimeType ?? Null);
if (recognizer != null) {
recognizer.onTap!();
}
}
return true;
});
expect(span.children!.length, 2);
expect(
gestureRecognizerTypes,
orderedEquals(<Type>[Null, TapGestureRecognizer]),
);
expectLinkTap(linkTapResults[0], const MarkdownLink('1', '#fn-a'));
},
);
},
);
}

View file

@ -0,0 +1,35 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Header', () {
testWidgets(
'level one',
(WidgetTester tester) async {
const String data = '# Header';
await tester.pumpWidget(boilerplate(const MarkdownBody(data: data)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['Header']);
},
);
});
}

View file

@ -0,0 +1,88 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Horizontal Rule', () {
testWidgets(
'3 consecutive hyphens',
(WidgetTester tester) async {
const String data = '---';
await tester.pumpWidget(boilerplate(const MarkdownBody(data: data)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]);
},
);
testWidgets(
'5 consecutive hyphens',
(WidgetTester tester) async {
const String data = '-----';
await tester.pumpWidget(boilerplate(const MarkdownBody(data: data)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]);
},
);
testWidgets(
'3 asterisks separated with spaces',
(WidgetTester tester) async {
const String data = '* * *';
await tester.pumpWidget(boilerplate(const MarkdownBody(data: data)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Container, DecoratedBox, Padding, LimitedBox, ConstrainedBox]);
},
);
testWidgets(
'3 asterisks separated with spaces alongside text Markdown',
(WidgetTester tester) async {
const String data = '# h1\n ## h2\n* * *';
await tester.pumpWidget(boilerplate(const MarkdownBody(data: data)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Column,
Wrap,
Text,
RichText,
SizedBox,
Column,
Wrap,
Text,
RichText,
SizedBox,
Container,
DecoratedBox,
Padding,
LimitedBox,
ConstrainedBox
]);
},
);
});
}

View file

@ -0,0 +1,55 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('HTML', () {
testWidgets(
'ignore tags',
(WidgetTester tester) async {
final List<String> data = <String>[
'Line 1\n<p>HTML content</p>\nLine 2',
'Line 1\n<!-- HTML\n comment\n ignored --><\nLine 2'
];
for (final String line in data) {
await tester.pumpWidget(boilerplate(MarkdownBody(data: line)));
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['Line 1', 'Line 2']);
}
},
);
testWidgets(
"doesn't convert & to &amp; when parsing",
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const Markdown(data: '&'),
),
);
expectTextStrings(tester.allWidgets, <String>['&']);
},
);
testWidgets(
"doesn't convert < to &lt; when parsing",
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const Markdown(data: '<'),
),
);
expectTextStrings(tester.allWidgets, <String>['<']);
},
);
});
}

View file

@ -0,0 +1,448 @@
// Copyright 2013 The Flutter 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' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'image_test_mocks.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Image', () {
setUp(() {
// Only needs to be done once since the HttpClient generated
// by this override is cached as a static singleton.
io.HttpOverrides.global = TestHttpOverrides();
});
testWidgets(
'should not interrupt styling',
(WidgetTester tester) async {
const String data = '_textbefore ![alt](https://img) textafter_';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Iterable<Text> texts = tester.widgetList(find.byType(Text));
final Text firstTextWidget = texts.first;
final TextSpan firstTextSpan = firstTextWidget.textSpan! as TextSpan;
final Image image = tester.widget(find.byType(Image));
final NetworkImage networkImage = image.image as NetworkImage;
final Text secondTextWidget = texts.last;
final TextSpan secondTextSpan = secondTextWidget.textSpan! as TextSpan;
expect(firstTextSpan.text, 'textbefore ');
expect(firstTextSpan.style!.fontStyle, FontStyle.italic);
expect(networkImage.url, 'https://img');
expect(secondTextSpan.text, ' textafter');
expect(secondTextSpan.style!.fontStyle, FontStyle.italic);
},
);
testWidgets(
'should work with a link',
(WidgetTester tester) async {
const String data = '![alt](https://img#50x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Image image = tester.widget(find.byType(Image));
final NetworkImage networkImage = image.image as NetworkImage;
expect(networkImage.url, 'https://img');
expect(image.width, 50);
expect(image.height, 50);
},
);
testWidgets(
'should work with relative remote image',
(WidgetTester tester) async {
const String data = '![alt](/img.png)';
await tester.pumpWidget(
boilerplate(
const Markdown(
data: data,
imageDirectory: 'https://localhost',
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image is NetworkImage, isTrue);
expect((image.image as NetworkImage).url, 'https://localhost/img.png');
},
);
testWidgets(
'local files should be files on non-web',
(WidgetTester tester) async {
const String data = '![alt](http.png)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image is FileImage, isTrue);
},
skip: kIsWeb || isLinux,
);
testWidgets(
'local files should be network on web',
(WidgetTester tester) async {
const String data = '![alt](http.png)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image is NetworkImage, isTrue);
},
skip: !kIsWeb || !isLinux,
);
testWidgets(
'should work with resources',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.ensureInitialized();
const String data = '![alt](resource:assets/logo.png)';
await tester.pumpWidget(
boilerplate(
MaterialApp(
home: DefaultAssetBundle(
bundle: TestAssetBundle(),
child: Center(
child: Container(
color: Colors.white,
width: 500,
child: const Markdown(
data: data,
),
),
),
),
),
),
);
final Image image = tester.allWidgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image is AssetImage, isTrue);
expect((image.image as AssetImage).assetName, 'assets/logo.png');
// Force the asset image to be rasterized so it can be compared.
await tester.runAsync(() async {
final Element element = tester.element(find.byType(Markdown));
await precacheImage(image.image, element);
});
await tester.pumpAndSettle();
await expectLater(
find.byType(Container), matchesGoldenFile('assets/images/golden/image_test/resource_asset_logo.png'));
},
skip: kIsWeb || isLinux, // Goldens are platform-specific.
);
testWidgets(
'should work with local image files',
(WidgetTester tester) async {
const String data = '![alt](img.png#50x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Image image = tester.widget(find.byType(Image));
final FileImage fileImage = image.image as FileImage;
expect(fileImage.file.path, 'img.png');
expect(image.width, 50);
expect(image.height, 50);
},
skip: kIsWeb || isLinux,
);
testWidgets(
'should show properly next to text',
(WidgetTester tester) async {
const String data = 'Hello ![alt](img#50x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Text text = tester.widget(find.byType(Text));
final TextSpan textSpan = text.textSpan! as TextSpan;
expect(textSpan.text, 'Hello ');
expect(textSpan.style, isNotNull);
},
);
testWidgets(
'should work when nested in a link',
(WidgetTester tester) async {
final List<String> tapTexts = <String>[];
final List<String?> tapResults = <String?>[];
const String data = '[![alt](https://img#50x50)](href)';
await tester.pumpWidget(
boilerplate(
Markdown(
data: data,
onTapLink: (String text, String? value, String title) {
tapTexts.add(text);
tapResults.add(value);
},
),
),
);
final GestureDetector detector = tester.widget(find.byType(GestureDetector));
detector.onTap!();
expect(tapTexts.length, 1);
expect(tapTexts, everyElement('alt'));
expect(tapResults.length, 1);
expect(tapResults, everyElement('href'));
},
);
testWidgets(
'should work when nested in a link with text',
(WidgetTester tester) async {
final List<String> tapTexts = <String>[];
final List<String?> tapResults = <String?>[];
const String data = '[Text before ![alt](https://img#50x50) text after](href)';
await tester.pumpWidget(
boilerplate(
Markdown(
data: data,
onTapLink: (String text, String? value, String title) {
tapTexts.add(text);
tapResults.add(value);
},
),
),
);
final GestureDetector detector = tester.widget(find.byType(GestureDetector));
detector.onTap!();
final Iterable<Text> texts = tester.widgetList(find.byType(Text));
final Text firstTextWidget = texts.first;
final TextSpan firstSpan = firstTextWidget.textSpan! as TextSpan;
(firstSpan.recognizer as TapGestureRecognizer?)!.onTap!();
final Text lastTextWidget = texts.last;
final TextSpan lastSpan = lastTextWidget.textSpan! as TextSpan;
(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(tapTexts.length, 3);
expect(tapTexts, everyElement('Text before alt text after'));
expect(tapResults.length, 3);
expect(tapResults, everyElement('href'));
},
);
testWidgets(
'should work alongside different links',
(WidgetTester tester) async {
final List<String> tapTexts = <String>[];
final List<String?> tapResults = <String?>[];
const String data = '[Link before](firstHref)[![alt](https://img#50x50)](imageHref)[link after](secondHref)';
await tester.pumpWidget(
boilerplate(
Markdown(
data: data,
onTapLink: (String text, String? value, String title) {
tapTexts.add(text);
tapResults.add(value);
},
),
),
);
final Iterable<Text> texts = tester.widgetList(find.byType(Text));
final Text firstTextWidget = texts.first;
final TextSpan firstSpan = firstTextWidget.textSpan! as TextSpan;
(firstSpan.recognizer as TapGestureRecognizer?)!.onTap!();
final GestureDetector detector = tester.widget(find.byType(GestureDetector));
detector.onTap!();
final Text lastTextWidget = texts.last;
final TextSpan lastSpan = lastTextWidget.textSpan! as TextSpan;
(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(tapTexts.length, 3);
expect(tapTexts, <String>['Link before', 'alt', 'link after']);
expect(tapResults.length, 3);
expect(tapResults, <String>['firstHref', 'imageHref', 'secondHref']);
},
);
testWidgets(
'should gracefully handle image URLs with empty scheme',
(WidgetTester tester) async {
const String data = '![alt](://img#x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
expect(find.byType(Image), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'should gracefully handle image URLs with invalid scheme',
(WidgetTester tester) async {
const String data = '![alt](ttps://img#x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
// On the web, any URI with an unrecognized scheme is treated as a network image.
// Thus the error builder of the Image widget is called.
// On non-web, any URI with an unrecognized scheme is treated as a file image.
// However, constructing a file from an invalid URI will throw an exception.
// Thus the Image widget is never created, nor is its error builder called.
if (kIsWeb || isLinux) {
expect(find.byType(Image), findsOneWidget);
} else {
expect(find.byType(Image), findsNothing);
}
expect(tester.takeException(), isNull);
},
);
testWidgets(
'should gracefully handle width parsing failures',
(WidgetTester tester) async {
const String data = '![alt](https://img#x50)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Image image = tester.widget(find.byType(Image));
final NetworkImage networkImage = image.image as NetworkImage;
expect(networkImage.url, 'https://img');
expect(image.width, null);
expect(image.height, 50);
},
);
testWidgets(
'should gracefully handle height parsing failures',
(WidgetTester tester) async {
const String data = ' ![alt](https://img#50x)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Image image = tester.widget(find.byType(Image));
final NetworkImage networkImage = image.image as NetworkImage;
expect(networkImage.url, 'https://img');
expect(image.width, 50);
expect(image.height, null);
},
);
testWidgets(
'custom image builder',
(WidgetTester tester) async {
const String data = '![alt](https://img.png)';
Widget builder(Uri uri, String? title, String? alt) => Image.asset('assets/logo.png');
await tester.pumpWidget(
boilerplate(
MaterialApp(
home: DefaultAssetBundle(
bundle: TestAssetBundle(),
child: Center(
child: Container(
color: Colors.white,
width: 500,
child: Markdown(
data: data,
imageBuilder: builder,
),
),
),
),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image.runtimeType, AssetImage);
expect((image.image as AssetImage).assetName, 'assets/logo.png');
// Force the asset image to be rasterized so it can be compared.
await tester.runAsync(() async {
final Element element = tester.element(find.byType(Markdown));
await precacheImage(image.image, element);
});
await tester.pumpAndSettle();
await expectLater(
find.byType(Container), matchesGoldenFile('assets/images/golden/image_test/custom_builder_asset_logo.png'));
imageCache.clear();
},
skip: kIsWeb || isLinux, // Goldens are platform-specific.
);
});
}

View file

@ -0,0 +1,375 @@
// Copyright 2013 The Flutter 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';
import 'package:mockito/mockito.dart';
class TestHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return createMockImageHttpClient(context);
}
}
MockHttpClient createMockImageHttpClient(SecurityContext? _) {
final MockHttpClient client = MockHttpClient();
final MockHttpClientRequest request = MockHttpClientRequest();
final MockHttpClientResponse response = MockHttpClientResponse();
final MockHttpHeaders headers = MockHttpHeaders();
final List<int> transparentImage = getTestImageData();
when(client.getUrl(any)).thenAnswer((_) => Future<MockHttpClientRequest>.value(request));
when(request.headers).thenReturn(headers);
when(request.close()).thenAnswer((_) => Future<MockHttpClientResponse>.value(response));
when(client.autoUncompress = any).thenAnswer((_) => null);
when(response.contentLength).thenReturn(transparentImage.length);
when(response.statusCode).thenReturn(HttpStatus.ok);
when(response.compressionState).thenReturn(HttpClientResponseCompressionState.notCompressed);
// Define an image stream that streams the mock test image for all
// image tests that request an image.
StreamSubscription<List<int>> imageStream(Invocation invocation) {
final void Function(List<int>)? onData = invocation.positionalArguments[0] as void Function(List<int>)?;
final void Function()? onDone = invocation.namedArguments[#onDone] as void Function()?;
final void Function(Object, [StackTrace?])? onError =
invocation.namedArguments[#onError] as void Function(Object, [StackTrace?])?;
final bool? cancelOnError = invocation.namedArguments[#cancelOnError] as bool?;
return Stream<List<int>>.fromIterable(<List<int>>[transparentImage]).listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}
when(response.listen(any,
onError: anyNamed('onError'), onDone: anyNamed('onDone'), cancelOnError: anyNamed('cancelOnError')))
.thenAnswer(imageStream);
return client;
}
// A list of integers that can be consumed as image data in a stream.
final List<int> _transparentImage = <int>[
// Image bytes.
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,
];
List<int> getTestImageData() {
return _transparentImage;
}
/// Define the "fake" data types to be used in mock data type definitions. These
/// fake data types are important in the definition of the return values of the
/// properties and methods of the mock data types for null safety.
// ignore: avoid_implementing_value_types
class _FakeDuration extends Fake implements Duration {}
class _FakeHttpClientRequest extends Fake implements HttpClientRequest {}
class _FakeUri extends Fake implements Uri {}
class _FakeHttpHeaders extends Fake implements HttpHeaders {}
class _FakeHttpClientResponse extends Fake implements HttpClientResponse {}
class _FakeSocket extends Fake implements Socket {}
class _FakeStreamSubscription<T> extends Fake implements StreamSubscription<T> {}
/// A class which mocks [HttpClient].
///
/// See the documentation for Mockito's code generation for more information.
class MockHttpClient extends Mock implements HttpClient {
MockHttpClient() {
throwOnMissingStub(this);
}
@override
Duration get idleTimeout =>
super.noSuchMethod(Invocation.getter(#idleTimeout), returnValue: _FakeDuration()) as Duration;
@override
set idleTimeout(Duration? idleTimeout) => super.noSuchMethod(Invocation.setter(#idleTimeout, idleTimeout));
@override
bool get autoUncompress => super.noSuchMethod(Invocation.getter(#autoUncompress), returnValue: false) as bool;
@override
set autoUncompress(bool? autoUncompress) => super.noSuchMethod(Invocation.setter(#autoUncompress, autoUncompress));
@override
Future<HttpClientRequest> open(String? method, String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#open, <Object?>[method, host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> openUrl(String? method, Uri? url) =>
super.noSuchMethod(Invocation.method(#openUrl, <Object?>[method, url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> get(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#get, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> getUrl(Uri? url) => super.noSuchMethod(Invocation.method(#getUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> post(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#post, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> postUrl(Uri? url) => super.noSuchMethod(Invocation.method(#postUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> put(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#put, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> putUrl(Uri? url) => super.noSuchMethod(Invocation.method(#putUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> delete(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#delete, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> deleteUrl(Uri? url) => super.noSuchMethod(Invocation.method(#deleteUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> patch(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#patch, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> patchUrl(Uri? url) => super.noSuchMethod(Invocation.method(#patchUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> head(String? host, int? port, String? path) =>
super.noSuchMethod(Invocation.method(#head, <Object?>[host, port, path]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
Future<HttpClientRequest> headUrl(Uri? url) => super.noSuchMethod(Invocation.method(#headUrl, <Object?>[url]),
returnValue: Future<_FakeHttpClientRequest>.value(_FakeHttpClientRequest())) as Future<HttpClientRequest>;
@override
void addCredentials(Uri? url, String? realm, HttpClientCredentials? credentials) =>
super.noSuchMethod(Invocation.method(#addCredentials, <Object?>[url, realm, credentials]));
@override
void addProxyCredentials(String? host, int? port, String? realm, HttpClientCredentials? credentials) =>
super.noSuchMethod(Invocation.method(#addProxyCredentials, <Object?>[host, port, realm, credentials]));
@override
void close({bool? force = false}) =>
super.noSuchMethod(Invocation.method(#close, <Object?>[], <Symbol, Object?>{#force: force}));
}
/// A class which mocks [HttpClientRequest].
///
/// See the documentation for Mockito's code generation for more information.
class MockHttpClientRequest extends Mock implements HttpClientRequest {
MockHttpClientRequest() {
throwOnMissingStub(this);
}
@override
bool get persistentConnection =>
super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool;
@override
set persistentConnection(bool? persistentConnection) =>
super.noSuchMethod(Invocation.setter(#persistentConnection, persistentConnection));
@override
bool get followRedirects => super.noSuchMethod(Invocation.getter(#followRedirects), returnValue: false) as bool;
@override
set followRedirects(bool? followRedirects) =>
super.noSuchMethod(Invocation.setter(#followRedirects, followRedirects));
@override
int get maxRedirects => super.noSuchMethod(Invocation.getter(#maxRedirects), returnValue: 0) as int;
@override
set maxRedirects(int? maxRedirects) => super.noSuchMethod(Invocation.setter(#maxRedirects, maxRedirects));
@override
int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int;
@override
set contentLength(int? contentLength) => super.noSuchMethod(Invocation.setter(#contentLength, contentLength));
@override
bool get bufferOutput => super.noSuchMethod(Invocation.getter(#bufferOutput), returnValue: false) as bool;
@override
set bufferOutput(bool? bufferOutput) => super.noSuchMethod(Invocation.setter(#bufferOutput, bufferOutput));
@override
String get method => super.noSuchMethod(Invocation.getter(#method), returnValue: '') as String;
@override
Uri get uri => super.noSuchMethod(Invocation.getter(#uri), returnValue: _FakeUri()) as Uri;
@override
HttpHeaders get headers =>
super.noSuchMethod(Invocation.getter(#headers), returnValue: _FakeHttpHeaders()) as HttpHeaders;
@override
List<Cookie> get cookies => super.noSuchMethod(Invocation.getter(#cookies), returnValue: <Cookie>[]) as List<Cookie>;
@override
Future<HttpClientResponse> get done => super.noSuchMethod(Invocation.getter(#done),
returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future<HttpClientResponse>;
@override
Future<HttpClientResponse> close() => super.noSuchMethod(Invocation.method(#close, <Object?>[]),
returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future<HttpClientResponse>;
}
/// A class which mocks [HttpClientResponse].
///
/// See the documentation for Mockito's code generation for more information.
class MockHttpClientResponse extends Mock implements HttpClientResponse {
MockHttpClientResponse() {
throwOnMissingStub(this);
}
// Include an override method for the inherited listen method. This method
// intercepts HttpClientResponse listen calls to return a mock image.
@override
StreamSubscription<List<int>> listen(void Function(List<int> event)? onData,
{Function? onError, void Function()? onDone, bool? cancelOnError}) =>
super.noSuchMethod(
Invocation.method(
#listen,
<Object?>[onData],
<Symbol, Object?>{#onError: onError, #onDone: onDone, #cancelOnError: cancelOnError},
),
returnValue: _FakeStreamSubscription<List<int>>()) as StreamSubscription<List<int>>;
@override
int get statusCode => super.noSuchMethod(Invocation.getter(#statusCode), returnValue: 0) as int;
@override
String get reasonPhrase => super.noSuchMethod(Invocation.getter(#reasonPhrase), returnValue: '') as String;
@override
int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int;
@override
HttpClientResponseCompressionState get compressionState => super.noSuchMethod(Invocation.getter(#compressionState),
returnValue: HttpClientResponseCompressionState.notCompressed) as HttpClientResponseCompressionState;
@override
bool get persistentConnection =>
super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool;
@override
bool get isRedirect => super.noSuchMethod(Invocation.getter(#isRedirect), returnValue: false) as bool;
@override
List<RedirectInfo> get redirects =>
super.noSuchMethod(Invocation.getter(#redirects), returnValue: <RedirectInfo>[]) as List<RedirectInfo>;
@override
HttpHeaders get headers =>
super.noSuchMethod(Invocation.getter(#headers), returnValue: _FakeHttpHeaders()) as HttpHeaders;
@override
List<Cookie> get cookies => super.noSuchMethod(Invocation.getter(#cookies), returnValue: <Cookie>[]) as List<Cookie>;
@override
Future<HttpClientResponse> redirect([String? method, Uri? url, bool? followLoops]) =>
super.noSuchMethod(Invocation.method(#redirect, <Object?>[method, url, followLoops]),
returnValue: Future<_FakeHttpClientResponse>.value(_FakeHttpClientResponse())) as Future<HttpClientResponse>;
@override
Future<Socket> detachSocket() => super.noSuchMethod(Invocation.method(#detachSocket, <Object?>[]),
returnValue: Future<_FakeSocket>.value(_FakeSocket())) as Future<Socket>;
}
/// A class which mocks [HttpHeaders].
///
/// See the documentation for Mockito's code generation for more information.
class MockHttpHeaders extends Mock implements HttpHeaders {
MockHttpHeaders() {
throwOnMissingStub(this);
}
@override
int get contentLength => super.noSuchMethod(Invocation.getter(#contentLength), returnValue: 0) as int;
@override
set contentLength(int? contentLength) => super.noSuchMethod(Invocation.setter(#contentLength, contentLength));
@override
bool get persistentConnection =>
super.noSuchMethod(Invocation.getter(#persistentConnection), returnValue: false) as bool;
@override
set persistentConnection(bool? persistentConnection) =>
super.noSuchMethod(Invocation.setter(#persistentConnection, persistentConnection));
@override
bool get chunkedTransferEncoding =>
super.noSuchMethod(Invocation.getter(#chunkedTransferEncoding), returnValue: false) as bool;
@override
set chunkedTransferEncoding(bool? chunkedTransferEncoding) =>
super.noSuchMethod(Invocation.setter(#chunkedTransferEncoding, chunkedTransferEncoding));
@override
List<String>? operator [](String? name) =>
super.noSuchMethod(Invocation.method(#[], <Object?>[name])) as List<String>?;
@override
String? value(String? name) => super.noSuchMethod(Invocation.method(#value, <Object?>[name])) as String?;
@override
void add(String? name, Object? value, {bool? preserveHeaderCase = false}) => super.noSuchMethod(
Invocation.method(#add, <Object?>[name, value], <Symbol, Object?>{#preserveHeaderCase: preserveHeaderCase}));
@override
void set(String? name, Object? value, {bool? preserveHeaderCase = false}) => super.noSuchMethod(
Invocation.method(#set, <Object?>[name, value], <Symbol, Object?>{#preserveHeaderCase: preserveHeaderCase}));
@override
void remove(String? name, Object? value) => super.noSuchMethod(Invocation.method(#remove, <Object?>[name, value]));
@override
void removeAll(String? name) => super.noSuchMethod(Invocation.method(#removeAll, <Object?>[name]));
@override
void forEach(void Function(String, List<String>)? action) =>
super.noSuchMethod(Invocation.method(#forEach, <Object?>[action]));
@override
void noFolding(String? name) => super.noSuchMethod(Invocation.method(#noFolding, <Object?>[name]));
}

View file

@ -0,0 +1,78 @@
// Copyright 2013 The Flutter 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:markdown/markdown.dart' as md;
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('InlineWidget', () {
testWidgets(
'Test inline widget',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: 'Hello, foo bar',
builders: <String, MarkdownElementBuilder>{
'sub': SubscriptBuilder(),
},
extensionSet: md.ExtensionSet(
<md.BlockSyntax>[],
<md.InlineSyntax>[SubscriptSyntax()],
),
),
),
);
final Text textWidget = tester.firstWidget(find.byType(Text));
final TextSpan span = textWidget.textSpan! as TextSpan;
final TextSpan part1 = span.children![0] as TextSpan;
expect(part1.toPlainText(), 'Hello, ');
final WidgetSpan part2 = span.children![1] as WidgetSpan;
expect(part2.alignment, PlaceholderAlignment.middle);
expect(part2.child, isA<Text>());
expect((part2.child as Text).data, 'foo');
final TextSpan part3 = span.children![2] as TextSpan;
expect(part3.toPlainText(), ' bar');
},
);
});
}
class SubscriptBuilder extends MarkdownElementBuilder {
@override
Widget visitElementAfterWithContext(
BuildContext context,
md.Element element,
TextStyle? preferredStyle,
TextStyle? parentStyle,
) {
return Text.rich(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(element.textContent),
));
}
}
class SubscriptSyntax extends md.InlineSyntax {
SubscriptSyntax() : super(_pattern);
static const String _pattern = r'(foo)';
@override
bool onMatch(md.InlineParser parser, Match match) {
parser.addNode(md.Element.text('sub', match[1]!));
return true;
}
}

View file

@ -0,0 +1,357 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Hard Line Breaks', () {
testWidgets(
// Example 654 from GFM.
'two spaces at end of line',
(WidgetTester tester) async {
const String data = 'foo \nbar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo\nbar');
},
);
testWidgets(
// Example 655 from GFM.
'backslash at end of line',
(WidgetTester tester) async {
const String data = 'foo\\\nbar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo\nbar');
},
);
testWidgets(
// Example 656 from GFM.
'more than two spaces at end of line',
(WidgetTester tester) async {
const String data = 'foo \nbar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo\nbar');
},
);
testWidgets(
// Example 657 from GFM.
'leading spaces at beginning of next line are ignored',
(WidgetTester tester) async {
const String data = 'foo \n bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo\nbar');
},
);
testWidgets(
// Example 658 from GFM.
'leading spaces at beginning of next line are ignored',
(WidgetTester tester) async {
const String data = 'foo\\\n bar';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo\nbar');
},
);
testWidgets(
// Example 659 from GFM.
'two spaces line break inside emphasis',
(WidgetTester tester) async {
const String data = '*foo \nbar*';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text textWidget = textFinder.evaluate().first.widget as Text;
final String text = textWidget.textSpan!.toPlainText();
expect(text, 'foo\nbar');
// There should be three spans of text.
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
expect(textSpan, isNotNull);
expect(textSpan.children!.length == 3, isTrue);
// First text span has italic style with normal weight.
final InlineSpan firstSpan = textSpan.children![0];
expectTextSpanStyle(firstSpan as TextSpan, FontStyle.italic, FontWeight.normal);
// Second span is just the newline character with no font style or weight.
// Third text span has italic style with normal weight.
final InlineSpan thirdSpan = textSpan.children![2];
expectTextSpanStyle(thirdSpan as TextSpan, FontStyle.italic, FontWeight.normal);
},
);
testWidgets(
// Example 660 from GFM.
'backslash line break inside emphasis',
(WidgetTester tester) async {
const String data = '*foo\\\nbar*';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text textWidget = textFinder.evaluate().first.widget as Text;
final String text = textWidget.textSpan!.toPlainText();
expect(text, 'foo\nbar');
// There should be three spans of text.
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
expect(textSpan, isNotNull);
expect(textSpan.children!.length == 3, isTrue);
// First text span has italic style with normal weight.
final InlineSpan firstSpan = textSpan.children![0];
expectTextSpanStyle(firstSpan as TextSpan, FontStyle.italic, FontWeight.normal);
// Second span is just the newline character with no font style or weight.
// Third text span has italic style with normal weight.
final InlineSpan thirdSpan = textSpan.children![2];
expectTextSpanStyle(thirdSpan as TextSpan, FontStyle.italic, FontWeight.normal);
},
);
testWidgets(
// Example 661 from GFM.
'two space line break does not occur in code span',
(WidgetTester tester) async {
const String data = '`code \nspan`';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text textWidget = textFinder.evaluate().first.widget as Text;
final String text = textWidget.textSpan!.toPlainText();
expect(text, 'code span');
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
expect(textSpan, isNotNull);
expect(textSpan.style, isNotNull);
expect(textSpan.style!.fontFamily == 'monospace', isTrue);
},
);
testWidgets(
// Example 662 from GFM.
'backslash line break does not occur in code span',
(WidgetTester tester) async {
const String data = '`code\\\nspan`';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text textWidget = textFinder.evaluate().first.widget as Text;
final String text = textWidget.textSpan!.toPlainText();
expect(text, r'code\ span');
final TextSpan textSpan = textWidget.textSpan! as TextSpan;
expect(textSpan, isNotNull);
expect(textSpan.style, isNotNull);
expect(textSpan.style!.fontFamily == 'monospace', isTrue);
},
);
testWidgets(
// Example 665 from GFM.
'backslash at end of paragraph is ignored',
(WidgetTester tester) async {
const String data = r'foo\';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, r'foo\');
},
);
testWidgets(
// Example 666 from GFM.
'two spaces at end of paragraph is ignored',
(WidgetTester tester) async {
const String data = 'foo ';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo');
},
);
testWidgets(
// Example 667 from GFM.
'backslash at end of header is ignored',
(WidgetTester tester) async {
const String data = r'### foo\';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, r'foo\');
},
);
testWidgets(
// Example 668 from GFM.
'two spaces at end of header is ignored',
(WidgetTester tester) async {
const String data = '### foo ';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo');
},
);
});
group('Soft Line Breaks', () {
testWidgets(
// Example 669 from GFM.
'lines of text in paragraph',
(WidgetTester tester) async {
const String data = 'foo\nbaz';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo baz');
},
);
testWidgets(
// Example 670 from GFM.
'spaces at beginning and end of lines of text in paragraph are removed',
(WidgetTester tester) async {
const String data = 'foo \n baz';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Finder richTextFinder = find.byType(RichText);
expect(richTextFinder, findsOneWidget);
final RichText richText = richTextFinder.evaluate().first.widget as RichText;
final String text = richText.text.toPlainText();
expect(text, 'foo baz');
},
);
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,302 @@
// Copyright 2013 The Flutter 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_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Unordered List', () {
testWidgets(
'simple 3 item list',
(WidgetTester tester) async {
const String data = '- Item 1\n- Item 2\n- Item 3';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'',
'Item 1',
'',
'Item 2',
'',
'Item 3',
]);
},
);
testWidgets(
'empty list item',
(WidgetTester tester) async {
const String data = '- \n- Item 2\n- Item 3';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'',
'',
'Item 2',
'',
'Item 3',
]);
},
);
testWidgets(
// Example 236 from the GitHub Flavored Markdown specification.
'leading space are ignored', (WidgetTester tester) async {
const String data = ' - one\n\n two';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'',
'one',
'two',
]);
});
testWidgets(
'leading spaces are ignored (non-paragraph test case)',
(WidgetTester tester) async {
const String data = '- one\n- two\n- three';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'',
'one',
'',
'two',
'',
'three',
]);
},
);
testWidgets('custom bullet builder', (WidgetTester tester) async {
const String data = '* Item 1\n * Item 2\n * Item 3\n * Item 4\n* Item 5';
Widget builder(MarkdownBulletParameters parameters) => Text(
'${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'} ${parameters.nestLevel}',
);
await tester.pumpWidget(
boilerplate(
Markdown(data: data, bulletBuilder: builder),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'0 unordered 0',
'Item 1',
'0 unordered 1',
'Item 2',
'0 unordered 2',
'Item 3',
'1 unordered 1',
'Item 4',
'1 unordered 0',
'Item 5',
]);
});
});
group('Ordered List', () {
testWidgets(
'2 distinct ordered lists with separate index values',
(WidgetTester tester) async {
const String data = '1. Item 1\n1. Item 2\n2. Item 3\n\n\n'
'10. Item 10\n13. Item 11';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(
widgets, <String>['1.', 'Item 1', '2.', 'Item 2', '3.', 'Item 3', '4.', 'Item 10', '5.', 'Item 11']);
},
);
testWidgets('leading space are ignored', (WidgetTester tester) async {
const String data = ' 1. one\n\n two';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['1.', 'one', 'two']);
});
testWidgets('custom bullet builder', (WidgetTester tester) async {
const String data = '1. Item 1\n 1. Item 2\n 1. Item 3\n 1. Item 4\n1. Item 5';
Widget builder(MarkdownBulletParameters parameters) => Text(
'${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'} ${parameters.nestLevel}',
);
await tester.pumpWidget(
boilerplate(
Markdown(data: data, bulletBuilder: builder),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'0 ordered 0',
'Item 1',
'0 ordered 1',
'Item 2',
'0 ordered 2',
'Item 3',
'1 ordered 1',
'Item 4',
'1 ordered 0',
'Item 5',
]);
});
});
group('Task List', () {
testWidgets(
'simple 2 item task list',
(WidgetTester tester) async {
const String data = '- [x] Item 1\n- [ ] Item 2';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
String.fromCharCode(Icons.check_box.codePoint),
'Item 1',
String.fromCharCode(Icons.check_box_outline_blank.codePoint),
'Item 2',
]);
},
);
testWidgets('custom bullet builder', (WidgetTester tester) async {
const String data = '* Item 1\n* Item 2\n1) Item 3\n2) Item 4';
Widget builder(MarkdownBulletParameters parameters) =>
Text('${parameters.index} ${parameters.style == BulletStyle.orderedList ? 'ordered' : 'unordered'}');
await tester.pumpWidget(
boilerplate(
Markdown(data: data, bulletBuilder: builder),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'0 unordered',
'Item 1',
'1 unordered',
'Item 2',
'0 ordered',
'Item 3',
'1 ordered',
'Item 4',
]);
});
testWidgets(
'custom checkbox builder',
(WidgetTester tester) async {
const String data = '- [x] Item 1\n- [ ] Item 2';
Widget builder(bool checked) => Text('$checked');
await tester.pumpWidget(
boilerplate(
Markdown(data: data, checkboxBuilder: builder),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>[
'true',
'Item 1',
'false',
'Item 2',
]);
},
);
});
group('fitContent', () {
testWidgets(
'uses maximum width when false',
(WidgetTester tester) async {
const String data = '- Foo\n- Bar';
await tester.pumpWidget(
boilerplate(
const Column(
children: <Widget>[
MarkdownBody(fitContent: false, data: data),
],
),
),
);
final double screenWidth = find.byType(Column).evaluate().first.size!.width;
final double markdownBodyWidth = find.byType(MarkdownBody).evaluate().single.size!.width;
expect(markdownBodyWidth, equals(screenWidth));
},
);
testWidgets(
'uses minimum width when true',
(WidgetTester tester) async {
const String data = '- Foo\n- Bar';
await tester.pumpWidget(
boilerplate(
const Column(
children: <Widget>[
MarkdownBody(data: data),
],
),
),
);
final double screenWidth = find.byType(Column).evaluate().first.size!.width;
final double markdownBodyWidth = find.byType(MarkdownBody).evaluate().single.size!.width;
expect(markdownBodyWidth, lessThan(screenWidth));
},
);
});
}

View file

@ -0,0 +1,76 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('MarkdownBody shrinkWrap test', () {
testWidgets(
'Given a MarkdownBody with shrinkWrap=true '
'Then it wraps its content',
(WidgetTester tester) async {
await tester.pumpWidget(boilerplate(
const Stack(
children: <Widget>[
Text('shrinkWrap=true'),
Align(
alignment: Alignment.bottomCenter,
child: MarkdownBody(
data: 'This is a [link](https://flutter.dev/)',
),
),
],
),
));
final Rect stackRect = tester.getRect(find.byType(Stack));
final Rect textRect = tester.getRect(find.text('shrinkWrap=true'));
final Rect markdownBodyRect = tester.getRect(find.byType(MarkdownBody));
// The Text should be on the top of the Stack
expect(textRect.top, equals(stackRect.top));
expect(textRect.bottom, lessThan(stackRect.bottom));
// The MarkdownBody should be on the bottom of the Stack
expect(markdownBodyRect.top, greaterThan(stackRect.top));
expect(markdownBodyRect.bottom, equals(stackRect.bottom));
},
);
testWidgets(
'Given a MarkdownBody with shrinkWrap=false '
'Then it expands to the maximum allowed height',
(WidgetTester tester) async {
await tester.pumpWidget(boilerplate(
const Stack(
children: <Widget>[
Text('shrinkWrap=false test'),
Align(
alignment: Alignment.bottomCenter,
child: MarkdownBody(
data: 'This is a [link](https://flutter.dev/)',
shrinkWrap: false,
),
),
],
),
));
final Rect stackRect = tester.getRect(find.byType(Stack));
final Rect textRect = tester.getRect(find.text('shrinkWrap=false test'));
final Rect markdownBodyRect = tester.getRect(find.byType(MarkdownBody));
// The Text should be on the top of the Stack
expect(textRect.top, equals(stackRect.top));
expect(textRect.bottom, lessThan(stackRect.bottom));
// The MarkdownBody should take all Stack's height
expect(markdownBodyRect.top, equals(stackRect.top));
expect(markdownBodyRect.bottom, equals(stackRect.bottom));
},
);
});
}

View file

@ -0,0 +1,67 @@
// Copyright 2013 The Flutter 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Padding builders', () {
testWidgets(
'use paddingBuilders for p',
(WidgetTester tester) async {
const double paddingX = 10.0;
await tester.pumpWidget(
boilerplate(
Markdown(
data: '**line 1**\n\n# H1\n![alt](/assets/images/logo.png)',
paddingBuilders: <String, MarkdownPaddingBuilder>{
'p': CustomPaddingBuilder(paddingX * 1),
'strong': CustomPaddingBuilder(paddingX * 2),
'h1': CustomPaddingBuilder(paddingX * 3),
'img': CustomPaddingBuilder(paddingX * 4),
}),
),
);
final List<Padding> paddings = tester.widgetList<Padding>(find.byType(Padding)).toList();
expect(paddings.length, 4);
expect(
paddings[0].padding.along(Axis.horizontal) == paddingX * 1 * 2,
true,
);
expect(
paddings[1].padding.along(Axis.horizontal) == paddingX * 3 * 2,
true,
);
expect(
paddings[2].padding.along(Axis.horizontal) == paddingX * 1 * 2,
true,
);
expect(
paddings[3].padding.along(Axis.horizontal) == paddingX * 4 * 2,
true,
);
imageCache.clear();
},
);
});
}
class CustomPaddingBuilder extends MarkdownPaddingBuilder {
CustomPaddingBuilder(this.paddingX);
double paddingX;
@override
EdgeInsets getPadding() {
return EdgeInsets.symmetric(horizontal: paddingX);
}
}

View file

@ -0,0 +1,195 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Scrollable', () {
testWidgets(
'code block',
(WidgetTester tester) async {
const String data = "```\nvoid main() {\n print('Hello World!');\n}\n```";
await tester.pumpWidget(
boilerplate(
const MediaQuery(
data: MediaQueryData(),
child: MarkdownBody(data: data),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Iterable<SingleChildScrollView> scrollViews = widgets.whereType<SingleChildScrollView>();
expect(scrollViews, isNotEmpty);
expect(scrollViews.first.controller, isNotNull);
},
);
testWidgets(
'two code blocks use different scroll controllers',
(WidgetTester tester) async {
const String data = "```\nvoid main() {\n print('Hello World!');\n}\n```"
'\n'
"```\nvoid main() {\n print('Hello World!');\n}\n```";
await tester.pumpWidget(
boilerplate(
const MediaQuery(
data: MediaQueryData(),
child: MarkdownBody(data: data),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Iterable<SingleChildScrollView> scrollViews = widgets.whereType<SingleChildScrollView>();
expect(scrollViews, hasLength(2));
expect(scrollViews.first.controller, isNotNull);
expect(scrollViews.last.controller, isNotNull);
expect(scrollViews.first.controller, isNot(equals(scrollViews.last.controller)));
},
);
testWidgets(
'controller',
(WidgetTester tester) async {
final ScrollController controller = ScrollController(
initialScrollOffset: 209.0,
);
addTearDown(controller.dispose);
await tester.pumpWidget(
boilerplate(
Markdown(controller: controller, data: ''),
),
);
double realOffset() {
return tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels;
}
expect(controller.offset, equals(209.0));
expect(realOffset(), equals(controller.offset));
},
);
testWidgets(
'Scrollable wrapping',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const Markdown(data: ''),
),
);
final List<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(Markdown),
tester,
).toList();
expectWidgetTypes(widgets.take(2), <Type>[
Markdown,
ListView,
]);
expectWidgetTypes(widgets.reversed.take(2).toList().reversed, <Type>[
SliverPadding,
SliverList,
]);
},
);
testWidgets(
'table with fixed column width',
(WidgetTester tester) async {
const String data = '|Header 1|Header 2|Header 3|'
'\n|-----|-----|-----|'
'\n|Col 1|Col 2|Col 3|';
await tester.pumpWidget(
boilerplate(
MediaQuery(
data: const MediaQueryData(),
child: MarkdownBody(
data: data,
styleSheet: MarkdownStyleSheet(
tableColumnWidth: const FixedColumnWidth(150),
),
),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Iterable<SingleChildScrollView> scrollViews = widgets.whereType<SingleChildScrollView>();
expect(scrollViews, isNotEmpty);
expect(scrollViews.first.controller, isNotNull);
},
);
testWidgets(
'table with intrinsic column width',
(WidgetTester tester) async {
const String data = '|Header 1|Header 2|Header 3|'
'\n|-----|-----|-----|'
'\n|Col 1|Col 2|Col 3|';
await tester.pumpWidget(
boilerplate(
MediaQuery(
data: const MediaQueryData(),
child: MarkdownBody(
data: data,
styleSheet: MarkdownStyleSheet(
tableColumnWidth: const IntrinsicColumnWidth(),
),
),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Iterable<SingleChildScrollView> scrollViews = widgets.whereType<SingleChildScrollView>();
expect(scrollViews, isNotEmpty);
expect(scrollViews.first.controller, isNotNull);
},
);
testWidgets(
'two tables use different scroll controllers',
(WidgetTester tester) async {
const String data = '|Header 1|Header 2|Header 3|'
'\n|-----|-----|-----|'
'\n|Col 1|Col 2|Col 3|'
'\n'
'\n|Header 1|Header 2|Header 3|'
'\n|-----|-----|-----|'
'\n|Col 1|Col 2|Col 3|';
await tester.pumpWidget(
boilerplate(
MediaQuery(
data: const MediaQueryData(),
child: MarkdownBody(
data: data,
styleSheet: MarkdownStyleSheet(
tableColumnWidth: const FixedColumnWidth(150),
),
),
),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Iterable<SingleChildScrollView> scrollViews = widgets.whereType<SingleChildScrollView>();
expect(scrollViews, hasLength(2));
expect(scrollViews.first.controller, isNotNull);
expect(scrollViews.last.controller, isNotNull);
expect(scrollViews.first.controller, isNot(equals(scrollViews.last.controller)));
},
);
});
}

View file

@ -0,0 +1,71 @@
// Copyright 2013 The Flutter 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
void main() => defineTests();
void defineTests() {
group('Compatible with SelectionArea when selectable is default to false', () {
testWidgets(
'Text can be selected',
(WidgetTester tester) async {
SelectedContent? content;
const String data = 'How are you?';
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: const Markdown(
data: data,
),
onSelectionChanged: (SelectedContent? selectedContent) => content = selectedContent,
)));
final TestGesture gesture =
await tester.startGesture(tester.getTopLeft(find.text('How are you?')), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getBottomRight(find.text('How are you?')));
await gesture.up();
await tester.pump();
expect(content, isNotNull);
expect(content!.plainText, 'How are you?');
},
);
testWidgets(
'List can be selected',
(WidgetTester tester) async {
SelectedContent? content;
const String data = '- Item 1\n- Item 2\n- Item 3';
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
child: const Markdown(
data: data,
),
onSelectionChanged: (SelectedContent? selectedContent) => content = selectedContent,
)));
final TestGesture gesture =
await tester.startGesture(tester.getTopLeft(find.byType(Markdown)), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getBottomRight(find.byType(Markdown)));
await gesture.up();
await tester.pump();
expect(content, isNotNull);
expect(content!.plainText, '•Item 1•Item 2•Item 3');
},
);
});
}

View file

@ -0,0 +1,445 @@
// Copyright 2013 The Flutter 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Style Sheet', () {
testWidgets(
'equality - Cupertino',
(WidgetTester tester) async {
const CupertinoThemeData theme = CupertinoThemeData(brightness: Brightness.light);
final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromCupertinoTheme(theme);
final MarkdownStyleSheet style2 = MarkdownStyleSheet.fromCupertinoTheme(theme);
expect(style1, equals(style2));
expect(style1.hashCode, equals(style2.hashCode));
},
);
testWidgets(
'equality - Material',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme);
final MarkdownStyleSheet style2 = MarkdownStyleSheet.fromTheme(theme);
expect(style1, equals(style2));
expect(style1.hashCode, equals(style2.hashCode));
},
);
testWidgets(
'MarkdownStyleSheet.fromCupertinoTheme',
(WidgetTester tester) async {
const CupertinoThemeData cTheme = CupertinoThemeData(
brightness: Brightness.dark,
);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromCupertinoTheme(cTheme);
// a
expect(style.a!.color, CupertinoColors.link.darkColor);
expect(style.a!.fontSize, cTheme.textTheme.textStyle.fontSize);
// p
expect(style.p, cTheme.textTheme.textStyle);
// code
expect(style.code!.color, cTheme.textTheme.textStyle.color);
expect(style.code!.fontSize, cTheme.textTheme.textStyle.fontSize! * 0.85);
expect(style.code!.fontFamily, 'monospace');
// H1
expect(style.h1!.color, cTheme.textTheme.textStyle.color);
expect(style.h1!.fontSize, cTheme.textTheme.textStyle.fontSize! + 10);
expect(style.h1!.fontWeight, FontWeight.w500);
// H2
expect(style.h2!.color, cTheme.textTheme.textStyle.color);
expect(style.h2!.fontSize, cTheme.textTheme.textStyle.fontSize! + 8);
expect(style.h2!.fontWeight, FontWeight.w500);
// H3
expect(style.h3!.color, cTheme.textTheme.textStyle.color);
expect(style.h3!.fontSize, cTheme.textTheme.textStyle.fontSize! + 6);
expect(style.h3!.fontWeight, FontWeight.w500);
// H4
expect(style.h4!.color, cTheme.textTheme.textStyle.color);
expect(style.h4!.fontSize, cTheme.textTheme.textStyle.fontSize! + 4);
expect(style.h4!.fontWeight, FontWeight.w500);
// H5
expect(style.h5!.color, cTheme.textTheme.textStyle.color);
expect(style.h5!.fontSize, cTheme.textTheme.textStyle.fontSize! + 2);
expect(style.h5!.fontWeight, FontWeight.w500);
// H6
expect(style.h6!.color, cTheme.textTheme.textStyle.color);
expect(style.h6!.fontSize, cTheme.textTheme.textStyle.fontSize);
expect(style.h6!.fontWeight, FontWeight.w500);
// em
expect(style.em!.color, cTheme.textTheme.textStyle.color);
expect(style.em!.fontSize, cTheme.textTheme.textStyle.fontSize);
expect(style.em!.fontStyle, FontStyle.italic);
// strong
expect(style.strong!.color, cTheme.textTheme.textStyle.color);
expect(style.strong!.fontSize, cTheme.textTheme.textStyle.fontSize);
expect(style.strong!.fontWeight, FontWeight.bold);
// del
expect(style.del!.color, cTheme.textTheme.textStyle.color);
expect(style.del!.fontSize, cTheme.textTheme.textStyle.fontSize);
expect(style.del!.decoration, TextDecoration.lineThrough);
// blockqoute
expect(style.blockquote, cTheme.textTheme.textStyle);
// img
expect(style.img, cTheme.textTheme.textStyle);
// checkbox
expect(style.checkbox!.color, cTheme.primaryColor);
expect(style.checkbox!.fontSize, cTheme.textTheme.textStyle.fontSize);
// tableHead
expect(style.tableHead!.color, cTheme.textTheme.textStyle.color);
expect(style.tableHead!.fontSize, cTheme.textTheme.textStyle.fontSize);
expect(style.tableHead!.fontWeight, FontWeight.w600);
// tableBody
expect(style.tableBody, cTheme.textTheme.textStyle);
},
);
testWidgets(
'MarkdownStyleSheet.fromTheme',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.dark().copyWith(
textTheme: const TextTheme(
bodyMedium: TextStyle(fontSize: 12.0),
),
);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme);
// a
expect(style.a!.color, Colors.blue);
// p
expect(style.p, theme.textTheme.bodyMedium);
// code
expect(style.code!.color, theme.textTheme.bodyMedium!.color);
expect(style.code!.fontSize, theme.textTheme.bodyMedium!.fontSize! * 0.85);
expect(style.code!.fontFamily, 'monospace');
expect(style.code!.backgroundColor, theme.cardTheme.color);
// H1
expect(style.h1, theme.textTheme.headlineSmall);
// H2
expect(style.h2, theme.textTheme.titleLarge);
// H3
expect(style.h3, theme.textTheme.titleMedium);
// H4
expect(style.h4, theme.textTheme.bodyLarge);
// H5
expect(style.h5, theme.textTheme.bodyLarge);
// H6
expect(style.h6, theme.textTheme.bodyLarge);
// em
expect(style.em!.fontStyle, FontStyle.italic);
expect(style.em!.color, theme.textTheme.bodyMedium!.color);
// strong
expect(style.strong!.fontWeight, FontWeight.bold);
expect(style.strong!.color, theme.textTheme.bodyMedium!.color);
// del
expect(style.del!.decoration, TextDecoration.lineThrough);
expect(style.del!.color, theme.textTheme.bodyMedium!.color);
// blockqoute
expect(style.blockquote, theme.textTheme.bodyMedium);
// img
expect(style.img, theme.textTheme.bodyMedium);
// checkbox
expect(style.checkbox!.color, theme.primaryColor);
expect(style.checkbox!.fontSize, theme.textTheme.bodyMedium!.fontSize);
// tableHead
expect(style.tableHead!.fontWeight, FontWeight.w600);
// tableBody
expect(style.tableBody, theme.textTheme.bodyMedium);
},
);
testWidgets(
'merge 2 style sheets',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme);
final MarkdownStyleSheet style2 = MarkdownStyleSheet(
p: const TextStyle(color: Colors.red),
blockquote: const TextStyle(fontSize: 16),
);
final MarkdownStyleSheet merged = style1.merge(style2);
expect(merged.p!.color, Colors.red);
expect(merged.blockquote!.fontSize, 16);
expect(merged.blockquote!.color, theme.textTheme.bodyMedium!.color);
},
);
testWidgets(
'create based on which theme',
(WidgetTester tester) async {
const String data = '[title](url)';
await tester.pumpWidget(
boilerplate(
const Markdown(
data: data,
styleSheetTheme: MarkdownStyleSheetBaseTheme.cupertino,
),
),
);
final Text widget = tester.widget(find.byType(Text));
expect(widget.textSpan!.style!.color, CupertinoColors.link.color);
},
);
testWidgets(
'apply 2 distinct style sheets',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme);
final MarkdownStyleSheet style2 = MarkdownStyleSheet.largeFromTheme(theme);
expect(style1, isNot(style2));
await tester.pumpWidget(
boilerplate(
Markdown(
data: '# Test',
styleSheet: style1,
),
),
);
final RichText text1 = tester.widget(find.byType(RichText));
await tester.pumpWidget(
boilerplate(
Markdown(
data: '# Test',
styleSheet: style2,
),
),
);
final RichText text2 = tester.widget(find.byType(RichText));
expect(text1.text, isNot(text2.text));
},
);
testWidgets(
'use stylesheet option listBulletPadding',
(WidgetTester tester) async {
const double paddingX = 20.0;
final MarkdownStyleSheet style =
MarkdownStyleSheet(listBulletPadding: const EdgeInsets.symmetric(horizontal: paddingX));
await tester.pumpWidget(
boilerplate(
Markdown(
data: '1. Bullet\n 2. Bullet\n * Bullet',
styleSheet: style,
),
),
);
final List<Padding> paddings = tester.widgetList<Padding>(find.byType(Padding)).toList();
expect(paddings.length, 3);
expect(
paddings.every(
(Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2,
),
true,
);
},
);
testWidgets(
'check widgets for use stylesheet option h1Padding',
(WidgetTester tester) async {
const String data = '# Header';
const double paddingX = 20.0;
final MarkdownStyleSheet style = MarkdownStyleSheet(
h1Padding: const EdgeInsets.symmetric(horizontal: paddingX),
);
await tester.pumpWidget(boilerplate(MarkdownBody(
data: data,
styleSheet: style,
)));
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Padding,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['Header']);
},
);
testWidgets(
'use stylesheet option pPadding',
(WidgetTester tester) async {
const double paddingX = 20.0;
final MarkdownStyleSheet style = MarkdownStyleSheet(
pPadding: const EdgeInsets.symmetric(horizontal: paddingX),
);
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'Test line 1\n\nTest line 2\n\nTest line 3\n# H1',
styleSheet: style,
),
),
);
final List<Padding> paddings = tester.widgetList<Padding>(find.byType(Padding)).toList();
expect(paddings.length, 3);
expect(
paddings.every(
(Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2,
),
true,
);
},
);
testWidgets(
'use stylesheet option h1Padding-h6Padding',
(WidgetTester tester) async {
const double paddingX = 20.0;
final MarkdownStyleSheet style = MarkdownStyleSheet(
h1Padding: const EdgeInsets.symmetric(horizontal: paddingX),
h2Padding: const EdgeInsets.symmetric(horizontal: paddingX),
h3Padding: const EdgeInsets.symmetric(horizontal: paddingX),
h4Padding: const EdgeInsets.symmetric(horizontal: paddingX),
h5Padding: const EdgeInsets.symmetric(horizontal: paddingX),
h6Padding: const EdgeInsets.symmetric(horizontal: paddingX),
);
await tester.pumpWidget(
boilerplate(
Markdown(
data: 'Test\n\n# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6\n',
styleSheet: style,
),
),
);
final List<Padding> paddings = tester.widgetList<Padding>(find.byType(Padding)).toList();
expect(paddings.length, 6);
expect(
paddings.every(
(Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2,
),
true,
);
},
);
testWidgets(
'deprecated textScaleFactor is converted to linear scaler',
(WidgetTester tester) async {
const double scaleFactor = 2.0;
final MarkdownStyleSheet style = MarkdownStyleSheet(
textScaleFactor: scaleFactor,
);
expect(style.textScaler, const TextScaler.linear(scaleFactor));
expect(style.textScaleFactor, scaleFactor);
},
);
testWidgets(
'deprecated textScaleFactor is null when a scaler is provided',
(WidgetTester tester) async {
const TextScaler scaler = TextScaler.linear(2.0);
final MarkdownStyleSheet style = MarkdownStyleSheet(
textScaler: scaler,
);
expect(style.textScaler, scaler);
expect(style.textScaleFactor, null);
},
);
testWidgets(
'copyWith textScaler overwrites both textScaler and textScaleFactor',
(WidgetTester tester) async {
final MarkdownStyleSheet original = MarkdownStyleSheet(
textScaleFactor: 2.0,
);
const TextScaler newScaler = TextScaler.linear(3.0);
final MarkdownStyleSheet copy = original.copyWith(
textScaler: newScaler,
);
expect(copy.textScaler, newScaler);
expect(copy.textScaleFactor, null);
},
);
testWidgets(
'copyWith textScaleFactor overwrites both textScaler and textScaleFactor',
(WidgetTester tester) async {
final MarkdownStyleSheet original = MarkdownStyleSheet(
textScaleFactor: 2.0,
);
const double newScaleFactor = 3.0;
final MarkdownStyleSheet copy = original.copyWith(
textScaleFactor: newScaleFactor,
);
expect(copy.textScaler, const TextScaler.linear(newScaleFactor));
expect(copy.textScaleFactor, newScaleFactor);
},
);
});
}

View file

@ -0,0 +1,702 @@
// Copyright 2013 The Flutter 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_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Table', () {
testWidgets(
'should show properly',
(WidgetTester tester) async {
const String data = '|Header 1|Header 2|\n|-----|-----|\n|Col 1|Col 2|';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['Header 1', 'Header 2', 'Col 1', 'Col 2']);
},
);
testWidgets(
'work without the outer pipes',
(WidgetTester tester) async {
const String data = 'Header 1|Header 2\n-----|-----\nCol 1|Col 2';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
expectTextStrings(widgets, <String>['Header 1', 'Header 2', 'Col 1', 'Col 2']);
},
);
testWidgets(
'should work with alignments',
(WidgetTester tester) async {
const String data = '|Header 1|Header 2|Header 3|\n|:----|:----:|----:|\n|Col 1|Col 2|Col 3|';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<DefaultTextStyle> styles = tester.widgetList(find.byType(DefaultTextStyle));
expect(styles.first.textAlign, TextAlign.left);
expect(styles.elementAt(1).textAlign, TextAlign.center);
expect(styles.last.textAlign, TextAlign.right);
final Iterable<Wrap> wraps = tester.widgetList(find.byType(Wrap));
expect(wraps.first.alignment, WrapAlignment.start);
expect(wraps.elementAt(1).alignment, WrapAlignment.center);
expect(wraps.last.alignment, WrapAlignment.end);
final Iterable<Text> texts = tester.widgetList(find.byType(Text));
expect(texts.first.textAlign, TextAlign.left);
expect(texts.elementAt(1).textAlign, TextAlign.center);
expect(texts.last.textAlign, TextAlign.right);
},
);
testWidgets(
'should work with styling',
(WidgetTester tester) async {
const String data = '|Header|\n|----|\n|*italic*|';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Text text = widgets.lastWhere((Widget widget) => widget is Text) as Text;
expectTextStrings(widgets, <String>['Header', 'italic']);
expect(text.textSpan!.style!.fontStyle, FontStyle.italic);
},
);
testWidgets(
'should work next to other tables',
(WidgetTester tester) async {
const String data = '|first header|\n|----|\n|first col|\n\n'
'|second header|\n|----|\n|second col|';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> tables = tester.widgetList(find.byType(Table));
expect(tables.length, 2);
},
);
testWidgets(
'column width should follow stylesheet',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
'table cell vertical alignment should default to middle',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.defaultVerticalAlignment, TableCellVerticalAlignment.middle);
},
);
testWidgets(
'table cell vertical alignment should follow stylesheet',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
const TableCellVerticalAlignment tableCellVerticalAlignment = TableCellVerticalAlignment.top;
final MarkdownStyleSheet style =
MarkdownStyleSheet.fromTheme(theme).copyWith(tableVerticalAlignment: tableCellVerticalAlignment);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.defaultVerticalAlignment, tableCellVerticalAlignment);
},
);
testWidgets(
'table cell vertical alignment should follow stylesheet for different values',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
const TableCellVerticalAlignment tableCellVerticalAlignment = TableCellVerticalAlignment.bottom;
final MarkdownStyleSheet style =
MarkdownStyleSheet.fromTheme(theme).copyWith(tableVerticalAlignment: tableCellVerticalAlignment);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.defaultVerticalAlignment, tableCellVerticalAlignment);
},
);
testWidgets(
'table scrollbar thumbVisibility should follow stylesheet',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
const bool tableScrollbarThumbVisibility = true;
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: const FixedColumnWidth(100),
tableScrollbarThumbVisibility: tableScrollbarThumbVisibility);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Scrollbar scrollbar = tester.widget(find.byType(Scrollbar));
expect(scrollbar.thumbVisibility, tableScrollbarThumbVisibility);
},
);
testWidgets(
'table scrollbar thumbVisibility should follow stylesheet',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Column|';
const bool tableScrollbarThumbVisibility = false;
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: const FixedColumnWidth(100),
tableScrollbarThumbVisibility: tableScrollbarThumbVisibility);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Scrollbar scrollbar = tester.widget(find.byType(Scrollbar));
expect(scrollbar.thumbVisibility, tableScrollbarThumbVisibility);
},
);
testWidgets(
'table with last row of empty table cells',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header 1|Header 2|\n|----|----|\n| | |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(2, 2);
expect(find.byType(Text), findsNWidgets(4));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'Header 1');
expect(cellText[1], 'Header 2');
expect(cellText[2], '');
expect(cellText[3], '');
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
'table with an empty row an last row has an empty table cell',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header 1|Header 2|\n|----|----|\n| | |\n| bar | |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(3, 2);
expect(find.byType(RichText), findsNWidgets(6));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text richText) => richText.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'Header 1');
expect(cellText[1], 'Header 2');
expect(cellText[2], '');
expect(cellText[3], '');
expect(cellText[4], 'bar');
expect(cellText[5], '');
expect(table.defaultColumnWidth, columnWidth);
},
);
group('GFM Examples', () {
testWidgets(
// Example 198 from GFM.
'simple table',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| foo | bar |\n| --- | --- |\n| baz | bim |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(2, 2);
expect(find.byType(Text), findsNWidgets(4));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'foo');
expect(cellText[1], 'bar');
expect(cellText[2], 'baz');
expect(cellText[3], 'bim');
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
// Example 199 from GFM.
'input table cell data does not need to match column length',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | defghi |\n:-: | -----------:\nbar | baz';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(2, 2);
expect(find.byType(Text), findsNWidgets(4));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'abc');
expect(cellText[1], 'defghi');
expect(cellText[2], 'bar');
expect(cellText[3], 'baz');
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
// Example 200 from GFM.
'include a pipe in table cell data by escaping the pipe',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| f\\|oo |\n| ------ |\n| b \\| az |\n| b **\\|** im |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(1, 3);
expect(find.byType(Text), findsNWidgets(4));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'f|oo');
expect(cellText[1], 'defghi');
expect(cellText[2], 'b | az');
expect(cellText[3], 'b | im');
expect(table.defaultColumnWidth, columnWidth);
},
// TODO(mjordan56): Remove skip once the issue #340 in the markdown package
// is fixed and released. https://github.com/dart-lang/markdown/issues/340
// This test will need adjusting once issue #340 is fixed.
skip: true,
);
testWidgets(
// Example 201 from GFM.
'table definition is complete at beginning of new block',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | def |\n| --- | --- |\n| bar | baz |\n> bar';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(2, 2);
expect(find.byType(Text), findsNWidgets(5));
final List<String?> text = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(text[0], 'abc');
expect(text[1], 'def');
expect(text[2], 'bar');
expect(text[3], 'baz');
expect(table.defaultColumnWidth, columnWidth);
// Blockquote
expect(find.byType(DecoratedBox), findsOneWidget);
expect(text[4], 'bar');
},
);
testWidgets(
// Example 202 from GFM.
'table definition is complete at first empty line',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | def |\n| --- | --- |\n| bar | baz |\nbar\n\nbar';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(3, 2);
expect(find.byType(Text), findsNWidgets(7));
final List<String?> text = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(text, <String>['abc', 'def', 'bar', 'baz', 'bar', '', 'bar']);
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
// Example 203 from GFM.
'table header row must match the delimiter row in number of cells',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | def |\n| --- |\n| bar |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
expect(find.byType(Table), findsNothing);
final List<String?> text = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(text[0], '| abc | def | | --- | | bar |');
},
);
testWidgets(
// Example 204 from GFM.
'remainder of table cells may vary, excess cells are ignored',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | def |\n| --- | --- |\n| bar |\n| bar | baz | boo |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(3, 2);
expect(find.byType(Text), findsNWidgets(6));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText, <String>['abc', 'def', 'bar', '', 'bar', 'baz']);
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
// Example 205 from GFM.
'no table body is created when no rows are defined',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '| abc | def |\n| --- | --- |';
const FixedColumnWidth columnWidth = FixedColumnWidth(100);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableColumnWidth: columnWidth,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expectTableSize(1, 2);
expect(find.byType(Text), findsNWidgets(2));
final List<String?> cellText = find
.byType(Text)
.evaluate()
.map((Element e) => e.widget)
.cast<Text>()
.map((Text text) => text.textSpan!)
.cast<TextSpan>()
.map((TextSpan e) => e.text)
.toList();
expect(cellText[0], 'abc');
expect(cellText[1], 'def');
expect(table.defaultColumnWidth, columnWidth);
},
);
testWidgets(
'table header cells should use tableHeadCellsPadding when specified',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Body|';
const EdgeInsets headerPadding = EdgeInsets.all(20);
const EdgeInsets bodyPadding = EdgeInsets.all(10);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableHeadCellsPadding: headerPadding,
tableCellsPadding: bodyPadding,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Iterable<Padding> paddings = tester.widgetList(find.byType(Padding));
// Filter to get only TableCell paddings (not other paddings like table padding)
final List<Padding> cellPaddings = paddings.where((Padding p) {
return p.padding == headerPadding || p.padding == bodyPadding;
}).toList();
expect(cellPaddings.length, 2);
expect(cellPaddings[0].padding, headerPadding); // Header cell
expect(cellPaddings[1].padding, bodyPadding); // Body cell
},
);
testWidgets(
'table header cells should use tableCellsPadding when tableHeadCellsPadding is null',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Body|';
const EdgeInsets cellPadding = EdgeInsets.all(12);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableCellsPadding: cellPadding,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Iterable<Padding> paddings = tester.widgetList(find.byType(Padding));
final List<Padding> cellPaddings = paddings.where((Padding p) {
return p.padding == cellPadding;
}).toList();
expect(cellPaddings.length, 2); // Both header and body use same padding
},
);
testWidgets(
'table header row should use tableHeadCellsDecoration when specified',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Body|';
final BoxDecoration headerDecoration = BoxDecoration(
color: Colors.blue.shade100,
);
final BoxDecoration bodyDecoration = BoxDecoration(
color: Colors.grey.shade100,
);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableHeadCellsDecoration: headerDecoration,
tableCellsDecoration: bodyDecoration,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.children.length, 2); // Header and body rows
expect(table.children[0].decoration, headerDecoration); // Header row
expect(table.children[1].decoration, isNull); // Body row (odd row, no decoration by default)
},
);
testWidgets(
'table header row should use tableCellsDecoration when tableHeadCellsDecoration is null',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|Header|\n|----|\n|Body|';
final BoxDecoration bodyDecoration = BoxDecoration(
color: Colors.grey.shade100,
);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableCellsDecoration: bodyDecoration,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.children.length, 2); // Header and body rows
expect(table.children[0].decoration, bodyDecoration); // Header falls back to body decoration
expect(table.children[1].decoration, isNull); // First body row (odd) remains undecorated
},
);
testWidgets(
'table with multiple rows should apply decorations correctly',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
const String data = '|H1|H2|\n|--|--|\n|R1C1|R1C2|\n|R2C1|R2C2|\n|R3C1|R3C2|';
final BoxDecoration headerDecoration = BoxDecoration(
color: Colors.blue.shade100,
);
final BoxDecoration bodyDecoration = BoxDecoration(
color: Colors.grey.shade100,
);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
tableHeadCellsDecoration: headerDecoration,
tableCellsDecoration: bodyDecoration,
);
await tester.pumpWidget(boilerplate(MarkdownBody(data: data, styleSheet: style)));
final Table table = tester.widget(find.byType(Table));
expect(table.children.length, 4); // 1 header + 3 body rows
expect(table.children[0].decoration, headerDecoration); // Header row (length=0)
expect(table.children[1].decoration, isNull); // Body row 1 (length=1, odd)
expect(table.children[2].decoration, bodyDecoration); // Body row 2 (length=2, even)
expect(table.children[3].decoration, isNull); // Body row 3 (length=3, odd)
},
);
});
});
}

View file

@ -0,0 +1,107 @@
// Copyright 2013 The Flutter 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_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Text Alignment', () {
testWidgets(
'apply text alignments from stylesheet',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style1 = MarkdownStyleSheet.fromTheme(theme).copyWith(
h1Align: WrapAlignment.center,
h3Align: WrapAlignment.end,
);
const String data = '# h1\n ## h2';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
styleSheet: style1,
),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Column,
Wrap,
Text,
RichText,
SizedBox,
Column,
Wrap,
Text,
RichText,
]);
expect((widgets.firstWhere((Widget w) => w is RichText) as RichText).textAlign, TextAlign.center);
expect((widgets.last as RichText).textAlign, TextAlign.start,
reason: 'default alignment if none is set in stylesheet');
},
);
testWidgets(
'should align formatted text',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
textAlign: WrapAlignment.spaceBetween,
);
const String data = 'hello __my formatted text__';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
data: data,
styleSheet: style,
),
),
);
final RichText text = tester.widgetList(find.byType(RichText)).single as RichText;
expect(text.textAlign, TextAlign.justify);
},
);
testWidgets(
'should align selectable text',
(WidgetTester tester) async {
final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme);
final MarkdownStyleSheet style = MarkdownStyleSheet.fromTheme(theme).copyWith(
textAlign: WrapAlignment.spaceBetween,
);
const String data = 'hello __my formatted text__';
await tester.pumpWidget(
boilerplate(
MediaQuery(
data: const MediaQueryData(),
child: MarkdownBody(
data: data,
styleSheet: style,
selectable: true,
),
),
),
);
final SelectableText text = tester.widgetList(find.byType(SelectableText)).single as SelectableText;
expect(text.textAlign, TextAlign.justify);
},
);
});
}

View file

@ -0,0 +1,76 @@
// Copyright 2013 The Flutter 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_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Text Scaler', () {
testWidgets(
'should use style textScaler in RichText',
(WidgetTester tester) async {
const TextScaler scaler = TextScaler.linear(2.0);
const String data = 'Hello';
await tester.pumpWidget(
boilerplate(
MarkdownBody(
styleSheet: MarkdownStyleSheet(textScaler: scaler),
data: data,
),
),
);
final RichText richText = tester.widget(find.byType(RichText));
expect(richText.textScaler, scaler);
},
);
testWidgets(
'should use MediaQuery textScaler in RichText',
(WidgetTester tester) async {
const TextScaler scaler = TextScaler.linear(2.0);
const String data = 'Hello';
await tester.pumpWidget(
boilerplate(
const MediaQuery(
data: MediaQueryData(textScaler: scaler),
child: MarkdownBody(
data: data,
),
),
),
);
final RichText richText = tester.widget(find.byType(RichText));
expect(richText.textScaler, scaler);
},
);
testWidgets(
'should use MediaQuery textScaler in SelectableText.rich',
(WidgetTester tester) async {
const TextScaler scaler = TextScaler.linear(2.0);
const String data = 'Hello';
await tester.pumpWidget(
boilerplate(
const MediaQuery(
data: MediaQueryData(textScaler: scaler),
child: MarkdownBody(
data: data,
selectable: true,
),
),
),
);
final SelectableText selectableText = tester.widget(find.byType(SelectableText));
expect(selectableText.textScaler, scaler);
},
);
});
}

View file

@ -0,0 +1,400 @@
// Copyright 2013 The Flutter 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Data', () {
testWidgets(
'simple data',
(WidgetTester tester) async {
// extract to variable; if run with --track-widget-creation using const
// widgets aren't necessarily identical if created on different lines.
const Markdown markdown = Markdown(data: 'Data1');
await tester.pumpWidget(boilerplate(markdown));
expectTextStrings(tester.allWidgets, <String>['Data1']);
final String stateBefore = dumpRenderView();
await tester.pumpWidget(boilerplate(markdown));
final String stateAfter = dumpRenderView();
expect(stateBefore, equals(stateAfter));
await tester.pumpWidget(boilerplate(const Markdown(data: 'Data2')));
expectTextStrings(tester.allWidgets, <String>['Data2']);
},
);
});
group('Text', () {
testWidgets(
'Empty string',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: ''),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
]);
},
);
testWidgets(
'Simple string',
(WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: 'Hello'),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['Hello']);
},
);
});
group('Leading spaces', () {
testWidgets(
// Example 192 from the GitHub Flavored Markdown specification.
'leading space are ignored', (WidgetTester tester) async {
const String data = ' aaa\n bbb';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['aaa bbb']);
});
});
group('Line Break', () {
testWidgets(
// Example 654 from the GitHub Flavored Markdown specification.
'two spaces at end of line inside a block element',
(WidgetTester tester) async {
const String data = 'line 1 \nline 2';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Column, Wrap, Text, RichText]);
expectTextStrings(widgets, <String>['line 1\nline 2']);
},
);
testWidgets(
// Example 655 from the GitHub Flavored Markdown specification.
'backslash at end of line inside a block element',
(WidgetTester tester) async {
const String data = 'line 1\\\nline 2';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Column, Wrap, Text, RichText]);
expectTextStrings(widgets, <String>['line 1\nline 2']);
},
);
testWidgets(
'non-applicable line break',
(WidgetTester tester) async {
const String data = 'line 1.\nline 2.';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['line 1. line 2.']);
},
);
testWidgets(
'non-applicable line break',
(WidgetTester tester) async {
const String data = 'line 1.\nline 2.';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['line 1. line 2.']);
},
);
testWidgets(
'soft line break',
(WidgetTester tester) async {
const String data = 'line 1.\nline 2.';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(
data: data,
softLineBreak: true,
),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[MarkdownBody, Column, Wrap, Text, RichText]);
expectTextStrings(widgets, <String>['line 1.\nline 2.']);
},
);
});
group('Selectable', () {
testWidgets(
'header with line of text',
(WidgetTester tester) async {
const String data = '# Title\nHello _World_!';
await tester.pumpWidget(
boilerplate(
const MediaQuery(
data: MediaQueryData(),
child: Markdown(
data: data,
selectable: true,
),
),
),
);
expect(find.byType(SelectableText), findsNWidgets(2));
},
);
testWidgets(
'header with line of text and onTap callback',
(WidgetTester tester) async {
const String data = '# Title\nHello _World_!';
String? textTapResults;
await tester.pumpWidget(
boilerplate(
MediaQuery(
data: const MediaQueryData(),
child: Markdown(
data: data,
selectable: true,
onTapText: () => textTapResults = 'Text has been tapped.',
),
),
),
);
final Iterable<Widget> selectableWidgets = tester.widgetList(find.byType(SelectableText));
expect(selectableWidgets.length, 2);
final SelectableText selectableTitle = selectableWidgets.first as SelectableText;
expect(selectableTitle, isNotNull);
expect(selectableTitle.onTap, isNotNull);
selectableTitle.onTap!();
expect(textTapResults == 'Text has been tapped.', true);
textTapResults = null;
final SelectableText selectableText = selectableWidgets.last as SelectableText;
expect(selectableText, isNotNull);
expect(selectableText.onTap, isNotNull);
selectableText.onTap!();
expect(textTapResults == 'Text has been tapped.', true);
},
);
testWidgets(
'Selectable without onSelectionChanged',
(WidgetTester tester) async {
const String data = '# abc def ghi\njkl opq';
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: MarkdownBody(
data: data,
selectable: true,
),
),
),
);
// Find the positions before character 'd' and 'f'.
final Offset dPos = positionInRenderedText(tester, 'abc def ghi', 4);
final Offset fPos = positionInRenderedText(tester, 'abc def ghi', 6);
// Select from 'd' until 'f'.
final TestGesture firstGesture = await tester.startGesture(dPos, kind: PointerDeviceKind.mouse);
addTearDown(firstGesture.removePointer);
await tester.pump();
await firstGesture.moveTo(fPos);
await firstGesture.up();
await tester.pump();
// Find the positions before character 'j' and 'o'.
final Offset jPos = positionInRenderedText(tester, 'jkl opq', 0);
final Offset oPos = positionInRenderedText(tester, 'jkl opq', 4);
// Select from 'j' until 'o'.
final TestGesture secondGesture = await tester.startGesture(jPos, kind: PointerDeviceKind.mouse);
addTearDown(secondGesture.removePointer);
await tester.pump();
await secondGesture.moveTo(oPos);
await secondGesture.up();
await tester.pump();
expect(tester.takeException(), isNull);
},
);
testWidgets(
'header with line of text and onSelectionChanged callback',
(WidgetTester tester) async {
const String data = '# abc def ghi\njkl opq';
String? selectableText;
String? selectedText;
void onSelectionChanged(String? text, TextSelection selection, SelectionChangedCause? cause) {
selectableText = text;
selectedText = text != null ? selection.textInside(text) : null;
}
await tester.pumpWidget(
MaterialApp(
home: Material(
child: MarkdownBody(
data: data,
selectable: true,
onSelectionChanged: onSelectionChanged,
),
),
),
);
// Find the positions before character 'd' and 'f'.
final Offset dPos = positionInRenderedText(tester, 'abc def ghi', 4);
final Offset fPos = positionInRenderedText(tester, 'abc def ghi', 6);
// Select from 'd' until 'f'.
final TestGesture firstGesture = await tester.startGesture(dPos, kind: PointerDeviceKind.mouse);
addTearDown(firstGesture.removePointer);
await tester.pump();
await firstGesture.moveTo(fPos);
await firstGesture.up();
await tester.pump();
expect(selectableText, 'abc def ghi');
expect(selectedText, 'de');
// Find the positions before character 'j' and 'o'.
final Offset jPos = positionInRenderedText(tester, 'jkl opq', 0);
final Offset oPos = positionInRenderedText(tester, 'jkl opq', 4);
// Select from 'j' until 'o'.
final TestGesture secondGesture = await tester.startGesture(jPos, kind: PointerDeviceKind.mouse);
addTearDown(secondGesture.removePointer);
await tester.pump();
await secondGesture.moveTo(oPos);
await secondGesture.up();
await tester.pump();
expect(selectableText, 'jkl opq');
expect(selectedText, 'jkl ');
},
);
});
group('Strikethrough', () {
testWidgets('single word', (WidgetTester tester) async {
const String data = '~~strikethrough~~';
await tester.pumpWidget(
boilerplate(
const MarkdownBody(data: data),
),
);
final Iterable<Widget> widgets = selfAndDescendantWidgetsOf(
find.byType(MarkdownBody),
tester,
);
expectWidgetTypes(widgets, <Type>[
MarkdownBody,
Column,
Wrap,
Text,
RichText,
]);
expectTextStrings(widgets, <String>['strikethrough']);
});
});
}

View file

@ -0,0 +1,94 @@
// Copyright 2013 The Flutter 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/widgets.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'utils.dart';
void main() => defineTests();
void defineTests() {
group('Uri Data Scheme', () {
testWidgets(
'should work with image in uri data scheme',
(WidgetTester tester) async {
const String data = '![alt](data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final Image image = widgets.firstWhere((Widget widget) => widget is Image) as Image;
expect(image.image.runtimeType, MemoryImage);
},
);
testWidgets(
'should work with base64 text in uri data scheme',
(WidgetTester tester) async {
const String imageData = '![alt](data:text/plan;base64,Rmx1dHRlcg==)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: imageData),
),
);
final Text widget = tester.widget(find.byType(Text));
expect(widget.runtimeType, Text);
expect(widget.data, 'Flutter');
},
);
testWidgets(
'should work with text in uri data scheme',
(WidgetTester tester) async {
const String imageData = '![alt](data:text/plan,Hello%2C%20Flutter)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: imageData),
),
);
final Text widget = tester.widget(find.byType(Text));
expect(widget.runtimeType, Text);
expect(widget.data, 'Hello, Flutter');
},
);
testWidgets(
'should work with empty uri data scheme',
(WidgetTester tester) async {
const String imageData = '![alt](data:,)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: imageData),
),
);
final Text widget = tester.widget(find.byType(Text));
expect(widget.runtimeType, Text);
expect(widget.data, '');
},
);
testWidgets(
'should work with unsupported mime types of uri data scheme',
(WidgetTester tester) async {
const String data = '![alt](data:application/javascript,var%20test=1)';
await tester.pumpWidget(
boilerplate(
const Markdown(data: data),
),
);
final Iterable<Widget> widgets = tester.allWidgets;
final SizedBox widget = widgets.firstWhere((Widget widget) => widget is SizedBox) as SizedBox;
expect(widget.runtimeType, SizedBox);
},
);
});
}

View file

@ -0,0 +1,248 @@
// Copyright 2013 The Flutter 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:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
final TextTheme textTheme =
Typography.material2018().black.merge(const TextTheme(bodyMedium: TextStyle(fontSize: 12.0)));
Iterable<Widget> selfAndDescendantWidgetsOf(Finder start, WidgetTester tester) {
final Element startElement = tester.element(start);
final Iterable<Widget> descendants =
collectAllElementsFrom(startElement, skipOffstage: false).map((Element e) => e.widget);
return <Widget>[
startElement.widget,
...descendants,
];
}
// Returns the RenderEditable displaying the given text.
RenderEditable findRenderEditableWithText(WidgetTester tester, String text) {
final Iterable<RenderObject> roots = tester.renderObjectList(find.byType(EditableText));
expect(roots, isNotEmpty);
late RenderEditable renderEditable;
void recursiveFinder(RenderObject child) {
if (child is RenderEditable && child.plainText == text) {
renderEditable = child;
return;
}
child.visitChildren(recursiveFinder);
}
for (final RenderObject root in roots) {
root.visitChildren(recursiveFinder);
}
expect(renderEditable, isNotNull);
return renderEditable;
}
// Returns the [textOffset] position in rendered [text].
Offset positionInRenderedText(WidgetTester tester, String text, int textOffset) {
final RenderEditable renderEditable = findRenderEditableWithText(tester, text);
final Iterable<TextSelectionPoint> textOffsetPoints = renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: textOffset),
);
// Map the points to global positions.
final List<TextSelectionPoint> endpoints = textOffsetPoints.map<TextSelectionPoint>((TextSelectionPoint point) {
return TextSelectionPoint(
renderEditable.localToGlobal(point.point),
point.direction,
);
}).toList();
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0);
}
void expectWidgetTypes(Iterable<Widget> widgets, List<Type> expected) {
final List<Type> actual = widgets.map((Widget w) => w.runtimeType).toList();
expect(actual, expected);
}
void expectTextStrings(Iterable<Widget> widgets, List<String> strings) {
int currentString = 0;
for (final Widget widget in widgets) {
TextSpan? span;
if (widget is RichText) {
span = widget.text as TextSpan;
} else if (widget is SelectableText) {
span = widget.textSpan;
}
if (span != null) {
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 (final TextSpan child in span.children!.toList().cast<TextSpan>()) {
text += _extractTextFromTextSpan(child);
}
}
return text;
}
// Check the font style and weight of the text span.
void expectTextSpanStyle(TextSpan textSpan, FontStyle? style, FontWeight weight) {
// Verify a text style is set
expect(textSpan.style, isNotNull, reason: 'text span text style is null');
// Font style check
if (style == null) {
expect(textSpan.style!.fontStyle, isNull, reason: 'font style is not null');
} else {
expect(textSpan.style!.fontStyle, isNotNull, reason: 'font style is null');
expect(
textSpan.style!.fontStyle == style,
isTrue,
reason: 'font style is not $style',
);
}
// Font weight check
expect(textSpan.style, isNotNull, reason: 'font style is null');
expect(
textSpan.style!.fontWeight == weight,
isTrue,
reason: 'font weight is not $weight',
);
}
@immutable
class MarkdownLink {
const MarkdownLink(this.text, this.destination, [this.title = '']);
final String text;
final String? destination;
final String title;
@override
bool operator ==(Object other) =>
other is MarkdownLink && other.text == text && other.destination == destination && other.title == title;
@override
int get hashCode => '$text$destination$title'.hashCode;
@override
String toString() {
return '[$text]($destination "$title")';
}
}
/// Verify a valid link structure has been created. This routine checks for the
/// link text and the associated [TapGestureRecognizer] on the text span.
void expectValidLink(String linkText) {
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text text = textFinder.evaluate().first.widget as Text;
// Verify the link text.
expect(text.textSpan, isNotNull);
expect(text.textSpan, isA<TextSpan>());
// Verify the link text is a onTap gesture recognizer.
final TextSpan textSpan = text.textSpan! as TextSpan;
expectLinkTextSpan(textSpan, linkText);
}
void expectLinkTextSpan(TextSpan textSpan, String linkText) {
expect(textSpan.children, isNull);
expect(textSpan.toPlainText(), linkText);
expect(textSpan.recognizer, isNotNull);
expect(textSpan.recognizer, isA<TapGestureRecognizer>());
final TapGestureRecognizer? tapRecognizer = textSpan.recognizer as TapGestureRecognizer?;
expect(tapRecognizer?.onTap, isNotNull);
// Execute the onTap callback handler.
tapRecognizer!.onTap!();
}
void expectInvalidLink(String linkText) {
final Finder textFinder = find.byType(Text);
expect(textFinder, findsOneWidget);
final Text text = textFinder.evaluate().first.widget as Text;
expect(text.textSpan, isNotNull);
expect(text.textSpan, isA<TextSpan>());
final String plainText = text.textSpan!.toPlainText();
expect(plainText, linkText);
final TextSpan textSpan = text.textSpan! as TextSpan;
expect(textSpan.recognizer, isNull);
}
void expectTableSize(int rows, int columns) {
final Finder tableFinder = find.byType(Table);
expect(tableFinder, findsOneWidget);
final Table table = tableFinder.evaluate().first.widget as Table;
expect(table.children.length, rows);
for (int index = 0; index < rows; index++) {
expect(table.children[index].children.length, columns);
}
}
void expectLinkTap(MarkdownLink? actual, MarkdownLink expected) {
expect(actual, equals(expected), reason: 'incorrect link tap results, actual: $actual expected: $expected');
}
String dumpRenderView() {
return WidgetsBinding.instance.rootElement!.toStringDeep().replaceAll(
RegExp(r'SliverChildListDelegate#\d+', multiLine: true),
'SliverChildListDelegate',
);
}
/// Wraps a widget with a left-to-right [Directionality] for tests.
Widget boilerplate(Widget child) {
return Directionality(
textDirection: TextDirection.ltr,
child: child,
);
}
class TestAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
if (key == 'AssetManifest.json') {
const String manifest = r'{"assets/logo.png":["assets/logo.png"]}';
final ByteData asset = ByteData.view(utf8.encoder.convert(manifest).buffer);
return Future<ByteData>.value(asset);
} else if (key == 'AssetManifest.bin') {
final ByteData manifest =
const StandardMessageCodec().encodeMessage(<String, List<Object>>{'assets/logo.png': <Object>[]})!;
return Future<ByteData>.value(manifest);
} else if (key == 'AssetManifest.smcbin') {
final ByteData manifest =
const StandardMessageCodec().encodeMessage(<String, List<Object>>{'assets/logo.png': <Object>[]})!;
return Future<ByteData>.value(manifest);
} else if (key == 'assets/logo.png') {
// The root directory tests are run from is different for 'flutter test'
// verses 'flutter test test/*_test.dart'. Adjust the root directory
// to access the assets directory.
final io.Directory rootDirectory = io.Directory.current.path.endsWith('${io.Platform.pathSeparator}test')
? io.Directory.current.parent
: io.Directory.current;
final io.File file = io.File('${rootDirectory.path}/test/assets/images/logo.png');
final ByteData asset = ByteData.view(file.readAsBytesSync().buffer);
return asset;
} else {
throw ArgumentError('Unknown asset key: $key');
}
}
}

View file

@ -5,6 +5,8 @@ dependency_overrides:
path: ./dependencies/dots_indicator path: ./dependencies/dots_indicator
ed25519_edwards: ed25519_edwards:
path: ./dependencies/ed25519_edwards path: ./dependencies/ed25519_edwards
flutter_markdown_plus:
path: ./dependencies/flutter_markdown_plus
flutter_sharing_intent: flutter_sharing_intent:
path: ./dependencies/flutter_sharing_intent path: ./dependencies/flutter_sharing_intent
hand_signature: hand_signature: