Skip to content

Commit f51e828

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 18bb12a commit f51e828

File tree

5 files changed

+278
-2
lines changed

5 files changed

+278
-2
lines changed

assets/l10n/app_en.arb

+26
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,24 @@
396396
"@topicValidationErrorMandatoryButEmpty": {
397397
"description": "Topic validation error when topic is required but was empty."
398398
},
399+
"subscribedToNChannels": "Subscribed to {num, plural, =0{no channels} =1{1 channel} other{{num} channels}}",
400+
"@subscribedToNChannels": {
401+
"description": "Test page label showing number of channels user is subscribed to.",
402+
"placeholders": {
403+
"num": {"type": "int", "example": "4"}
404+
}
405+
},
406+
"browseNMoreChannels": "Browse {num, plural, =1{1 more channel} other{{num} more channels}}",
407+
"@browseNMoreChannels": {
408+
"description": "Label showing the number of other channels that user can subscribe to",
409+
"placeholders": {
410+
"num": {"type": "int", "example": "4"}
411+
}
412+
},
413+
"browseAllChannels": "Browse all channels",
414+
"@browseAllChannels": {
415+
"description": "Label for the option to show all channels, this is only shown if user is already subscribed to all visible channels"
416+
},
399417
"errorInvalidResponse": "The server sent an invalid response",
400418
"@errorInvalidResponse": {
401419
"description": "Error message when an API call returned an invalid response."
@@ -532,6 +550,14 @@
532550
"@starredMessagesPageTitle": {
533551
"description": "Title for the page of starred messages."
534552
},
553+
"channelListPageTitle": "All channels",
554+
"@channelListPageTitle": {
555+
"description": "Title for the page of all channels."
556+
},
557+
"noChannelsFound": "There are no channels you can view in this organization.",
558+
"@noChannelsFound": {
559+
"description": "Message when no channels are found"
560+
},
535561
"notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}",
536562
"@notifGroupDmConversationLabel": {
537563
"description": "Label for a group DM conversation notification.",

lib/widgets/channel_list.dart

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
// TODO(design) check if this is the right variable
72+
color: designVariables.background,
73+
child: InkWell(
74+
onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context,
75+
narrow: ChannelNarrow(stream.streamId))),
76+
child: Padding(
77+
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
78+
child: Row(children: [
79+
Icon(size: 16, iconDataForStream(stream)),
80+
const SizedBox(width: 8),
81+
Expanded(child: Column(
82+
crossAxisAlignment: CrossAxisAlignment.start,
83+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
84+
children: [
85+
Text(stream.name,
86+
style: TextStyle(
87+
fontSize: 18,
88+
height: (20 / 18),
89+
// TODO(design) check if this is the right variable
90+
color: designVariables.labelMenuButton),
91+
maxLines: 1,
92+
overflow: TextOverflow.ellipsis),
93+
// TODO(#488) parse and show `stream.renderedDescription` with content widget
94+
if (stream.description.isNotEmpty) Text(
95+
stream.description,
96+
style: TextStyle(
97+
fontSize: 12,
98+
// TODO(design) check if this is the right variable
99+
color: designVariables.labelMenuButton.withValues(alpha: 0xBF)),
100+
maxLines: 1,
101+
overflow: TextOverflow.ellipsis),
102+
])),
103+
]))));
104+
}
105+
}

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.browseNMoreChannels(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
@@ -23,11 +23,12 @@ void main() {
2323
required List<Subscription> subscriptions,
2424
List<UserTopicItem> userTopics = const [],
2525
UnreadMessagesSnapshot? unreadMsgs,
26+
List<ZulipStream>? streams,
2627
}) async {
2728
addTearDown(testBinding.reset);
2829
final initialSnapshot = eg.initialSnapshot(
2930
subscriptions: subscriptions,
30-
streams: subscriptions,
31+
streams: streams ?? subscriptions,
3132
userTopics: userTopics,
3233
unreadMsgs: unreadMsgs,
3334
);
@@ -59,6 +60,37 @@ void main() {
5960
check(isUnpinnedHeaderInTree()).isFalse();
6061
});
6162

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

0 commit comments

Comments
 (0)