From 1c90c3801deed686fac31218906a653a4ca15279 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:36:37 +0100 Subject: [PATCH 1/9] Add integration test suite and modernize tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — test infrastructure: - Add example/integration_test/ with an on-device suite covering link taps, scrolling, GFM checkboxes, text selection, and example app launch, plus a reusable markdown_harness.dart. - Add scripts/integration_test.sh and a parallel Android emulator CI job (reactivecircus/android-emulator-runner) alongside the existing macOS gate. - Exclude the locally symlinked flutter_markdown_plus_latex package from analysis, git, and publishing so it no longer breaks `flutter analyze`. Phase 2 — modernization: - Migrate linting to package:flutter_lints ^6, retaining strict type analysis and required public API documentation. - Bump markdown to ^7.3.1, mockito to ^5.7.0, and pin leak_tracker_flutter_testing. - Align the example app's Flutter constraint with the package and add a documentation link to the pubspec. - Bump version to 1.0.8 and update the changelog. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/flutter.yaml | 28 +- .gitignore | 5 + .pubignore | 3 + CHANGELOG.md | 9 + analysis_options.yaml | 246 ++---------------- example/android/gradle.properties | 4 + example/integration_test/app_render_test.dart | 88 +++++++ .../integration_test/markdown_harness.dart | 41 +++ example/pubspec.lock | 83 +++++- example/pubspec.yaml | 4 +- lib/flutter_markdown_plus.dart | 2 +- pubspec.lock | 66 +++-- pubspec.yaml | 10 +- scripts/integration_test.sh | 19 ++ 14 files changed, 336 insertions(+), 272 deletions(-) create mode 100644 example/integration_test/app_render_test.dart create mode 100644 example/integration_test/markdown_harness.dart create mode 100755 scripts/integration_test.sh 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..4edc3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.0.8 + +* 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 +* Excluded the locally symlinked `flutter_markdown_plus_latex` package from analysis and publishing + ## 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/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/pubspec.lock b/example/pubspec.lock index b522d74..564800c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -29,10 +29,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: @@ -57,23 +57,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_markdown_plus: dependency: "direct main" description: path: ".." relative: true source: path - version: "1.0.7" + version: "1.0.8" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" leak_tracker: dependency: transitive description: @@ -102,34 +125,34 @@ 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: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -138,6 +161,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" sky_engine: dependency: transitive description: flutter @@ -175,6 +214,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -187,10 +234,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" vector_math: dependency: transitive description: @@ -207,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" sdks: - dart: ">=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/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 From b78a3fcc65185ca1874b588a5ee224fe0e4dc584 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:39:17 +0100 Subject: [PATCH 2/9] Fix hr tag ignoring custom builders and paddingBuilders (#131) The `hr` block previously always rendered the default horizontal-rule Container, overriding any custom `builders['hr']` output, and never applied `paddingBuilders['hr']`. Skip the default Container when a custom builder is registered, and wrap the hr child in the configured padding. Recreated from PR #131 by @joelbrostrom. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/builder.dart | 11 +++++- test/horizontal_rule_test.dart | 66 +++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 31a7c95..86f6a89 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -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); 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); + } +} From 6c59dca76ba341b0615f5df0c2ec812a9add4c4c Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:42:31 +0100 Subject: [PATCH 3/9] Fix strong text changing paragraph line height (#130) Bold (strong) spans previously altered the line height of a paragraph because the rich text used the per-span font metrics. Build a forced strut height from the paragraph style and pass it to both the SelectableText.rich and Text.rich constructors so line height stays consistent regardless of font weight. Recreated from PR #130 by @szuwest. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/builder.dart | 14 ++++++++++++++ test/text_test.dart | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 86f6a89..97e476f 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -989,11 +989,24 @@ 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); + // Build strutStyle from paragraph style to enforce consistent line height + // across spans with different font weights. + final TextStyle? pStyle = styleSheet.p; + final StrutStyle? strutStyle = pStyle != null + ? StrutStyle( + fontFamily: pStyle.fontFamily, + fontSize: pStyle.fontSize, + height: pStyle.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) @@ -1006,6 +1019,7 @@ class MarkdownBuilder implements md.NodeVisitor { text, textScaler: styleSheet.textScaler, textAlign: textAlign ?? TextAlign.start, + strutStyle: strutStyle, key: k, ); } diff --git a/test/text_test.dart b/test/text_test.dart index 72f0efc..51f5cd5 100644 --- a/test/text_test.dart +++ b/test/text_test.dart @@ -397,4 +397,34 @@ 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 paragraph 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); + }, + ); + }); } From 3bbbc6810c6a813a0ccd5785b6f77583fa5fb59e Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:45:47 +0100 Subject: [PATCH 4/9] Derive paragraph strut from the span's own style (#130) _buildRichText is the generic rich-text builder, also used for headers, blockquotes and list items. Deriving the strut from styleSheet.p forced large headers down to the paragraph line height, vertically clipping them. Build the strut from the span's own base style instead, falling back to styleSheet.p for size/height when the span omits them. This keeps headers/blockquotes at their correct height while still keeping line height consistent across mixed font weights within a block. Adds a regression test proving a header's strut follows the header size, not the (smaller) paragraph size. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/builder.dart | 15 ++++++------- test/text_test.dart | 50 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 97e476f..9150d7c 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -989,14 +989,15 @@ 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); - // Build strutStyle from paragraph style to enforce consistent line height - // across spans with different font weights. - final TextStyle? pStyle = styleSheet.p; - final StrutStyle? strutStyle = pStyle != null + // 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: pStyle.fontFamily, - fontSize: pStyle.fontSize, - height: pStyle.height, + fontFamily: baseStyle.fontFamily, + fontSize: baseStyle.fontSize ?? styleSheet.p?.fontSize, + height: baseStyle.height ?? styleSheet.p?.height, leading: 0, forceStrutHeight: true, ) diff --git a/test/text_test.dart b/test/text_test.dart index 51f5cd5..4ba6839 100644 --- a/test/text_test.dart +++ b/test/text_test.dart @@ -403,8 +403,8 @@ void defineTests() { '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 paragraph style so the - // line height is consistent regardless of font weight (see PR #130). + // 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), @@ -426,5 +426,51 @@ void defineTests() { 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); + }, + ); }); } From 5a726f513a298ba463a8c19efce95604cf77540b Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:50:13 +0100 Subject: [PATCH 5/9] Add noScroll option to Markdown widget (#38) Recreated from PR #38 by @senlinjun (functional change only; the PR's whole-file 80-col reformatting was dropped, and the field doc comment corrected). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/widget.dart | 13 +++++++++++ test/scrollable_test.dart | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) 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/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); + }, + ); }); } From 277f6397607ad88377b81bd75b83d2bb51bc1f37 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:55:16 +0100 Subject: [PATCH 6/9] Fix inline formatting in blockquotes and theme blockquote styling (#128, #42) Recreated from PR #128 by @vsaase (blockquote inline bold/italic/link rendering + tests) and PR #42 by @domhel (theme-aware blockquote decoration); contributor lockfile changes dropped. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/builder.dart | 4 ++-- lib/src/style_sheet.dart | 24 ++++++++++++++++++------ test/blockquote_test.dart | 39 ++++++++++++++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 9150d7c..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, ), @@ -735,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), )); } } 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/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); }, ); }); From 881599c72401a27ba0c7ed01d4ce434f6891c858 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 10:56:36 +0100 Subject: [PATCH 7/9] Update changelog for 1.0.8 with integrated PR fixes Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edc3b3..6899a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## 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 @@ -7,6 +12,13 @@ * Aligned the example app's Flutter constraint with the package and added a `documentation` link to the pubspec * 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 From d19f24b0edcabace8c111afd7961821f7567dfe5 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 25 May 2026 11:26:22 +0100 Subject: [PATCH 8/9] Fix example Android build: bump AGP to 8.6.0, drop GCP artifact registry plugin The example app pinned Android Gradle Plugin 8.2.1, below Flutter 3.44's minimum of 8.6.0, which broke `flutter run`/`flutter build apk`. Also removed the unused com.google.cloud.artifactregistry Gradle plugin, a Google-internal build dependency not needed for a public example app. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + example/android/settings.gradle | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6899a9b..ccb1127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * 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:** 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" From 9a0676978312bf9ac279de397d54d8374f7e867d Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 6 Jul 2026 09:08:34 +0100 Subject: [PATCH 9/9] More updates to get the tests runging --- example/ios/Flutter/AppFrameworkInfo.plist | 2 -- example/ios/Runner.xcodeproj/project.pbxproj | 28 +++++++++++++++++-- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) 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 @@ + + + + + + + + + +