Skip to content

Commit

Permalink
channel_list: Create "All channels" page
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Khader-1 committed Aug 22, 2024
1 parent c2a736b commit 494e969
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 2 deletions.
19 changes: 19 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,17 @@
"num": {"type": "int", "example": "4"}
}
},
"browseMoreNChannels": "Browse {num, plural, =0{no other channels} =1{1 more channel} other{{num} more channels}}",
"@browseMoreNChannels": {
"description": "Label showing the number of other channels that user can subscribe to",
"placeholders": {
"num": {"type": "int", "example": "4"}
}
},
"browseAllChannels": "Browse all channels",
"@browseAllChannels": {
"description": "Label for the option to show all channels, this is only shown if user is already subscribed to all visible channels"
},
"errorInvalidResponse": "The server sent an invalid response",
"@errorInvalidResponse": {
"description": "Error message when an API call returned an invalid response."
Expand Down Expand Up @@ -503,6 +514,14 @@
"@starredMessagesPageTitle": {
"description": "Title for the page of starred messages."
},
"channelListPageTitle": "All channels",
"@channelListPageTitle": {
"description": "Title for the page of all channels."
},
"noChannelsFound": "There are no channels you can view in this organization.",
"@noChannelsFound": {
"description": "Message when no channels are found"
},
"notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}",
"@notifGroupDmConversationLabel": {
"description": "Label for a group DM conversation notification.",
Expand Down
104 changes: 104 additions & 0 deletions lib/widgets/channel_list.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../api/model/model.dart';
import '../model/narrow.dart';
import 'app_bar.dart';
import 'icons.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';
import 'theme.dart';

class ChannelListPage extends StatelessWidget {
const ChannelListPage({super.key});

static Route<void> buildRoute({int? accountId, BuildContext? context}) {
return MaterialAccountWidgetRoute(accountId: accountId, context: context,
page: const ChannelListPage());
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
final streams = store.streams.values.toList()..sort((a, b) {
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
return Scaffold(
appBar: ZulipAppBar(title: Text(zulipLocalizations.channelListPageTitle)),
body: SafeArea(
// Don't pad the bottom here; we want the list content to do that.
bottom: false,
child: streams.isEmpty ? const _NoChannelsItem() : ListView.builder(
itemCount: streams.length,
itemBuilder: (context, index) => ChannelItem(stream: streams[index]))));
}
}

class _NoChannelsItem extends StatelessWidget {
const _NoChannelsItem();

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final designVariables = DesignVariables.of(context);

return Center(
child: Padding(
padding: const EdgeInsets.all(10),
child: Text(zulipLocalizations.noChannelsFound,
textAlign: TextAlign.center,
style: TextStyle(
// TODO(design) check if this is the right variable
color: designVariables.subscriptionListHeaderText,
fontSize: 18,
height: (20 / 18),
))));
}
}

@visibleForTesting
class ChannelItem extends StatelessWidget {
const ChannelItem({super.key, required this.stream});

final ZulipStream stream;

@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
return Material(
color: designVariables.background,
child: InkWell(
onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context,
narrow: ChannelNarrow(stream.streamId))),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
child: Row(children: [
Icon(size: 16, iconDataForStream(stream)),
const SizedBox(width: 8),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(stream.name,
style: TextStyle(
fontSize: 18,
height: (20 / 18),
// TODO(design) check if this is the right variable
color: designVariables.labelMenuButton),
maxLines: 1,
overflow: TextOverflow.ellipsis),
// TODO(#488) parse and show `stream.renderedDescription` with content widget
if (stream.description.isNotEmpty) Text(
stream.description,
style: TextStyle(
fontSize: 12,
// TODO(design) check if this is the right variable
color: designVariables.labelMenuButton.withOpacity(0.75)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
])),
]))));
}
}
41 changes: 40 additions & 1 deletion lib/widgets/subscription_list.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../api/model/model.dart';
import '../model/narrow.dart';
Expand All @@ -8,6 +9,7 @@ import 'icons.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';
import 'channel_list.dart';
import 'text.dart';
import 'theme.dart';
import 'unread_count_badge.dart';
Expand Down Expand Up @@ -107,7 +109,7 @@ class _SubscriptionListPageState extends State<SubscriptionListPage> with PerAcc
_SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned),
],

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

// This ensures last item in scrollable can settle in an unobstructed area.
const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())),
Expand Down Expand Up @@ -199,6 +201,43 @@ class _SubscriptionList extends StatelessWidget {
}
}

