Skip to content

Commit

Permalink
channel_list: Implement subscribe to channel
Browse files Browse the repository at this point in the history
Fixes: #188
  • Loading branch information
Khader-1 committed Aug 29, 2024
1 parent 3e3e4d5 commit 296a844
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 4 deletions.
25 changes: 23 additions & 2 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,8 @@
"num": {"type": "int", "example": "4"}
}
},
"browseMoreNChannels": "Browse {num, plural, =0{no other channels} =1{1 more channel} other{{num} more channels}}",
"@browseMoreNChannels": {
"browseNMoreChannels": "Browse {num, plural, =1{1 more channel} other{{num} more channels}}",
"@browseNMoreChannels": {
"description": "Label showing the number of other channels that user can subscribe to",
"placeholders": {
"num": {"type": "int", "example": "4"}
Expand Down Expand Up @@ -552,5 +552,26 @@
"manyPeopleTyping": "Several people are typing…",
"@manyPeopleTyping": {
"description": "Text to display when there are multiple users typing."
},
"messageSubscribedToChannel": "You've just subscribed to #{channelName}.",
"@messageSubscribedToChannel": {
"description": "A message shown to inform user that subscription is successful",
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
},
"messageAlreadySubscribedToChannel": "You're already subscribed to #{channelName}.",
"@messageAlreadySubscribedToChannel": {
"description": "A message shown to inform user that subscription is already made",
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
},
"errorFailedToSubscribedToChannel": "Failed to subscribe to #{channelName}.",
"@errorFailedToSubscribedToChannel": {
"description": "An error message when subscribe action fails",
"placeholders": {
"channelName": {"type": "String", "example": "announce"}
}
}
}
69 changes: 69 additions & 0 deletions lib/widgets/channel_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../api/model/model.dart';
import '../api/route/channels.dart';
import '../model/narrow.dart';
import 'app_bar.dart';
import '../model/store.dart';
import 'dialog.dart';
import 'icons.dart';
import 'message_list.dart';
import 'page.dart';
Expand Down Expand Up @@ -68,6 +71,7 @@ class ChannelItem extends StatelessWidget {
Widget build(BuildContext context) {
final designVariables = DesignVariables.of(context);
return Material(
// TODO(design) check if this is the right variable
color: designVariables.background,
child: InkWell(
onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context,
Expand Down Expand Up @@ -99,6 +103,71 @@ class ChannelItem extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis),
])),
const SizedBox(width: 8),
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream, channelItemContext: context),
]))));
}
}

class _ChannelItemSubscribeButton extends StatefulWidget {
const _ChannelItemSubscribeButton({required this.stream, required this.channelItemContext});

final ZulipStream stream;
final BuildContext channelItemContext;

@override
State<_ChannelItemSubscribeButton> createState() => _ChannelItemSubscribeButtonState();
}

class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton> {
bool _isLoading = false;

void _setIsLoading(bool value) {
if (!mounted) return;
setState(() => _isLoading = value);
}

@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.add),
onPressed: _isLoading ? null : () async {
_setIsLoading(true);
await _subscribeToChannel(context, widget.stream);
_setIsLoading(false);
});
}

Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {
final store = PerAccountStoreWidget.of(context);
final connection = store.connection;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
try {
final res = await subscribeToChannels(connection, [stream]);
if (!context.mounted) return;
if (_emailSubscriptionsContains(store, res.subscribed, stream.name)) {
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.messageSubscribedToChannel(stream.name))));
} else if (_emailSubscriptionsContains(store, res.alreadySubscribed, stream.name)) {
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.messageAlreadySubscribedToChannel(stream.name))));
} else {
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
content: Text(zulipLocalizations.errorFailedToSubscribedToChannel(stream.name))));
}
} catch (e) {
if (!context.mounted) return;
final zulipLocalizations = ZulipLocalizations.of(context);
showErrorDialog(context: context,
title: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
message: e.toString()); // TODO(#741): extract user-facing message better
}
}

