Skip to content

Commit bdd4494

Browse files
committed
channel_list: Implement unsubscribe from channel
1 parent 3e68b33 commit bdd4494

File tree

3 files changed

+124
-5
lines changed

3 files changed

+124
-5
lines changed

assets/l10n/app_en.arb

+14
Original file line numberDiff line numberDiff line change
@@ -546,5 +546,19 @@
546546
"placeholders": {
547547
"channelName": {"type": "String", "example": "announce"}
548548
}
549+
},
550+
"messageUnsubscribedFromChannel": "You've unsubscribed from {channelName}!",
551+
"@messageUnsubscribedFromChannel": {
552+
"description": "A message shown to inform user that unsubscribe action passes",
553+
"placeholders": {
554+
"channelName": {"type": "String", "example": "announce"}
555+
}
556+
},
557+
"errorFailedToUnsubscribedFromChannel": "Failed to unsubscribe to {channelName}",
558+
"@errorFailedToUnsubscribedFromChannel": {
559+
"description": "An error message when unsubscribe action fails",
560+
"placeholders": {
561+
"channelName": {"type": "String", "example": "announce"}
562+
}
549563
}
550564
}

lib/widgets/channel_list.dart

+36-5
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,52 @@ class ChannelItem extends StatelessWidget {
9595
overflow: TextOverflow.ellipsis),
9696
])),
9797
const SizedBox(width: 8),
98-
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream),
98+
_ChannelItemSubscriptionToggle(stream: stream),
9999
]))));
100100
}
101101
}
102102

103-
class _ChannelItemSubscribeButton extends StatelessWidget {
104-
const _ChannelItemSubscribeButton({required this.stream});
103+
class _ChannelItemSubscriptionToggle extends StatelessWidget {
104+
const _ChannelItemSubscriptionToggle({required this.stream});
105105

106106
final ZulipStream stream;
107107

108108
@override
109109
Widget build(BuildContext context) {
110+
final colorScheme = Theme.of(context).colorScheme;
111+
final (icon, color, onPressed) = stream is Subscription
112+
? (Icons.check, colorScheme.primary, _unsubscribeFromChannel)
113+
: (Icons.add, null, _subscribeToChannel);
114+
110115
return IconButton(
111-
icon: const Icon(Icons.add),
112-
onPressed: () => _subscribeToChannel(context, stream));
116+
color: color,
117+
icon: Icon(icon),
118+
onPressed: () => onPressed(context, stream));
119+
}
120+
121+
Future<void> _unsubscribeFromChannel(BuildContext context, ZulipStream stream) async {
122+
final store = PerAccountStoreWidget.of(context);
123+
final connection = store.connection;
124+
final scaffoldMessenger = ScaffoldMessenger.of(context);
125+
final zulipLocalizations = ZulipLocalizations.of(context);
126+
try {
127+
final res = await unsubscribeFromChannels(connection, [stream]);
128+
if (!context.mounted) return;
129+
scaffoldMessenger.clearSnackBars();
130+
if (res.removed?.contains(stream.name) ?? false) {
131+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
132+
content: Text(zulipLocalizations.messageUnsubscribedFromChannel(stream.name))));
133+
} else if (res.notRemoved?.contains(stream.name) ?? false) {
134+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
135+
content: Text(zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name))));
136+
}
137+
} catch (e) {
138+
if (!context.mounted) return;
139+
final zulipLocalizations = ZulipLocalizations.of(context);
140+
await showErrorDialog(context: context,
141+
title: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
142+
message: e.toString()); // TODO(#741): extract user-facing message better
143+
}
113144
}
114145

115146
Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {

test/widgets/channel_list_test.dart

+74
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,20 @@ void main() {
9191
return stream;
9292
}
9393

94+
Future<Subscription> prepareSingleSubscription(WidgetTester tester) async {
95+
final stream = eg.subscription(eg.stream());
96+
await setupChannelListPage(tester, streams: [stream], subscriptions: [stream]);
97+
return stream;
98+
}
99+
94100
Future<void> tapSubscribeButton(WidgetTester tester) async {
95101
await tester.tap(find.byIcon(Icons.add));
96102
}
97103

104+
Future<void> tapUnsubscribeButton(WidgetTester tester) async {
105+
await tester.tap(find.byIcon(Icons.check));
106+
}
107+
98108
Future<void> waitAndCheckSnackbarIsShown(WidgetTester tester, String message) async {
99109
await tester.pump(Duration.zero);
100110
await tester.pumpAndSettle();
@@ -108,17 +118,20 @@ void main() {
108118
alreadySubscribed: {}).toJson());
109119

110120
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
121+
check(find.byIcon(Icons.check).evaluate()).isEmpty();
111122

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

116127
check(find.byIcon(Icons.add).evaluate()).isEmpty();
128+
check(find.byIcon(Icons.check).evaluate()).isNotEmpty();
117129

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

121133
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
134+
check(find.byIcon(Icons.check).evaluate()).isEmpty();
122135
}, skip: true);
123136

124137
group('subscribe', () {
@@ -193,5 +206,66 @@ void main() {
193206
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
194207
}, skip: true);
195208
});
209+
210+
group('unsubscribe', () {
211+
testWidgets('is shown only for streams that user is subscribed to', (tester) async {
212+
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
213+
final subscriptions = [streams[2]];
214+
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions.cast());
215+
216+
check(find.byIcon(Icons.check).evaluate().length).equals(1);
217+
});
218+
219+
testWidgets('smoke api', (tester) async {
220+
final stream = await prepareSingleSubscription(tester);
221+
connection.prepare(json: UnsubscribeFromChannelsResult(
222+
removed: [stream.name],
223+
notRemoved: []).toJson());
224+
await tapUnsubscribeButton(tester);
225+
226+
await tester.pump(Duration.zero);
227+
await tester.pumpAndSettle();
228+
check(connection.lastRequest).isA<http.Request>()
229+
..method.equals('DELETE')
230+
..url.path.equals('/api/v1/users/me/subscriptions')
231+
..bodyFields.deepEquals({
232+
'subscriptions': jsonEncode([stream.name])
233+
});
234+
});
235+
236+
testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
237+
final stream = await prepareSingleSubscription(tester);
238+
connection.prepare(json: UnsubscribeFromChannelsResult(
239+
removed: [stream.name],
240+
notRemoved: []).toJson());
241+
await tapUnsubscribeButton(tester);
242+
243+
await waitAndCheckSnackbarIsShown(tester,
244+
zulipLocalizations.messageUnsubscribedFromChannel(stream.name));
245+
}, skip: true);
246+
247+
testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
248+
final stream = await prepareSingleSubscription(tester);
249+
connection.prepare(json: UnsubscribeFromChannelsResult(
250+
removed: [],
251+
notRemoved: [stream.name]).toJson());
252+
await tapUnsubscribeButton(tester);
253+
254+
await waitAndCheckSnackbarIsShown(tester,
255+
zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name));
256+
}, skip: true);
257+
258+
testWidgets('catch-all api errors', (WidgetTester tester) async {
259+
final stream = await prepareSingleSubscription(tester);
260+
connection.prepare(exception: http.ClientException('Oops'));
261+
await tapUnsubscribeButton(tester);
262+
await tester.pump(Duration.zero);
263+
await tester.pumpAndSettle();
264+
265+
checkErrorDialog(tester,
266+
expectedTitle: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
267+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
268+
}, skip: true);
269+
});
196270
});
197271
}

0 commit comments

Comments
 (0)