Skip to content

Commit f0148ac

Browse files
committed
subscription_list: Add new SubscriptionListPage
Fixes: zulip#187
1 parent 81b7a51 commit f0148ac

File tree

3 files changed

+391
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)