Skip to content

Commit f816da7

Browse files
committed
channel_list: Implement unsubscribe from channel
1 parent bf383fe commit f816da7

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
@@ -550,5 +550,19 @@
550550
"placeholders": {
551551
"channelName": {"type": "String", "example": "announce"}
552552
}
553+
},
554+
"messageUnsubscribedFromChannel": "You've unsubscribed from {channelName}!",
555+
"@messageUnsubscribedFromChannel": {
556+
"description": "A message shown to inform user that unsubscribe action passes",
557+
"placeholders": {
558+
"channelName": {"type": "String", "example": "announce"}
559+
}
560+
},
561+
"errorFailedToUnsubscribedFromChannel": "Failed to unsubscribe to {channelName}",
562+
"@errorFailedToUnsubscribedFromChannel": {
563+
"description": "An error message when unsubscribe action fails",
564+
"placeholders": {
565+
"channelName": {"type": "String", "example": "announce"}
566+
}
553567
}
554568
}

lib/widgets/channel_list.dart

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

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

114114
final ZulipStream stream;
115115
final BuildContext channelItemContext;
116116

117117
@override
118-
State<_ChannelItemSubscribeButton> createState() => _ChannelItemSubscribeButtonState();
118+
State<_ChannelItemSubscriptionToggle> createState() => _ChannelItemSubscriptionToggleState();
119119
}
120120

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

124124
void _setIsLoading(bool value) {
@@ -128,15 +128,45 @@ class _ChannelItemSubscribeButtonState extends State<_ChannelItemSubscribeButton
128128

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

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