diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 65db3206e..faaee6877 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [DemoApp][Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) - [Library] update `Phone number input` component to v1.3 ([#690](https://github.com/Orange-OpenSource/ouds-flutter/issues/690)) - [Library] update `tag` component to v1.5 ([#694](https://github.com/Orange-OpenSource/ouds-flutter/issues/694)) - [Library] update `input tag` component to v1.2 ([#695](https://github.com/Orange-OpenSource/ouds-flutter/issues/695)) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index a4c50d01e..2790e361f 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - app_settings (5.1.1): + - app_settings (6.1.2): - Flutter - Flutter (1.0.0) - package_info_plus (0.4.5): @@ -36,12 +36,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_settings: 58017cd26b604ae98c3e65acbdd8ba173703cc82 + app_settings: 0341ec6daa4f0c50f5a421bf0ad7c36084db6e90 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 diff --git a/app/lib/ui/components/alert/alert_message_demo_screen.dart b/app/lib/ui/components/alert/alert_message_demo_screen.dart index 4027b6f59..bfd43c7ca 100644 --- a/app/lib/ui/components/alert/alert_message_demo_screen.dart +++ b/app/lib/ui/components/alert/alert_message_demo_screen.dart @@ -36,6 +36,7 @@ import 'package:ouds_flutter_demo/ui/utilities/sheets_bottom/ouds_sheets_bottom. import 'package:ouds_theme_contract/ouds_component_version.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; /// Screen for the [OudsAlertMessage] component demo. class AlertMessageDemoScreen extends StatefulWidget { @@ -157,6 +158,9 @@ class _AlertMessageDemoState extends State<_AlertMessageDemo> { ), ), onClose: customizationState.hasCloseButton ? () {} : null, + onDescriptionLinkTapped: (link) async { + await launchUrl(Uri.parse(link)); + }, ), ); } diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index b3370dc7c..47464f4ee 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) - [Library] update `Phone number input` component to v1.3 ([#690](https://github.com/Orange-OpenSource/ouds-flutter/issues/690)) - [Library] update `tag` component to v1.5 ([#694](https://github.com/Orange-OpenSource/ouds-flutter/issues/694)) - [Library] update `input tag` component to v1.2 ([#695](https://github.com/Orange-OpenSource/ouds-flutter/issues/695)) diff --git a/ouds_core/lib/components/alert/ouds_alert_message.dart b/ouds_core/lib/components/alert/ouds_alert_message.dart index 706bab18f..86d05921a 100644 --- a/ouds_core/lib/components/alert/ouds_alert_message.dart +++ b/ouds_core/lib/components/alert/ouds_alert_message.dart @@ -24,6 +24,7 @@ import 'package:ouds_core/components/common/OudsBorder.dart'; import 'package:ouds_core/components/common/ouds_icon_status.dart'; import 'package:ouds_core/components/link/ouds_link.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; +import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; @@ -84,24 +85,43 @@ class OudsAlertMessageActionLayout { /// - [label]: Label displayed in the alert message. Main message that should be short, clear, and readable at a glance. /// - [status]: The status of the alert message. Its background color and its icon color are based on this status. /// There are two types of statuses: -/// - Non-functional statuses [Neutral] or [Accent] used for informational or decorative alert messages. They +/// - Non-functional statuses [Neutral] or [Accent] used for informational or decorative alert messages. They /// provide context or highlight content without implying a specific state, system event, or user action. These alerts are not tied to UX patterns such as /// success, error, or warning, and may use contextual or brand-related icons to enhance recognition or storytelling. -/// - Functional statuses communicate specific system statuses, results, or user feedback: [Positive], [Warning], +/// - Functional statuses communicate specific system statuses, results, or user feedback: [Positive], [Warning], /// [Negative], [Info]. /// Each variant conveys a clear semantic meaning and must always be paired with its dedicated functional icon to ensure clarity and accessibility. /// Use functional alerts to inform user about state changes, confirmations, or issues that are directly connected to system logic or user actions. These /// messages carry functional meaning and help guide user response or acknowledgment. -/// - [description]: Optional supplementary text in an alert message. Use only when additional detail or guidance is needed beyond the label. It should remain -/// short, clear and scannable, helping the user to understand what happened and what he can do next. +/// +/// - [description]: Optional supplementary text displayed below the alert label. Use it only when additional context, guidance or next steps are needed. +/// The content should remain concise, clear and easy to scan. +/// Supports lightweight markdown rich text formatting: +/// - Strong text using `**bold**`, +/// - Underline bold text using `__**underline bold**__`, +/// - Hyperlinks using `[link](https://example.com)` +/// +/// - [onDescriptionLinkTapped]: Callback invoked when a link in the description is tapped. The URL of the link is passed as an argument. +/// The caller is responsible for handling link opening behavior +/// (for example with `url_launcher` or an in-app WebView). +/// +/// Example: +/// ```dart +/// onDescriptionLinkTapped: (link) async { +/// await launchUrl(Uri.parse(link)); +/// }, +/// ``` +/// /// - [onClose]: Callback invoked when the close button is clicked. If `null`, the close button is not displayed and the alert message remains visible until /// the context changes (e.g., the issue is resolved, the screen is refreshed). Otherwise, the alert message is dismissable and includes a close button, /// allowing the user to dismiss it when he has acknowledged the message. /// Some alerts must remain visible to ensure user is aware of important information; others can be closed to reduce visual clutter. /// - [actionLayout]: An optional action link to be displayed in the alert message. It can be used to trigger an action. -/// - [bulletList]: An optional list of bullet points to be displayed in the alert message following the label or the optional [description]. -/// Add this list when you need to highlight multiple points, such as service features, plan details, or next steps. Each bullet should be short and written -/// as a clear phrase or fragment — avoid long sentences or complex structures. +/// - [bulletList]: An optional list of bullet points displayed below the label or the optional [description]. +/// Use this list to highlight multiple items such as service features, plan details or next steps. +/// Each bullet should remain short, clear and easy to scan. Avoid long sentences or complex structures. +/// Supports lightweight inline markdown formatting for text emphasis : +/// - Strong text `**bold**`. /// /// ## Usage Example: /// @@ -121,6 +141,7 @@ class OudsAlertMessage extends StatefulWidget { required this.status, this.description, this.onClose, + this.onDescriptionLinkTapped, this.actionLayout, this.bulletList, }); @@ -137,6 +158,19 @@ class OudsAlertMessage extends StatefulWidget { /// A callback invoked when the close button is clicked. If `null`, the close button is not shown. final VoidCallback? onClose; + /// A callback invoked when a link in the description is tapped. + /// + /// The caller is responsible for handling link opening behavior + /// (for example with `url_launcher` or an in-app WebView). + /// + /// Example: + /// ```dart + /// onDescriptionLinkTapped: (link) async { + /// await launchUrl(Uri.parse(link)); + /// }, + /// ``` + final ValueChanged? onDescriptionLinkTapped; + /// An optional clickable link to trigger an action. final OudsAlertMessageActionLayout? actionLayout; @@ -182,16 +216,7 @@ class _OudsAlertMessageState extends State { // Optional description text. if (widget.description != null && widget.description!.isNotEmpty) ...[ SizedBox(height: alertTokens.spaceRowGap), - Text( - widget.description!, - style: theme.typographyTokens - .typeLabelDefaultMedium(context) - .copyWith( - color: alertMessageStatusModifier.getStatusTextColor( - widget.status, - ), - ), - ), + _buildDescription(context), ], // Optional bullet list. A gap is added only if the list is not empty. if (widget.bulletList != null && @@ -376,6 +401,27 @@ class _OudsAlertMessageState extends State { ); } + /// Builds the description text with support for bold and hyperlinks. + Widget _buildDescription(BuildContext context) { + final theme = OudsTheme.of(context); + final alertMessageStatusModifier = OudsAlertStatusModifier(context); + + final textStyle = theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: alertMessageStatusModifier.getStatusTextColor(widget.status), + ); + + return Text.rich( + MarkdownSpanBuilder.buildRichText( + context, + widget.description ?? '', + baseStyle: textStyle, + onLinkTap: widget.onDescriptionLinkTapped, + ), + ); + } + /// Builds a single bullet list item for the alert message. /// /// This widget creates a row containing a bullet icon and a text label, @@ -436,7 +482,9 @@ class _OudsAlertMessageState extends State { Flexible( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxTextWidth), - child: Text(label, style: textStyle), + child: Text.rich( + MarkdownSpanBuilder.buildBoldOnly(label, baseStyle: textStyle), + ), ), ), ], diff --git a/ouds_core/lib/components/checkbox/ouds_checkbox_item.dart b/ouds_core/lib/components/checkbox/ouds_checkbox_item.dart index f2070e650..303973379 100644 --- a/ouds_core/lib/components/checkbox/ouds_checkbox_item.dart +++ b/ouds_core/lib/components/checkbox/ouds_checkbox_item.dart @@ -53,6 +53,9 @@ import 'package:ouds_core/components/control/ouds_control_item.dart'; /// - [constrainedMaxWidth]: When `true`, the item width is constrained to a maximum value defined by the design system. /// When `false`, no specific width constraint is applied, allowing the component to size itself or follow external modifiers. /// Defaults to `false`. +/// - [errorText]: Text shown below the checkbox item indicating an error state. Supports only strong text formatting using `**bold**`. +/// Rich text is supported only for error messages. +/// /// /// ### You can use [OudsCheckboxItem] component in your project, customizing parameters as needed : /// diff --git a/ouds_core/lib/components/control/ouds_control_item.dart b/ouds_core/lib/components/control/ouds_control_item.dart index 99bd2b5a1..cbadf598e 100644 --- a/ouds_core/lib/components/control/ouds_control_item.dart +++ b/ouds_core/lib/components/control/ouds_control_item.dart @@ -22,13 +22,13 @@ import 'package:ouds_core/components/control/internal/modifier/ouds_control_back import 'package:ouds_core/components/control/internal/modifier/ouds_control_border_modifier.dart'; import 'package:ouds_core/components/control/internal/modifier/ouds_control_indicator.dart'; import 'package:ouds_core/components/control/internal/modifier/ouds_control_text_modifier.dart'; +import 'package:ouds_core/components/control/internal/modifier/ouds_control_tick_modifier.dart'; import 'package:ouds_core/components/control/internal/ouds_control_state.dart'; import 'package:ouds_core/components/divider/ouds_divider.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; +import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; -import 'internal/modifier/ouds_control_tick_modifier.dart'; - enum OudsControlItemType { switchButton, checkbox, radio } /// Refactor of controls for [Checkbox], [Switch], and [RadioButton]. @@ -266,15 +266,17 @@ class OudsControlItemState extends State { top: controlItemTokens.spacePaddingBlockTopHelperText, end: controlItemTokens.spacePaddingInline, ), - child: Text( - widget.errorText ?? '', - style: OudsTheme.of(context).typographyTokens - .typeLabelDefaultMedium(context) - .copyWith( - color: controlItemTextModifier.getErrorMessageTextColor( - controlItemState, + child: Text.rich( + MarkdownSpanBuilder.buildBoldOnly( + widget.errorText ?? '', + baseStyle: OudsTheme.of(context).typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: controlItemTextModifier.getErrorMessageTextColor( + controlItemState, + ), ), - ), + ), ), ), ], diff --git a/ouds_core/lib/components/form_input/internal/ouds_form_input_decoration.dart b/ouds_core/lib/components/form_input/internal/ouds_form_input_decoration.dart index 3d6ccbe7c..1f9325ca2 100644 --- a/ouds_core/lib/components/form_input/internal/ouds_form_input_decoration.dart +++ b/ouds_core/lib/components/form_input/internal/ouds_form_input_decoration.dart @@ -10,7 +10,8 @@ * // Software description: Flutter library of reusable graphical components * // */ -/// @nodoc +/// {@category Text input} +/// {@category Phone number input} library; import 'dart:ui'; @@ -43,7 +44,7 @@ class OudsInputDecoration extends OudsFormInputDecoration { }); } -/// Configuration for decorating the [OudsformInput] widget. +/// Configuration for decorating the [OudsTextField] and [OudsPhoneNumberInput] widgets. /// /// Provides properties to customize labels, hints, icons, helper and error texts, /// loading states, and styling. @@ -52,8 +53,9 @@ class OudsInputDecoration extends OudsFormInputDecoration { /// /// - [labelText]: The main label text displayed above or inside the input field. /// -/// - [helperText]: Additional information displayed below the input, -/// often used to guide or assist the user. +/// - [helperText]: Additional information displayed below the input, often used to guide or assist the user. +/// Supports strong text formatting using `**bold**`. +/// Hyperlinks are not supported in helper text. Use the dedicated helper link component instead. /// /// - [hintText]: A short placeholder or hint shown inside the input when empty, /// describing the expected input. @@ -72,6 +74,7 @@ class OudsInputDecoration extends OudsFormInputDecoration { /// - [suffix]: A string displayed after the user's input, often used for units or context. /// /// - [errorText]: Text shown below the input indicating an error state or invalid input. +/// Supports strong text formatting using `**bold**`. /// /// - [loader]: When true, displays a loading indicator inside the input. /// diff --git a/ouds_core/lib/components/form_input/ouds_phone_number_input.dart b/ouds_core/lib/components/form_input/ouds_phone_number_input.dart index f2886d3ea..023ae9969 100644 --- a/ouds_core/lib/components/form_input/ouds_phone_number_input.dart +++ b/ouds_core/lib/components/form_input/ouds_phone_number_input.dart @@ -28,6 +28,7 @@ import 'package:ouds_core/components/form_input/internal/modifier/ouds_form_inpu import 'package:ouds_core/components/form_input/internal/ouds_form_input_control_state.dart'; import 'package:ouds_core/components/form_input/internal/ouds_form_input_decoration.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; +import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/ouds_theme_contract.dart'; @@ -750,13 +751,15 @@ class _OudsPhoneNumberInputState extends State { left: textInput.spacePaddingInlineDefault, right: textInput.spacePaddingInlineDefault, ), - child: Text( - text, - style: theme.typographyTokens - .typeLabelDefaultMedium(context) - .copyWith( - color: inputTextTextModifier.getHelperTextColor(state, isError), - ), + child: Text.rich( + MarkdownSpanBuilder.buildBoldOnly( + text, + baseStyle: theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: inputTextTextModifier.getHelperTextColor(state, isError), + ), + ), ), ); } diff --git a/ouds_core/lib/components/form_input/ouds_text_input.dart b/ouds_core/lib/components/form_input/ouds_text_input.dart index f309eac24..52b16963b 100644 --- a/ouds_core/lib/components/form_input/ouds_text_input.dart +++ b/ouds_core/lib/components/form_input/ouds_text_input.dart @@ -24,6 +24,7 @@ import 'package:ouds_core/components/form_input/internal/ouds_form_input_decorat import 'package:ouds_core/components/link/ouds_link.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; import 'package:ouds_core/components/utilities/input_utils.dart'; +import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/ouds_theme_contract.dart'; @@ -660,13 +661,15 @@ class _OudsTextInputState extends State { left: textInput.spacePaddingInlineDefault, right: textInput.spacePaddingInlineDefault, ), - child: Text( - text, - style: theme.typographyTokens - .typeLabelDefaultMedium(context) - .copyWith( - color: inputTextTextModifier.getHelperTextColor(state, isError), - ), + child: Text.rich( + MarkdownSpanBuilder.buildBoldOnly( + text, + baseStyle: theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: inputTextTextModifier.getHelperTextColor(state, isError), + ), + ), ), ); } diff --git a/ouds_core/lib/components/form_input/password_input/ouds_password_input.dart b/ouds_core/lib/components/form_input/password_input/ouds_password_input.dart index 1bbdd7c02..e7273a338 100644 --- a/ouds_core/lib/components/form_input/password_input/ouds_password_input.dart +++ b/ouds_core/lib/components/form_input/password_input/ouds_password_input.dart @@ -23,6 +23,7 @@ import 'package:ouds_core/components/form_input/internal/ouds_form_input_control import 'package:ouds_core/components/form_input/password_input/ouds_password_input_decoration.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; import 'package:ouds_core/components/utilities/input_utils.dart'; +import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/ouds_theme_contract.dart'; @@ -524,13 +525,15 @@ class _OudsPasswordInputState extends State { left: textInput.spacePaddingInlineDefault, right: textInput.spacePaddingInlineDefault, ), - child: Text( - text, - style: theme.typographyTokens - .typeLabelDefaultMedium(context) - .copyWith( - color: inputTextTextModifier.getHelperTextColor(state, isError), - ), + child: Text.rich( + MarkdownSpanBuilder.buildBoldOnly( + text, + baseStyle: theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: inputTextTextModifier.getHelperTextColor(state, isError), + ), + ), ), ); } diff --git a/ouds_core/lib/components/form_input/password_input/ouds_password_input_decoration.dart b/ouds_core/lib/components/form_input/password_input/ouds_password_input_decoration.dart index 26374a86f..238cca7a9 100644 --- a/ouds_core/lib/components/form_input/password_input/ouds_password_input_decoration.dart +++ b/ouds_core/lib/components/form_input/password_input/ouds_password_input_decoration.dart @@ -11,10 +11,10 @@ * // */ -/// @nodoc +/// {@category Password input} library; -/// Configuration for decorating the [OudsTextInput] widget. +/// Configuration for decorating the [OudsPasswordInput] widget. /// /// Provides properties to customize labels, hints, icons, helper and error texts, /// loading states, and styling. diff --git a/ouds_core/lib/components/radio_button/ouds_radio_button_item.dart b/ouds_core/lib/components/radio_button/ouds_radio_button_item.dart index a592d937a..64442b6be 100644 --- a/ouds_core/lib/components/radio_button/ouds_radio_button_item.dart +++ b/ouds_core/lib/components/radio_button/ouds_radio_button_item.dart @@ -51,6 +51,8 @@ import 'package:ouds_core/components/radio_button/ouds_radio_button.dart'; /// - [constrainedMaxWidth]: When `true`, the item width is constrained to a maximum value defined by the design system. /// When `false`, no specific width constraint is applied, allowing the component to size itself or follow external modifiers. /// Defaults to `false`. +/// - [errorText]: Text shown below the radio button item indicating an error state. Supports only strong text formatting using `**bold**`. +/// Rich text is supported only for error messages. /// /// /// ### You can use [OudsRadioButtonItem] component in your project, customizing parameters as needed : diff --git a/ouds_core/lib/components/switch/ouds_switch_item.dart b/ouds_core/lib/components/switch/ouds_switch_item.dart index d8c3b1255..4bd65ff61 100644 --- a/ouds_core/lib/components/switch/ouds_switch_item.dart +++ b/ouds_core/lib/components/switch/ouds_switch_item.dart @@ -45,6 +45,8 @@ import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; /// - [constrainedMaxWidth]: When `true`, the item width is constrained to a maximum value defined by the design system. /// When `false`, no specific width constraint is applied, allowing the component to size itself or follow external modifiers. /// Defaults to `false`. +/// - [errorText]: Text shown below the switch item indicating an error state. Supports only strong text formatting using `**bold**`. +/// Rich text is supported only for error messages. /// /// /// ### You can use [OudsSwitchItem] component in your project, customizing parameters as needed : diff --git a/ouds_core/lib/components/utilities/markdown_span_builder.dart b/ouds_core/lib/components/utilities/markdown_span_builder.dart new file mode 100644 index 000000000..dd9f388a2 --- /dev/null +++ b/ouds_core/lib/components/utilities/markdown_span_builder.dart @@ -0,0 +1,198 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ + +/// @nodoc +library; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; + +/// A lightweight markdown span builder for Flutter `RichText`. +/// +/// Supported syntax: +/// - `**bold**` +/// - `__**underline bold**__` +/// - `[link](https://example.com)` +/// +/// This is not a full markdown parser. It is intended for simple styling +/// and inline links in lightweight text content. +class MarkdownSpanBuilder { + /// Builds an [InlineSpan] from plain text supporting only bold markdown. + /// + /// Supported syntax: + /// - `**bold**` + /// + /// Any text outside markdown markers is preserved as-is. + /// + /// Parameters: + /// - [text]: The source text to parse. + /// - [baseStyle]: The default style applied to all text. + /// + /// Returns: + /// A tree of [InlineSpan] that can be used in `Text.rich` or `RichText`. + static InlineSpan buildBoldOnly(String text, {required TextStyle baseStyle}) { + final spans = []; + + final regex = RegExp(r'(\*\*.*?\*\*)'); + + int currentPosition = 0; + + for (final match in regex.allMatches(text)) { + if (match.start > currentPosition) { + spans.add( + TextSpan( + text: text.substring(currentPosition, match.start), + style: baseStyle, + ), + ); + } + + final matchText = match.group(0)!; + + spans.add( + TextSpan( + text: matchText.substring(2, matchText.length - 2), + style: baseStyle.copyWith(fontWeight: FontWeight.bold), + ), + ); + + currentPosition = match.end; + } + + if (currentPosition < text.length) { + spans.add( + TextSpan(text: text.substring(currentPosition), style: baseStyle), + ); + } + + return TextSpan(style: baseStyle, children: spans); + } + + /// Builds an [InlineSpan] supporting bold text, underline-bold text, and links. + /// + /// Supported syntax: + /// - `**bold**` + /// - `__**underline bold**__` + /// - `[link](https://example.com)` + /// + /// Link text is rendered with an underline style and can trigger [onLinkTap] + /// when tapped. + /// + /// Parameters: + /// - [context]: Used to access theme typography tokens. + /// - [text]: The source text to parse. + /// - [baseStyle]: The default style applied to non-markdown text. + /// - [onLinkTap]: Optional callback invoked when a link is tapped. + /// + /// Returns: + /// A tree of [InlineSpan] that can be used in `Text.rich` or `RichText`. + /// + /// Note: + /// This function only parses and exposes links through [onLinkTap]. + /// The caller is responsible for handling link opening behavior + /// (for example with `url_launcher` or an in-app WebView). + /// + /// Example: + /// onLinkTap: (link) async { + /// await launchUrl(Uri.parse(link)); + /// }, + /// + static InlineSpan buildRichText( + BuildContext context, + String text, { + required TextStyle baseStyle, + void Function(String url)? onLinkTap, + }) { + final theme = OudsTheme.of(context); + final spans = []; + + final regex = RegExp( + r'(__\*\*.*?\*\*__)|' // underline bold + r'(\*\*.*?\*\*)|' // bold + r'(\[.*?\]\(.*?\))', // link + ); + + int currentPosition = 0; + + for (final match in regex.allMatches(text)) { + if (match.start > currentPosition) { + spans.add( + TextSpan( + text: text.substring(currentPosition, match.start), + style: baseStyle, + ), + ); + } + + final matchText = match.group(0)!; + + // Underline + Bold + if (matchText.startsWith('__**')) { + spans.add( + TextSpan( + text: matchText.substring(4, matchText.length - 4), + style: baseStyle.copyWith( + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ); + } + // Bold + else if (matchText.startsWith('**')) { + spans.add( + TextSpan( + text: matchText.substring(2, matchText.length - 2), + style: baseStyle.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + // Link + else if (matchText.startsWith('[')) { + final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchText); + + if (linkMatch != null) { + final linkText = linkMatch.group(1)!; + final url = linkMatch.group(2)!; + + spans.add( + TextSpan( + text: linkText, + style: theme.typographyTokens + .typeLabelStrongMedium(context) + .copyWith( + decoration: TextDecoration.underline, + color: baseStyle.color, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + onLinkTap?.call(url); + }, + ), + ); + } + } + + currentPosition = match.end; + } + + if (currentPosition < text.length) { + spans.add( + TextSpan(text: text.substring(currentPosition), style: baseStyle), + ); + } + + return TextSpan(style: baseStyle, children: spans); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8a5e12e0b..1e002876e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: app_settings - sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795" + sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a" url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "7.0.0" args: dependency: transitive description: