From b601bd598bd1be2ceb9e8eacc7ffe41302b886fb Mon Sep 17 00:00:00 2001 From: MdSaifAliMolla <145194907+MdSaifAliMolla@users.noreply.github.com> Date: Fri, 24 Oct 2025 17:44:04 +0530 Subject: [PATCH] settings-page UI redesign --- lib/widgets/settings.dart | 349 ++++++++++++++++++++++++-------- test/widgets/settings_test.dart | 49 +++-- 2 files changed, 282 insertions(+), 116 deletions(-) diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 5995cdcbfe..1e6f04c37e 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -3,8 +3,12 @@ import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/settings.dart'; import 'app_bar.dart'; +import 'button.dart'; +import 'icons.dart'; import 'page.dart'; import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -18,22 +22,121 @@ class SettingsPage extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); return Scaffold( - appBar: ZulipAppBar( - title: Text(zulipLocalizations.settingsPageTitle)), - body: Column(children: [ + appBar: ZulipAppBar(title: Text(zulipLocalizations.settingsPageTitle),centerTitle: true), + body: ListView(children: [ + const _SettingsHeader(title: 'THEME'), const _ThemeSetting(), - const _BrowserPreferenceSetting(), - const _VisitFirstUnreadSetting(), - const _MarkReadOnScrollSetting(), + const _SettingsItem( + title: 'Open links with in-app browser', + child: _BrowserPreferenceSetting()), + _SettingsNavitem( + title: 'Notifications', + onTap: () { + // TODO: Implement notifications settings page + }), + _SettingsNavitem( + title: 'Open message feeds at', + subtitle: VisitFirstUnreadSettingPage._valueDisplayName( + GlobalStoreWidget.settingsOf(context).visitFirstUnread, + zulipLocalizations: zulipLocalizations, + ), + onTap: () => Navigator.push(context, + VisitFirstUnreadSettingPage.buildRoute()), + ), + _SettingsNavitem( + title: 'Mark messages as read on scroll', + subtitle: MarkReadOnScrollSettingPage._valueDisplayName( + GlobalStoreWidget.settingsOf(context).markReadOnScroll, + zulipLocalizations: zulipLocalizations), + onTap: () => Navigator.push(context, + MarkReadOnScrollSettingPage.buildRoute()), + ), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) - ListTile( - title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), + _SettingsNavitem( + title: zulipLocalizations.experimentalFeatureSettingsPageTitle, onTap: () => Navigator.push(context, ExperimentalFeaturesPage.buildRoute())) ])); } } +class _SettingsHeader extends StatelessWidget { + const _SettingsHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16,8), + child: Text( + title, + style: TextStyle( + fontSize: 17, + ).merge(weightVariableTextStyle(context, wght: 600)), + ))); + }} + +class _SettingsItem extends StatelessWidget { + const _SettingsItem({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return Material( + color: Colors.transparent, + child: ListTile( + title: Text( + title, + style: TextStyle( + color: designVariables.contextMenuItemText, + fontSize: 20, + ).merge(weightVariableTextStyle(context, wght: 600))), + trailing: child, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + )); + } +} + +class _SettingsNavitem extends StatelessWidget { + const _SettingsNavitem({ + required this.title, + this.subtitle, + required this.onTap, + }); + + final String title; + final String? subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return Material( + color: Colors.transparent, + child: ListTile( + title: Text(title, + style: TextStyle( + color: designVariables.contextMenuItemText, + fontSize: 20, + ).merge(weightVariableTextStyle(context, wght: 600))), + subtitle: subtitle != null ? Text( + subtitle!, + style: TextStyle( + fontSize: 17, + ).merge(weightVariableTextStyle(context, wght: 400))) : null, + onTap: onTap, + trailing: Icon( + ZulipIcons.chevron_right, + color: designVariables.contextMenuItemIcon,), + ));} +} + class _ThemeSetting extends StatelessWidget { const _ThemeSetting(); @@ -46,18 +149,19 @@ class _ThemeSetting extends StatelessWidget { Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); final globalSettings = GlobalStoreWidget.settingsOf(context); - return RadioGroup( - groupValue: globalSettings.themeSetting, - onChanged: (newValue) => _handleChange(context, newValue), + final themeSetting = globalSettings.themeSetting; + return Material( + color: Colors.transparent, child: Column( children: [ - ListTile(title: Text(zulipLocalizations.themeSettingTitle)), - for (final themeSettingOption in [null, ...ThemeSetting.values]) - RadioListTile.adaptive( - title: Text(ThemeSetting.displayName( + for (final themeSettingOption in [ThemeSetting.dark, ThemeSetting.light, null]) + CustomRadioTile( + value: themeSettingOption, + groupValue: themeSetting, + label: ThemeSetting.displayName( themeSetting: themeSettingOption, - zulipLocalizations: zulipLocalizations)), - value: themeSettingOption), + zulipLocalizations: zulipLocalizations), + onChanged: (v) => _handleChange(context, v)), ])); } } @@ -74,30 +178,12 @@ class _BrowserPreferenceSetting extends StatelessWidget { @override Widget build(BuildContext context) { - final zulipLocalizations = ZulipLocalizations.of(context); final globalSettings = GlobalStoreWidget.settingsOf(context); final openLinksWithInAppBrowser = globalSettings.effectiveBrowserPreference == BrowserPreference.inApp; - return SwitchListTile.adaptive( - title: Text(zulipLocalizations.openLinksWithInAppBrowser), - value: openLinksWithInAppBrowser, - onChanged: (newValue) => _handleChange(context, newValue)); - } -} - -class _VisitFirstUnreadSetting extends StatelessWidget { - const _VisitFirstUnreadSetting(); - - @override - Widget build(BuildContext context) { - final zulipLocalizations = ZulipLocalizations.of(context); - final globalSettings = GlobalStoreWidget.settingsOf(context); - return ListTile( - title: Text(zulipLocalizations.initialAnchorSettingTitle), - subtitle: Text(VisitFirstUnreadSettingPage._valueDisplayName( - globalSettings.visitFirstUnread, zulipLocalizations: zulipLocalizations)), - onTap: () => Navigator.push(context, - VisitFirstUnreadSettingPage.buildRoute())); + return _CustomSwitch( + value: openLinksWithInAppBrowser, + onChanged: (newValue) => _handleChange(context, newValue)); } } @@ -133,33 +219,19 @@ class VisitFirstUnreadSettingPage extends StatelessWidget { final globalSettings = GlobalStoreWidget.settingsOf(context); return Scaffold( appBar: AppBar(title: Text(zulipLocalizations.initialAnchorSettingTitle)), - 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), - ]))); - } -} - -class _MarkReadOnScrollSetting extends StatelessWidget { - const _MarkReadOnScrollSetting(); - - @override - Widget build(BuildContext context) { - final zulipLocalizations = ZulipLocalizations.of(context); - final globalSettings = GlobalStoreWidget.settingsOf(context); - return ListTile( - title: Text(zulipLocalizations.markReadOnScrollSettingTitle), - subtitle: Text(MarkReadOnScrollSettingPage._valueDisplayName( - globalSettings.markReadOnScroll, zulipLocalizations: zulipLocalizations)), - onTap: () => Navigator.push(context, - MarkReadOnScrollSettingPage.buildRoute())); + body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text(zulipLocalizations.initialAnchorSettingDescription, + style: TextStyle(fontSize: 17, + ).merge(weightVariableTextStyle(context, wght: 400)))), + for (final value in VisitFirstUnreadSetting.values) + CustomRadioTile( + value: value, + groupValue: globalSettings.visitFirstUnread, + label: _valueDisplayName(value, zulipLocalizations: zulipLocalizations), + onChanged:(newValue) => _handleChange(context, newValue)) + ])); } } @@ -206,22 +278,21 @@ class MarkReadOnScrollSettingPage extends StatelessWidget { final globalSettings = GlobalStoreWidget.settingsOf(context); return Scaffold( appBar: AppBar(title: Text(zulipLocalizations.markReadOnScrollSettingTitle)), - 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), - ]))); + body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.all(16.0), + child: Text( + zulipLocalizations.markReadOnScrollSettingDescription, + style: TextStyle( + fontSize: 17, + ).merge(weightVariableTextStyle(context, wght: 400)))), + for (final value in MarkReadOnScrollSetting.values) + CustomRadioTile( + value: value, + groupValue: globalSettings.markReadOnScroll, + label: _valueDisplayName(value, zulipLocalizations: zulipLocalizations), + onChanged: (newValue) => _handleChange(context, newValue), + description: _valueDescription(value, zulipLocalizations: zulipLocalizations)) + ])); } } @@ -238,17 +309,115 @@ class ExperimentalFeaturesPage extends StatelessWidget { final globalSettings = GlobalStoreWidget.settingsOf(context); final flags = GlobalSettingsStore.experimentalFeatureFlags; assert(flags.isNotEmpty); + final designVariables = DesignVariables.of(context); return Scaffold( appBar: AppBar( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle)), - body: Column(children: [ - ListTile( - title: Text(zulipLocalizations.experimentalFeatureSettingsWarning)), - for (final flag in flags) - SwitchListTile.adaptive( - title: Text(flag.name), // no i18n; these are developer-facing settings - value: globalSettings.getBool(flag), - onChanged: (value) => globalSettings.setBool(flag, value)), - ])); + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + zulipLocalizations.experimentalFeatureSettingsWarning, + style: TextStyle(fontSize: 17) + .merge(weightVariableTextStyle(context, wght: 400)))), + for (final flag in flags) + Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(flag.name, + style: TextStyle( + fontSize: 20, + color: designVariables.contextMenuItemText, + ).merge(weightVariableTextStyle(context, wght: 600)))), + _CustomSwitch( + value: globalSettings.getBool(flag), + onChanged: (value) => globalSettings.setBool(flag, value)), + ])), + ])); + } +} + +class CustomRadioTile extends StatelessWidget { + final T value; + final T groupValue; + final String label; + final ValueChanged onChanged; + final String? description; + + const CustomRadioTile({ + super.key, + required this.value, + required this.groupValue, + required this.label, + required this.onChanged, + this.description, + }); + + @override + Widget build(BuildContext context) { + final selected = value == groupValue; + final size = 20.0; + + return InkWell( + onTap: () => onChanged(value), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(top: 4), + width: size, + height: size, + decoration: BoxDecoration( + color: selected ? const Color(0xff4370f0) : Colors.transparent, + border: Border.all( + color: selected ? const Color(0xff4370f0) : Colors.grey.shade400, + width: 2), + borderRadius: BorderRadius.circular(size / 2)), + child: selected? const Icon( + ZulipIcons.check, + size: 16, + color: Colors.white, + ): null), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label,style: TextStyle(fontSize: 18) + .merge(weightVariableTextStyle(context, wght: 500))), + if (description != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(description!, + style: TextStyle(fontSize: 17) + .merge(weightVariableTextStyle(context, wght: 400)),), + )])), + ]))); + } +} +class _CustomSwitch extends StatelessWidget { + const _CustomSwitch({ + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onChanged(!value), + child: Toggle( + value: value, + onChanged: onChanged, + )); } } diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index e889fa0d3d..504f0c46a7 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -4,6 +4,7 @@ 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/button.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/settings.dart'; import 'package:zulip/widgets/store.dart'; @@ -49,19 +50,17 @@ void main() { matching: find.text(expectedSubtitle))).findsOne(); } - Finder findRadioListTileWithTitle(String title) => find.ancestor( + Finder findCustomRadioTileWithTitle(String title) => find.ancestor( of: find.text(title), - matching: find.byType(RadioListTile)); + matching: find.byType(CustomRadioTile)); - void checkRadioButtonAppearsChecked(WidgetTester tester, + void checkCustomRadioTileAppearsChecked(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); + final tile = tester.widget>(findCustomRadioTileWithTitle(title)); + if (expectedIsChecked) { + check(tile.value).equals(tile.groupValue); + } else { + check(tile.value).not((it) => it.equals(tile.groupValue));} } group('ThemeSetting', () { @@ -74,7 +73,7 @@ void main() { ThemeSetting.dark => 'Dark', }; for (final title in ['System', 'Light', 'Dark']) { - checkRadioButtonAppearsChecked(tester, title, title == expectedCheckedTitle); + checkCustomRadioTileAppearsChecked(tester, title, title == expectedCheckedTitle); } check(testBinding.globalStore) .settings.themeSetting.equals(expectedThemeSetting); @@ -89,13 +88,13 @@ void main() { check(Theme.of(element)).brightness.equals(Brightness.light); checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light); - await tester.tap(findRadioListTileWithTitle('Dark')); + await tester.tap(findCustomRadioTileWithTitle('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(findCustomRadioTileWithTitle('System')); await tester.pump(); await tester.pump(Duration(milliseconds: 250)); // wait for transition check(Theme.of(element)).brightness.equals(Brightness.light); @@ -117,15 +116,13 @@ void main() { }); group('BrowserPreference', () { - Finder useInAppBrowserSwitchFinder = find.ancestor( - of: find.text('Open links with in-app browser'), - matching: find.byType(SwitchListTile)); + Finder useInAppBrowserSwitchFinder = find.byType(Toggle).first; void checkSwitchAndGlobalSettings(WidgetTester tester, { required bool checked, required BrowserPreference? expectedBrowserPreference, }) { - check(tester.widget(useInAppBrowserSwitchFinder)) + check(tester.widget(useInAppBrowserSwitchFinder)) .value.equals(checked); check(testBinding.globalStore) .settings.browserPreference.equals(expectedBrowserPreference); @@ -172,7 +169,7 @@ void main() { }) { for (final setting in VisitFirstUnreadSetting.values) { final thisSettingTitle = settingTitle(setting); - checkRadioButtonAppearsChecked(tester, + checkCustomRadioTileAppearsChecked(tester, thisSettingTitle, setting == expectedSetting); } } @@ -195,17 +192,17 @@ void main() { await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(VisitFirstUnreadSetting.always))); await tester.pump(); checkPage(tester, expectedSetting: VisitFirstUnreadSetting.always); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(VisitFirstUnreadSetting.conversations))); await tester.pump(); checkPage(tester, expectedSetting: VisitFirstUnreadSetting.conversations); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(VisitFirstUnreadSetting.never))); await tester.pump(); checkPage(tester, expectedSetting: VisitFirstUnreadSetting.never); @@ -242,7 +239,7 @@ void main() { }) { for (final setting in MarkReadOnScrollSetting.values) { final thisSettingTitle = settingTitle(setting); - checkRadioButtonAppearsChecked(tester, + checkCustomRadioTileAppearsChecked(tester, thisSettingTitle, setting == expectedSetting, subtitle: settingSubtitle(setting)); @@ -267,17 +264,17 @@ void main() { await tester.pump((lastPushedRoute as TransitionRoute).transitionDuration); checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(MarkReadOnScrollSetting.always))); await tester.pump(); checkPage(tester, expectedSetting: MarkReadOnScrollSetting.always); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(MarkReadOnScrollSetting.conversations))); await tester.pump(); checkPage(tester, expectedSetting: MarkReadOnScrollSetting.conversations); - await tester.tap(findRadioListTileWithTitle( + await tester.tap(findCustomRadioTileWithTitle( settingTitle(MarkReadOnScrollSetting.never))); await tester.pump(); checkPage(tester, expectedSetting: MarkReadOnScrollSetting.never); @@ -301,4 +298,4 @@ void main() { // (The main ingredient in writing such tests would be to wire up // [GlobalSettingsStore.experimentalFeatureFlags] so that tests can // control making it empty, or non-empty, at will.) -} +} \ No newline at end of file