Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ unlinked_spec.ds
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
*.jks
.cxx/

# iOS/XCode related
**/ios/**/*.mode1v3
Expand Down
6 changes: 3 additions & 3 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ android {
ndkVersion = flutter.ndkVersion

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
jvmTarget = JavaVersion.VERSION_17
}

defaultConfig {
Expand Down
2 changes: 0 additions & 2 deletions example/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ allprojects {
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}

Expand Down
2 changes: 1 addition & 1 deletion example/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
2 changes: 1 addition & 1 deletion example/android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
4 changes: 2 additions & 2 deletions example/android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ pluginManagement {

plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "com.android.application" version "8.7.3" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}

include ":app"
26 changes: 26 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dcc_toolkit/ui/annotated_text/annotated_text.dart';
import 'package:example/core/injectable/injectable.dart';
import 'package:example/profile/presentation/cubit/user_cubit.dart';
import 'package:example/profile/presentation/user_page.dart';
Expand Down Expand Up @@ -33,6 +34,31 @@ class MyHomePage extends StatelessWidget {
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: AnnotatedText(
text:
'[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',
defaultStyle: const TextStyle(color: Colors.black),
annotationStyle: const TextStyle(color: Colors.blue),
actions: {
'onFlutterTap':
() =>
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: Text('Flutter')),
),
'onTap':
() =>
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: Text('Tap')),
),
},
),
),
ElevatedButton(
onPressed:
() => Navigator.of(context).push(
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.0.14"
version: "0.0.15"
equatable:
dependency: transitive
description:
Expand Down
98 changes: 98 additions & 0 deletions lib/ui/annotated_text/annotated_text.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

/// A widget that displays a text with inline actions.
/// Supported formats: [text](action) or [text]
/// This way translations can be done with inline actions.
///
/// The text is displayed as a [RichText] widget.
/// The annotations are displayed as a [TextSpan] widget with optionally a [TapGestureRecognizer] attached to it (if the action is not null).
/// The [actions] map is used to map the action name to the action to perform when the text is tapped.
/// The [defaultStyle] is the style of the default text.
/// The [annotationStyle] is the style of the annotated text.
///
/// [some text] only highlights the text, but does not trigger an action.
/// [some text](action) highlights the text and triggers the action when tapped.
/// [some text](action) without a defined action for the exact name 'action' will not trigger an action.
///
/// Example:
/// ```dart
/// AnnotatedText(
/// text: 'Hello [world](onWorldTapped)',
/// actions: {'onWorldTapped': () => print('world')},
/// defaultStyle: TextStyle(color: Colors.black),
/// annotationStyle: TextStyle(color: Colors.blue),
/// )
/// ```
class AnnotatedText extends StatelessWidget {
/// Creates a widget that displays a text with annotations.
const AnnotatedText({
required this.text,
required this.actions,
required this.defaultStyle,
required this.annotationStyle,
super.key,
});

/// The complete text to display.
final String text;

/// A map {actionName: action} of actions to perform when the text is tapped.
final Map<String, VoidCallback>? actions;

/// The style of the default text.
final TextStyle defaultStyle;

/// The style of the annotated text.
final TextStyle annotationStyle;

@override
Widget build(BuildContext context) {
return RichText(
text: _buildTextSpan(text: text, defaultStyle: defaultStyle, annotationStyle: annotationStyle, actions: actions),
);
}
}

TextSpan _buildTextSpan({
required String text,
required TextStyle defaultStyle,
required TextStyle annotationStyle,
Map<String, VoidCallback>? actions,
}) {
/// matches [text](action) with an action, or [text] without an action
final regex = RegExp(r'\[([^\]]+?)\](?:\((.*?)\))?');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: What if the text contains a [] can we escape it?

Maybe a feature for later moment when needed 😉

final spans = <TextSpan>[];
var currentIndex = 0;

for (final match in regex.allMatches(text)) {
final matchStart = match.start;
final matchEnd = match.end;

// Add normal text before match
if (matchStart > currentIndex) {
spans.add(TextSpan(text: text.substring(currentIndex, matchStart), style: defaultStyle));
}

final displayText = match.group(1)!;
final actionKey = match.group(2);
final action = (actionKey != null && actionKey.isNotEmpty && actions != null) ? actions[actionKey] : null;

spans.add(
TextSpan(
text: displayText,
style: annotationStyle,
recognizer: action != null ? (TapGestureRecognizer()..onTap = action) : null,
),
);

currentIndex = matchEnd;
}

// Add remaining text
if (currentIndex < text.length) {
spans.add(TextSpan(text: text.substring(currentIndex), style: defaultStyle));
}

return TextSpan(children: spans);
}
75 changes: 75 additions & 0 deletions test/ui/annotated_text_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:dcc_toolkit/ui/annotated_text/annotated_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
testWidgets('renders annotated text without action', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: AnnotatedText(
text: 'Hello [world]',
actions: {},
defaultStyle: TextStyle(color: Colors.black),
annotationStyle: TextStyle(color: Colors.blue),
),
),
);

// Get the only RichText widget in the tree
final richTextWidget = tester.widget<RichText>(find.byType(RichText));
final rootSpan = richTextWidget.text as TextSpan;

// Combine all spans into a single string
final fullText = rootSpan.children!.map((span) => (span as TextSpan).text).join();

expect(fullText, equals('Hello world'));
final annotatedSpan = rootSpan.children![1]; // "world"
expect((annotatedSpan as TextSpan).style!.color, equals(Colors.blue));
});

testWidgets('annotated text applies annotationStyle', (WidgetTester tester) async {
const annotationStyle = TextStyle(color: Colors.blue);

await tester.pumpWidget(
const MaterialApp(
home: AnnotatedText(
text: 'Hello [world]',
actions: {},
defaultStyle: TextStyle(color: Colors.black),
annotationStyle: annotationStyle,
),
),
);

final richText = tester.widget<RichText>(find.byType(RichText));
final rootSpan = richText.text as TextSpan;

final annotatedSpan = rootSpan.children![1]; // "world"
expect((annotatedSpan as TextSpan).style!.color, equals(annotationStyle.color));
});

testWidgets('annotated text with action has a GestureRecognizer', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: AnnotatedText(
text: 'Click [here](onTap)',
actions: {'onTap': () {}},
defaultStyle: const TextStyle(color: Colors.black),
annotationStyle: const TextStyle(color: Colors.blue),
),
),
);

// Get the RichText widget
final richTextWidget = tester.widget<RichText>(find.byType(RichText));
final rootSpan = richTextWidget.text as TextSpan;

// Locate the annotated span (second span in the children list)
final annotatedSpan = rootSpan.children![1] as TextSpan;

// Verify a recognizer exists and is a TapGestureRecognizer
expect(annotatedSpan.recognizer, isNotNull);
expect(annotatedSpan.recognizer, isA<TapGestureRecognizer>());
});
}