diff --git a/.gitignore b/.gitignore index d5197f7..986e0b9 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ unlinked_spec.ds **/android/**/GeneratedPluginRegistrant.java **/android/key.properties *.jks +.cxx/ # iOS/XCode related **/ios/**/*.mode1v3 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b5511a9..3c33143 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -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 { diff --git a/example/android/build.gradle b/example/android/build.gradle index d2ffbff..ebc7045 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -8,8 +8,6 @@ allprojects { rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { project.evaluationDependsOn(":app") } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 2597170..f018a61 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -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 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..ac3b479 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 536165d..cb7d7dd 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -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" diff --git a/example/lib/main.dart b/example/lib/main.dart index 42ec05f..70a0daa 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -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'; @@ -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( diff --git a/example/pubspec.lock b/example/pubspec.lock index 211575d..3a3df14 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -212,7 +212,7 @@ packages: path: ".." relative: true source: path - version: "0.0.14" + version: "0.0.15" equatable: dependency: transitive description: diff --git a/lib/ui/annotated_text/annotated_text.dart b/lib/ui/annotated_text/annotated_text.dart new file mode 100644 index 0000000..67f5984 --- /dev/null +++ b/lib/ui/annotated_text/annotated_text.dart @@ -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? 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? actions, +}) { + /// matches [text](action) with an action, or [text] without an action + final regex = RegExp(r'\[([^\]]+?)\](?:\((.*?)\))?'); + final spans = []; + 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); +} diff --git a/test/ui/annotated_text_test.dart b/test/ui/annotated_text_test.dart new file mode 100644 index 0000000..9eba988 --- /dev/null +++ b/test/ui/annotated_text_test.dart @@ -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(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(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(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()); + }); +}