Skip to content

Commit f7669ea

Browse files
committed
ui: Set letter spacing to 1% in buttons
It's a bit annoying that we have to repeat all of elevatedButtonTheme filledButtonTheme menuButtonTheme outlinedButtonTheme segmentedButtonTheme textButtonTheme but anyway, here we are; I don't think I've left any out. [ThemeData.buttonTheme] *sounds* like it might be one central place to define button text's letter spacing...but on a closer look, it doesn't allow setting letter spacing, and moreover it's deprecated, with these various FooButtonThemes meant to replace it. Empirically, the mark-all-as-read button (a [FilledButton.icon]) ignores the TextStyle in the ambient FilledButtonThemeData unless explicitly told to use it; that's because we use the `textStyle` param. So, explicitly merge that in, and adjust some tests (action_sheet_test, autocomplete_test, message_list_test) that weren't providing the FilledButtonThemeData so that they do provide it. ...hmm, but for that specific button, the Figma asks for a specific font size, which differs from the default `labelLarge`. That means we need to set `letterSpacing` right alongside `fontSize`, because the letter spacing should be proportional to the font size. Having done so, the restatement of the TextStyle in FilledButtonThemeData is now completely redundant. Shrug...I guess... if we add to those theme styles, we'll want them to be merged in, as done here, and that seems really easy to forget to do. Fixes: #548
1 parent dd97594 commit f7669ea

File tree

8 files changed

+190
-39
lines changed

8 files changed

+190
-39
lines changed

lib/widgets/app.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
128128
(_) => widget._declareReady());
129129
}
130130
GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context);
131-
return child!;
131+
return Theme(data: innerThemeData(context),
132+
child: child!);
132133
},
133134

134135
// We use onGenerateInitialRoutes for the real work of specifying the

lib/widgets/message_list.dart

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'profile.dart';
2222
import 'sticky_header.dart';
2323
import 'store.dart';
2424
import 'text.dart';
25+
import 'theme.dart';
2526

