Skip to content

Commit 6d80be5

Browse files
committed
subscription_list: Add new SubscriptionListPage
Fixes: zulip#187
1 parent ef9d184 commit 6d80be5

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed

lib/widgets/app.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'message_list.dart';
1414
import 'page.dart';
1515
import 'recent_dm_conversations.dart';
1616
import 'store.dart';
17+
import 'subscription_list.dart';
1718

1819
class ZulipApp extends StatelessWidget {
1920
const ZulipApp({super.key, this.navigatorObservers});
@@ -247,6 +248,11 @@ class HomePage extends StatelessWidget {
247248
InboxPage.buildRoute(context: context)),
248249
child: const Text("Inbox")), // TODO(i18n)
249250
const SizedBox(height: 16),
251+
ElevatedButton(
252+
onPressed: () => Navigator.push(context,
253+
SubscriptionListPage.buildRoute(context: context)),
254+
child: const Text("Subscribed streams")),
255+
const SizedBox(height: 16),
250256
ElevatedButton(
251257
onPressed: () => Navigator.push(context,
252258
RecentDmConversationsPage.buildRoute(context: context)),

lib/widgets/subscription_list.dart

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/material.dart';
3+
4+
import '../api/model/model.dart';
5+
import '../model/narrow.dart';
6+
import '../model/unreads.dart';
7+
import 'icons.dart';
8+
import 'message_list.dart';
9+
import 'page.dart';
10+
import 'store.dart';
11+
import 'text.dart';
12+
import 'unread_count_badge.dart';
13+
14+
/// Scrollable listing of subscribed streams.
15+
class SubscriptionListPage extends StatefulWidget {
16+
const SubscriptionListPage({super.key});
17+
18+
static Route<void> buildRoute({required BuildContext context}) {
19+
return MaterialAccountWidgetRoute(context: context,
20+
page: const SubscriptionListPage());
21+
}
22+
23+
@override
24+
State<SubscriptionListPage> createState() => _SubscriptionListPageState();
25+
}
26+
27+
class _SubscriptionListPageState extends State<SubscriptionListPage> with PerAccountStoreAwareStateMixin<SubscriptionListPage> {
28+
Unreads? unreadsModel;
29+
30+
@override
31+
void onNewStore() {
32+
unreadsModel?.removeListener(_modelChanged);
33+
unreadsModel = PerAccountStoreWidget.of(context).unreads
34+
..addListener(_modelChanged);
35+
}
36+
37+
@override
38+
void dispose() {
39+
unreadsModel?.removeListener(_modelChanged);
40+
super.dispose();
41+
}
42+
43+
void _modelChanged() {
44+
setState(() {
45+
// The actual state lives in [subscriptions] and [unreadsModel].
46+
// This method was called because one of those just changed.
47+
});
48+
}
49+
50+
@override
51+
Widget build(BuildContext context) {
52+
// Design referenced from:
53+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=171-12359&mode=design&t=4d0vykoYQ0KGpFuu-0
54+
55+
// This is an initial version with "Pinned" and "Unpinned"
56+
// sections following behavior in mobile. Recalculating
57+
// groups and sorting on every `build` here: it performs well
58+
// enough and not worth optimizing as it will be replaced
59+
// with a different behavior:
60+
// TODO: Implement new grouping behavior and design, see discussion at:
61+
// https://chat.zulip.org/#narrow/stream/101-design/topic/UI.20redesign.3A.20left.20sidebar/near/1540147
62+
63+
// TODO: Implement collapsible topics
64+
65+
// TODO(i18n): localize strings on page
66+
// Strings here left unlocalized as they likely will not
67+
// exist in the settled design.
68+
final store = PerAccountStoreWidget.of(context);
69+
70+
final List<Subscription> pinned = [];
71+
final List<Subscription> unpinned = [];
72+
for (final subscription in store.subscriptions.values) {
73+
if (subscription.pinToTop) {
74+
pinned.add(subscription);
75+
} else {
76+
unpinned.add(subscription);
77+
}
78+
}
79+
// TODO(i18n): add locale-aware sorting
80+
pinned.sortBy((subscription) => subscription.name);
81+
unpinned.sortBy((subscription) => subscription.name);
82+
83+
return Scaffold(
84+
appBar: AppBar(title: const Text("Streams")),
85+
body: Center(
86+
child: CustomScrollView(
87+
slivers: [
88+
if (pinned.isEmpty && unpinned.isEmpty)
89+
const _NoSubscriptionsItem(),
90+
if (pinned.isNotEmpty) ...[
91+
const _SubscriptionListHeader(label: "Pinned"),
92+
_SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned),
93+
],
94+
if (unpinned.isNotEmpty) ...[
95+
const _SubscriptionListHeader(label: "Unpinned"),
96+
_SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned),
97+
],
98+
99+
// TODO(#188): add button leading to "All Streams" page with ability to subscribe
100+
101+
// This ensures last item in scrollable can settle in an unobstructed area.
102+
const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())),
103+
])));
104+
}
105+
}
106+
107+
class _NoSubscriptionsItem extends StatelessWidget {
108+
const _NoSubscriptionsItem();
109+
110+
@override
111+
Widget build(BuildContext context) {
112+
return SliverToBoxAdapter(
113+
child: Padding(
114+
padding: const EdgeInsets.all(10),
115+
child: Text("No streams found",
116+
textAlign: TextAlign.center,
117+
style: TextStyle(
118+
color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(),
119+
fontFamily: 'Source Sans 3',
120+
fontSize: 18,
121+
height: (20 / 18),
122+
).merge(weightVariableTextStyle(context)))));
123+
}
124+
}
125+
126+
class _SubscriptionListHeader extends StatelessWidget {
127+
const _SubscriptionListHeader({required this.label});
128+
129+
final String label;
130+
131+
@override
132+
Widget build(BuildContext context) {
133+
return SliverToBoxAdapter(
134+
child: ColoredBox(
135+
color: Colors.white,
136+
child: Row(crossAxisAlignment: CrossAxisAlignment.center,
137+
children: [
138+
const SizedBox(width: 16),
139+
Expanded(child: Divider(
140+
color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())),
141+
const SizedBox(width: 8),
142+
Padding(
143+
padding: const EdgeInsets.symmetric(vertical: 7),
144+
child: Text(label,
145+
textAlign: TextAlign.center,
146+
style: TextStyle(
147+
color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(),
148+
fontFamily: 'Source Sans 3',
149+
fontSize: 14,
150+
letterSpacing: 0.04 * 14,
151+
height: (16 / 14),
152+
).merge(weightVariableTextStyle(context)))),
153+
const SizedBox(width: 8),
154+
Expanded(child: Divider(
155+
color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())),
156+
const SizedBox(width: 16),
157+
])));
158+
}
159+
}
160+
161+
class _SubscriptionList extends StatelessWidget {
162+
const _SubscriptionList({
163+
required this.unreadsModel,
164+
required this.subscriptions,
165+
});
166+
167+
final Unreads? unreadsModel;
168+
final List<Subscription> subscriptions;
169+
170+
@override
171+
Widget build(BuildContext context) {
172+
return SliverList.builder(
173+
itemCount: subscriptions.length,
174+
itemBuilder: (BuildContext context, int index) {
175+
final subscription = subscriptions[index];
176+
final unreadCount = unreadsModel!.countInStreamNarrow(subscription.streamId);
177+
return SubscriptionItem(subscription: subscription, unreadCount: unreadCount);
178+
});
179+
}
180+
}
181+
182+
@visibleForTesting
183+
class SubscriptionItem extends StatelessWidget {
184+
const SubscriptionItem({
185+
super.key,
186+
required this.subscription,
187+
required this.unreadCount,
188+
});
189+
190+
final Subscription subscription;
191+
final int unreadCount;
192+
193+
@override
194+
Widget build(BuildContext context) {
195+
final swatch = subscription.colorSwatch();
196+
final hasUnreads = (unreadCount > 0);
197+
return Material(
198+
color: Colors.white,
199+
child: InkWell(
200+
onTap: () {
201+
Navigator.push(context,
202+
MessageListPage.buildRoute(context: context,
203+
narrow: StreamNarrow(subscription.streamId)));
204+
},
205+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
206+
const SizedBox(width: 16),
207+
Padding(
208+
padding: const EdgeInsets.symmetric(vertical: 11),
209+
child: Icon(size: 18, color: swatch.iconOnPlainBackground,
210+
iconDataForStream(subscription))),
211+
const SizedBox(width: 5),
212+
Expanded(
213+
child: Padding(
214+
padding: const EdgeInsets.symmetric(vertical: 10),
215+
// TODO(design): unclear whether bold text is applied to all subscriptions
216+
// or only those with unreads:
217+
// https://github.com/zulip/zulip-flutter/pull/397#pullrequestreview-1742524205
218+
child: Text(
219+
style: const TextStyle(
220+
fontFamily: 'Source Sans 3',
221+
fontSize: 18,
222+
height: (20 / 18),
223+
color: Color(0xFF262626),
224+
).merge(hasUnreads
225+
? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900)
226+
: weightVariableTextStyle(context)),
227+
maxLines: 1,
228+
overflow: TextOverflow.ellipsis,
229+
subscription.name))),
230+
if (unreadCount > 0) ...[
231+
const SizedBox(width: 12),
232+
// TODO(#384) show @-mention indicator when it applies
233+
UnreadCountBadge(count: unreadCount, backgroundColor: swatch, bold: true),
234+
],
235+
const SizedBox(width: 16),
236+
])));
237+
}
238+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/model/initial_snapshot.dart';
5+
import 'package:zulip/api/model/model.dart';
6+
import 'package:zulip/widgets/store.dart';
7+
import 'package:zulip/widgets/subscription_list.dart';
8+
import 'package:zulip/widgets/unread_count_badge.dart';
9+
10+
import '../model/binding.dart';
11+
import '../example_data.dart' as eg;
12+
13+
void main() {
14+
TestZulipBinding.ensureInitialized();
15+
16+
Future<void> setupStreamListPage(WidgetTester tester, {
17+
required List<Subscription> subscriptions,
18+
UnreadMessagesSnapshot? unreadMsgs,
19+
}) async {
20+
addTearDown(testBinding.reset);
21+
final initialSnapshot = eg.initialSnapshot(
22+
subscriptions: subscriptions,
23+
streams: subscriptions.toList(),
24+
unreadMsgs: unreadMsgs,
25+
);
26+
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
27+
28+
await tester.pumpWidget(
29+
MaterialApp(
30+
home: GlobalStoreWidget(
31+
child: PerAccountStoreWidget(
32+
accountId: eg.selfAccount.id,
33+
child: const SubscriptionListPage()))));
34+
35+
// global store, per-account store
36+
await tester.pumpAndSettle();
37+
}
38+
39+
bool isPinnedHeaderInTree() {
40+
return find.text('Pinned').evaluate().isNotEmpty;
41+
}
42+
43+
bool isUnpinnedHeaderInTree() {
44+
return find.text('Unpinned').evaluate().isNotEmpty;
45+
}
46+
47+
int getItemCount() {
48+
return find.byType(SubscriptionItem).evaluate().length;
49+
}
50+
51+
testWidgets('smoke', (tester) async {
52+
await setupStreamListPage(tester, subscriptions: []);
53+
check(getItemCount()).equals(0);
54+
check(isPinnedHeaderInTree()).isFalse();
55+
check(isUnpinnedHeaderInTree()).isFalse();
56+
});
57+
58+
testWidgets('basic subscriptions', (tester) async {
59+
await setupStreamListPage(tester, subscriptions: [
60+
eg.subscription(eg.stream(streamId: 1), pinToTop: true),
61+
eg.subscription(eg.stream(streamId: 2), pinToTop: true),
62+
eg.subscription(eg.stream(streamId: 3), pinToTop: false),
63+
]);
64+
check(getItemCount()).equals(3);
65+
check(isPinnedHeaderInTree()).isTrue();
66+
check(isUnpinnedHeaderInTree()).isTrue();
67+
});
68+
69+
testWidgets('only pinned subscriptions', (tester) async {
70+
await setupStreamListPage(tester, subscriptions: [
71+
eg.subscription(eg.stream(streamId: 1), pinToTop: true),
72+
eg.subscription(eg.stream(streamId: 2), pinToTop: true),
73+
]);
74+
check(getItemCount()).equals(2);
75+
check(isPinnedHeaderInTree()).isTrue();
76+
check(isUnpinnedHeaderInTree()).isFalse();
77+
});
78+
79+
testWidgets('only unpinned subscriptions', (tester) async {
80+
await setupStreamListPage(tester, subscriptions: [
81+
eg.subscription(eg.stream(streamId: 1), pinToTop: false),
82+
eg.subscription(eg.stream(streamId: 2), pinToTop: false),
83+
]);
84+
check(getItemCount()).equals(2);
85+
check(isPinnedHeaderInTree()).isFalse();
86+
check(isUnpinnedHeaderInTree()).isTrue();
87+
});
88+
89+
testWidgets('subscription sort', (tester) async {
90+
await setupStreamListPage(tester, subscriptions: [
91+
eg.subscription(eg.stream(streamId: 1, name: 'd'), pinToTop: true),
92+
eg.subscription(eg.stream(streamId: 2, name: 'c'), pinToTop: false),
93+
eg.subscription(eg.stream(streamId: 3, name: 'b'), pinToTop: true),
94+
eg.subscription(eg.stream(streamId: 4, name: 'a'), pinToTop: false),
95+
]);
96+
check(isPinnedHeaderInTree()).isTrue();
97+
check(isUnpinnedHeaderInTree()).isTrue();
98+
99+
final streamListItems = tester.widgetList<SubscriptionItem>(find.byType(SubscriptionItem)).toList();
100+
check(streamListItems.map((e) => e.subscription.streamId)).deepEquals([3, 1, 4, 2]);
101+
});
102+
103+
testWidgets('unread badge shows with unreads', (tester) async {
104+
final stream = eg.stream();
105+
final unreadMsgs = eg.unreadMsgs(streams: [
106+
UnreadStreamSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
107+
]);
108+
await setupStreamListPage(tester, subscriptions: [
109+
eg.subscription(stream),
110+
], unreadMsgs: unreadMsgs);
111+
check(find.byType(UnreadCountBadge).evaluate()).length.equals(1);
112+
});
113+
114+
testWidgets('unread badge does not show with no unreads', (tester) async {
115+
final stream = eg.stream();
116+
final unreadMsgs = eg.unreadMsgs(streams: []);
117+
await setupStreamListPage(tester, subscriptions: [
118+
eg.subscription(stream),
119+
], unreadMsgs: unreadMsgs);
120+
check(find.byType(UnreadCountBadge).evaluate()).length.equals(0);
121+
});
122+
123+
testWidgets('color propagates to icon and badge', (tester) async {
124+
final stream = eg.stream();
125+
final unreadMsgs = eg.unreadMsgs(streams: [
126+
UnreadStreamSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]),
127+
]);
128+
final subscription = eg.subscription(stream, color: Colors.red.value);
129+
final swatch = subscription.colorSwatch();
130+
await setupStreamListPage(tester, subscriptions: [
131+
subscription,
132+
], unreadMsgs: unreadMsgs);
133+
check(getItemCount()).equals(1);
134+
check(tester.widget<Icon>(find.byType(Icon)).color)
135+
.equals(swatch.iconOnPlainBackground);
136+
check(tester.widget<UnreadCountBadge>(find.byType(UnreadCountBadge)).backgroundColor)
137+
.equals(swatch);
138+
});
139+
}

0 commit comments

Comments
 (0)