diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 0be670d5cb..f0807d5e6d 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/inbox_done.svg b/assets/icons/inbox_done.svg new file mode 100644 index 0000000000..ac164358de --- /dev/null +++ b/assets/icons/inbox_done.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0640af9ee1..c23d00d82b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -824,5 +824,9 @@ "zulipAppTitle": "Zulip", "@zulipAppTitle": { "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "emptyInboxMessage": "There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.", + "@emptyInboxMessage": { + "description": "Message shown when inbox is empty. [combined feed] will be replaced with a clickable link to Combined Feed." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3cbd917563..5320e0d564 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1208,6 +1208,12 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Zulip'** String get zulipAppTitle; + + /// Message shown when inbox is empty. [combined feed] will be replaced with a clickable link to Combined Feed. + /// + /// In en, this message translates to: + /// **'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'** + String get emptyInboxMessage; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 0304fd3e6f..b9ee5b0224 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 7af8cd7bab..05615377d5 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 6ac34645e2..51349ef6a4 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index b3360e9f62..0945d45ccc 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cab571c163..f8835802a4 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index babbc976fd..ea17fb7d68 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 964dbc29ad..a10a2701ab 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -643,4 +643,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get emptyInboxMessage => 'There are no unread messages in your Inbox.\nCheck out the [combined feed] for recent messages.'; } diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 82cb83704b..e18695aad9 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -84,59 +84,62 @@ abstract final class ZulipIcons { /// The Zulip custom icon "inbox". static const IconData inbox = IconData(0xf114, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "inbox_done". + static const IconData inbox_done = IconData(0xf115, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "info". - static const IconData info = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf127, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 799f763f1c..02c640bbb9 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -6,6 +6,7 @@ import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; import 'action_sheet.dart'; +import 'color.dart'; import 'icons.dart'; import 'message_list.dart'; import 'sticky_header.dart'; @@ -160,6 +161,10 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } + if (sections.isEmpty) { + return const InboxEmptyWidget(); + } + return SafeArea( // Don't pad the bottom here; we want the list content to do that. bottom: false, @@ -182,6 +187,92 @@ class _InboxPageState extends State with PerAccountStoreAwareStat } } +class InboxEmptyWidget extends StatelessWidget { + const InboxEmptyWidget({super.key}); + + // Splits a message containing text in square brackets into three parts. + List _splitMessage(String message) { + final pattern = RegExp(r'(.*?)\[(.*?)\](.*)', dotAll: true); + final match = pattern.firstMatch(message); + + return match == null + ? [message, '', ''] + : [ + match.group(1) ?? '', + match.group(2) ?? '', + match.group(3) ?? '', + ]; + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + final messageParts = _splitMessage(zulipLocalizations.emptyInboxMessage); + + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(height: 48), + Icon( + ZulipIcons.inbox_done, + size: 80, + color: designVariables.foreground.withFadedAlpha(0.3), + ), + const SizedBox(height: 16), + Text.rich( + TextSpan( + style: TextStyle( + color: designVariables.labelSearchPrompt, + fontSize: 17, + fontWeight: FontWeight.w500, + ), + children: [ + TextSpan(text: messageParts[0]), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + splashFactory: NoSplash.splashFactory + ), + onPressed: () => Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: const CombinedFeedNarrow())), + child: Text( + messageParts[1], + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: designVariables.link, + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.solid, + decorationThickness: 2.5, + decorationColor: designVariables.link, + height: 1.5, + ) + ), + ), + ), + TextSpan(text: messageParts[2]), + ], + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + sealed class _InboxSectionData { const _InboxSectionData(); } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ec8ad8aecc..782316fec3 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -157,8 +157,10 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), + labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), + link: const Color(0xff066bd0), modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.3), mutedUnreadBadge: const HSLColor.fromAHSL(0.5, 0, 0, 0.8).toColor(), navigationButtonBg: Colors.black.withValues(alpha: 0.05), @@ -209,8 +211,10 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), + labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), + link: const Color(0xff00aaff), modalBarrierColor: const Color(0xff000000).withValues(alpha: 0.5), // TODO(design-dark) need proper dark-theme color (this is ad hoc) mutedUnreadBadge: const HSLColor.fromAHSL(0.5, 0, 0, 0.6).toColor(), @@ -263,8 +267,10 @@ class DesignVariables extends ThemeExtension { required this.groupDmConversationIcon, required this.groupDmConversationIconBg, required this.inboxItemIconMarker, + required this.labelSearchPrompt, required this.loginOrDivider, required this.loginOrDividerText, + required this.link, required this.modalBarrierColor, required this.mutedUnreadBadge, required this.navigationButtonBg, @@ -325,8 +331,10 @@ class DesignVariables extends ThemeExtension { final Color groupDmConversationIcon; final Color groupDmConversationIconBg; final Color inboxItemIconMarker; + final Color labelSearchPrompt; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) + final Color link; final Color modalBarrierColor; final Color mutedUnreadBadge; final Color navigationButtonBg; @@ -374,8 +382,10 @@ class DesignVariables extends ThemeExtension { Color? groupDmConversationIcon, Color? groupDmConversationIconBg, Color? inboxItemIconMarker, + Color? labelSearchPrompt, Color? loginOrDivider, Color? loginOrDividerText, + Color? link, Color? modalBarrierColor, Color? mutedUnreadBadge, Color? navigationButtonBg, @@ -422,8 +432,10 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, + labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, + link: link ?? this.link, modalBarrierColor: modalBarrierColor ?? this.modalBarrierColor, mutedUnreadBadge: mutedUnreadBadge ?? this.mutedUnreadBadge, navigationButtonBg: navigationButtonBg ?? this.navigationButtonBg, @@ -477,8 +489,10 @@ class DesignVariables extends ThemeExtension { groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, + labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, + link: Color.lerp(link, other.link, t)!, modalBarrierColor: Color.lerp(modalBarrierColor, other.modalBarrierColor, t)!, mutedUnreadBadge: Color.lerp(mutedUnreadBadge, other.mutedUnreadBadge, t)!, navigationButtonBg: Color.lerp(navigationButtonBg, other.navigationButtonBg, t)!, diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 3fa3713d5d..7cc4f3305b 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -4,17 +4,24 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'message_list_checks.dart'; import 'test_app.dart'; +import 'page_checks.dart'; /// Repeatedly drags `view` by `moveStep` until `finder` is invisible. /// @@ -52,6 +59,7 @@ void main() { TestZulipBinding.ensureInitialized(); late PerAccountStore store; + late FakeApiConnection connection; Future setupPage(WidgetTester tester, { List? streams, @@ -203,6 +211,36 @@ void main() { await setupVarious(tester); }); + testWidgets('empty inbox shows empty state', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, + unreadMessages: [], + navigatorObserver: testNavObserver); + pushedRoutes.clear(); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, + messages: []).toJson()); + + expect(find.byIcon(ZulipIcons.inbox_done), findsOneWidget); + expect(find.textContaining('There are no unread messages in your Inbox.'), findsOneWidget); + + final combinedFeedButton = find.text('combined feed'); + expect(combinedFeedButton, findsOneWidget); + + await tester.tap(combinedFeedButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + check(pushedRoutes).single.isA().page + .isA() + .initNarrow.equals(const CombinedFeedNarrow()); + }); + // TODO test that tapping a conversation row opens the message list // for the conversation