class _ChannelListLinkItem extends StatelessWidget {
const _ChannelListLinkItem();

@override
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
final store = PerAccountStoreWidget.of(context);
final notShownStreams = store.streams.length - store.subscriptions.length;
final zulipLocalizations = ZulipLocalizations.of(context);
final label = notShownStreams != 0
? zulipLocalizations.browseMoreNChannels(notShownStreams)
: zulipLocalizations.browseAllChannels;
return SliverToBoxAdapter(
child: Material(
color: designVariables.background,
child: InkWell(
onTap: () => Navigator.push(context,
ChannelListPage.buildRoute(context: context)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
style: TextStyle(
fontSize: 18,
height: (20 / 18),
// TODO(design) check if this is the right variable
color: designVariables.labelMenuButton,
).merge(weightVariableTextStyle(context, wght: 600)),
label),
Icon(Icons.adaptive.arrow_forward, size: 18),
])))));
}
}

@visibleForTesting
class SubscriptionItem extends StatelessWidget {
const SubscriptionItem({
Expand Down
70 changes: 70 additions & 0 deletions test/widgets/channel_list_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:checks/checks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/widgets/channel_list.dart';

import '../model/binding.dart';
import '../example_data.dart' as eg;
import 'test_app.dart';

void main() {
TestZulipBinding.ensureInitialized();

Future<void> setupChannelListPage(WidgetTester tester, {
required List<ZulipStream> streams,
required List<Subscription> subscriptions
}) async {
addTearDown(testBinding.reset);
final initialSnapshot = eg.initialSnapshot(
subscriptions: subscriptions,
streams: streams,
);
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);

await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const ChannelListPage()));

// global store, per-account store
await tester.pumpAndSettle();
}

void checkItemCount(int expectedCount) {
check(find.byType(ChannelItem).evaluate().length).equals(expectedCount);
}

testWidgets('smoke', (tester) async {
await setupChannelListPage(tester, streams: [], subscriptions: []);
checkItemCount(0);
check(find.text('There are no channels you can view in this organization.').evaluate()).single;
});

testWidgets('basic list', (tester) async {
final streams = List.generate(3, (index) => eg.stream());
await setupChannelListPage(tester, streams: streams, subscriptions: []);
checkItemCount(3);
});

group('list ordering', () {
Iterable<String> listedStreamNames(WidgetTester tester) => tester
.widgetList<ChannelItem>(find.byType(ChannelItem))
.map((e) => e.stream.name);

List<ZulipStream> streamsFromNames(List<String> names) {
return names.map((name) => eg.stream(name: name)).toList();
}

testWidgets('is alphabetically case-insensitive', (tester) async {
final streams = streamsFromNames(['b', 'C', 'A']);
await setupChannelListPage(tester, streams: streams, subscriptions: []);

check(listedStreamNames(tester)).deepEquals(['A', 'b', 'C']);
});

testWidgets('is insensitive of user subscription', (tester) async {
final streams = streamsFromNames(['b', 'c', 'a']);
await setupChannelListPage(tester, streams: streams,
subscriptions: [eg.subscription(streams[0])]);

check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']);
});
});
}
34 changes: 33 additions & 1 deletion test/widgets/subscription_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ void main() {
required List<Subscription> subscriptions,
List<UserTopicItem> userTopics = const [],
UnreadMessagesSnapshot? unreadMsgs,
List<ZulipStream>? streams,
}) async {
addTearDown(testBinding.reset);
final initialSnapshot = eg.initialSnapshot(
subscriptions: subscriptions,
streams: subscriptions,
streams: streams ?? subscriptions,
userTopics: userTopics,
unreadMsgs: unreadMsgs,
);
Expand Down Expand Up @@ -57,6 +58,37 @@ void main() {
check(isUnpinnedHeaderInTree()).isFalse();
});

testWidgets('link to channels is shown with 1 unsubscribed channel', (tester) async {
final streams = List.generate(2, (index) => eg.stream());
await setupStreamListPage(tester,
streams: streams,
subscriptions: [eg.subscription(streams[1])]);

check(find.text('Browse 1 more channel').evaluate()).isNotEmpty();
});

testWidgets('link to channels is shown with n unsubscribed channels', (tester) async {
final streams = List.generate(5, (index) => eg.stream());
await setupStreamListPage(tester,
streams: streams,
subscriptions: [eg.subscription(streams[1])]);

check(find.text('Browse 4 more channels').evaluate()).isNotEmpty();
});

testWidgets('link to channels is shown with 0 unsubscribed channels', (tester) async {
final subscriptions = List.generate(5, (index) => eg.subscription(eg.stream()));
await setupStreamListPage(tester, subscriptions: subscriptions);

check(find.text('Browse all channels').evaluate()).isNotEmpty();
});

testWidgets('link to channels is not shown if there are no channels', (tester) async {
await setupStreamListPage(tester, streams: [], subscriptions: []);

check(find.text('Browse all channels').evaluate()).isEmpty();
});

testWidgets('basic subscriptions', (tester) async {
await setupStreamListPage(tester, subscriptions: [
eg.subscription(eg.stream(streamId: 1), pinToTop: true),
Expand Down

0 comments on commit 494e969

Please sign in to comment.