Skip to content

Commit 368ad56

Browse files
committed
channel_list: Implement unsubscribe from channel
1 parent 296a844 commit 368ad56

File tree

3 files changed

+125
-7
lines changed

3 files changed

+125
-7
lines changed

assets/l10n/app_en.arb

+14
Original file line numberDiff line numberDiff line change
@@ -573,5 +573,19 @@
573573
"placeholders": {
574574
"channelName": {"type": "String", "example": "announce"}
575575
}
576+
},
577+
"messageUnsubscribedFromChannel": "You've unsubscribed from #{channelName}.",
578+
"@messageUnsubscribedFromChannel": {
579+
"description": "A message shown to inform user that unsubscribe action passes",
580+
"placeholders": {
581+
"channelName": {"type": "String", "example": "announce"}
582+
}
583+
},
584+
"errorFailedToUnsubscribedFromChannel": "Failed to unsubscribe to #{channelName}.",
585+
"@errorFailedToUnsubscribedFromChannel": {
586+
"description": "An error message when unsubscribe action fails",
587+
"placeholders": {
588+
"channelName": {"type": "String", "example": "announce"}
589+
}
576590
}
577591
}

lib/widgets/channel_list.dart

+37-7
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,22 @@ class ChannelItem extends StatelessWidget {
104104
overflow: TextOverflow.ellipsis),
105105
])),
106106
const SizedBox(width: 8),
107-
if (stream is! Subscription) _ChannelItemSubscribeButton(stream: stream, channelItemContext: context),
107+
_ChannelItemSubscriptionToggle(stream: stream, channelItemContext: context),
108108
]))));
109109
}
110110
}
111111

112-
class _ChannelItemSubscribeButton extends StatefulWidget {
113-
const _ChannelItemSubscribeButton({required this.stream, required this.channelItemContext});
112+
class _ChannelItemSubscriptionToggle extends StatefulWidget {
113+
const _ChannelItemSubscriptionToggle({required this.stream, required this.channelItemContext});
114114

115115
final ZulipStream stream;
116116
final BuildContext channelItemContext;
117117

118118
@override
119-
State<_ChannelItemSubscribeButton> createState() => _ChannelItemSubscribeButtonState();
119+
State<_ChannelItemSubscriptionToggle> createState() => _ChannelItemSubscriptionToggleState();
120120
}
121121

122-
class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton> {
122+
class _ChannelItemSubscriptionToggleState extends State<_ChannelItemSubscriptionToggle> {
123123
bool _isLoading = false;
124124

125125
void _setIsLoading(bool value) {
@@ -129,15 +129,45 @@ class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton
129129

130130
@override
131131
Widget build(BuildContext context) {
132+
final colorScheme = Theme.of(context).colorScheme;
133+
final (icon, color, onPressed) = widget.stream is Subscription
134+
? (Icons.check, colorScheme.primary, _unsubscribeFromChannel)
135+
: (Icons.add, null, _subscribeToChannel);
136+
132137
return IconButton(
133-
icon: const Icon(Icons.add),
138+
color: color,
139+
icon: Icon(icon),
134140
onPressed: _isLoading ? null : () async {
135141
_setIsLoading(true);
136-
await _subscribeToChannel(context, widget.stream);
142+
await onPressed(context, widget.stream);
137143
_setIsLoading(false);
138144
});
139145
}
140146

147+
Future<void> _unsubscribeFromChannel(BuildContext context, ZulipStream stream) async {
148+
final store = PerAccountStoreWidget.of(context);
149+
final connection = store.connection;
150+
final scaffoldMessenger = ScaffoldMessenger.of(context);
151+
final zulipLocalizations = ZulipLocalizations.of(context);
152+
try {
153+
final res = await unsubscribeFromChannels(connection, [stream]);
154+
if (!context.mounted) return;
155+
if (res.removed.contains(stream.name)) {
156+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
157+
content: Text(zulipLocalizations.messageUnsubscribedFromChannel(stream.name))));
158+
} else if (res.notRemoved.contains(stream.name)) {
159+
scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating,
160+
content: Text(zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name))));
161+
}
162+
} catch (e) {
163+
if (!context.mounted) return;
164+
final zulipLocalizations = ZulipLocalizations.of(context);
165+
await showErrorDialog(context: context,
166+
title: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
167+
message: e.toString()); // TODO(#741): extract user-facing message better
168+
}
169+
}
170+
141171
Future<void> _subscribeToChannel(BuildContext context, ZulipStream stream) async {
142172
final store = PerAccountStoreWidget.of(context);
143173
final connection = store.connection;

test/widgets/channel_list_test.dart

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

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

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

111121
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
122+
check(find.byIcon(Icons.check).evaluate()).isEmpty();
112123

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

117128
check(find.byIcon(Icons.add).evaluate()).isEmpty();
129+
check(find.byIcon(Icons.check).evaluate()).isNotEmpty();
118130

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

122134
check(find.byIcon(Icons.add).evaluate()).isNotEmpty();
135+
check(find.byIcon(Icons.check).evaluate()).isEmpty();
123136
});
124137

