Unverified Commit 4db47db1 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

LinkedText (Linkify) (#125927)

New LinkedText widget and TextLinker class for easily adding hyperlinks to text.
parent ee9aef01
// Copyright 2014 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';
// This example demonstrates highlighting both URLs and Twitter handles with
// different actions and different styles.
void main() {
runApp(const TextLinkerApp());
}
class TextLinkerApp extends StatelessWidget {
const TextLinkerApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Link Twitter Handle Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title
});
final String title;
static const String _text = '@FlutterDev is our Twitter account, or find us at www.flutter.dev';
void _handleTapTwitterHandle(BuildContext context, String linkString) {
final String handleWithoutAt = linkString.substring(1);
final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt';
final Uri? uri = Uri.tryParse(twitterUriString);
if (uri == null) {
throw Exception('Failed to parse $twitterUriString.');
}
_showDialog(context, uri);
}
void _handleTapUrl(BuildContext context, String urlText) {
final Uri? uri = Uri.tryParse(urlText);
if (uri == null) {
throw Exception('Failed to parse $urlText.');
}
_showDialog(context, uri);
}
void _showDialog(BuildContext context, Uri uri) {
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: _TwitterAndUrlLinkedText(
text: _text,
onTapUrl: (String urlString) => _handleTapUrl(context, urlString),
onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString),
),
);
},
),
),
);
}
}
class _TwitterAndUrlLinkedText extends StatefulWidget {
const _TwitterAndUrlLinkedText({
required this.text,
required this.onTapUrl,
required this.onTapTwitterHandle,
});
final String text;
final ValueChanged<String> onTapUrl;
final ValueChanged<String> onTapTwitterHandle;
@override
State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState();
}
class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> {
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
late Iterable<InlineSpan> _linkedSpans;
late final List<TextLinker> _textLinkers;
final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}');
void _disposeRecognizers() {
for (final GestureRecognizer recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _linkSpans() {
_disposeRecognizers();
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
<TextSpan>[TextSpan(text: widget.text)],
_textLinkers,
);
_linkedSpans = linkedSpans;
}
@override
void initState() {
super.initState();
_textLinkers = <TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTapUrl(linkString);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
text: displayString,
color: const Color(0xff0000ee),
recognizer: recognizer,
);
},
),
TextLinker(
regExp: _twitterHandleRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTapTwitterHandle(linkString);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
text: displayString,
color: const Color(0xff00aaaa),
recognizer: recognizer,
);
},
),
];
_linkSpans();
}
@override
void didUpdateWidget(_TwitterAndUrlLinkedText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.text != oldWidget.text
|| widget.onTapUrl != oldWidget.onTapUrl
|| widget.onTapTwitterHandle != oldWidget.onTapTwitterHandle) {
_linkSpans();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_linkedSpans.isEmpty) {
return const SizedBox.shrink();
}
return Text.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: _linkedSpans.toList(),
),
);
}
}
class _MyInlineLinkSpan extends TextSpan {
_MyInlineLinkSpan({
required String text,
required Color color,
required super.recognizer,
}) : super(
style: TextStyle(
color: color,
decorationColor: color,
decoration: TextDecoration.underline,
),
mouseCursor: SystemMouseCursors.click,
text: text,
);
}
// Copyright 2014 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';
// This example demonstrates creating links in a TextSpan tree instead of a flat
// String.
void main() {
runApp(const TextLinkerApp());
}
class TextLinkerApp extends StatelessWidget {
const TextLinkerApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter TextLinker Span Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title
});
final String title;
void _handleTapTwitterHandle(BuildContext context, String linkString) {
final String handleWithoutAt = linkString.substring(1);
final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt';
final Uri? uri = Uri.tryParse(twitterUriString);
if (uri == null) {
throw Exception('Failed to parse $twitterUriString.');
}
_showDialog(context, uri);
}
void _handleTapUrl(BuildContext context, String urlText) {
final Uri? uri = Uri.tryParse(urlText);
if (uri == null) {
throw Exception('Failed to parse $urlText.');
}
_showDialog(context, uri);
}
void _showDialog(BuildContext context, Uri uri) {
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: _TwitterAndUrlLinkedText(
spans: <InlineSpan>[
TextSpan(
text: '@FlutterDev is our Twitter, or find us at www.',
style: DefaultTextStyle.of(context).style,
children: const <InlineSpan>[
TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
),
text: 'flutter',
),
],
),
TextSpan(
text: '.dev',
style: DefaultTextStyle.of(context).style,
),
],
onTapUrl: (String urlString) => _handleTapUrl(context, urlString),
onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString),
),
);
},
),
),
);
}
}
class _TwitterAndUrlLinkedText extends StatefulWidget {
const _TwitterAndUrlLinkedText({
required this.spans,
required this.onTapUrl,
required this.onTapTwitterHandle,
});
final List<InlineSpan> spans;
final ValueChanged<String> onTapUrl;
final ValueChanged<String> onTapTwitterHandle;
@override
State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState();
}
class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> {
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
late Iterable<InlineSpan> _linkedSpans;
late final List<TextLinker> _textLinkers;
final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}');
void _disposeRecognizers() {
for (final GestureRecognizer recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _linkSpans() {
_disposeRecognizers();
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
widget.spans,
_textLinkers,
);
_linkedSpans = linkedSpans;
}
@override
void initState() {
super.initState();
_textLinkers = <TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
// The linkString always contains the full matched text, so that's
// what should be linked to.
..onTap = () => widget.onTapUrl(linkString);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
// The displayString contains only the portion of the matched text
// in a given TextSpan. For example, the bold "flutter" text in
// the overall "www.flutter.dev" URL is in its own TextSpan with its
// bold styling. linkBuilder is called separately for each part.
text: displayString,
color: const Color(0xff0000ee),
recognizer: recognizer,
);
},
),
TextLinker(
regExp: _twitterHandleRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTapTwitterHandle(linkString);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
text: displayString,
color: const Color(0xff00aaaa),
recognizer: recognizer,
);
},
),
];
_linkSpans();
}
@override
void didUpdateWidget(_TwitterAndUrlLinkedText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.spans != oldWidget.spans
|| widget.onTapUrl != oldWidget.onTapUrl
|| widget.onTapTwitterHandle != oldWidget.onTapTwitterHandle) {
_linkSpans();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_linkedSpans.isEmpty) {
return const SizedBox.shrink();
}
return Text.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: _linkedSpans.toList(),
),
);
}
}
class _MyInlineLinkSpan extends TextSpan {
_MyInlineLinkSpan({
required String text,
required Color color,
required super.recognizer,
}) : super(
style: TextStyle(
color: color,
decorationColor: color,
decoration: TextDecoration.underline,
),
mouseCursor: SystemMouseCursors.click,
text: text,
);
}
// Copyright 2014 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';
// This example demonstrates using LinkedText to make URLs open on tap.
void main() {
runApp(const LinkedTextApp());
}
class LinkedTextApp extends StatelessWidget {
const LinkedTextApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Link Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title,
});
final String title;
static const String _text = 'Check out https://www.flutter.dev, or maybe just flutter.dev or www.flutter.dev.';
void _handleTapUri(BuildContext context, Uri uri) {
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
LinkedText(
text: _text,
onTapUri: (Uri uri) => _handleTapUri(context, uri),
),
],
),
);
},
),
),
);
}
}
// Copyright 2014 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';
// This example demonstrates highlighting and linking Twitter handles.
void main() {
runApp(const LinkedTextApp());
}
class LinkedTextApp extends StatelessWidget {
const LinkedTextApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Link Twitter Handle Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
MyHomePage({
super.key,
required this.title
});
final String title;
static const String _text = 'Please check out @FlutterDev on Twitter for the latest.';
void _handleTapTwitterHandle(BuildContext context, String linkText) {
final String handleWithoutAt = linkText.substring(1);
final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt';
final Uri? uri = Uri.tryParse(twitterUriString);
if (uri == null) {
throw Exception('Failed to parse $twitterUriString.');
}
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
LinkedText.regExp(
text: _text,
regExp: _twitterHandleRegExp,
onTap: (String twitterHandleString) => _handleTapTwitterHandle(context, twitterHandleString),
),
],
),
);
},
),
),
);
}
}
// Copyright 2014 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';
// This example demonstrates highlighting URLs in a TextSpan tree instead of a
// flat String.
void main() {
runApp(const LinkedTextApp());
}
class LinkedTextApp extends StatelessWidget {
const LinkedTextApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter LinkedText.spans Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title,
});
final String title;
void _onTapUri (BuildContext context, Uri uri) {
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
LinkedText(
onTapUri: (Uri uri) => _onTapUri(context, uri),
spans: <InlineSpan>[
TextSpan(
text: 'Check out https://www.',
style: DefaultTextStyle.of(context).style,
children: const <InlineSpan>[
TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
),
text: 'flutter',
),
],
),
TextSpan(
text: '.dev!',
style: DefaultTextStyle.of(context).style,
),
],
),
],
),
);
},
),
),
);
}
}
// Copyright 2014 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';
// This example demonstrates highlighting both URLs and Twitter handles with
// different actions and different styles.
void main() {
runApp(const LinkedTextApp());
}
class LinkedTextApp extends StatelessWidget {
const LinkedTextApp({
super.key,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Link Twitter Handle Demo'),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title
});
final String title;
static const String _text = '@FlutterDev is our Twitter account, or find us at www.flutter.dev';
void _handleTapTwitterHandle(BuildContext context, String linkText) {
final String handleWithoutAt = linkText.substring(1);
final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt';
final Uri? uri = Uri.tryParse(twitterUriString);
if (uri == null) {
throw Exception('Failed to parse $twitterUriString.');
}
_showDialog(context, uri);
}
void _handleTapUrl(BuildContext context, String urlText) {
final Uri? uri = Uri.tryParse(urlText);
if (uri == null) {
throw Exception('Failed to parse $urlText.');
}
_showDialog(context, uri);
}
void _showDialog(BuildContext context, Uri uri) {
// A package like url_launcher would be useful for actually opening the URL
// here instead of just showing a dialog.
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Builder(
builder: (BuildContext context) {
return SelectionArea(
child: _TwitterAndUrlLinkedText(
text: _text,
onTapUrl: (String urlString) => _handleTapUrl(context, urlString),
onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString),
),
);
},
),
),
);
}
}
class _TwitterAndUrlLinkedText extends StatefulWidget {
const _TwitterAndUrlLinkedText({
required this.text,
required this.onTapUrl,
required this.onTapTwitterHandle,
});
final String text;
final ValueChanged<String> onTapUrl;
final ValueChanged<String> onTapTwitterHandle;
@override
State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState();
}
class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> {
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
late final List<TextLinker> _textLinkers;
final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}');
void _disposeRecognizers() {
for (final GestureRecognizer recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
@override
void initState() {
super.initState();
_textLinkers = <TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayText, String linkText) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTapUrl(linkText);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
text: displayText,
color: const Color(0xff0000ee),
recognizer: recognizer,
);
},
),
TextLinker(
regExp: _twitterHandleRegExp,
linkBuilder: (String displayText, String linkText) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTapTwitterHandle(linkText);
_recognizers.add(recognizer);
return _MyInlineLinkSpan(
text: displayText,
color: const Color(0xff00aaaa),
recognizer: recognizer,
);
},
),
];
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LinkedText.textLinkers(
text: widget.text,
textLinkers: _textLinkers,
);
}
}
class _MyInlineLinkSpan extends TextSpan {
_MyInlineLinkSpan({
required String text,
required Color color,
required super.recognizer,
}) : super(
style: TextStyle(
color: color,
decorationColor: color,
decoration: TextDecoration.underline,
),
mouseCursor: SystemMouseCursors.click,
text: text,
);
}
// Copyright 2014 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_api_samples/painting/text_linker/text_linker.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('can tap different link types with different results', (WidgetTester tester) async {
await tester.pumpWidget(
const example.TextLinkerApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(SelectionArea),
matching: find.byType(Text),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getTopLeft(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget);
await tester.tapAt(tester.getTopLeft(find.byType(Scaffold)));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: www.flutter.dev'), findsOneWidget);
});
}
// Copyright 2014 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_api_samples/painting/text_linker/text_linker.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('can tap different link types with different results', (WidgetTester tester) async {
await tester.pumpWidget(
const example.TextLinkerApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(SelectionArea),
matching: find.byType(Text),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getTopLeft(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget);
await tester.tapAt(tester.getTopLeft(find.byType(Scaffold)));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: www.flutter.dev'), findsOneWidget);
});
}
// Copyright 2014 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_api_samples/widgets/linked_text/linked_text.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('tapping a link shows a dialog with the tapped uri', (WidgetTester tester) async {
await tester.pumpWidget(
const example.LinkedTextApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(LinkedText),
matching: find.byType(RichText),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.flutter.dev'), findsOneWidget);
});
}
// Copyright 2014 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_api_samples/widgets/linked_text/linked_text.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('tapping a Twitter handle shows a dialog with the uri of the user', (WidgetTester tester) async {
await tester.pumpWidget(
const example.LinkedTextApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(LinkedText),
matching: find.byType(RichText),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget);
});
}
// Copyright 2014 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_api_samples/widgets/linked_text/linked_text.2.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('can tap links generated from TextSpans', (WidgetTester tester) async {
await tester.pumpWidget(
const example.LinkedTextApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(LinkedText),
matching: find.byType(RichText),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.flutter.dev'), findsOneWidget);
});
}
// Copyright 2014 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_api_samples/widgets/linked_text/linked_text.3.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('can tap different link types with different results', (WidgetTester tester) async {
await tester.pumpWidget(
const example.LinkedTextApp(),
);
final Finder textFinder = find.descendant(
of: find.byType(SelectionArea),
matching: find.byType(Text),
);
expect(textFinder, findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getTopLeft(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget);
await tester.tapAt(tester.getTopLeft(find.byType(Scaffold)));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
await tester.tapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('You tapped: www.flutter.dev'), findsOneWidget);
});
}
...@@ -59,6 +59,7 @@ export 'src/painting/shape_decoration.dart'; ...@@ -59,6 +59,7 @@ export 'src/painting/shape_decoration.dart';
export 'src/painting/stadium_border.dart'; export 'src/painting/stadium_border.dart';
export 'src/painting/star_border.dart'; export 'src/painting/star_border.dart';
export 'src/painting/strut_style.dart'; export 'src/painting/strut_style.dart';
export 'src/painting/text_linker.dart';
export 'src/painting/text_painter.dart'; export 'src/painting/text_painter.dart';
export 'src/painting/text_scaler.dart'; export 'src/painting/text_scaler.dart';
export 'src/painting/text_span.dart'; export 'src/painting/text_span.dart';
......
// Copyright 2014 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:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'inline_span.dart';
import 'text_span.dart';
/// Signature for a function that builds an [InlineSpan] link.
///
/// The link displays [displayString] and links to [linkString] when tapped.
/// These are distinct because sometimes a link may be split across multiple
/// [TextSpan]s.
///
/// For example, consider the [TextSpan]s
/// `[TextSpan(text: 'http://'), TextSpan(text: 'google.com'), TextSpan(text: '/')]`.
/// This builder would be called three times, with the following parameters:
///
/// 1. `displayString: 'http://', linkString: 'http://google.com/'`
/// 2. `displayString: 'google.com', linkString: 'http://google.com/'`
/// 3. `displayString: '/', linkString: 'http://google.com/'`
///
/// {@template flutter.painting.LinkBuilder.recognizer}
/// It's necessary for the owning widget to manage the lifecycle of any
/// [GestureRecognizer]s created in this function, such as for handling a tap on
/// the link. See [TextSpan.recognizer] for more.
/// {@endtemplate}
///
/// {@tool dartpad}
/// This example shows how to use [TextLinker] to link both URLs and Twitter
/// handles in a [TextSpan] tree. It also illustrates the difference between
/// `displayString` and `linkString`.
///
/// ** See code in examples/api/lib/painting/text_linker/text_linker.1.dart **
/// {@end-tool}
typedef InlineLinkBuilder = InlineSpan Function(
String displayString,
String linkString,
);
/// Specifies a way to find and style parts of some text.
///
/// [TextLinker]s can be applied to some text using the [linkSpans] method.
///
/// {@tool dartpad}
/// This example shows how to use [TextLinker] to link both URLs and Twitter
/// handles in the same text.
///
/// ** See code in examples/api/lib/painting/text_linker/text_linker.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how to use [TextLinker] to link both URLs and Twitter
/// handles in a [TextSpan] tree instead of a flat string.
///
/// ** See code in examples/api/lib/painting/text_linker/text_linker.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full control
/// over matching and building different types of links.
/// * [LinkedText.new], which is simpler than using [TextLinker] and
/// automatically manages the lifecycle of any [GestureRecognizer]s.
class TextLinker {
/// Creates an instance of [TextLinker] with a [RegExp] and an [InlineLinkBuilder
/// [InlineLinkBuilder].
///
/// Does not manage the lifecycle of any [GestureRecognizer]s created in the
/// [InlineLinkBuilder], so it's the responsibility of the caller to do so.
/// See [TextSpan.recognizer] for more.
TextLinker({
required this.regExp,
required this.linkBuilder,
});
/// Builds an [InlineSpan] to display the text that it's passed.
///
/// {@macro flutter.painting.LinkBuilder.recognizer}
final InlineLinkBuilder linkBuilder;
/// Matches text that should be turned into a link with [linkBuilder].
final RegExp regExp;
/// Applies the given [TextLinker]s to the given [InlineSpan]s and returns the
/// new resulting spans and any created [GestureRecognizer]s.
static Iterable<InlineSpan> linkSpans(Iterable<InlineSpan> spans, Iterable<TextLinker> textLinkers) {
final _LinkedSpans linkedSpans = _LinkedSpans(
spans: spans,
textLinkers: textLinkers,
);
return linkedSpans.linkedSpans;
}
// Turns all matches from the regExp into a list of TextRanges.
static Iterable<TextRange> _textRangesFromText(String text, RegExp regExp) {
final Iterable<RegExpMatch> matches = regExp.allMatches(text);
return matches.map((RegExpMatch match) {
return TextRange(
start: match.start,
end: match.end,
);
});
}
/// Apply this [TextLinker] to a [String].
Iterable<_TextLinkerMatch> _link(String text) {
final Iterable<TextRange> textRanges = _textRangesFromText(text, regExp);
return textRanges.map((TextRange textRange) {
return _TextLinkerMatch(
textRange: textRange,
linkBuilder: linkBuilder,
linkString: text.substring(textRange.start, textRange.end),
);
});
}
@override
String toString() => '${objectRuntimeType(this, 'TextLinker')}($regExp)';
}
/// A matched replacement on some string.
///
/// Produced by applying a [TextLinker]'s [RegExp] to a string.
class _TextLinkerMatch {
_TextLinkerMatch({
required this.textRange,
required this.linkBuilder,
required this.linkString,
}) : assert(textRange.end - textRange.start == linkString.length);
final InlineLinkBuilder linkBuilder;
final TextRange textRange;
/// The string that [textRange] matches.
final String linkString;
/// Get all [_TextLinkerMatch]s obtained from applying the given
/// `textLinker`s with the given `text`.
static List<_TextLinkerMatch> fromTextLinkers(Iterable<TextLinker> textLinkers, String text) {
return textLinkers
.fold<List<_TextLinkerMatch>>(
<_TextLinkerMatch>[],
(List<_TextLinkerMatch> previousValue, TextLinker value) {
return previousValue..addAll(value._link(text));
});
}
@override
String toString() => '${objectRuntimeType(this, '_TextLinkerMatch')}($textRange, $linkBuilder, $linkString)';
}
/// Used to cache information about a span's recursive text.
///
/// Avoids repeatedly calling [TextSpan.toPlainText].
class _TextCache {
factory _TextCache({
required InlineSpan span,
}) {
if (span is! TextSpan) {
return _TextCache._(
text: '',
lengths: <InlineSpan, int>{span: 0},
);
}
_TextCache childrenTextCache = _TextCache._empty();
for (final InlineSpan child in span.children ?? <InlineSpan>[]) {
final _TextCache childTextCache = _TextCache(
span: child,
);
childrenTextCache = childrenTextCache._merge(childTextCache);
}
final String text = (span.text ?? '') + childrenTextCache.text;
return _TextCache._(
text: text,
lengths: <InlineSpan, int>{
span: text.length,
...childrenTextCache._lengths,
},
);
}
factory _TextCache.fromMany({
required Iterable<InlineSpan> spans,
}) {
_TextCache textCache = _TextCache._empty();
for (final InlineSpan span in spans) {
final _TextCache spanTextCache = _TextCache(
span: span,
);
textCache = textCache._merge(spanTextCache);
}
return textCache;
}
_TextCache._empty(
) : text = '',
_lengths = <InlineSpan, int>{};
const _TextCache._({
required this.text,
required Map<InlineSpan, int> lengths,
}) : _lengths = lengths;
/// The flattened text of all spans in the span tree.
final String text;
/// A [Map] containing the lengths of all spans in the span tree.
///
/// The length is defined as the length of the flattened text at the point in
/// the tree where the node resides.
///
/// The length of [text] is the length of the root node in [_lengths].
final Map<InlineSpan, int> _lengths;
/// Merges the given _TextCache with this one by appending it to the end.
///
/// Returns a new _TextCache and makes no modifications to either passed in.
_TextCache _merge(_TextCache other) {
return _TextCache._(
text: text + other.text,
lengths: Map<InlineSpan, int>.from(_lengths)..addAll(other._lengths),
);
}
int? getLength(InlineSpan span) => _lengths[span];
@override
String toString() => '${objectRuntimeType(this, '_TextCache')}($text, $_lengths)';
}
/// Signature for the output of linking an InlineSpan to some
/// _TextLinkerMatches.
typedef _LinkSpanRecursion = (
/// The output of linking the input InlineSpan.
InlineSpan linkedSpan,
/// The provided _TextLinkerMatches, but with those completely used during
/// linking removed.
Iterable<_TextLinkerMatch> unusedTextLinkerMatches,
);
/// Signature for the output of linking a List of InlineSpans to some
/// _TextLinkerMatches.
typedef _LinkSpansRecursion = (
/// The output of linking the input InlineSpans.
Iterable<InlineSpan> linkedSpans,
/// The provided _TextLinkerMatches, but with those completely used during
/// linking removed.
Iterable<_TextLinkerMatch> unusedTextLinkerMatches,
);
/// Applies some [TextLinker]s to some [InlineSpan]s and produces a new list of
/// [linkedSpans] as well as the [recognizers] created for each generated link.
class _LinkedSpans {
factory _LinkedSpans({
required Iterable<InlineSpan> spans,
required Iterable<TextLinker> textLinkers,
}) {
// Flatten the spans and store all string lengths, so that matches across
// span boundaries can be matched in the flat string. This is calculated
// once in the beginning to avoid recomputing.
final _TextCache textCache = _TextCache.fromMany(spans: spans);
final Iterable<_TextLinkerMatch> textLinkerMatches =
_cleanTextLinkerMatches(
_TextLinkerMatch.fromTextLinkers(textLinkers, textCache.text),
);
final (Iterable<InlineSpan> linkedSpans, Iterable<_TextLinkerMatch> _) =
_linkSpansRecurse(
spans,
textCache,
textLinkerMatches,
);
return _LinkedSpans._(
linkedSpans: linkedSpans,
);
}
const _LinkedSpans._({
required this.linkedSpans,
});
final Iterable<InlineSpan> linkedSpans;
static List<_TextLinkerMatch> _cleanTextLinkerMatches(Iterable<_TextLinkerMatch> textLinkerMatches) {
final List<_TextLinkerMatch> nextTextLinkerMatches = textLinkerMatches.toList();
// Sort by start.
nextTextLinkerMatches.sort((_TextLinkerMatch a, _TextLinkerMatch b) {
return a.textRange.start.compareTo(b.textRange.start);
});
// Validate that there are no overlapping matches.
int lastEnd = 0;
for (final _TextLinkerMatch textLinkerMatch in nextTextLinkerMatches) {
if (textLinkerMatch.textRange.start < lastEnd) {
throw ArgumentError('Matches must not overlap. Overlapping text was "${textLinkerMatch.linkString}" located at ${textLinkerMatch.textRange.start}-${textLinkerMatch.textRange.end}.');
}
lastEnd = textLinkerMatch.textRange.end;
}
// Remove empty ranges.
nextTextLinkerMatches.removeWhere((_TextLinkerMatch textLinkerMatch) {
return textLinkerMatch.textRange.start == textLinkerMatch.textRange.end;
});
return nextTextLinkerMatches;
}
// `index` is the index of the start of `span` in the overall flattened tree
// string.
static _LinkSpansRecursion _linkSpansRecurse(Iterable<InlineSpan> spans, _TextCache textCache, Iterable<_TextLinkerMatch> textLinkerMatches, [int index = 0]) {
final List<InlineSpan> output = <InlineSpan>[];
Iterable<_TextLinkerMatch> nextTextLinkerMatches = textLinkerMatches;
int nextIndex = index;
for (final InlineSpan span in spans) {
final (InlineSpan childSpan, Iterable<_TextLinkerMatch> childTextLinkerMatches) = _linkSpanRecurse(
span,
textCache,
nextTextLinkerMatches,
nextIndex,
);
output.add(childSpan);
nextTextLinkerMatches = childTextLinkerMatches;
nextIndex += textCache.getLength(span)!;
}
return (output, nextTextLinkerMatches);
}
// `index` is the index of the start of `span` in the overall flattened tree
// string.
static _LinkSpanRecursion _linkSpanRecurse(InlineSpan span, _TextCache textCache, Iterable<_TextLinkerMatch> textLinkerMatches, [int index = 0]) {
if (span is! TextSpan) {
return (span, textLinkerMatches);
}
final List<InlineSpan> nextChildren = <InlineSpan>[];
List<_TextLinkerMatch> nextTextLinkerMatches = <_TextLinkerMatch>[...textLinkerMatches];
int lastLinkEnd = index;
if (span.text?.isNotEmpty ?? false) {
final int textEnd = index + span.text!.length;
for (final _TextLinkerMatch textLinkerMatch in textLinkerMatches) {
if (textLinkerMatch.textRange.start >= textEnd) {
// Because ranges is ordered, there are no more relevant ranges for this
// text.
break;
}
if (textLinkerMatch.textRange.end <= index) {
// This range ends before this span and is therefore irrelevant to it.
// It should have been removed from ranges.
assert(false, 'Invalid ranges.');
nextTextLinkerMatches.removeAt(0);
continue;
}
if (textLinkerMatch.textRange.start > index) {
// Add the unlinked text before the range.
nextChildren.add(TextSpan(
text: span.text!.substring(
lastLinkEnd - index,
textLinkerMatch.textRange.start - index,
),
));
}
// Add the link itself.
final int linkStart = math.max(textLinkerMatch.textRange.start, index);
lastLinkEnd = math.min(textLinkerMatch.textRange.end, textEnd);
final InlineSpan nextChild = textLinkerMatch.linkBuilder(
span.text!.substring(linkStart - index, lastLinkEnd - index),
textLinkerMatch.linkString,
);
nextChildren.add(nextChild);
if (textLinkerMatch.textRange.end > textEnd) {
// If we only partially used this range, keep it in nextRanges. Since
// overlapping ranges have been removed, this must be the last relevant
// range for this span.
break;
}
nextTextLinkerMatches.removeAt(0);
}
// Add any extra text after any ranges.
final String remainingText = span.text!.substring(lastLinkEnd - index);
if (remainingText.isNotEmpty) {
nextChildren.add(TextSpan(
text: remainingText,
));
}
}
// Recurse on the children.
if (span.children?.isNotEmpty ?? false) {
final (
Iterable<InlineSpan> childrenSpans,
Iterable<_TextLinkerMatch> childrenTextLinkerMatches,
) = _linkSpansRecurse(
span.children!,
textCache,
nextTextLinkerMatches,
index + (span.text?.length ?? 0),
);
nextTextLinkerMatches = childrenTextLinkerMatches.toList();
nextChildren.addAll(childrenSpans);
}
return (
TextSpan(
style: span.style,
children: nextChildren,
),
nextTextLinkerMatches,
);
}
}
// Copyright 2014 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/foundation.dart';
import 'package:flutter/gestures.dart';
import 'basic.dart';
import 'framework.dart';
import 'text.dart';
/// Singature for a function that builds the [Widget] output by [LinkedText].
///
/// Typically a [Text.rich] containing a [TextSpan] whose children are the
/// [linkedSpans].
typedef LinkedTextWidgetBuilder = Widget Function (
BuildContext context,
Iterable<InlineSpan> linkedSpans,
);
/// A widget that displays text with parts of it made interactive.
///
/// By default, any URLs in the text are made interactive, and clicking one
/// calls the provided callback.
///
/// Works with either a flat [String] (`text`) or a list of [InlineSpan]s
/// (`spans`). When using `spans`, only [TextSpan]s will be converted to links.
///
/// {@tool dartpad}
/// This example shows how to create a [LinkedText] that turns URLs into
/// working links.
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link Twitter handles by
/// passing in a custom [RegExp].
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link URLs in a TextSpan tree
/// instead of in a flat string.
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.2.dart **
/// {@end-tool}
class LinkedText extends StatefulWidget {
/// Creates an instance of [LinkedText] from the given [text] or [spans],
/// turning any URLs into interactive links.
///
/// See also:
///
/// * [LinkedText.regExp], which matches based on any given [RegExp].
/// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full
/// control over matching and building different types of links.
LinkedText({
super.key,
required ValueChanged<Uri> onTapUri,
this.builder = _defaultBuilder,
List<InlineSpan>? spans,
String? text,
}) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'),
spans = spans ?? <InlineSpan>[
TextSpan(
text: text,
),
],
onTap = _getOnTap(onTapUri),
regExp = defaultUriRegExp,
textLinkers = null;
/// Creates an instance of [LinkedText] from the given [text] or [spans],
/// turning anything matched by [regExp] into interactive links.
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link Twitter handles by
/// passing in a custom [RegExp].
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [LinkedText.new], which matches [Uri]s.
/// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full
/// control over matching and building different types of links.
LinkedText.regExp({
super.key,
required this.onTap,
required this.regExp,
this.builder = _defaultBuilder,
List<InlineSpan>? spans,
String? text,
}) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'),
spans = spans ?? <InlineSpan>[
TextSpan(
text: text,
),
],
textLinkers = null;
/// Creates an instance of [LinkedText] where the given [textLinkers] are
/// applied.
///
/// Useful for independently matching different types of strings with
/// different behaviors. For example, highlighting both URLs and Twitter
/// handles with different style and/or behavior.
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link both URLs and Twitter
/// handles in the same text.
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.3.dart **
/// {@end-tool}
///
/// See also:
///
/// * [LinkedText.new], which matches [Uri]s.
/// * [LinkedText.regExp], which matches based on any given [RegExp].
LinkedText.textLinkers({
super.key,
this.builder = _defaultBuilder,
String? text,
List<InlineSpan>? spans,
required List<TextLinker> textLinkers,
}) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'),
assert(textLinkers.isNotEmpty),
textLinkers = textLinkers, // ignore: prefer_initializing_formals
spans = spans ?? <InlineSpan>[
TextSpan(
text: text,
),
],
onTap = null,
regExp = null;
/// The spans on which to create links.
///
/// It's also possible to specify a plain string by using the `text`
/// parameter instead.
final List<InlineSpan> spans;
/// Builds the [Widget] that is output by [LinkedText].
///
/// By default, builds a [Text.rich] with a single [TextSpan] whose children
/// are the linked [TextSpan]s, and whose style is [DefaultTextStyle].
final LinkedTextWidgetBuilder builder;
/// Handles tapping on a link.
///
/// This is irrelevant when using [LinkedText.textLinkers], where this is
/// controlled with an [InlineLinkBuilder] instead.
final ValueChanged<String>? onTap;
/// Matches the text that should be turned into a link.
///
/// This is irrelevant when using [LinkedText.textLinkers], where each
/// [TextLinker] specifies its own [TextLinker.regExp].
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link Twitter handles by
/// passing in a custom [RegExp].
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart **
/// {@end-tool}
final RegExp? regExp;
/// Defines what parts of the text to match and how to link them.
///
/// [TextLinker]s are applied in the order given. Overlapping matches are not
/// supported and will produce an error.
///
/// {@tool dartpad}
/// This example shows how to use [LinkedText] to link both URLs and Twitter
/// handles in the same text with [TextLinker]s.
///
/// ** See code in examples/api/lib/widgets/linked_text/linked_text.3.dart **
/// {@end-tool}
final List<TextLinker>? textLinkers;
/// The default [RegExp], which matches [Uri]s by default.
///
/// Matches with and without a host, but only "http" or "https". Ignores email
/// addresses.
static final RegExp defaultUriRegExp = RegExp(r'(?<!@[a-zA-Z0-9-]*)(?<![\/\.a-zA-Z0-9-])((https?:\/\/)?(([a-zA-Z0-9-]*\.)*[a-zA-Z0-9-]+(\.[a-zA-Z]+)+))(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?(?![a-zA-Z0-9-]*@)');
/// Returns a generic [ValueChanged]<String> given a callback specifically for
/// tapping on a [Uri].
static ValueChanged<String> _getOnTap(ValueChanged<Uri> onTapUri) {
return (String linkString) {
Uri uri = Uri.parse(linkString);
if (uri.host.isEmpty) {
// defaultUriRegExp matches Uris without a host, but packages like
// url_launcher require a host to launch a Uri. So add the host.
uri = Uri.parse('https://$linkString');
}
onTapUri(uri);
};
}
/// The default value of [builder].
///
/// Builds a [Text.rich] with a single [TextSpan] whose children are the
/// linked [TextSpan]s, and whose style is [DefaultTextStyle]. If there are no
/// linked [TextSpan]s to display, builds a [SizedBox.shrink].
static Widget _defaultBuilder(BuildContext context, Iterable<InlineSpan> linkedSpans) {
if (linkedSpans.isEmpty) {
return const SizedBox.shrink();
}
return Text.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: linkedSpans.toList(),
),
);
}
/// The style used for the link by default if none is given.
@visibleForTesting
static TextStyle defaultLinkStyle = _InlineLinkSpan.defaultLinkStyle;
@override
State<LinkedText> createState() => _LinkedTextState();
}
class _LinkedTextState extends State<LinkedText> {
final List<GestureRecognizer> _recognizers = <GestureRecognizer>[];
late Iterable<InlineSpan> _linkedSpans;
late final List<TextLinker> _textLinkers;
void _disposeRecognizers() {
for (final GestureRecognizer recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _linkSpans() {
_disposeRecognizers();
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
widget.spans,
_textLinkers,
);
_linkedSpans = linkedSpans;
}
@override
void initState() {
super.initState();
_textLinkers = widget.textLinkers ?? <TextLinker>[
TextLinker(
regExp: widget.regExp ?? LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () => widget.onTap!(linkString);
// Keep track of created recognizers so that they can be disposed.
_recognizers.add(recognizer);
return _InlineLinkSpan(
recognizer: recognizer,
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
),
];
_linkSpans();
}
@override
void didUpdateWidget(LinkedText oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.spans != oldWidget.spans || widget.textLinkers != oldWidget.textLinkers) {
_linkSpans();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _linkedSpans);
}
}
/// An inline, interactive text link.
///
/// See also:
///
/// * [LinkedText], which creates links with this class by default.
class _InlineLinkSpan extends TextSpan {
/// Create an instance of [_InlineLinkSpan].
_InlineLinkSpan({
required String text,
TextStyle? style,
super.recognizer,
}) : super(
style: style ?? defaultLinkStyle,
mouseCursor: SystemMouseCursors.click,
text: text,
);
static Color get _linkColor {
return switch (defaultTargetPlatform) {
// This value was taken from Safari on an iPhone 14 Pro iOS 16.4
// simulator.
TargetPlatform.iOS => const Color(0xff1717f0),
// This value was taken from Chrome on macOS 13.4.1.
TargetPlatform.macOS => const Color(0xff0000ee),
// This value was taken from Chrome on Android 14.
TargetPlatform.android || TargetPlatform.fuchsia => const Color(0xff0e0eef),
// This value was taken from the Chrome browser running on GNOME 43.3 on
// Debian.
TargetPlatform.linux => const Color(0xff0026e8),
// This value was taken from the Edge browser running on Windows 10.
TargetPlatform.windows => const Color(0xff1e2b8b),
};
}
/// The style used for the link by default if none is given.
@visibleForTesting
static TextStyle defaultLinkStyle = TextStyle(
color: _linkColor,
decorationColor: _linkColor,
decoration: TextDecoration.underline,
);
}
...@@ -73,6 +73,7 @@ export 'src/widgets/inherited_theme.dart'; ...@@ -73,6 +73,7 @@ export 'src/widgets/inherited_theme.dart';
export 'src/widgets/interactive_viewer.dart'; export 'src/widgets/interactive_viewer.dart';
export 'src/widgets/keyboard_listener.dart'; export 'src/widgets/keyboard_listener.dart';
export 'src/widgets/layout_builder.dart'; export 'src/widgets/layout_builder.dart';
export 'src/widgets/linked_text.dart';
export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/list_wheel_scroll_view.dart';
export 'src/widgets/localizations.dart'; export 'src/widgets/localizations.dart';
export 'src/widgets/lookup_boundary.dart'; export 'src/widgets/lookup_boundary.dart';
......
// Copyright 2014 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_test/flutter_test.dart';
void main() {
final RegExp hashTagRegExp = RegExp(r'#[a-zA-Z0-9]*');
final RegExp urlRegExp = RegExp(r'(?<!@[a-zA-Z0-9-]*)(?<![\/\.a-zA-Z0-9-])((https?:\/\/)?(([a-zA-Z0-9-]*\.)*[a-zA-Z0-9-]+(\.[a-zA-Z]+)+))(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?(?![a-zA-Z0-9-]*@)');
group('TextLinker.linkSpans', () {
group('url matching', () {
for (final String text in <String>[
'https://www.example.com',
'www.example123.co.uk',
'subdomain.example.net',
'ftp.subdomain.example.net',
'http://subdomain.example.net',
'https://subdomain.example.net',
'http://example.com/',
'https://www.example.org/',
'ftp.subdomain.example.net',
'example.com',
'subdomain.example.io',
'www.example123.co.uk',
'http://example.com:8080/',
'https://www.example.com/path/to/resource',
'http://www.example.com/index.php?query=test#fragment',
'https://subdomain.example.io:8443/resource/file.html?search=query#result',
'example.com',
'subsub.www.example.com',
'https://subsub.www.example.com'
]) {
test('converts the valid url $text to a link by default', () {
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
<InlineSpan>[
TextSpan(
text: text,
),
],
<TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
),
],
);
expect(linkedSpans, hasLength(1));
expect(linkedSpans.first, isA<TextSpan>());
final TextSpan wrapperSpan = linkedSpans.first as TextSpan;
expect(wrapperSpan.text, isNull);
expect(wrapperSpan.children, hasLength(1));
final TextSpan span = wrapperSpan.children!.first as TextSpan;
expect(span.text, text);
expect(span.style, LinkedText.defaultLinkStyle);
expect(span.children, isNull);
});
}
for (final String text in <String>[
'abcd://subdomain.example.net',
'ftp://subdomain.example.net',
]) {
test('does nothing to the invalid url $text', () {
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
<InlineSpan>[
TextSpan(
text: text,
),
],
<TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
text: displayString,
);
},
),
],
);
expect(linkedSpans, hasLength(1));
expect(linkedSpans.first, isA<TextSpan>());
final TextSpan wrapperSpan = linkedSpans.first as TextSpan;
expect(wrapperSpan.text, isNull);
expect(wrapperSpan.children, hasLength(1));
final TextSpan span = wrapperSpan.children!.first as TextSpan;
expect(span.text, text);
expect(span.style, isNull);
expect(span.children, isNull);
});
}
for (final String text in <String>[
'"example.com"',
"'example.com'",
'(example.com)',
]) {
test('can parse url $text with leading and trailing characters', () {
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
<InlineSpan>[
TextSpan(
text: text,
),
],
<TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
),
],
);
expect(linkedSpans, hasLength(1));
expect(linkedSpans.first, isA<TextSpan>());
final TextSpan wrapperSpan = linkedSpans.first as TextSpan;
expect(wrapperSpan.text, isNull);
expect(wrapperSpan.children, hasLength(3));
expect(wrapperSpan.children!.first, isA<TextSpan>());
final TextSpan leadingSpan = wrapperSpan.children!.first as TextSpan;
expect(leadingSpan.text, hasLength(1));
expect(leadingSpan.style, isNull);
expect(leadingSpan.children, isNull);
expect(wrapperSpan.children![1], isA<TextSpan>());
final TextSpan bodySpan = wrapperSpan.children![1] as TextSpan;
expect(bodySpan.text, 'example.com');
expect(bodySpan.style, LinkedText.defaultLinkStyle);
expect(bodySpan.children, isNull);
expect(wrapperSpan.children!.last, isA<TextSpan>());
final TextSpan trailingSpan = wrapperSpan.children!.last as TextSpan;
expect(trailingSpan.text, hasLength(1));
expect(trailingSpan.style, isNull);
expect(trailingSpan.children, isNull);
});
}
});
test('multiple TextLinkers', () {
final TextLinker urlTextLinker = TextLinker(
regExp: urlRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
);
final TextLinker hashTagTextLinker = TextLinker(
regExp: hashTagRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
);
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
<InlineSpan>[
const TextSpan(
text: 'Flutter is great #crossplatform #declarative check out flutter.dev.',
),
],
<TextLinker>[urlTextLinker, hashTagTextLinker],
);
expect(linkedSpans, hasLength(1));
expect(linkedSpans.first, isA<TextSpan>());
final TextSpan wrapperSpan = linkedSpans.first as TextSpan;
expect(wrapperSpan.text, isNull);
expect(wrapperSpan.children, hasLength(7));
expect(wrapperSpan.children!.first, isA<TextSpan>());
final TextSpan textSpan1 = wrapperSpan.children!.first as TextSpan;
expect(textSpan1.text, 'Flutter is great ');
expect(textSpan1.style, isNull);
expect(textSpan1.children, isNull);
expect(wrapperSpan.children![1], isA<TextSpan>());
final TextSpan hashTagSpan1 = wrapperSpan.children![1] as TextSpan;
expect(hashTagSpan1.text, '#crossplatform');
expect(hashTagSpan1.style, LinkedText.defaultLinkStyle);
expect(hashTagSpan1.children, isNull);
expect(wrapperSpan.children![2], isA<TextSpan>());
final TextSpan textSpan2 = wrapperSpan.children![2] as TextSpan;
expect(textSpan2.text, ' ');
expect(textSpan2.style, isNull);
expect(textSpan2.children, isNull);
expect(wrapperSpan.children![3], isA<TextSpan>());
final TextSpan hashTagSpan2 = wrapperSpan.children![3] as TextSpan;
expect(hashTagSpan2.text, '#declarative');
expect(hashTagSpan2.style, LinkedText.defaultLinkStyle);
expect(hashTagSpan2.children, isNull);
expect(wrapperSpan.children![4], isA<TextSpan>());
final TextSpan textSpan3 = wrapperSpan.children![4] as TextSpan;
expect(textSpan3.text, ' check out ');
expect(textSpan3.style, isNull);
expect(textSpan3.children, isNull);
expect(wrapperSpan.children![5], isA<TextSpan>());
final TextSpan urlSpan = wrapperSpan.children![5] as TextSpan;
expect(urlSpan.text, 'flutter.dev');
expect(urlSpan.style, LinkedText.defaultLinkStyle);
expect(urlSpan.children, isNull);
expect(wrapperSpan.children![6], isA<TextSpan>());
final TextSpan textSpan4 = wrapperSpan.children![6] as TextSpan;
expect(textSpan4.text, '.');
expect(textSpan4.style, isNull);
expect(textSpan4.children, isNull);
});
test('complex span tree', () {
final Iterable<InlineSpan> linkedSpans = TextLinker.linkSpans(
const <InlineSpan>[
TextSpan(
text: 'Check out https://www.',
children: <InlineSpan>[
TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
),
text: 'flutter',
),
],
),
TextSpan(
text: '.dev!',
),
],
<TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
);
},
),
],
);
expect(linkedSpans, hasLength(2));
expect(linkedSpans.first, isA<TextSpan>());
final TextSpan span1 = linkedSpans.first as TextSpan;
expect(span1.text, isNull);
expect(span1.style, isNull);
expect(span1.children, hasLength(3));
// First span's children ('Check out https://www.flutter').
expect(span1.children![0], isA<TextSpan>());
final TextSpan span1Child1 = span1.children![0] as TextSpan;
expect(span1Child1.text, 'Check out ');
expect(span1Child1.style, isNull);
expect(span1Child1.children, isNull);
expect(span1.children![1], isA<TextSpan>());
final TextSpan span1Child2 = span1.children![1] as TextSpan;
expect(span1Child2.text, 'https://www.');
expect(span1Child2.style, LinkedText.defaultLinkStyle);
expect(span1Child2.children, isNull);
expect(span1.children![2], isA<TextSpan>());
final TextSpan span1Child3 = span1.children![2] as TextSpan;
expect(span1Child3.text, null);
expect(span1Child3.style, const TextStyle(fontWeight: FontWeight.w800));
expect(span1Child3.children, hasLength(1));
expect(span1Child3.children![0], isA<TextSpan>());
final TextSpan span1Child3Child1 = span1Child3.children![0] as TextSpan;
expect(span1Child3Child1.text, 'flutter');
expect(span1Child3Child1.style, LinkedText.defaultLinkStyle);
expect(span1Child3Child1.children, isNull);
// Second span's children ('.dev!').
expect(linkedSpans.elementAt(1), isA<TextSpan>());
final TextSpan span2 = linkedSpans.elementAt(1) as TextSpan;
expect(span2.text, isNull);
expect(span2.children, hasLength(2));
expect(span2.style, isNull);
expect(span2.children![0], isA<TextSpan>());
final TextSpan span2Child1 = span2.children![0] as TextSpan;
expect(span2Child1.text, '.dev');
expect(span2Child1.style, LinkedText.defaultLinkStyle);
expect(span2Child1.children, isNull);
expect(span2.children![1], isA<TextSpan>());
final TextSpan span2Child2 = span2.children![1] as TextSpan;
expect(span2Child2.text, '!');
expect(span2Child2.children, isNull);
});
});
}
// Copyright 2014 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_test/flutter_test.dart';
void main() {
final RegExp hashTagRegExp = RegExp(r'#[a-zA-Z0-9]*');
final RegExp urlRegExp = RegExp(r'(?<!@[a-zA-Z0-9-]*)(?<![\/\.a-zA-Z0-9-])((https?:\/\/)?(([a-zA-Z0-9-]*\.)*[a-zA-Z0-9-]+(\.[a-zA-Z]+)+))(?::\d{1,5})?(?:\/[^\s]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?(?![a-zA-Z0-9-]*@)');
testWidgets('links urls by default', (WidgetTester tester) async {
Uri? lastTappedUri;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText(
onTapUri: (Uri uri) {
lastTappedUri = uri;
},
text: 'Check out flutter.dev.',
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedUri, isNull);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
// The https:// host is automatically added.
expect(lastTappedUri, Uri.parse('https://flutter.dev'));
});
testWidgets('can pass custom regexp', (WidgetTester tester) async {
String? lastTappedLink;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText.regExp(
regExp: hashTagRegExp,
onTap: (String linkString) {
lastTappedLink = linkString;
},
text: 'Flutter is great #crossplatform #declarative',
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedLink, isNull);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
expect(lastTappedLink, '#crossplatform');
});
testWidgets('can pass custom regexp with .textLinkers', (WidgetTester tester) async {
String? lastTappedLink;
final List<TapGestureRecognizer> recognizers = <TapGestureRecognizer>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText.textLinkers(
textLinkers: <TextLinker>[
TextLinker(
regExp: hashTagRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
lastTappedLink = linkString;
};
recognizers.add(recognizer);
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
recognizer: recognizer,
);
},
),
],
text: 'Flutter is great #crossplatform #declarative',
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedLink, isNull);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
expect(lastTappedLink, '#crossplatform');
expect(recognizers, hasLength(2));
for (final TapGestureRecognizer recognizer in recognizers) {
recognizer.dispose();
}
});
testWidgets('can link multiple different types', (WidgetTester tester) async {
String? lastTappedLink;
final List<TapGestureRecognizer> recognizers = <TapGestureRecognizer>[];
final TextLinker urlTextLinker = TextLinker(
regExp: urlRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
lastTappedLink = linkString;
};
recognizers.add(recognizer);
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
recognizer: recognizer,
);
},
);
final TextLinker hashTagTextLinker = TextLinker(
regExp: hashTagRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
lastTappedLink = linkString;
};
recognizers.add(recognizer);
return TextSpan(
style: LinkedText.defaultLinkStyle,
text: displayString,
recognizer: recognizer,
);
},
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText.textLinkers(
textLinkers: <TextLinker>[urlTextLinker, hashTagTextLinker],
text: 'flutter.dev is great #crossplatform #declarative',
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedLink, isNull);
await tester.tapAt(tester.getTopLeft(find.byType(RichText)));
expect(lastTappedLink, 'flutter.dev');
await tester.tapAt(tester.getCenter(find.byType(RichText)));
expect(lastTappedLink, '#crossplatform');
expect(recognizers, hasLength(3));
for (final TapGestureRecognizer recognizer in recognizers) {
recognizer.dispose();
}
});
testWidgets('can customize linkBuilder', (WidgetTester tester) async {
String? lastTappedLink;
final List<TapGestureRecognizer> recognizers = <TapGestureRecognizer>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText.textLinkers(
textLinkers: <TextLinker>[
TextLinker(
regExp: LinkedText.defaultUriRegExp,
linkBuilder: (String displayString, String linkString) {
final TapGestureRecognizer recognizer = TapGestureRecognizer()
..onTap = () {
lastTappedLink = linkString;
};
recognizers.add(recognizer);
return TextSpan(
recognizer: recognizer,
text: displayString,
mouseCursor: SystemMouseCursors.help,
);
},
),
],
text: 'Check out flutter.dev.',
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedLink, isNull);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1);
await gesture.addPointer(location: tester.getCenter(find.byType(Scaffold)));
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic);
await gesture.moveTo(tester.getCenter(find.byType(RichText)));
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.help);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
expect(lastTappedLink, 'flutter.dev');
expect(recognizers, hasLength(1));
for (final TapGestureRecognizer recognizer in recognizers) {
recognizer.dispose();
}
});
testWidgets('can take nested spans', (WidgetTester tester) async {
Uri? lastTappedUri;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText(
onTapUri: (Uri uri) {
lastTappedUri = uri;
},
spans: <InlineSpan>[
TextSpan(
text: 'Check out fl',
style: DefaultTextStyle.of(context).style,
children: const <InlineSpan>[
TextSpan(
text: 'u',
children: <InlineSpan>[
TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
),
text: 'tt',
),
TextSpan(
text: 'er',
),
],
),
],
),
const TextSpan(
text: '.dev.',
),
],
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedUri, isNull);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
// The https:// host is automatically added.
expect(lastTappedUri, Uri.parse('https://flutter.dev'));
});
testWidgets('can handle WidgetSpans', (WidgetTester tester) async {
Uri? lastTappedUri;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText(
onTapUri: (Uri uri) {
lastTappedUri = uri;
},
spans: <InlineSpan>[
TextSpan(
text: 'Check out fl',
style: DefaultTextStyle.of(context).style,
children: const <InlineSpan>[
TextSpan(
text: 'u',
children: <InlineSpan>[
TextSpan(
style: TextStyle(
fontWeight: FontWeight.w800,
),
text: 'tt',
),
WidgetSpan(
child: FlutterLogo(),
),
TextSpan(
text: 'er',
),
],
),
],
),
const TextSpan(
text: '.dev.',
),
],
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
expect(lastTappedUri, isNull);
await tester.tapAt(tester.getCenter(find.byType(RichText)));
// The WidgetSpan is ignored, so a link is still produced even though it has
// a FlutterLogo in the middle of it.
expect(lastTappedUri, Uri.parse('https://flutter.dev'));
});
testWidgets('builds the widget specified by builder', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) {
return LinkedText(
onTapUri: (Uri uri) {},
text: 'Check out flutter.dev.',
builder: (BuildContext context, Iterable<InlineSpan> linkedSpans) {
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: linkedSpans.toList(),
),
);
},
);
},
),
),
),
);
expect(find.byType(RichText), findsOneWidget);
final RichText richText = tester.widget(find.byType(RichText));
expect(richText.textAlign, TextAlign.center);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment