Repository: zulip/zulip-flutter (language: dart)

Issue:
content: Bold spans aren't bolder than surrounding text in spoiler headers or h1/h2/etc.
Bold spans should be bolder than surrounding text, even when that surrounding text is bold because it's in a spoiler header or an h1/h2/etc. (or column headings in tables, with #360).

| Current | Better |
| --- | --- |
| ![75927CB2-41BB-415B-9530-CE418DBB9368](https://github.com/zulip/zulip-flutter/assets/22248748/c21677b2-4b15-4513-8b70-16354ffe48ff) | ![445F7D01-3E38-4A19-B5CD-7746474655D5](https://github.com/zulip/zulip-flutter/assets/22248748/c4c49bcd-e103-47dd-9b94-af7ecc10b5ab) |

In fact, in the spoiler header, the bold text is _less_ bold. That's because, following web, we hard-code bold spans to weight 600 but spoiler headers to weight 700.




Discussion hints:
nan

Pull request: content: Make **bold** bolder even in spoiler headers and h1/h2/etc.
This fixes #705 by making boldness context-dependent, much like we did for some font sizes in #544.

That user-facing change is nice but fairly subtle. I'm also hoping this will be helpful by adding content tests for boldness in the first place; we didn't have any before. And I've set a pattern in the `UserMention` tests that should help reduce confusion when we write boldness tests for #647. (Confusion mainly related to the fact that `UserMention` is in a `WidgetSpan`.)

The need for more helpers in lib/widgets/text.dart is another reminder that it'll be great to have https://github.com/flutter/flutter/issues/148026 fixed upstream. But (as with the existing helpers there) I'm hoping this handles things in a way that makes it straightforward to migrate once that's done.

Fixes: #705

| Before | After |
| --- | --- |
| ![75927CB2-41BB-415B-9530-CE418DBB9368](https://github.com/zulip/zulip-flutter/assets/22248748/c21677b2-4b15-4513-8b70-16354ffe48ff) | ![445F7D01-3E38-4A19-B5CD-7746474655D5](https://github.com/zulip/zulip-flutter/assets/22248748/c4c49bcd-e103-47dd-9b94-af7ecc10b5ab) |

Test command: flutter test test/widgets/content_test.dart test/widgets/text_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 9e7008aca1..2e81a13ce6 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -252,6 +252,11 @@ class Heading extends StatelessWidget {
           fontSize: kBaseFontSize * emHeight,
           height: 1.4,
         )
+          // Could set boldness relative to ambient text style, which itself
+          // might be bolder than normal (e.g. in spoiler headers).
+          // But this didn't seem like a clear improvement and would make inline
+          // **bold** spans less distinct; discussion:
+          //   https://github.com/zulip/zulip-flutter/pull/706#issuecomment-2141326257
           .merge(weightVariableTextStyle(context, wght: 600)),
         node: node));
   }
@@ -723,6 +728,12 @@ class InlineContent extends StatelessWidget {
     required this.nodes,
   }) {
     assert(style.fontSize != null);
+    assert(
+      style.debugLabel!.contains('weightVariableTextStyle')
+      // ([ContentTheme.textStylePlainParagraph] applies [weightVariableTextStyle])
+      || style.debugLabel!.contains('ContentTheme.textStylePlainParagraph')
+      || style.debugLabel!.contains('bolderWghtTextStyle')
+    );
     _builder = _InlineContentBuilder(this);
   }
 
@@ -733,6 +744,7 @@ class InlineContent extends StatelessWidget {
   ///
   /// Must set [TextStyle.fontSize]. Some descendant spans will consume it,
   /// e.g., to make their content slightly smaller than surrounding text.
+  /// Similarly must set a font weight using [weightVariableTextStyle].
   final TextStyle style;
 
   final List<InlineContentNode> nodes;
@@ -832,7 +844,7 @@ class _InlineContentBuilder {
   }
 
   InlineSpan _buildStrong(StrongNode node) => _buildNodes(node.nodes,
-    style: weightVariableTextStyle(_context!, wght: 600));
+    style: bolderWghtTextStyle(widget.style, by: 200));
 
   InlineSpan _buildDeleted(DeletedNode node) => _buildNodes(node.nodes,
     style: const TextStyle(decoration: TextDecoration.lineThrough));
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 73093a4294..3f4389e592 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -1,4 +1,5 @@
 import 'dart:io';
+import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 
@@ -209,7 +210,7 @@ TextStyle weightVariableTextStyle(BuildContext context, {
   double? wght,
   double? wghtIfPlatformRequestsBold,
 }) {
-  double value = wght ?? FontWeight.normal.value.toDouble();
+  double value = wght ?? wghtFromFontWeight(FontWeight.normal);
   if (MediaQuery.boldTextOf(context)) {
     // The framework has a condition on [MediaQueryData.boldText]
     // in the [Text] widget, but that only affects `fontWeight`.
@@ -254,9 +255,51 @@ const kWghtMin = 1.0;
 const kWghtMax = 1000.0;
 
 /// A [FontVariation] "wght" value that's 300 above a given, clamped to [kWghtMax].
-double bolderWght(double baseWght) {
+///
+/// The input value must be between [kWghtMin] and [kWghtMax].
+///
+/// Pass [by] to use a value other than 300.
+double bolderWght(double baseWght, {double by = 300}) {
   assert(kWghtMin <= baseWght && baseWght <= kWghtMax);
-  return clampDouble(baseWght + 300, kWghtMin, kWghtMax);
+  return clampDouble(baseWght + by, kWghtMin, kWghtMax);
+}
+
+/// A [TextStyle] whose [FontVariation] "wght" and [TextStyle.fontWeight]
+/// have been raised using [bolderWght].
+///
+/// [style] must have already been processed with [weightVariableTextStyle],
+/// and [by] must be positive.
+///
+/// The increase done here will commute with any increase that was done by
+/// [weightVariableTextStyle] to respond to the device bold-text setting,
+/// because both adjustments are done with [bolderWght] with positive `by`.
+///
+/// [by] defaults to 300.
+TextStyle bolderWghtTextStyle(TextStyle style, {double by = 300}) {
+  assert(
+    style.debugLabel!.contains('weightVariableTextStyle')
+    // ([ContentTheme.textStylePlainParagraph] applies [weightVariableTextStyle])
+    || style.debugLabel!.contains('ContentTheme.textStylePlainParagraph')
+    || style.debugLabel!.contains('bolderWghtTextStyle')
+  );
+  assert(by > 0);
+  assert(style.fontVariations!.where((v) => v.axis == 'wght').length == 1);
+
+  final newWght = bolderWght(wghtFromTextStyle(style)!, by: by);
+
+  TextStyle result = style.copyWith(
+    fontVariations: style.fontVariations!.map((v) => v.axis == 'wght'
+      ? FontVariation('wght', newWght)
+      : v).toList(),
+    fontWeight: clampVariableFontWeight(newWght),
+  );
+
+  assert(() {
+    result = result.copyWith(debugLabel: 'bolderWghtTextStyle(by: $by)');
+    return true;
+  }());
+
+  return result;
 }
 
 /// Find the nearest [FontWeight] constant for a variable-font "wght"-axis value.
@@ -286,6 +329,23 @@ FontWeight clampVariableFontWeight(double wght) {
   }
 }
 
+/// A "wght" value extracted from a [TextStyle].
+///
+/// Returns the value in [TextStyle.fontVariations] if present.
+/// If that's absent but a [TextStyle.fontWeight] is present,
+/// returns [wghtFromFontWeight] for that value.
+///
+/// The returned value already reflects any response to the system bold-text
+/// setting, so if the [TextStyle] was built using [weightVariableTextStyle],
+/// this value might be larger than the `wght` that was passed to that function.
+double? wghtFromTextStyle(TextStyle style) {
+  double? result = style.fontVariations?.firstWhereOrNull((v) => v.axis == 'wght')?.value;
+  if (result == null && style.fontWeight != null) {
+    result = wghtFromFontWeight(style.fontWeight!);
+  }
+  return result;
+}
+
 /// A good guess at a font's "wght" value to match a given [FontWeight].
 ///
 /// Returns [FontWeight.value] as a double.


Test patch:
diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart
index 8c03d410ed..0a3fbcba91 100644
--- a/test/flutter_checks.dart
+++ b/test/flutter_checks.dart
@@ -77,6 +77,10 @@ extension TextStyleChecks on Subject<TextStyle> {
   // TODO others
 }
 
+extension FontVariationChecks on Subject<FontVariation> {
+  Subject<String> get axis => has((x) => x.axis, 'axis');
+  Subject<double> get value => has((x) => x.value, 'value');
+}
 
 extension TextThemeChecks on Subject<TextTheme> {
   Subject<TextStyle?> get displayLarge => has((t) => t.displayLarge, 'displayLarge');
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 110a85ae1a..26116f8214 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -14,6 +14,7 @@ import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/message_list.dart';
 import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';
+import 'package:zulip/widgets/text.dart';
 import 'package:zulip/widgets/theme.dart';
 
 import '../example_data.dart' as eg;
@@ -154,6 +155,41 @@ void main() {
     });
   }
 
+  /// Test the font weight found by [styleFinder] in the rendering of [content].
+  ///
+  /// The weight will be expected to be [expectedWght] when the system
+  /// bold-text setting is not set, and to vary appropriately when it is set.
+  ///
+  /// [styleFinder] must return the [TextStyle] containing the "wght"
+  /// (in [TextStyle.fontVariations]) and the [TextStyle.fontWeight]
+  /// to be checked.
+  Future<void> testFontWeight(String description, {
+    required Widget content,
+    required double expectedWght,
+    required TextStyle Function(WidgetTester tester) styleFinder,
+  }) async {
+    for (final platformRequestsBold in [false, true]) {
+      testWidgets(
+        description + (platformRequestsBold ? ' (platform requests bold)' : ''),
+        (tester) async {
+          tester.platformDispatcher.accessibilityFeaturesTestValue =
+            FakeAccessibilityFeatures(boldText: platformRequestsBold);
+          await prepareContent(tester, content);
+          final style = styleFinder(tester);
+          double effectiveExpectedWght = expectedWght;
+          if (platformRequestsBold) {
+            // bolderWght because that's what [weightVariableTextStyle] uses
+            effectiveExpectedWght = bolderWght(expectedWght);
+          }
+          check(style)
+            ..fontVariations.isNotNull()
+              .any((it) => it..axis.equals('wght')..value.equals(effectiveExpectedWght))
+            ..fontWeight.equals(clampVariableFontWeight(effectiveExpectedWght));
+          tester.platformDispatcher.clearAccessibilityFeaturesTestValue();
+        });
+    }
+  }
+
   group('ThematicBreak', () {
     testWidgets('smoke ThematicBreak', (tester) async {
       await prepareContent(tester, plainContent(ContentExample.thematicBreak.html));
@@ -469,7 +505,69 @@ void main() {
     check((ratioInHeader - ratioInParagraph).abs()).isLessThan(0.001);
   }
 
-  testContentSmoke(ContentExample.strong);
+  group('strong (bold)', () {
+    testContentSmoke(ContentExample.strong);
+
+    TextStyle findWordBold(WidgetTester tester) {
+      final root = tester.renderObject<RenderParagraph>(find.textContaining('bold')).text;
+      return mergedStyleOfSubstring(root, 'bold')!;
+    }
+
+    testFontWeight('in plain paragraph',
+      expectedWght: 600,
+      // **bold**
+      content: plainContent('<p><strong>bold</strong></p>'),
+      styleFinder: findWordBold,
+    );
+
+    for (final level in HeadingLevel.values) {
+      final name = level.name;
+      assert(RegExp(r'^h[1-6]$').hasMatch(name));
+      testFontWeight('in $name',
+        expectedWght: 800,
+        // # **bold**, ## **bold**, ### **bold**, etc.
+        content: plainContent('<$name><strong>bold</strong></$name>'),
+        styleFinder: findWordBold,
+      );
+    }
+
+    testFontWeight('in different kind of span in h1',
+      expectedWght: 800,
+      // # ~~**bold**~~
+      content: plainContent('<h1><del><strong>bold</strong></del></h1>'),
+      styleFinder: findWordBold,
+    );
+
+    testFontWeight('in spoiler header',
+      expectedWght: 900,
+      // ```spoiler regular **bold**
+      // content
+      // ```
+      content: plainContent(
+        '<div class="spoiler-block"><div class="spoiler-header">\n'
+          '<p>regular <strong>bold</strong></p>\n'
+          '</div><div class="spoiler-content" aria-hidden="true">\n'
+          '<p>content</p>\n'
+          '</div></div>'
+      ),
+      styleFinder: findWordBold,
+    );
+
+    testFontWeight('in different kind of span in spoiler header',
+      expectedWght: 900,
+      // ```spoiler *italic **bold***
+      // content
+      // ```
+      content: plainContent(
+        '<div class="spoiler-block"><div class="spoiler-header">\n'
+          '<p><em>italic <strong>bold</strong></em></p>\n'
+          '</div><div class="spoiler-content" aria-hidden="true">\n'
+          '<p>content</p>\n'
+          '</div></div>'
+      ),
+      styleFinder: findWordBold,
+    );
+  });
 
   testContentSmoke(ContentExample.emphasis);
 
@@ -489,25 +587,63 @@ void main() {
     testContentSmoke(ContentExample.groupMentionPlain);
     testContentSmoke(ContentExample.groupMentionSilent);
 
+    UserMention? findUserMentionInSpan(InlineSpan rootSpan) {
+      UserMention? result;
+      rootSpan.visitChildren((span) {
+        if (span case (WidgetSpan(child: UserMention() && var widget))) {
+          result = widget;
+          return false;
+        }
+        return true;
+      });
+      return result;
+    }
+
+    TextStyle textStyleFromWidget(WidgetTester tester, UserMention widget, String mentionText) {
+      final fullNameSpan = tester.renderObject<RenderParagraph>(
+        find.descendant(
+          of: find.byWidget(widget), matching: find.text(mentionText))
+      ).text;
+      return mergedStyleOfSubstring(fullNameSpan, mentionText)!;
+    }
+
     testWidgets('maintains font-size ratio with surrounding text', (tester) async {
       await checkFontSizeRatio(tester,
         targetHtml: '<span class="user-mention" data-user-id="13313">@Chris Bobbe</span>',
         targetFontSizeFinder: (rootSpan) {
-          late final double result;
-          rootSpan.visitChildren((span) {
-            if (span case WidgetSpan(child: UserMention() && var widget)) {
-              final fullNameSpan = tester.renderObject<RenderParagraph>(
-                find.descendant(
-                  of: find.byWidget(widget), matching: find.text('@Chris Bobbe'))
-              ).text;
-              result = mergedStyleOfSubstring(fullNameSpan, '@Chris Bobbe')!.fontSize!;
-              return false;
-            }
-            return true;
-          });
-          return result;
+          final widget = findUserMentionInSpan(rootSpan);
+          final style = textStyleFromWidget(tester, widget!, '@Chris Bobbe');
+          return style.fontSize!;
         });
     });
+
+    testFontWeight('silent or non-self mention in plain paragraph',
+      expectedWght: 400,
+      // @_**Greg Price**
+      content: plainContent(
+        '<p><span class="user-mention silent" data-user-id="2187">Greg Price</span></p>'),
+      styleFinder: (tester) {
+        return textStyleFromWidget(tester,
+          tester.widget(find.byType(UserMention)), 'Greg Price');
+      });
+
+    // TODO(#647):
+    //  testFontWeight('non-silent self-user mention in plain paragraph',
+    //    expectedWght: 600, // [etc.]
+
+    testFontWeight('silent or non-self mention in bold context',
+      expectedWght: 600,
+      // # @_**Chris Bobbe**
+      content: plainContent(
+        '<h1><span class="user-mention silent" data-user-id="13313">Chris Bobbe</span></h1>'),
+      styleFinder: (tester) {
+        return textStyleFromWidget(tester,
+          tester.widget(find.byType(UserMention)), 'Chris Bobbe');
+      });
+
+    // TODO(#647):
+    //  testFontWeight('non-silent self-user mention in bold context',
+    //    expectedWght: 800, // [etc.]
   });
 
   Future<void> tapText(WidgetTester tester, Finder textFinder) async {
diff --git a/test/widgets/text_test.dart b/test/widgets/text_test.dart
index 1a956345d6..824bc574eb 100644
--- a/test/widgets/text_test.dart
+++ b/test/widgets/text_test.dart
@@ -1,4 +1,5 @@
 import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/widgets/text.dart';
@@ -151,10 +152,98 @@ void main() {
   });
 
   test('bolderWght', () {
+    check(() => bolderWght(kWghtMin - 1)).throws<void>();
+    check(() => bolderWght(kWghtMax + 1)).throws<void>();
+
     check(bolderWght(1)).equals(301);
     check(bolderWght(400)).equals(700);
     check(bolderWght(600)).equals(900);
     check(bolderWght(900)).equals(1000);
+
+    check(bolderWght(1,    by: -200)).equals(1);
+    check(bolderWght(201,  by: -200)).equals(1);
+    check(bolderWght(1000, by: -200)).equals(800);
+
+    check(bolderWght(1,   by: 200)).equals(201);
+    check(bolderWght(400, by: 200)).equals(600);
+    check(bolderWght(600, by: 200)).equals(800);
+    check(bolderWght(900, by: 200)).equals(1000);
+  });
+
+  group('bolderWghtTextStyle', () {
+    Future<void> testBolderWghtTextStyle(
+      String description, {
+      required TextStyle Function(BuildContext context) makeStyle,
+      bool platformRequestsBold = false,
+      required double expectedWght,
+      required FontWeight expectedFontWeight,
+    }) async {
+      testWidgets(description, (WidgetTester tester) async {
+        tester.platformDispatcher.accessibilityFeaturesTestValue =
+          FakeAccessibilityFeatures(boldText: platformRequestsBold);
+
+        await tester.pumpWidget(
+          MaterialApp(
+            home: Builder(builder: (context) =>
+              Text('', style: makeStyle(context)))));
+
+        final TextStyle? style = tester.widget<Text>(find.byType(Text)).style;
+
+        check(style).isNotNull().fontWeight.isNotNull().equals(expectedFontWeight);
+
+        final fontVariations = style!.fontVariations;
+        check(fontVariations).isNotNull();
+        final wghtVariation = fontVariations!.singleWhereOrNull((v) => v.axis == 'wght');
+        check(wghtVariation).isNotNull().value.equals(expectedWght);
+
+        tester.platformDispatcher.clearAccessibilityFeaturesTestValue();
+      });
+    }
+
+    testBolderWghtTextStyle('default + default',
+      makeStyle: (context) => bolderWghtTextStyle(weightVariableTextStyle(context)),
+      expectedWght: 700,
+      expectedFontWeight: FontWeight.w700);
+
+    testBolderWghtTextStyle('default + default (platform requests bold)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(weightVariableTextStyle(context)),
+      expectedWght: 1000,
+      expectedFontWeight: FontWeight.w900);
+
+    testBolderWghtTextStyle('320 + 200',
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320),
+        by: 200,
+      ),
+      expectedWght: 520,
+      expectedFontWeight: FontWeight.w500);
+
+    testBolderWghtTextStyle('320 + 200 (platform requests bold)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320),
+        by: 200,
+      ),
+      expectedWght: 820,
+      expectedFontWeight: FontWeight.w800);
+
+    testBolderWghtTextStyle('320 + 200 (platform requests bold; custom response to setting)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320, wghtIfPlatformRequestsBold: 410),
+        by: 200,
+      ),
+      expectedWght: 610,
+      expectedFontWeight: FontWeight.w600);
+
+    testBolderWghtTextStyle('900 + 200',
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 900),
+        by: 200,
+      ),
+      expectedWght: 1000,
+      expectedFontWeight: FontWeight.w900);
   });
 
   test('clampVariableFontWeight: FontWeight has the assumed list of values', () {
@@ -209,6 +298,38 @@ void main() {
     check(clampVariableFontWeight(1000)) .equals(FontWeight.w900);
   });
 
+  test('wghtFromTextStyle', () {
+    doCheck(TextStyle style, double? expected) {
+      check(wghtFromTextStyle(style)).equals(expected);
+    }
+
+    doCheck(const TextStyle(), null);
+    doCheck(const TextStyle(fontVariations: []), null);
+    doCheck(const TextStyle(fontVariations: [FontVariation.slant(45)]), null);
+
+    doCheck(const TextStyle(fontVariations: [FontVariation('wght', 100)]), 100);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 160)],
+      fontWeight: FontWeight.w200,
+    ), 160);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 100), FontVariation('wght', 200)]
+    ), 100);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 100), FontVariation('wght', 100)],
+      fontWeight: FontWeight.w900,
+    ), 100);
+
+    doCheck(const TextStyle(
+      fontVariations: [],
+      fontWeight: FontWeight.w900,
+    ), 900);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation.slant(45)],
+      fontWeight: FontWeight.w900,
+    ), 900);
+  });
+
   group('proportionalLetterSpacing', () {
     Future<void> testLetterSpacing(
       String description, {