Skip to content

Commit 21d4849

Browse files
committed
subscription_list: Add new SubscriptionListPage
Fixes: #187
1 parent 5d5c2f9 commit 21d4849

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-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});
@@ -260,6 +261,11 @@ class HomePage extends StatelessWidget {
260261
InboxPage.buildRoute(context: context)),
261262
child: const Text("Inbox")), // TODO(i18n)
262263
const SizedBox(height: 16),
264+
ElevatedButton(
265+
onPressed: () => Navigator.push(context,
266+
SubscriptionListPage.buildRoute(context: context)),
267+
child: const Text("Subscribed streams")),
268+
const SizedBox(height: 16),
263269
ElevatedButton(
264270
onPressed: () => Navigator.push(context,
265271
RecentDmConversationsPage.buildRoute(context: context)),

lib/widgets/subscription_list.dart

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