Skip to content

Commit 4e8df19

Browse files
committed
channel_list: Create "All channels" page
This creates the page that contains all channels list, still we need to implement subscribe/unsubscribe logic which will be introduced in the upcoming commits. Fixes parts of zulip#188
1 parent c2a736b commit 4e8df19

File tree

5 files changed

+270
-2
lines changed

5 files changed

+270
-2
lines changed

assets/l10n/app_en.arb

+19
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,17 @@
367367
"num": {"type": "int", "example": "4"}
368368
}
369369
},
370+
"browseMoreNChannels": "Browse {num, plural, =0{no other channels} =1{1 more channel} other{{num} more channels}}",
371+
"@browseMoreNChannels": {
372+
"description": "Label showing the number of other channels that user can subscribe to",
373+
"placeholders": {
374+
"num": {"type": "int", "example": "4"}
375+
}
376+
},
377+
"browseAllChannels": "Browse all channels",
378+
"@browseAllChannels": {
379+
"description": "Label for the option to show all channels, this is only shown if user is already subscribed to all visible channels"
380+
},
370381
"errorInvalidResponse": "The server sent an invalid response",
371382
"@errorInvalidResponse": {
372383
"description": "Error message when an API call returned an invalid response."
@@ -503,6 +514,14 @@
503514
"@starredMessagesPageTitle": {
504515
"description": "Title for the page of starred messages."
505516
},
517+
"channelListPageTitle": "All channels",
518+
"@channelListPageTitle": {
519+
"description": "Title for the page of all channels."
520+
},
521+
"noChannelsFound": "There are no channels you can view in this organization.",
522+
"@noChannelsFound": {
523+
"description": "Message when no channels are found"
524+
},
506525
"notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}",
507526
"@notifGroupDmConversationLabel": {
508527
"description": "Label for a group DM conversation notification.",

lib/widgets/channel_list.dart

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
3+
4+
import '../api/model/model.dart';
5+
import '../model/narrow.dart';
6+
import 'app_bar.dart';
7+
import 'icons.dart';
8+
import 'message_list.dart';
9+
import 'page.dart';
10+
import 'store.dart';
11+
import 'theme.dart';
12+
13+
class ChannelListPage extends StatelessWidget {
14+
const ChannelListPage({super.key});
15+
16+
static Route<void> buildRoute({int? accountId, BuildContext? context}) {
17+
return MaterialAccountWidgetRoute(accountId: accountId, context: context,
18+
page: const ChannelListPage());
19+
}
20+
21+
@override
22+
Widget build(BuildContext context) {
23+
final store = PerAccountStoreWidget.of(context);
24+
final zulipLocalizations = ZulipLocalizations.of(context);
25+
final streams = store.streams.values.toList()..sort((a, b) {
26+
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
27+
});
28+
return Scaffold(
29+
appBar: ZulipAppBar(title: Text(zulipLocalizations.channelListPageTitle)),
30+
body: SafeArea(
31+
// Don't pad the bottom here; we want the list content to do that.
32+
bottom: false,
33+
child: streams.isEmpty ? const _NoChannelsItem() : ListView.builder(
34+
itemCount: streams.length,
35+
itemBuilder: (context, index) => ChannelItem(stream: streams[index]))));
36+
}
37+
}
38+
39+
class _NoChannelsItem extends StatelessWidget {
40+
const _NoChannelsItem();
41+
42+
@override
43+
Widget build(BuildContext context) {
44+
final zulipLocalizations = ZulipLocalizations.of(context);
45+
final designVariables = DesignVariables.of(context);
46+
47+
return Center(
48+
child: Padding(
49+
padding: const EdgeInsets.all(10),
50+
child: Text(zulipLocalizations.noChannelsFound,
51+
textAlign: TextAlign.center,
52+
style: TextStyle(
53+
// TODO(design) check if this is the right variable
54+
color: designVariables.subscriptionListHeaderText,
55+
fontSize: 18,
56+
height: (20 / 18),
57+
))));
58+
}
59+
}
60+
61+
@visibleForTesting
62+
class ChannelItem extends StatelessWidget {
63+
const ChannelItem({super.key, required this.stream});
64+
65+
final ZulipStream stream;
66+
67+
@override
68+
Widget build(BuildContext context) {
69+
final designVariables = DesignVariables.of(context);
70+
return Material(
71+
color: designVariables.background,
72+
child: InkWell(
73+
onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context,
74+
narrow: ChannelNarrow(stream.streamId))),
75+
child: Padding(
76+
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
77+
child: Row(children: [
78+
Icon(size: 16, iconDataForStream(stream)),
79+
const SizedBox(width: 8),
80+
Expanded(child: Column(
81+
crossAxisAlignment: CrossAxisAlignment.start,
82+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
83+
children: [
84+
Text(stream.name,
85+
style: TextStyle(
86+
fontSize: 18,
87+
height: (20 / 18),
88+
// TODO(design) check if this is the right variable
89+
color: designVariables.labelMenuButton),
90+
maxLines: 1,
91+
overflow: TextOverflow.ellipsis),
92+
// TODO(#488) parse and show `stream.renderedDescription` with content widget
93+
if (stream.description.isNotEmpty) Text(
94+
stream.description,
95+
style: TextStyle(
96+
fontSize: 12,
97+
// TODO(design) check if this is the right variable
98+
color: designVariables.labelMenuButton.withOpacity(0.75)),
99+
maxLines: 1,
100+
overflow: TextOverflow.ellipsis),
101+
])),
102+
]))));
103+
}
104+
}