2627
class MessageListPage extends StatefulWidget {
2728
const MessageListPage({super.key, required this.narrow});
@@ -458,6 +459,11 @@ class MarkAsReadWidget extends StatelessWidget {
458459

459460
@override
460461
Widget build(BuildContext context) {
462+
final themeData = Theme.of(context);
463+
assert(themeData.filledButtonTheme.style?.textStyle != null);
464+
final filledButtonThemeTextStyle =
465+
themeData.filledButtonTheme.style?.textStyle!.resolve({});
466+
assert(filledButtonThemeTextStyle != null);
461467
final zulipLocalizations = ZulipLocalizations.of(context);
462468
final store = PerAccountStoreWidget.of(context);
463469
final unreadCount = store.unreads.countInNarrow(narrow);
@@ -480,12 +486,13 @@ class MarkAsReadWidget extends StatelessWidget {
480486
backgroundColor: _UnreadMarker.color,
481487
minimumSize: const Size.fromHeight(38),
482488
textStyle:
483-
// Restate [FilledButton]'s default, which inherits from
484-
// [zulipTypography]…
485-
Theme.of(context).textTheme.labelLarge!
489+
// Restate [FilledButton]'s default…
490+
themeData.textTheme.labelLarge!.merge(filledButtonThemeTextStyle)
486491
// …then clobber some attributes to follow Figma:
487-
.merge(const TextStyle(
492+
.merge(TextStyle(
488493
fontSize: 18,
494+
letterSpacing: proportionalLetterSpacing(context,
495+
kButtonTextLetterSpacingProportion, baseFontSize: 18),
489496
height: (23 / 18))
490497
.merge(weightVariableTextStyle(context, wght: 400))),
491498
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),

lib/widgets/theme.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,44 @@ ThemeData outerThemeData(BuildContext context) {
3737
/// This is chosen as the sRGB midpoint of the Zulip logo's gradient.
3838
// As computed by Anders: https://github.com/zulip/zulip-mobile/pull/4467
3939
const kZulipBrandColor = Color.fromRGBO(0x64, 0x92, 0xfe, 1);
40+
41+
/// A [ThemeData] that depends on [outerThemeData] and copies it with additions.
42+
///
43+
/// [context] must be below a [Theme] that provides [outerThemeData],
44+
/// so that this can use [Theme.of] to synthesize a [ThemeData.textTheme]
45+
/// based on [outerThemeData]'s [ThemeData.typography].
46+
// TODO(upstream) nicer if this could return a [ThemeData] with just the added
47+
// fields, instead of having to call `copyWith` on `Theme.of(context)`.
48+
// Then callers could call a hypothetical ThemeData.merge method with it.
49+
// But such a method doesn't exist yet:
50+
// https://github.com/flutter/flutter/issues/43823
51+
ThemeData innerThemeData(BuildContext context) {
52+
final theme = Theme.of(context);
53+
assert(theme.textTheme.labelLarge!.debugLabel!.contains('zulipTypography'));
54+
return theme.copyWith(
55+
elevatedButtonTheme: ElevatedButtonThemeData( style: _commonButtonStyle(context)),
56+
filledButtonTheme: FilledButtonThemeData( style: _commonButtonStyle(context)),
57+
iconButtonTheme: IconButtonThemeData( style: _commonButtonStyle(context)),
58+
menuButtonTheme: MenuButtonThemeData( style: _commonButtonStyle(context)),
59+
outlinedButtonTheme: OutlinedButtonThemeData( style: _commonButtonStyle(context)),
60+
segmentedButtonTheme: SegmentedButtonThemeData(style: _commonButtonStyle(context)),
61+
textButtonTheme: TextButtonThemeData( style: _commonButtonStyle(context)),
62+
);
63+
}
64+
65+
double kButtonTextLetterSpacingProportion = 0.01;
66+
67+
ButtonStyle _commonButtonStyle(BuildContext context) {
68+
// labelLarge is the default for all kinds of buttons that can have text. See:
69+
// [ElevatedButton.defaultStyleOf], [FilledButton.defaultStyleOf],
70+
// [MenuItemButton.defaultStyleOf], [MenuItemButton.defaultStyleOf],
71+
// [OutlinedButton.defaultStyleOf], `defaults` in [SegmentedButton.build],
72+
// and [TextButton.defaultStyleOf].
73+
final fontSize = Theme.of(context).textTheme.labelLarge!.fontSize!;
74+
return ButtonStyle(
75+
textStyle: WidgetStatePropertyAll(TextStyle(
76+
// If changing `fontSize` or `letterSpacing`, change the other too.
77+
fontSize: fontSize,
78+
letterSpacing: proportionalLetterSpacing(context,
79+
kButtonTextLetterSpacingProportion, baseFontSize: fontSize))));
80+
}

test/flutter_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ extension ValueNotifierChecks<T> on Subject<ValueNotifier<T>> {
5757

5858
extension TextChecks on Subject<Text> {
5959
Subject<String?> get data => has((t) => t.data, 'data');
60+
Subject<TextStyle?> get style => has((t) => t.style, 'style');
6061
}
6162

6263
extension TextFieldChecks on Subject<TextField> {
@@ -65,6 +66,7 @@ extension TextFieldChecks on Subject<TextField> {
6566

6667
extension TextStyleChecks on Subject<TextStyle> {
6768
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
69+
Subject<double?> get fontSize => has((t) => t.fontSize, 'fontSize');
6870
Subject<FontWeight?> get fontWeight => has((t) => t.fontWeight, 'fontWeight');
6971
Subject<double?> get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing');
7072
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');

test/widgets/action_sheet_test.dart

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import 'dart:convert';
33
import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter/services.dart';
6-
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
76
import 'package:flutter_test/flutter_test.dart';
87
import 'package:http/http.dart' as http;
98
import 'package:zulip/api/model/model.dart';
@@ -12,11 +11,11 @@ import 'package:zulip/model/compose.dart';
1211
import 'package:zulip/model/localizations.dart';
1312
import 'package:zulip/model/narrow.dart';
1413
import 'package:zulip/model/store.dart';
14+
import 'package:zulip/widgets/app.dart';
1515
import 'package:zulip/widgets/compose_box.dart';
1616
import 'package:zulip/widgets/content.dart';
1717
import 'package:zulip/widgets/icons.dart';
1818
import 'package:zulip/widgets/message_list.dart';
19-
import 'package:zulip/widgets/store.dart';
2019
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
2120
import '../api/fake_api.dart';
2221

@@ -56,14 +55,12 @@ Future<void> setupToMessageActionSheet(WidgetTester tester, {
5655
messages: [message],
5756
).toJson());
5857

59-
await tester.pumpWidget(
60-
MaterialApp(
61-
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
62-
supportedLocales: ZulipLocalizations.supportedLocales,
63-
home: GlobalStoreWidget(
64-
child: PerAccountStoreWidget(
65-
accountId: eg.selfAccount.id,
66-
child: MessageListPage(narrow: narrow)))));
58+
59+
await tester.pumpWidget(const ZulipApp());
60+
await tester.pump();
61+
final navigator = await ZulipApp.navigator;
62+
navigator.push(MessageListPage.buildRoute(accountId: eg.selfAccount.id,
63+
narrow: narrow));
6764

6865
// global store, per-account store, and message list get loaded
6966
await tester.pumpAndSettle();

test/widgets/autocomplete_test.dart

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import 'package:checks/checks.dart';
22
import 'package:flutter/material.dart';
3-
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
43
import 'package:flutter_test/flutter_test.dart';
54
import 'package:zulip/api/model/model.dart';
65
import 'package:zulip/api/route/messages.dart';
76
import 'package:zulip/model/compose.dart';
87
import 'package:zulip/model/narrow.dart';
98
import 'package:zulip/model/store.dart';
9+
import 'package:zulip/widgets/app.dart';
1010
import 'package:zulip/widgets/message_list.dart';
11-
import 'package:zulip/widgets/store.dart';
1211

1312
import '../api/fake_api.dart';
1413
import '../example_data.dart' as eg;
@@ -48,18 +47,14 @@ Future<Finder> setupToComposeInput(WidgetTester tester, {
4847

4948
prepareBoringImageHttpClient();
5049

51-
await tester.pumpWidget(
52-
MaterialApp(
53-
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
54-
supportedLocales: ZulipLocalizations.supportedLocales,
55-
home: GlobalStoreWidget(
56-
child: PerAccountStoreWidget(
57-
accountId: eg.selfAccount.id,
58-
child: MessageListPage(
59-
narrow: DmNarrow(
60-
allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId],
61-
selfUserId: eg.selfUser.userId,
62-
))))));
50+
await tester.pumpWidget(const ZulipApp());
51+
await tester.pump();
52+
final navigator = await ZulipApp.navigator;
53+
navigator.push(MessageListPage.buildRoute(accountId: eg.selfAccount.id,
54+
narrow: DmNarrow(
55+
allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId],
56+
selfUserId: eg.selfUser.userId,
57+
)));
6358

6459
// global store, per-account store, and message list get loaded
6560
await tester.pumpAndSettle();

test/widgets/message_list_test.dart

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import 'dart:convert';
22

33
import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
5-
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
65
import 'package:flutter_test/flutter_test.dart';
76
import 'package:http/http.dart' as http;
87
import 'package:zulip/api/model/events.dart';
@@ -13,10 +12,10 @@ import 'package:zulip/api/route/messages.dart';
1312
import 'package:zulip/model/localizations.dart';
1413
import 'package:zulip/model/narrow.dart';
1514
import 'package:zulip/model/store.dart';
15+
import 'package:zulip/widgets/app.dart';
1616
import 'package:zulip/widgets/content.dart';
1717
import 'package:zulip/widgets/icons.dart';
1818
import 'package:zulip/widgets/message_list.dart';
19-
import 'package:zulip/widgets/store.dart';
2019

2120
import '../api/fake_api.dart';
2221
import '../example_data.dart' as eg;
@@ -64,14 +63,11 @@ void main() {
6463
connection.prepare(json:
6564
newestResult(foundOldest: foundOldest, messages: messages).toJson());
6665

67-
await tester.pumpWidget(
68-
MaterialApp(
69-
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
70-
supportedLocales: ZulipLocalizations.supportedLocales,
71-
home: GlobalStoreWidget(
72-
child: PerAccountStoreWidget(
73-
accountId: eg.selfAccount.id,
74-
child: MessageListPage(narrow: narrow)))));
66+
await tester.pumpWidget(const ZulipApp());
67+
await tester.pump();
68+
final navigator = await ZulipApp.navigator;
69+
navigator.push(MessageListPage.buildRoute(accountId: eg.selfAccount.id,
70+
narrow: narrow));
7571

7672
// global store, per-account store, and message list get loaded
7773
await tester.pumpAndSettle();

test/widgets/theme_test.dart

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter/rendering.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:zulip/widgets/text.dart';
6+
import 'package:zulip/widgets/theme.dart';
7+
8+
import '../flutter_checks.dart';
9+
10+
void main() {
11+
group('innerThemeData', () {
12+
testWidgets('smoke', (tester) async {
13+
ThemeData? outerThemeDataValue;
14+
ThemeData? value;
15+
await tester.pumpWidget(
16+
Builder(builder: (context) => MaterialApp(
17+
theme: outerThemeData(context),
18+
home: Builder(builder: (context) {
19+
outerThemeDataValue = Theme.of(context);
20+
return Theme(
21+
data: innerThemeData(context),
22+
child: Builder(builder: (context) {
23+
value = Theme.of(context);
24+
return const Placeholder();
25+
}));
26+
}))));
27+
28+
final outerElevatedButtonLetterSpacing = outerThemeDataValue
29+
!.elevatedButtonTheme.style?.textStyle?.resolve({})?.letterSpacing;
30+
final elevatedButtonLetterSpacing = value
31+
!.elevatedButtonTheme.style?.textStyle?.resolve({})?.letterSpacing;
32+
check(outerElevatedButtonLetterSpacing).isNull();
33+
check(elevatedButtonLetterSpacing).isNotNull();
34+
35+
// innerThemeData should extend outerThemeData using [ThemeData.copyWith]
36+
// (at least for now, lacking a ThemeData.merge method). Pick a value that
37+
// we set in outerThemeData and check that it hasn't been dropped.
38+
check(value!.scaffoldBackgroundColor)
39+
..equals(outerThemeDataValue!.scaffoldBackgroundColor)
40+
..equals(const Color(0xfff6f6f6));
41+
});
42+
43+
group('button text size and letter spacing', () {
44+
Future<void> testButtonLetterSpacing(
45+
String description, {
46+
required Widget Function(BuildContext context, String text) buttonBuilder,
47+
double? ambientTextScaleFactor,
48+
}) async {
49+
testWidgets(description, (WidgetTester tester) async {
50+
if (ambientTextScaleFactor != null) {
51+
tester.platformDispatcher.textScaleFactorTestValue = ambientTextScaleFactor;
52+
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
53+
}
54+
const buttonText = 'Zulip';
55+
double? expectedFontSize;
56+
double? expectedLetterSpacing;
57+
await tester.pumpWidget(
58+
Builder(builder: (context) => MaterialApp(
59+
theme: outerThemeData(context),
60+
home: Builder(builder: (context) {
61+
expectedFontSize = Theme.of(context).textTheme.labelLarge!.fontSize!;
62+
expectedLetterSpacing = proportionalLetterSpacing(context,
63+
0.01, baseFontSize: expectedFontSize!);
64+
return Theme(data: innerThemeData(context),
65+
child: Builder(builder: (context) =>
66+
buttonBuilder(context, buttonText)));
67+
}))));
68+
69+
final text = tester.renderObject<RenderParagraph>(find.text(buttonText)).text;
70+
check(text.style!)
71+
..fontSize.equals(expectedFontSize)
72+
..letterSpacing.equals(expectedLetterSpacing);
73+
});
74+
}
75+
76+
testButtonLetterSpacing('with device text size adjusted',
77+
ambientTextScaleFactor: 2.0,
78+
buttonBuilder: (context, text) => ElevatedButton(onPressed: () {},
79+
child: Text(text)));
80+
81+
testButtonLetterSpacing('ElevatedButton',
82+
buttonBuilder: (context, text) => ElevatedButton(onPressed: () {},
83+
child: Text(text)));
84+
85+
testButtonLetterSpacing('FilledButton',
86+
buttonBuilder: (context, text) => FilledButton(onPressed: () {},
87+
child: Text(text)));
88+
89+
// IconButton can't have text; skip
90+
91+
testButtonLetterSpacing('MenuItemButton',
92+
buttonBuilder: (context, text) => MenuItemButton(onPressed: () {},
93+
child: Text(text)));
94+
95+
testButtonLetterSpacing('SubmenuButton',
96+
buttonBuilder: (context, text) => SubmenuButton(menuChildren: const [],
97+
child: Text(text)));
98+
99+
testButtonLetterSpacing('OutlinedButton',
100+
buttonBuilder: (context, text) => OutlinedButton(onPressed: () {},
101+
child: Text(text)));
102+
103+
testButtonLetterSpacing('SegmentedButton',
104+
buttonBuilder: (context, text) => SegmentedButton(selected: const {1},
105+
segments: [ButtonSegment(value: 1, label: Text(text))]));
106+
107+
testButtonLetterSpacing('TextButton',
108+
buttonBuilder: (context, text) => TextButton(onPressed: () {},
109+
child: Text(text)));
110+
});
111+
});
112+
}

0 commit comments

Comments
 (0)