Skip to content

Commit 4a6f6be

Browse files
authored
Merge pull request #32 from DutchCodingCompany/feature/highlighted_text
Setup annotated text with actions and highlights
2 parents 6e8264b + 4f8f8af commit 4a6f6be

File tree

10 files changed

+208
-10
lines changed

10 files changed

+208
-10
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ unlinked_spec.ds
8484
**/android/**/GeneratedPluginRegistrant.java
8585
**/android/key.properties
8686
*.jks
87+
.cxx/
8788

8889
# iOS/XCode related
8990
**/ios/**/*.mode1v3

example/android/app/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ android {
1111
ndkVersion = flutter.ndkVersion
1212

1313
compileOptions {
14-
sourceCompatibility = JavaVersion.VERSION_1_8
15-
targetCompatibility = JavaVersion.VERSION_1_8
14+
sourceCompatibility = JavaVersion.VERSION_17
15+
targetCompatibility = JavaVersion.VERSION_17
1616
}
1717

1818
kotlinOptions {
19-
jvmTarget = JavaVersion.VERSION_1_8
19+
jvmTarget = JavaVersion.VERSION_17
2020
}
2121

2222
defaultConfig {

example/android/build.gradle

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ allprojects {
88
rootProject.buildDir = "../build"
99
subprojects {
1010
project.buildDir = "${rootProject.buildDir}/${project.name}"
11-
}
12-
subprojects {
1311
project.evaluationDependsOn(":app")
1412
}
1513

example/android/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
1+
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
22
android.useAndroidX=true
33
android.enableJetifier=true

example/android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

example/android/settings.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ pluginManagement {
1818

1919
plugins {
2020
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
21-
id "com.android.application" version "7.3.0" apply false
22-
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
21+
id "com.android.application" version "8.7.3" apply false
22+
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
2323
}
2424

2525
include ":app"

example/lib/main.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:dcc_toolkit/ui/annotated_text/annotated_text.dart';
12
import 'package:example/core/injectable/injectable.dart';
23
import 'package:example/profile/presentation/cubit/user_cubit.dart';
34
import 'package:example/profile/presentation/user_page.dart';
@@ -33,6 +34,31 @@ class MyHomePage extends StatelessWidget {
3334
body: SafeArea(
3435
child: Column(
3536
children: [
37+
Padding(
38+
padding: const EdgeInsets.all(16),
39+
child: AnnotatedText(
40+
text:
41+
'[Flutter](onFlutterTap) example of using a Rich Text with annotations with multiple [tap](onTap) actions.\nThis tap [action](action) does nothing. And [action] without () does nothing as well',
42+
defaultStyle: const TextStyle(color: Colors.black),
43+
annotationStyle: const TextStyle(color: Colors.blue),
44+
actions: {
45+
'onFlutterTap':
46+
() =>
47+
ScaffoldMessenger.of(context)
48+
..hideCurrentSnackBar()
49+
..showSnackBar(
50+
const SnackBar(content: Text('Flutter')),
51+
),
52+
'onTap':
53+
() =>
54+
ScaffoldMessenger.of(context)
55+
..hideCurrentSnackBar()
56+
..showSnackBar(
57+
const SnackBar(content: Text('Tap')),
58+
),
59+
},
60+
),
61+
),
3662
ElevatedButton(
3763
onPressed:
3864
() => Navigator.of(context).push(

example/pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ packages:
212212
path: ".."
213213
relative: true
214214
source: path
215-
version: "0.0.14"
215+
version: "0.0.15"
216216
equatable:
217217
dependency: transitive
218218
description:
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import 'package:flutter/gestures.dart';
2+
import 'package:flutter/material.dart';
3+
4+
/// A widget that displays a text with inline actions.
5+
/// Supported formats: [text](action) or [text]
6+
/// This way translations can be done with inline actions.
7+
///
8+
/// The text is displayed as a [RichText] widget.
9+
/// The annotations are displayed as a [TextSpan] widget with optionally a [TapGestureRecognizer] attached to it (if the action is not null).
10+
/// The [actions] map is used to map the action name to the action to perform when the text is tapped.
11+
/// The [defaultStyle] is the style of the default text.
12+
/// The [annotationStyle] is the style of the annotated text.
13+
///
14+
/// [some text] only highlights the text, but does not trigger an action.
15+
/// [some text](action) highlights the text and triggers the action when tapped.
16+
/// [some text](action) without a defined action for the exact name 'action' will not trigger an action.
17+
///
18+
/// Example:
19+
/// ```dart
20+
/// AnnotatedText(
21+
/// text: 'Hello [world](onWorldTapped)',
22+
/// actions: {'onWorldTapped': () => print('world')},
23+
/// defaultStyle: TextStyle(color: Colors.black),
24+
/// annotationStyle: TextStyle(color: Colors.blue),
25+
/// )
26+
/// ```
27+
class AnnotatedText extends StatelessWidget {
28+
/// Creates a widget that displays a text with annotations.
29+
const AnnotatedText({
30+
required this.text,
31+
required this.actions,
32+
required this.defaultStyle,
33+
required this.annotationStyle,
34+
super.key,
35+
});
36+
37+
/// The complete text to display.
38+
final String text;
39+
40+
/// A map {actionName: action} of actions to perform when the text is tapped.
41+
final Map<String, VoidCallback>? actions;
42+
43+
/// The style of the default text.
44+
final TextStyle defaultStyle;
45+
46+
/// The style of the annotated text.
47+
final TextStyle annotationStyle;
48+
49+
@override
50+
Widget build(BuildContext context) {
51+
return RichText(
52+
text: _buildTextSpan(text: text, defaultStyle: defaultStyle, annotationStyle: annotationStyle, actions: actions),
53+
);
54+
}
55+
}
56+
57+
TextSpan _buildTextSpan({
58+
required String text,
59+
required TextStyle defaultStyle,
60+
required TextStyle annotationStyle,
61+
Map<String, VoidCallback>? actions,
62+
}) {
63+
/// matches [text](action) with an action, or [text] without an action
64+
final regex = RegExp(r'\[([^\]]+?)\](?:\((.*?)\))?');
65+
final spans = <TextSpan>[];
66+
var currentIndex = 0;
67+
68+
for (final match in regex.allMatches(text)) {
69+
final matchStart = match.start;
70+
final matchEnd = match.end;
71+
72+
// Add normal text before match
73+
if (matchStart > currentIndex) {
74+
spans.add(TextSpan(text: text.substring(currentIndex, matchStart), style: defaultStyle));
75+
}
76+
77+
final displayText = match.group(1)!;
78+
final actionKey = match.group(2);
79+
final action = (actionKey != null && actionKey.isNotEmpty && actions != null) ? actions[actionKey] : null;
80+
81+
spans.add(
82+
TextSpan(
83+
text: displayText,
84+
style: annotationStyle,
85+
recognizer: action != null ? (TapGestureRecognizer()..onTap = action) : null,
86+
),
87+
);
88+
89+
currentIndex = matchEnd;
90+
}
91+
92+
// Add remaining text
93+
if (currentIndex < text.length) {
94+
spans.add(TextSpan(text: text.substring(currentIndex), style: defaultStyle));
95+
}
96+
97+
return TextSpan(children: spans);
98+
}

test/ui/annotated_text_test.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import 'package:dcc_toolkit/ui/annotated_text/annotated_text.dart';
2+
import 'package:flutter/gestures.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
void main() {
7+
testWidgets('renders annotated text without action', (WidgetTester tester) async {
8+
await tester.pumpWidget(
9+
const MaterialApp(
10+
home: AnnotatedText(
11+
text: 'Hello [world]',
12+
actions: {},
13+
defaultStyle: TextStyle(color: Colors.black),
14+
annotationStyle: TextStyle(color: Colors.blue),
15+
),
16+
),
17+
);
18+
19+
// Get the only RichText widget in the tree
20+
final richTextWidget = tester.widget<RichText>(find.byType(RichText));
21+
final rootSpan = richTextWidget.text as TextSpan;
22+
23+
// Combine all spans into a single string
24+
final fullText = rootSpan.children!.map((span) => (span as TextSpan).text).join();
25+
26+
expect(fullText, equals('Hello world'));
27+
final annotatedSpan = rootSpan.children![1]; // "world"
28+
expect((annotatedSpan as TextSpan).style!.color, equals(Colors.blue));
29+
});
30+
31+
testWidgets('annotated text applies annotationStyle', (WidgetTester tester) async {
32+
const annotationStyle = TextStyle(color: Colors.blue);
33+
34+
await tester.pumpWidget(
35+
const MaterialApp(
36+
home: AnnotatedText(
37+
text: 'Hello [world]',
38+
actions: {},
39+
defaultStyle: TextStyle(color: Colors.black),
40+
annotationStyle: annotationStyle,
41+
),
42+
),
43+
);
44+
45+
final richText = tester.widget<RichText>(find.byType(RichText));
46+
final rootSpan = richText.text as TextSpan;
47+
48+
final annotatedSpan = rootSpan.children![1]; // "world"
49+
expect((annotatedSpan as TextSpan).style!.color, equals(annotationStyle.color));
50+
});
51+
52+
testWidgets('annotated text with action has a GestureRecognizer', (WidgetTester tester) async {
53+
await tester.pumpWidget(
54+
MaterialApp(
55+
home: AnnotatedText(
56+
text: 'Click [here](onTap)',
57+
actions: {'onTap': () {}},
58+
defaultStyle: const TextStyle(color: Colors.black),
59+
annotationStyle: const TextStyle(color: Colors.blue),
60+
),
61+
),
62+
);
63+
64+
// Get the RichText widget
65+
final richTextWidget = tester.widget<RichText>(find.byType(RichText));
66+
final rootSpan = richTextWidget.text as TextSpan;
67+
68+
// Locate the annotated span (second span in the children list)
69+
final annotatedSpan = rootSpan.children![1] as TextSpan;
70+
71+
// Verify a recognizer exists and is a TapGestureRecognizer
72+
expect(annotatedSpan.recognizer, isNotNull);
73+
expect(annotatedSpan.recognizer, isA<TapGestureRecognizer>());
74+
});
75+
}

0 commit comments

Comments
 (0)