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