lib/widgets/subscription_list.dart

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
23

34
import '../api/model/model.dart';
45
import '../model/narrow.dart';
@@ -8,6 +9,7 @@ import 'icons.dart';
89
import 'message_list.dart';
910
import 'page.dart';
1011
import 'store.dart';
12+
import 'channel_list.dart';
1113
import 'text.dart';
1214
import 'theme.dart';
1315
import 'unread_count_badge.dart';
@@ -107,7 +109,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> with PerAcc
107109
_SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned),
108110
],
109111

110-
// TODO(#188): add button leading to "All Streams" page with ability to subscribe
112+
if (store.streams.isNotEmpty) const _ChannelListLinkItem(),
111113

112114
// This ensures last item in scrollable can settle in an unobstructed area.
113115
const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())),
@@ -199,6 +201,47 @@ class _SubscriptionList extends StatelessWidget {
199201
}
200202
}
201203

204+
class _ChannelListLinkItem extends StatelessWidget {
205+
const _ChannelListLinkItem();
206+
207+
@override
208+
Widget build(BuildContext context) {
209+
final designVariables = DesignVariables.of(context);
210+
final store = PerAccountStoreWidget.of(context);
211+
final notShownStreams = store.streams.length - store.subscriptions.length;
212+
final zulipLocalizations = ZulipLocalizations.of(context);
213+
final label = notShownStreams != 0
214+
? zulipLocalizations.browseMoreNChannels(notShownStreams)
215+
: zulipLocalizations.browseAllChannels;
216+
return SliverToBoxAdapter(
217+
child: Material(
218+
color: designVariables.background,
219+
child: InkWell(
220+
onTap: () => Navigator.push(context,
221+
ChannelListPage.buildRoute(context: context)),
222+
child: Padding(
223+
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
224+
child: Row(
225+
crossAxisAlignment: CrossAxisAlignment.center,
226+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
227+
children: [
228+
Text(
229+
style: TextStyle(
230+
fontSize: 18,
231+
height: (20 / 18),
232+
// TODO(design) check if this is the right variable
233+
color: designVariables.labelMenuButton,
234+
).merge(weightVariableTextStyle(context, wght: 600)),
235+
label),
236+
Icon(
237+
Icons.adaptive.arrow_forward,
238+
size: 18,
239+
// TODO(design) check if this is the right variable
240+
color: designVariables.labelMenuButton),
241+
])))));
242+
}
243+
}
244+
202245
@visibleForTesting
203246
class SubscriptionItem extends StatelessWidget {
204247
const SubscriptionItem({

test/widgets/channel_list_test.dart

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:zulip/api/model/model.dart';
4+
import 'package:zulip/widgets/channel_list.dart';
5+
6+
import '../model/binding.dart';
7+
import '../example_data.dart' as eg;
8+
import 'test_app.dart';
9+
10+
void main() {
11+
TestZulipBinding.ensureInitialized();
12+
13+
Future<void> setupChannelListPage(WidgetTester tester, {
14+
required List<ZulipStream> streams,
15+
required List<Subscription> subscriptions
16+
}) async {
17+
addTearDown(testBinding.reset);
18+
final initialSnapshot = eg.initialSnapshot(
19+
subscriptions: subscriptions,
20+
streams: streams,
21+
);
22+
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
23+
24+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const ChannelListPage()));
25+
26+
// global store, per-account store
27+
await tester.pumpAndSettle();
28+
}
29+
30+
void checkItemCount(int expectedCount) {
31+
check(find.byType(ChannelItem).evaluate()).length.equals(expectedCount);
32+
}
33+
34+
testWidgets('smoke', (tester) async {
35+
await setupChannelListPage(tester, streams: [], subscriptions: []);
36+
checkItemCount(0);
37+
check(find.text('There are no channels you can view in this organization.').evaluate()).single;
38+
});
39+
40+
testWidgets('basic list', (tester) async {
41+
final streams = List.generate(3, (index) => eg.stream());
42+
await setupChannelListPage(tester, streams: streams, subscriptions: []);
43+
checkItemCount(3);
44+
});
45+
46+
group('list ordering', () {
47+
Iterable<String> listedStreamNames(WidgetTester tester) => tester
48+
.widgetList<ChannelItem>(find.byType(ChannelItem))
49+
.map((e) => e.stream.name);
50+
51+
List<ZulipStream> streamsFromNames(List<String> names) {
52+
return names.map((name) => eg.stream(name: name)).toList();
53+
}
54+
55+
testWidgets('is alphabetically case-insensitive', (tester) async {
56+
final streams = streamsFromNames(['b', 'C', 'A']);
57+
await setupChannelListPage(tester, streams: streams, subscriptions: []);
58+
59+
check(listedStreamNames(tester)).deepEquals(['A', 'b', 'C']);
60+
});
61+
62+
testWidgets('is insensitive of user subscription', (tester) async {
63+
final streams = streamsFromNames(['b', 'c', 'a']);
64+
await setupChannelListPage(tester, streams: streams,
65+
subscriptions: [eg.subscription(streams[0])]);
66+
67+
check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']);
68+
});
69+
});
70+
}

test/widgets/subscription_list_test.dart

+33-1
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ void main() {
2121
required List<Subscription> subscriptions,
2222
List<UserTopicItem> userTopics = const [],
2323
UnreadMessagesSnapshot? unreadMsgs,
24+
List<ZulipStream>? streams,
2425
}) async {
2526
addTearDown(testBinding.reset);
2627
final initialSnapshot = eg.initialSnapshot(
2728
subscriptions: subscriptions,
28-
streams: subscriptions,
29+
streams: streams ?? subscriptions,
2930
userTopics: userTopics,
3031
unreadMsgs: unreadMsgs,
3132
);
@@ -57,6 +58,37 @@ void main() {
5758
check(isUnpinnedHeaderInTree()).isFalse();
5859
});
5960

