diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 394415a8be..5995cdcbfe 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -46,21 +46,19 @@ class _ThemeSetting extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final globalSettings = GlobalStoreWidget.settingsOf(context); - return Column( - children: [ - ListTile(title: Text(zulipLocalizations.themeSettingTitle)), - for (final themeSettingOption in [null, ...ThemeSetting.values]) - RadioListTile.adaptive( - title: Text(ThemeSetting.displayName( - themeSetting: themeSettingOption, - zulipLocalizations: zulipLocalizations)), - value: themeSettingOption, - // TODO(#1545) stop using the deprecated members - // ignore: deprecated_member_use - groupValue: globalSettings.themeSetting, - // ignore: deprecated_member_use - onChanged: (newValue) => _handleChange(context, newValue)), - ]); + return RadioGroup( + groupValue: globalSettings.themeSetting, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column( + children: [ + ListTile(title: Text(zulipLocalizations.themeSettingTitle)), + for (final themeSettingOption in [null, ...ThemeSetting.values]) + RadioListTile.adaptive( + title: Text(ThemeSetting.displayName( + themeSetting: themeSettingOption, + zulipLocalizations: zulipLocalizations)), + value: themeSettingOption), + ])); } } @@ -135,19 +133,17 @@ class VisitFirstUnreadSettingPage extends StatelessWidget { final globalSettings = GlobalStoreWidget.settingsOf(context); return Scaffold( appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)), - body: Column(children: [ - ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), - for (final value in VisitFirstUnreadSetting.values) - RadioListTile.adaptive( - title: Text(_valueDisplayName(value, - zulipLocalizations: zulipLocalizations)), - value: value, - // TODO(#1545) stop using the deprecated members - // ignore: deprecated_member_use - groupValue: globalSettings.visitFirstUnread, - // ignore: deprecated_member_use - onChanged: (newValue) => _handleChange(context, newValue)), - ])); + body: RadioGroup( + groupValue: globalSettings.visitFirstUnread, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.initialAnchorSettingDescription)), + for (final value in VisitFirstUnreadSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + value: value), + ]))); } } @@ -210,24 +206,22 @@ class MarkReadOnScrollSettingPage extends StatelessWidget { final globalSettings = GlobalStoreWidget.settingsOf(context); return Scaffold( appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)), - body: Column(children: [ - ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)), - for (final value in MarkReadOnScrollSetting.values) - RadioListTile.adaptive( - title: Text(_valueDisplayName(value, - zulipLocalizations: zulipLocalizations)), - subtitle: () { - final result = _valueDescription(value, - zulipLocalizations: zulipLocalizations); - return result == null ? null : Text(result); - }(), - value: value, - // TODO(#1545) stop using the deprecated members - // ignore: deprecated_member_use - groupValue: globalSettings.markReadOnScroll, - // ignore: deprecated_member_use - onChanged: (newValue) => _handleChange(context, newValue)), - ])); + body: RadioGroup( + groupValue: globalSettings.markReadOnScroll, + onChanged: (newValue) => _handleChange(context, newValue), + child: Column(children: [ + ListTile(title: Text(zulipLocalizations.markReadOnScrollSettingDescription)), + for (final value in MarkReadOnScrollSetting.values) + RadioListTile.adaptive( + title: Text(_valueDisplayName(value, + zulipLocalizations: zulipLocalizations)), + subtitle: () { + final result = _valueDescription(value, + zulipLocalizations: zulipLocalizations); + return result == null ? null : Text(result); + }(), + value: value), + ]))); } } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 1bafd6636f..c6e31bb1fa 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -249,9 +249,3 @@ extension IconButtonChecks on Subject { extension SwitchListTileChecks on Subject { Subject get value => has((x) => x.value, 'value'); } - -extension RadioListTileChecks on Subject> { - // TODO(#1545) stop using the deprecated member - // ignore: deprecated_member_use - Subject get checked => has((x) => x.checked, 'checked'); -} diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 93e24dffdd..67c53212cf 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -32,6 +32,8 @@ extension GlobalSettingsStoreChecks on Subject { Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); + Subject get visitFirstUnread => has((x) => x.visitFirstUnread, 'visitFirstUnread'); + Subject get markReadOnScroll => has((x) => x.markReadOnScroll, 'markReadOnScroll'); Subject getBool(BoolGlobalSetting setting) => has((x) => x.getBool(setting), 'getBool(${setting.name}'); } diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 96fd62feeb..f48584be26 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -1,35 +1,70 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/settings.dart'; +import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/store_checks.dart'; import '../example_data.dart' as eg; +import '../test_navigation.dart'; +import 'page_checks.dart'; import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + late TestNavigatorObserver testNavObserver; + late Route? lastPushedRoute; + late Route? lastPoppedRoute; + Future prepare(WidgetTester tester) async { addTearDown(testBinding.reset); + testNavObserver = TestNavigatorObserver() + ..onPushed = ((route, _) => lastPushedRoute = route) + ..onPopped = ((route, _) => lastPoppedRoute = route); + lastPushedRoute = null; + lastPoppedRoute = null; + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, + navigatorObservers: [testNavObserver], child: SettingsPage())); await tester.pump(); await tester.pump(); } - group('ThemeSetting', () { - Finder findRadioListTileWithTitle(String title) => find.ancestor( - of: find.text(title), - matching: find.byType(RadioListTile)); + void checkTileOnSettingsPage(WidgetTester tester, { + required String expectedTitle, + required String expectedSubtitle, + }) { + check(find.descendant(of: find.widgetWithText(ListTile, expectedTitle), + matching: find.text(expectedSubtitle))).findsOne(); + } + + Finder findRadioListTileWithTitle(String title) => find.ancestor( + of: find.text(title), + matching: find.byType(RadioListTile)); + + void checkRadioButtonAppearsChecked(WidgetTester tester, + String title, bool expectedIsChecked, {String? subtitle}) { + check(tester.semantics.find(findRadioListTileWithTitle(title))) + .containsSemantics( + label: subtitle == null + ? title + : '$title\n$subtitle', + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, isChecked: expectedIsChecked); + } + group('ThemeSetting', () { void checkThemeSetting(WidgetTester tester, { required ThemeSetting? expectedThemeSetting, }) { @@ -39,9 +74,7 @@ void main() { ThemeSetting.dark => 'Dark', }; for (final title in ['System', 'Light', 'Dark']) { - check(tester.widget>( - findRadioListTileWithTitle(title))) - .checked.equals(title == expectedCheckedTitle); + checkRadioButtonAppearsChecked(tester, title, title == expectedCheckedTitle); } check(testBinding.globalStore) .settings.themeSetting.equals(expectedThemeSetting); @@ -56,13 +89,13 @@ void main() { check(Theme.of(element)).brightness.equals(Brightness.light); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light); - await tester.tap(findRadioListTileWithTitle('Dark')); + await tester.tap(findRadioListTileWithTitle('Dark')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.dark); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.dark); - await tester.tap(findRadioListTileWithTitle('System')); + await tester.tap(findRadioListTileWithTitle('System')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.light); @@ -127,7 +160,140 @@ void main() { }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); }); - // TODO(#1571): test visitFirstUnread setting UI + group('VisitFirstUnreadSetting', () { + String settingTitle(VisitFirstUnreadSetting setting) => switch (setting) { + VisitFirstUnreadSetting.always => 'First unread message', + VisitFirstUnreadSetting.conversations => 'First unread message in conversation views, newest message elsewhere', + VisitFirstUnreadSetting.never => 'Newest message', + }; + + void checkPage(WidgetTester tester, { + required VisitFirstUnreadSetting expectedSetting, + }) { + for (final setting in VisitFirstUnreadSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, setting == expectedSetting); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.conversations)); + + await tester.tap(find.text('Open message feeds at')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(VisitFirstUnreadSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: VisitFirstUnreadSetting.never); + + await tester.tap(find.backButton()); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .visitFirstUnread.equals(VisitFirstUnreadSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Open message feeds at', + expectedSubtitle: settingTitle(VisitFirstUnreadSetting.never)); + }); + }); + + group('MarkReadOnScrollSetting', () { + String settingTitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => 'Always', + MarkReadOnScrollSetting.conversations => 'Only in conversation views', + MarkReadOnScrollSetting.never => 'Never', + }; + + String? settingSubtitle(MarkReadOnScrollSetting setting) => switch (setting) { + MarkReadOnScrollSetting.always => null, + MarkReadOnScrollSetting.conversations => + 'Messages will be automatically marked as read only when viewing a single topic or direct message conversation.', + MarkReadOnScrollSetting.never => null, + }; + + void checkPage(WidgetTester tester, { + required MarkReadOnScrollSetting expectedSetting, + }) { + for (final setting in MarkReadOnScrollSetting.values) { + final thisSettingTitle = settingTitle(setting); + checkRadioButtonAppearsChecked(tester, + thisSettingTitle, + setting == expectedSetting, + subtitle: settingSubtitle(setting)); + } + } + + testWidgets('smoke', (tester) async { + await prepare(tester); + + // "conversations" is the default, and it appears in the SettingsPage + // (as the setting tile's subtitle) + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.conversations); + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.conversations)); + + await tester.tap(find.text('Mark messages as read on scroll')); + await tester.pump(); + check(lastPushedRoute).isA() + .page.isA(); + await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.always))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.always); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.conversations))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); + + await tester.tap(findRadioListTileWithTitle( + settingTitle(MarkReadOnScrollSetting.never))); + await tester.pump(); + checkPage(tester, expectedSetting: MarkReadOnScrollSetting.never); + + await tester.tap(find.byType(BackButton)); + check(lastPoppedRoute).isA() + .page.isA(); + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(GlobalStoreWidget.settingsOf(tester.element(find.byType(SettingsPage)))) + .markReadOnScroll.equals(MarkReadOnScrollSetting.never); + + checkTileOnSettingsPage(tester, + expectedTitle: 'Mark messages as read on scroll', + expectedSubtitle: settingTitle(MarkReadOnScrollSetting.never)); + }); + }); // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings // Or maybe not; after all, it's a developer-facing feature, so