diff --git a/.github/workflows/flutter.yaml b/.github/workflows/flutter.yaml index e40f26d..594bec8 100644 --- a/.github/workflows/flutter.yaml +++ b/.github/workflows/flutter.yaml @@ -8,10 +8,36 @@ jobs: name: Validate runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: channel: stable - name: Run Flutter validation script run: ./validate.sh shell: bash + + integration-android: + name: Android integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + channel: stable + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run integration tests on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + script: cd example && flutter pub get && flutter test integration_test diff --git a/.gitignore b/.gitignore index d9fe855..93ca1bf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ **/doc/api/ .dart_tool/ .flutter-plugins +.flutter-plugins-dependencies .packages .pub-cache/ .pub/ @@ -37,3 +38,7 @@ lib/generated_plugin_registrant.dart !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages example/android/app/.cxx/Debug example/ios/Flutter/ephemeral + +# Local symlink to the sibling flutter_markdown_plus_latex package, used for +# cross-package development. Not part of this repository. +flutter_markdown_plus_latex diff --git a/.pubignore b/.pubignore index e10928a..8291c82 100644 --- a/.pubignore +++ b/.pubignore @@ -1,3 +1,6 @@ +# Local symlink to the sibling flutter_markdown_plus_latex package (dev only) +flutter_markdown_plus_latex/ + # Example build artifacts example/build/ example/.dart_tool/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 557c102..ccb1127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## 1.0.8 + +* Fixed custom `builders` and `paddingBuilders` for the `hr` tag being ignored +* Fixed bold, italic, and link formatting not rendering inside blockquotes +* Forced a consistent line height within each text block so mixed font weights (e.g. bold) no longer shift line height, while headers and other blocks keep their own height +* Updated the default blockquote styling to be theme-aware (uses `surfaceContainerHighest` with a primary-coloured left border) for better light/dark mode support +* Added a `noScroll` option to the `Markdown` widget to render content in a non-scrolling `Column` for use inside an existing scroll view +* Added an integration test suite (`example/integration_test/`) that runs on a device or emulator, covering link taps, scrolling, task-list checkboxes, text selection, and example app launch +* Added `scripts/integration_test.sh` and a GitHub Actions job that runs the integration tests on an Android emulator +* Migrated linting to `package:flutter_lints`, retaining strict type analysis and required public API documentation +* Updated dependencies: `markdown` to `^7.3.1`, `mockito` to `^5.7.0`, and pinned `leak_tracker_flutter_testing` +* Aligned the example app's Flutter constraint with the package and added a `documentation` link to the pubspec +* Updated the example app's Android Gradle Plugin to 8.6.0 (Flutter's current minimum) and removed an unused Google Cloud Artifact Registry Gradle plugin +* Excluded the locally symlinked `flutter_markdown_plus_latex` package from analysis and publishing + +**Thanks to our contributors:** +* [@joelbrostrom](https://github.com/joelbrostrom) for fixing custom builders on the `hr` tag (#131) +* [@vsaase](https://github.com/vsaase) for fixing inline formatting inside blockquotes (#128) +* [@domhel](https://github.com/domhel) for theme-aware blockquote styling (#42) +* [@szuwest](https://github.com/szuwest) for the strong text line-height fix (#130) +* [@senlinjun](https://github.com/senlinjun) for the `noScroll` option (#38) + ## 1.0.7 * Improved pub.dev score by making example app source code available in published package diff --git a/analysis_options.yaml b/analysis_options.yaml index 68be6a0..cd005be 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,9 +1,10 @@ -# Specify analysis options. +# Analysis options for flutter_markdown_plus. # -# This file is a copy of analysis_options.yaml from flutter repo -# as of 2023-12-18, but with some modifications marked with -# "DIFFERENT FROM FLUTTER/FLUTTER" below. The file is expected to -# be kept in sync with the master file from the flutter repo. +# Builds on the standard package:flutter_lints rule set (the convention for +# published Flutter packages), layered with a few stricter settings this package +# relies on: type-strict analysis and required public API documentation. + +include: package:flutter_lints/flutter.yaml analyzer: language: @@ -11,235 +12,28 @@ analyzer: strict-inference: true strict-raw-types: true errors: - # allow deprecated members (we do this because otherwise we have to annotate - # every member in every test, assert, etc, when we or the Dart SDK deprecates - # something (https://github.com/flutter/flutter/issues/143312) + # Allow deprecated members: otherwise every usage would need annotating each + # time the Dart SDK or Flutter deprecates something we still support. + # https://github.com/flutter/flutter/issues/143312 deprecated_member_use: ignore deprecated_member_use_from_same_package: ignore - exclude: # DIFFERENT FROM FLUTTER/FLUTTER - # Ignore generated files + exclude: + # Generated files. - '**/*.g.dart' - '**/*.mocks.dart' # Mockito @GenerateMocks - + # Locally symlinked sibling package (flutter_markdown_plus_latex) used for + # cross-package development; not part of this package. + - 'flutter_markdown_plus_latex/**' + formatter: page_width: 120 linter: rules: - # This list is derived from the list of all available lints located at - # https://github.com/dart-lang/linter/blob/main/example/all.yaml - - always_declare_return_types - - always_put_control_body_on_new_line - # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 - - always_specify_types - # - always_use_package_imports # we do this commonly - - annotate_overrides - # - avoid_annotating_with_dynamic # conflicts with always_specify_types - - avoid_bool_literals_in_conditional_expressions - # - avoid_catches_without_on_clauses # blocked on https://github.com/dart-lang/linter/issues/3023 - # - avoid_catching_errors # blocked on https://github.com/dart-lang/linter/issues/3023 - # - avoid_classes_with_only_static_members # we do this commonly for `abstract final class`es - - avoid_double_and_int_checks - - avoid_dynamic_calls - - avoid_empty_else - - avoid_equals_and_hash_code_on_mutable_classes - - avoid_escaping_inner_quotes - - avoid_field_initializers_in_const_classes - # - avoid_final_parameters # incompatible with prefer_final_parameters - - avoid_function_literals_in_foreach_calls - # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 - - avoid_init_to_null - - avoid_js_rounded_ints - # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to - # - avoid_positional_boolean_parameters # would have been nice to enable this but by now there's too many places that break it - - avoid_print - # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) - - avoid_redundant_argument_values - - avoid_relative_lib_imports - - avoid_renaming_method_parameters - - avoid_return_types_on_setters - - avoid_returning_null_for_void - # - avoid_returning_this # there are enough valid reasons to return `this` that this lint ends up with too many false positives - - avoid_setters_without_getters - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_slow_async_io - - avoid_type_to_string - - avoid_types_as_parameter_names - # - avoid_types_on_closure_parameters # conflicts with always_specify_types - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - avoid_void_async - # - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere - - await_only_futures - - camel_case_extensions - - camel_case_types - - cancel_subscriptions - # - cascade_invocations # doesn't match the typical style of this repo - - cast_nullable_to_non_nullable - # - close_sinks # not reliable enough - - collection_methods_unrelated_type - - combinators_ordering - # - comment_references # blocked on https://github.com/dart-lang/linter/issues/1142 - - conditional_uri_does_not_exist - # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 - - control_flow_in_finally - - curly_braces_in_flow_control_structures - - dangling_library_doc_comments - - depend_on_referenced_packages - - deprecated_consistency - # - deprecated_member_use_from_same_package # we allow self-references to deprecated members - # - diagnostic_describe_all_properties # enabled only at the framework level (packages/flutter/lib) - - directives_ordering - # - discarded_futures # too many false positives, similar to unawaited_futures - # - do_not_use_environment # there are appropriate times to use the environment, especially in our tests and build logic - - empty_catches - - empty_constructor_bodies - - empty_statements - - eol_at_end_of_file - - exhaustive_cases - - file_names - - flutter_style_todos - - hash_and_equals - - implementation_imports - - implicit_call_tearoffs - - implicit_reopen - - invalid_case_patterns - # - join_return_with_assignment # not required by flutter style - - leading_newlines_in_multiline_strings - - library_annotations - - library_names - - library_prefixes - - library_private_types_in_public_api - # - lines_longer_than_80_chars # not required by flutter style - - literal_only_boolean_expressions - # - matching_super_parameters # blocked on https://github.com/dart-lang/language/issues/2509 - - missing_code_block_language_in_doc_comment - - missing_whitespace_between_adjacent_strings - - no_adjacent_strings_in_list - - no_default_cases - - no_duplicate_case_values - - no_leading_underscores_for_library_prefixes - - no_leading_underscores_for_local_identifiers - - no_literal_bool_comparisons - - no_logic_in_create_state - - no_runtimeType_toString # DIFFERENT FROM FLUTTER/FLUTTER - - no_self_assignments - - no_wildcard_variable_uses - - non_constant_identifier_names - - noop_primitive_operations - - null_check_on_nullable_type_parameter - - null_closures - # - omit_local_variable_types # opposite of always_specify_types - # - one_member_abstracts # too many false positives - - only_throw_errors # this does get disabled in a few places where we have legacy code that uses strings et al - - overridden_fields - # - package_api_docs # Deprecated (https://github.com/dart-lang/linter/issues/5107) - - package_names - - package_prefixed_library_names - # - parameter_assignments # we do this commonly - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - # - prefer_asserts_with_message # not required by flutter style - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - # - prefer_constructors_over_static_methods # far too many false positives - - prefer_contains - # - prefer_double_quotes # opposite of prefer_single_quotes - # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md#consider-using--for-short-functions-and-methods - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - # - prefer_final_parameters # adds too much verbosity - - prefer_for_elements_to_map_fromIterable - - prefer_foreach - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - # - prefer_int_literals # conflicts with https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md#use-double-literals-for-double-constants - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_iterable_whereType - - prefer_mixin - # - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere - - prefer_null_aware_operators + # The public API of a published package must be documented. + - public_member_api_docs + # Missing awaits have caused production issues in plugins. + - unawaited_futures - prefer_relative_imports - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - prefer_void_to_null - - provide_deprecation_message - - public_member_api_docs # DIFFERENT FROM FLUTTER/FLUTTER - - recursive_getters - # - require_trailing_commas # would be nice, but requires a lot of manual work: 10,000+ code locations would need to be reformatted by hand after bulk fix is applied - - secure_pubspec_urls - - sized_box_for_whitespace - - sized_box_shrink_expand - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_pub_dependencies # DIFFERENT FROM FLUTTER/FLUTTER: Flutter's use case for not sorting does not apply to this repository. - - sort_unnamed_constructors_first - - test_types_in_equals - - throw_in_finally - - tighten_type_of_initializing_formals - # - type_annotate_public_apis # subset of always_specify_types - - type_init_formals - - type_literal_in_constant_pattern - - unawaited_futures # DIFFERENT FROM FLUTTER/FLUTTER: It's disabled there for "too many false positives"; that's not an issue here, and missing awaits have caused production issues in plugins. - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_breaks - - unnecessary_const - - unnecessary_constructor_name - # - unnecessary_final # conflicts with prefer_final_locals - - unnecessary_getters_setters - # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 - - unnecessary_late - - unnecessary_library_directive - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_aware_operator_on_extension_on_nullable - - unnecessary_null_checks - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_overrides - - unnecessary_parenthesis - # - unnecessary_raw_strings # what's "necessary" is a matter of opinion; consistency across strings can help readability more than this lint - - unnecessary_statements - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - unnecessary_to_list_in_spreads - - unreachable_from_main - - unrelated_type_equality_checks - - use_build_context_synchronously - - use_colored_box - # - use_decorated_box # leads to bugs: DecoratedBox and Container are not equivalent (Container inserts extra padding) - - use_enums - - use_full_hex_values_for_flutter_colors - - use_function_type_syntax_for_parameters - - use_if_null_to_convert_nulls_to_bools - - use_is_even_rather_than_modulo - - use_key_in_widget_constructors - - use_late_for_private_fields_and_variables - - use_named_constants - - use_raw_strings - - use_rethrow_when_possible - - use_setters_to_change_properties - # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 - - use_string_in_part_of_directives - - use_super_parameters - - use_test_throws_matchers - # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review - - valid_regexps - - void_checks + - sort_pub_dependencies diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 598d13f..f2122b2 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 57000f7..15c796f 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -19,9 +19,8 @@ pluginManagement { // See https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure for more info. plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.2.1" apply false + id "com.android.application" version "8.6.0" apply false id "org.jetbrains.kotlin.android" version "1.9.0" apply false - id "com.google.cloud.artifactregistry.gradle-plugin" version "2.2.1" } include ":app" diff --git a/example/integration_test/app_render_test.dart b/example/integration_test/app_render_test.dart new file mode 100644 index 0000000..0450c0f --- /dev/null +++ b/example/integration_test/app_render_test.dart @@ -0,0 +1,88 @@ +// Copyright 2013 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_markdown_example/main.dart' as app; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'markdown_harness.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('flutter_markdown_plus on-device rendering', () { + testWidgets('tapping a link fires onTapLink with the href', (WidgetTester tester) async { + String? tappedHref; + await tester.pumpWidget( + buildMarkdownApp( + data: '[tap me](https://example.com)', + onTapLink: (String text, String? href, String title) => tappedHref = href, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('tap me')); + await tester.pumpAndSettle(); + + expect(tappedHref, 'https://example.com'); + }); + + testWidgets('a long document scrolls to reveal off-screen content', (WidgetTester tester) async { + final StringBuffer buffer = StringBuffer(); + for (int i = 0; i < 80; i++) { + buffer.writeln('Paragraph $i with enough text to occupy vertical space on screen.'); + buffer.writeln(); + } + buffer.writeln('THE_BOTTOM_SENTINEL'); + + await tester.pumpWidget(buildMarkdownApp(data: buffer.toString())); + await tester.pumpAndSettle(); + + // The sentinel is below the fold and the ListView has not built it yet. + expect(find.text('THE_BOTTOM_SENTINEL'), findsNothing); + + await tester.drag(find.byType(Markdown), const Offset(0, -8000)); + await tester.pumpAndSettle(); + + expect(find.text('THE_BOTTOM_SENTINEL'), findsOneWidget); + }); + + testWidgets('GFM task list renders checkbox icons', (WidgetTester tester) async { + await tester.pumpWidget( + buildMarkdownBodyApp(data: '- [ ] todo item\n- [x] done item'), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + expect(find.byIcon(Icons.check_box), findsOneWidget); + expect(find.text('todo item'), findsOneWidget); + expect(find.text('done item'), findsOneWidget); + }); + + testWidgets('selectable markdown surfaces the selection toolbar on long press', (WidgetTester tester) async { + await tester.pumpWidget( + buildMarkdownApp( + data: 'A selectable paragraph of text.', + selectable: true, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.textContaining('selectable paragraph')); + await tester.pumpAndSettle(); + + // The Material selection toolbar exposes a Copy action once text is selected. + expect(find.text('Copy'), findsWidgets); + }); + + testWidgets('the example app launches and lists its demos', (WidgetTester tester) async { + app.main(); + await tester.pumpAndSettle(); + + expect(find.text('Markdown Demos'), findsOneWidget); + }); + }); +} diff --git a/example/integration_test/markdown_harness.dart b/example/integration_test/markdown_harness.dart new file mode 100644 index 0000000..703ddaa --- /dev/null +++ b/example/integration_test/markdown_harness.dart @@ -0,0 +1,41 @@ +// Copyright 2013 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_markdown_plus/flutter_markdown_plus.dart'; + +/// Wraps a scrollable [Markdown] widget in a minimal [MaterialApp] so +/// integration tests can pump a deterministic widget tree without depending on +/// the example app's demo navigation. +Widget buildMarkdownApp({ + required String data, + MarkdownTapLinkCallback? onTapLink, + bool selectable = false, +}) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: Markdown( + data: data, + onTapLink: onTapLink, + selectable: selectable, + ), + ), + ), + ); +} + +/// Wraps a non-scrolling [MarkdownBody] in a minimal [MaterialApp] for cases +/// where the rendered content fits on screen (e.g. checkbox rendering). +Widget buildMarkdownBodyApp({required String data}) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + child: MarkdownBody(data: data), + ), + ), + ), + ); +} diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 3c5ddc8..33b4f90 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -42,6 +43,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +51,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -58,6 +61,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -102,6 +106,9 @@ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -125,6 +132,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { LastUpgradeCheck = 1510; @@ -275,7 +285,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -349,7 +359,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -398,7 +408,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -469,6 +479,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fc5ae03..0bd6d42 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + =3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.1" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 71bf8dc..cebb540 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: none environment: sdk: ^3.4.0 - flutter: ">=3.22.0" + flutter: ">=3.27.1" dependencies: flutter: @@ -23,6 +23,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: assets: diff --git a/lib/flutter_markdown_plus.dart b/lib/flutter_markdown_plus.dart index 12ed0a9..8e764d7 100644 --- a/lib/flutter_markdown_plus.dart +++ b/lib/flutter_markdown_plus.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. /// A library to render markdown formatted text. -library flutter_markdown_plus; +library; export 'src/builder.dart'; export 'src/style_sheet.dart'; diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 31a7c95..4f75a5f 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -358,7 +358,7 @@ class MarkdownBuilder implements md.NodeVisitor { } else { child = _buildRichText( TextSpan( - style: _isInBlockquote ? styleSheet.blockquote : _inlines.last.style, + style: _inlines.last.style, text: trimText(text.text), recognizer: _linkHandlers.isNotEmpty ? _linkHandlers.last : null, ), @@ -474,7 +474,16 @@ class MarkdownBuilder implements md.NodeVisitor { child: child, ); } else if (tag == 'hr') { - child = Container(decoration: styleSheet.horizontalRuleDecoration); + if (!builders.containsKey(tag)) { + child = Container(decoration: styleSheet.horizontalRuleDecoration); + } + } + + if (tag == 'hr' && paddingBuilders.containsKey(tag)) { + child = Padding( + padding: paddingBuilders[tag]!.getPadding(), + child: child, + ); } _addBlockChild(child); @@ -726,7 +735,7 @@ class MarkdownBuilder implements md.NodeVisitor { if (_inlines.isEmpty) { _inlines.add(_InlineElement( tag, - style: tag != null ? styleSheet.styles[tag] : null, + style: _isInBlockquote ? styleSheet.blockquote : (tag != null ? styleSheet.styles[tag] : null), )); } } @@ -980,11 +989,25 @@ class MarkdownBuilder implements md.NodeVisitor { Widget _buildRichText(TextSpan text, {TextAlign? textAlign, String? key}) { //Adding a unique key prevents the problem of using the same link handler for text spans with the same text final Key k = key == null ? UniqueKey() : Key(key); + // Force a consistent line height within each text block, derived from the + // span's own base style so headers/blockquotes keep their correct height + // while mixed font weights within a block no longer shift line height. + final TextStyle? baseStyle = text.style ?? styleSheet.p; + final StrutStyle? strutStyle = baseStyle != null + ? StrutStyle( + fontFamily: baseStyle.fontFamily, + fontSize: baseStyle.fontSize ?? styleSheet.p?.fontSize, + height: baseStyle.height ?? styleSheet.p?.height, + leading: 0, + forceStrutHeight: true, + ) + : null; if (selectable) { return SelectableText.rich( text, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, + strutStyle: strutStyle, onSelectionChanged: onSelectionChanged != null ? (TextSelection selection, SelectionChangedCause? cause) => onSelectionChanged!(text.text, selection, cause) @@ -997,6 +1020,7 @@ class MarkdownBuilder implements md.NodeVisitor { text, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, + strutStyle: strutStyle, key: k, ); } diff --git a/lib/src/style_sheet.dart b/lib/src/style_sheet.dart index 9927731..eb9f1ed 100644 --- a/lib/src/style_sheet.dart +++ b/lib/src/style_sheet.dart @@ -143,10 +143,16 @@ class MarkdownStyleSheet { tableColumnWidth: const FlexColumnWidth(), tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), tableCellsDecoration: const BoxDecoration(), - blockquotePadding: const EdgeInsets.all(8.0), + blockquotePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), blockquoteDecoration: BoxDecoration( - color: Colors.blue.shade100, - borderRadius: BorderRadius.circular(2.0), + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(3), + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), ), codeblockPadding: const EdgeInsets.all(8.0), codeblockDecoration: BoxDecoration( @@ -318,10 +324,16 @@ class MarkdownStyleSheet { tableColumnWidth: const FlexColumnWidth(), tableCellsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), tableCellsDecoration: const BoxDecoration(), - blockquotePadding: const EdgeInsets.all(8.0), + blockquotePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), blockquoteDecoration: BoxDecoration( - color: Colors.blue.shade100, - borderRadius: BorderRadius.circular(2.0), + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(3), + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), ), codeblockPadding: const EdgeInsets.all(8.0), codeblockDecoration: BoxDecoration( diff --git a/lib/src/widget.dart b/lib/src/widget.dart index 5e8af27..1bb99aa 100644 --- a/lib/src/widget.dart +++ b/lib/src/widget.dart @@ -522,6 +522,7 @@ class Markdown extends MarkdownWidget { this.controller, this.physics, this.shrinkWrap = false, + this.noScroll = false, super.softLineBreak, }); @@ -544,8 +545,20 @@ class Markdown extends MarkdownWidget { /// See also: [ScrollView.shrinkWrap] final bool shrinkWrap; + /// If true, the markdown is rendered in a non-scrolling [Column] instead of + /// a scrolling [ListView]. Use this when the widget is already inside a + /// scrolling parent. + final bool noScroll; + @override Widget build(BuildContext context, List? children) { + if (noScroll) { + final List childrenWithPadding = []; + for (final Widget child in children ?? []) { + childrenWithPadding.add(Padding(padding: padding, child: child)); + } + return Column(children: childrenWithPadding); + } return ListView( padding: padding, controller: controller, diff --git a/pubspec.lock b/pubspec.lock index ce71bba..23b1e5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b url: "https://pub.dev" source: hosted - version: "88.0.0" + version: "100.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "13.0.0" args: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: build - sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.6" built_collection: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: dart_style - sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.9" fake_async: dependency: transitive description: @@ -150,6 +150,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -187,6 +195,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" logging: dependency: transitive description: @@ -199,42 +215,42 @@ packages: dependency: "direct main" description: name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.3.1" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: "direct main" description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mockito: dependency: "direct dev" description: name: mockito - sha256: "4feb43bc4eb6c03e832f5fcd637d1abb44b98f9cfa245c58e27382f58859f8f6" + sha256: c8d040d754367108fbe482dcb79dd72b8fe60ac6727abd15b4783c5560297ee6 url: "https://pub.dev" source: hosted - version: "5.5.1" + version: "5.7.0" package_config: dependency: transitive description: @@ -268,10 +284,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.2.3" source_span: dependency: transitive description: @@ -324,10 +340,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" typed_data: dependency: transitive description: @@ -369,5 +385,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.11.0 <4.0.0" flutter: ">=3.27.1" diff --git a/pubspec.yaml b/pubspec.yaml index 351a9d7..60ef218 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,8 @@ description: A Markdown renderer for Flutter. Create rich text output, formatted with simple Markdown tags. repository: https://github.com/foresightmobile/flutter_markdown_plus issue_tracker: https://github.com/foresightmobile/flutter_markdown_plus/issues -version: 1.0.7 +documentation: https://github.com/foresightmobile/flutter_markdown_plus#readme +version: 1.0.8 environment: sdk: ^3.4.0 @@ -13,15 +14,16 @@ environment: dependencies: flutter: sdk: flutter - markdown: ^7.3.0 + markdown: ^7.3.1 meta: ^1.16.0 path: ^1.9.1 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter - leak_tracker_flutter_testing: any - mockito: ^5.5.0 + leak_tracker_flutter_testing: ^3.0.10 + mockito: ^5.7.0 standard_message_codec: ^0.0.1+4 topics: diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh new file mode 100755 index 0000000..bf3273b --- /dev/null +++ b/scripts/integration_test.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e -x + +############################################################################### +# Runs the example app's integration tests on a connected device/emulator. +# +# Locally: ANDROID_DEVICE=emulator-5554 sh ./scripts/integration_test.sh +# CI: device id is omitted so the single booted emulator is auto-selected. +############################################################################### + +cd "$(dirname "$0")/../example" + +flutter pub get + +if [ -n "$ANDROID_DEVICE" ]; then + flutter test integration_test -d "$ANDROID_DEVICE" +else + flutter test integration_test +fi diff --git a/test/blockquote_test.dart b/test/blockquote_test.dart index 487bedc..aa4c010 100644 --- a/test/blockquote_test.dart +++ b/test/blockquote_test.dart @@ -87,19 +87,48 @@ void defineTests() { theme.textTheme.bodyMedium!.color, ); - /// Markdown guide + /// Markdown guide (link - should use link color) expect(styledTextParts[1].text, 'Markdown guide'); - expect(styledTextParts[1].style!.color, styleSheet.blockquote!.color); + expect(styledTextParts[1].style!.color, Colors.blue); /// and this is + expect(styledTextParts[2].text, ' and this is '); expect( styledTextParts[2].style!.color, theme.textTheme.bodyMedium!.color, ); - /// bold - expect(styledTextParts[2].text, ' and this is bold and italic'); - expect(styledTextParts[2].style!.fontWeight, FontWeight.w400); + /// bold (should be bold) + expect(styledTextParts[3].text, 'bold'); + expect(styledTextParts[3].style!.fontWeight, FontWeight.bold); + + /// and + expect(styledTextParts[4].text, ' and '); + + /// italic (should be italic) + expect(styledTextParts[5].text, 'italic'); + expect(styledTextParts[5].style!.fontStyle, FontStyle.italic); + }, + ); + + testWidgets( + 'bold text in blockquote renders with bold font weight', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const MarkdownBody(data: '> **bold text**'), + ), + ); + + final Text quoteText = tester.widget(find.byType(Text)); + final TextSpan textSpan = quoteText.textSpan! as TextSpan; + + // The bold span is either the root span directly (when it's the only + // element) or the first child span. + final TextSpan boldSpan = textSpan.children != null ? textSpan.children!.cast().first : textSpan; + + expect(boldSpan.text, 'bold text'); + expect(boldSpan.style!.fontWeight, FontWeight.bold); }, ); }); diff --git a/test/horizontal_rule_test.dart b/test/horizontal_rule_test.dart index 03a465a..79c4311 100644 --- a/test/horizontal_rule_test.dart +++ b/test/horizontal_rule_test.dart @@ -2,9 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; import 'utils.dart'; void main() => defineTests(); @@ -84,5 +85,68 @@ void defineTests() { ]); }, ); + testWidgets( + 'custom builder for hr is respected', + (WidgetTester tester) async { + const String data = '---'; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + builders: { + 'hr': _CustomHrBuilder(), + }, + ), + ), + ); + + expect(find.byType(ColoredBox), findsOneWidget); + final ColoredBox box = tester.widget(find.byType(ColoredBox)); + expect(box.color, Colors.red); + }, + ); + + testWidgets( + 'paddingBuilders for hr is applied', + (WidgetTester tester) async { + const String data = '---'; + const double paddingX = 16.0; + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: data, + paddingBuilders: { + 'hr': _CustomPaddingBuilder(paddingX), + }, + ), + ), + ); + + final Finder paddingFinder = find.byType(Padding); + final List paddings = tester.widgetList(paddingFinder).toList(); + final bool hasHrPadding = paddings.any( + (Padding p) => p.padding.along(Axis.horizontal) == paddingX * 2, + ); + expect(hasHrPadding, isTrue); + }, + ); }); } + +class _CustomHrBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { + return const ColoredBox(color: Colors.red, child: SizedBox(height: 2, width: double.infinity)); + } +} + +class _CustomPaddingBuilder extends MarkdownPaddingBuilder { + _CustomPaddingBuilder(this.paddingX); + + final double paddingX; + + @override + EdgeInsets getPadding() { + return EdgeInsets.symmetric(horizontal: paddingX); + } +} diff --git a/test/scrollable_test.dart b/test/scrollable_test.dart index a865cca..557ad6c 100644 --- a/test/scrollable_test.dart +++ b/test/scrollable_test.dart @@ -191,5 +191,51 @@ void defineTests() { expect(scrollViews.first.controller, isNot(equals(scrollViews.last.controller))); }, ); + + testWidgets( + 'noScroll renders content in a non-scrolling Column', + (WidgetTester tester) async { + const String data = '# A\n\npara one\n\npara two'; + + await tester.pumpWidget( + boilerplate( + const Markdown(data: data, noScroll: true), + ), + ); + + // No scroll view should be present when noScroll is true. + expect(find.byType(Scrollable), findsNothing); + + // The content should be laid out in at least one Column. + expect(find.byType(Column), findsWidgets); + + // Each direct child rendered by the noScroll branch is wrapped in Padding. + final Finder columnFinder = find.descendant( + of: find.byType(Markdown), + matching: find.byType(Column), + ); + final Column column = tester.widgetList(columnFinder).first; + expect(column.children, isNotEmpty); + for (final Widget child in column.children) { + expect(child, isA()); + } + }, + ); + + testWidgets( + 'noScroll defaults to false and keeps the scrolling ListView', + (WidgetTester tester) async { + const String data = '# A\n\npara one\n\npara two'; + + await tester.pumpWidget( + boilerplate( + const Markdown(data: data), + ), + ); + + // With default args the ListView still scrolls. + expect(find.byType(Scrollable), findsOneWidget); + }, + ); }); } diff --git a/test/text_test.dart b/test/text_test.dart index 72f0efc..4ba6839 100644 --- a/test/text_test.dart +++ b/test/text_test.dart @@ -397,4 +397,80 @@ void defineTests() { expectTextStrings(widgets, ['strikethrough']); }); }); + + group('Strut style', () { + testWidgets( + 'paragraph rich text forces a consistent strut height across font weights', + (WidgetTester tester) async { + // A bold span should not change the paragraph line height. The builder + // applies a forced strut height derived from the span's base style so + // the line height is consistent regardless of font weight (see PR #130). + const double paragraphFontSize = 17.0; + final MarkdownStyleSheet styleSheet = MarkdownStyleSheet( + p: const TextStyle(fontSize: paragraphFontSize, height: 1.5), + ); + + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: 'normal **bold** text', + styleSheet: styleSheet, + ), + ), + ); + + final Text text = tester.widget(find.byType(Text)); + expect(text.strutStyle, isNotNull); + expect(text.strutStyle!.forceStrutHeight, isTrue); + expect(text.strutStyle!.fontSize, paragraphFontSize); + expect(text.strutStyle!.height, 1.5); + }, + ); + + testWidgets( + 'header strut is derived from the header style, not the paragraph style', + (WidgetTester tester) async { + // Regression guard: _buildRichText is the generic rich-text builder used + // for headers/blockquotes/list items as well as paragraphs. The strut + // must follow each block's own style so a large header is not forced + // down to the (smaller) paragraph size, which would clip the header. + const double headerFontSize = 32.0; + const double paragraphFontSize = 14.0; + final ThemeData theme = ThemeData.light().copyWith(textTheme: textTheme); + final MarkdownStyleSheet styleSheet = MarkdownStyleSheet.fromTheme(theme).merge( + MarkdownStyleSheet( + h1: const TextStyle(fontSize: headerFontSize), + p: const TextStyle(fontSize: paragraphFontSize), + ), + ); + + await tester.pumpWidget( + boilerplate( + MarkdownBody( + data: '# Heading\n\nbody', + styleSheet: styleSheet, + ), + ), + ); + + // The first Text is the header, the second is the paragraph body. + final List texts = tester.widgetList(find.byType(Text)).toList(); + expect(texts.length, 2); + + final Text headerText = texts.first; + final Text bodyText = texts.last; + + expect(headerText.strutStyle, isNotNull); + expect(headerText.strutStyle!.forceStrutHeight, isTrue); + expect(headerText.strutStyle!.fontSize, headerFontSize); + + expect(bodyText.strutStyle, isNotNull); + expect(bodyText.strutStyle!.fontSize, paragraphFontSize); + + // The header strut must be taller than the paragraph strut, otherwise + // the header text would be vertically clipped. + expect(headerText.strutStyle!.fontSize! > bodyText.strutStyle!.fontSize!, isTrue); + }, + ); + }); }