bool _emailSubscriptionsContains(PerAccountStore store, Map<String, List<String>> emailSubs, String subscription) {
final expectedEmail = store.users[store.selfUserId]?.email;
final found = emailSubs[expectedEmail]?.contains(subscription);
return found ?? false;
}
}
2 changes: 1 addition & 1 deletion lib/widgets/subscription_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ class _ChannelListLinkItem extends StatelessWidget {
final notShownStreams = store.streams.length - store.subscriptions.length;
final zulipLocalizations = ZulipLocalizations.of(context);
final label = notShownStreams != 0
? zulipLocalizations.browseMoreNChannels(notShownStreams)
? zulipLocalizations.browseNMoreChannels(notShownStreams)
: zulipLocalizations.browseAllChannels;
return SliverToBoxAdapter(
child: Material(
Expand Down
164 changes: 163 additions & 1 deletion test/widgets/channel_list_test.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import 'dart:convert';

import 'package:checks/checks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/channels.dart';
import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/channel_list.dart';

import '../api/fake_api.dart';
import '../model/binding.dart';
import '../example_data.dart' as eg;
import '../stdlib_checks.dart';
import 'dialog_checks.dart';
import 'test_app.dart';

void main() {
TestZulipBinding.ensureInitialized();
late FakeApiConnection connection;
late PerAccountStore store;

Future<void> setupChannelListPage(WidgetTester tester, {
required List<ZulipStream> streams,
Expand All @@ -18,8 +31,10 @@ void main() {
final initialSnapshot = eg.initialSnapshot(
subscriptions: subscriptions,
streams: streams,
);
realmUsers: [eg.selfUser]);
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
connection = store.connection as FakeApiConnection;

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

Expand Down Expand Up @@ -67,4 +82,151 @@ void main() {
check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']);
});
});

group('subscription toggle', () {
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;

Future<ZulipStream> prepareSingleStream(WidgetTester tester) async {
final stream = eg.stream();
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
return stream;
}

Future<void> tapSubscribeButton(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.add));
}

Future<void> waitAndCheckSnackbarIsShown(WidgetTester tester, String message) async {
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
check(find.text(message).evaluate()).isNotEmpty();
}

testWidgets('is affected by subscription events', (WidgetTester tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {eg.selfUser.email: [stream.name]},
alreadySubscribed: {}).toJson());

check(find.byIcon(Icons.add).evaluate()).isNotEmpty();

await store.handleEvent(SubscriptionAddEvent(id: 1,
subscriptions: [eg.subscription(stream)]));
await tester.pumpAndSettle();

check(find.byIcon(Icons.add).evaluate()).isEmpty();

await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId]));
await tester.pumpAndSettle();

check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
});

testWidgets('is disabled while loading', (WidgetTester tester) async {
final stream = eg.stream();
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {eg.selfUser.email: [stream.name]},
alreadySubscribed: {}).toJson());
await tapSubscribeButton(tester);
await tester.pump();

check(tester.widget<IconButton>(
find.byType(IconButton)).onPressed).isNull();

await tester.pump(const Duration(seconds: 2));

check(tester.widget<IconButton>(
find.byType(IconButton)).onPressed).isNotNull();
});

testWidgets('is disabled while loading and enabled back when loading fails', (WidgetTester tester) async {
final stream = eg.stream();
await setupChannelListPage(tester, streams: [stream], subscriptions: []);
connection.prepare(exception: http.ClientException('Oops'), delay: const Duration(seconds: 2));
await tapSubscribeButton(tester);
await tester.pump();

check(tester.widget<IconButton>(
find.byType(IconButton)).onPressed).isNull();

await tester.pump(const Duration(seconds: 2));

check(tester.widget<IconButton>(
find.byType(IconButton)).onPressed).isNotNull();
});

group('subscribe', () {
testWidgets('is shown only for streams that user is not subscribed to', (tester) async {
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
final subscriptions = [streams[2] as Subscription];
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions);

check(find.byIcon(Icons.add).evaluate().length).equals(2);
});

testWidgets('smoke api', (tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {eg.selfUser.email: [stream.name]},
alreadySubscribed: {}).toJson());
await tapSubscribeButton(tester);

await tester.pump(Duration.zero);
await tester.pumpAndSettle();
check(connection.lastRequest).isA<http.Request>()
..method.equals('POST')
..url.path.equals('/api/v1/users/me/subscriptions')
..bodyFields.deepEquals({
'subscriptions': jsonEncode([{'name': stream.name}])
});
});

testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {eg.selfUser.email: [stream.name]},
alreadySubscribed: {}).toJson());
await tapSubscribeButton(tester);

await waitAndCheckSnackbarIsShown(tester,
zulipLocalizations.messageSubscribedToChannel(stream.name));
});

testWidgets('shows a snackbar when already subscribed', (WidgetTester tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {},
alreadySubscribed: {eg.selfUser.email: [stream.name]}).toJson());
await tapSubscribeButton(tester);

await waitAndCheckSnackbarIsShown(tester,
zulipLocalizations.messageAlreadySubscribedToChannel(stream.name));
});

testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(json: SubscribeToChannelsResult(
subscribed: {},
alreadySubscribed: {},
unauthorized: [stream.name]).toJson());
await tapSubscribeButton(tester);

await waitAndCheckSnackbarIsShown(tester,
zulipLocalizations.errorFailedToSubscribedToChannel(stream.name));
});

testWidgets('catch-all api errors', (WidgetTester tester) async {
final stream = await prepareSingleStream(tester);
connection.prepare(exception: http.ClientException('Oops'));
await tapSubscribeButton(tester);
await tester.pump(Duration.zero);
await tester.pumpAndSettle();

checkErrorDialog(tester,
expectedTitle: zulipLocalizations.errorFailedToSubscribedToChannel(stream.name),
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
});
});
});
}

0 comments on commit 296a844

Please sign in to comment.