61+
testWidgets('link to channels is shown with 1 unsubscribed channel', (tester) async {
62+
final streams = List.generate(2, (index) => eg.stream());
63+
await setupStreamListPage(tester,
64+
streams: streams,
65+
subscriptions: [eg.subscription(streams[1])]);
66+
67+
check(find.text('Browse 1 more channel').evaluate()).isNotEmpty();
68+
});
69+
70+
testWidgets('link to channels is shown with n unsubscribed channels', (tester) async {
71+
final streams = List.generate(5, (index) => eg.stream());
72+
await setupStreamListPage(tester,
73+
streams: streams,
74+
subscriptions: [eg.subscription(streams[1])]);
75+
76+
check(find.text('Browse 4 more channels').evaluate()).isNotEmpty();
77+
});
78+
79+
testWidgets('link to channels is shown with 0 unsubscribed channels', (tester) async {
80+
final subscriptions = List.generate(5, (index) => eg.subscription(eg.stream()));
81+
await setupStreamListPage(tester, subscriptions: subscriptions);
82+
83+
check(find.text('Browse all channels').evaluate()).isNotEmpty();
84+
});
85+
86+
testWidgets('link to channels is not shown if there are no channels', (tester) async {
87+
await setupStreamListPage(tester, streams: [], subscriptions: []);
88+
89+
check(find.text('Browse all channels').evaluate()).isEmpty();
90+
});
91+
6092
testWidgets('basic subscriptions', (tester) async {
6193
await setupStreamListPage(tester, subscriptions: [
6294
eg.subscription(eg.stream(streamId: 1), pinToTop: true),

0 commit comments

Comments
 (0)