125138
testWidgets('is disabled while loading', (WidgetTester tester) async {
@@ -228,5 +241,66 @@ void main() {
228241
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
229242
});
230243
});
244+
245+
group('unsubscribe', () {
246+
testWidgets('is shown only for streams that user is subscribed to', (tester) async {
247+
final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())];
248+
final subscriptions = [streams[2] as Subscription];
249+
await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions);
250+
251+
check(find.byIcon(Icons.check).evaluate().length).equals(1);
252+
});
253+
254+
testWidgets('smoke api', (tester) async {
255+
final stream = await prepareSingleSubscription(tester);
256+
connection.prepare(json: UnsubscribeFromChannelsResult(
257+
removed: [stream.name],
258+
notRemoved: []).toJson());
259+
await tapUnsubscribeButton(tester);
260+
261+
await tester.pump(Duration.zero);
262+
await tester.pumpAndSettle();
263+
check(connection.lastRequest).isA<http.Request>()
264+
..method.equals('DELETE')
265+
..url.path.equals('/api/v1/users/me/subscriptions')
266+
..bodyFields.deepEquals({
267+
'subscriptions': jsonEncode([stream.name])
268+
});
269+
});
270+
271+
testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async {
272+
final stream = await prepareSingleSubscription(tester);
273+
connection.prepare(json: UnsubscribeFromChannelsResult(
274+
removed: [stream.name],
275+
notRemoved: []).toJson());
276+
await tapUnsubscribeButton(tester);
277+
278+
await waitAndCheckSnackbarIsShown(tester,
279+
zulipLocalizations.messageUnsubscribedFromChannel(stream.name));
280+
});
281+
282+
testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async {
283+
final stream = await prepareSingleSubscription(tester);
284+
connection.prepare(json: UnsubscribeFromChannelsResult(
285+
removed: [],
286+
notRemoved: [stream.name]).toJson());
287+
await tapUnsubscribeButton(tester);
288+
289+
await waitAndCheckSnackbarIsShown(tester,
290+
zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name));
291+
});
292+
293+
testWidgets('catch-all api errors', (WidgetTester tester) async {
294+
final stream = await prepareSingleSubscription(tester);
295+
connection.prepare(exception: http.ClientException('Oops'));
296+
await tapUnsubscribeButton(tester);
297+
await tester.pump(Duration.zero);
298+
await tester.pumpAndSettle();
299+
300+
checkErrorDialog(tester,
301+
expectedTitle: zulipLocalizations.errorFailedToUnsubscribedFromChannel(stream.name),
302+
expectedMessage: 'NetworkException: Oops (ClientException: Oops)');
303+
});
304+
});
231305
});
232306
}

0 commit comments

Comments
 (0)