Skip to content

Commit ebffb0c

Browse files
committed
actions [nfc]: Add PlatformActions, alongside ZulipActions
And bring over one function that fits well here. This seems like a good place to put a function for opening a link; we'll do that next.
1 parent 41ed578 commit ebffb0c

File tree

7 files changed

+123
-124
lines changed

7 files changed

+123
-124
lines changed

lib/model/binding.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ typedef FirebaseRemoteMessage = firebase_messaging.RemoteMessage;
3232
///
3333
/// Most code should not interact with the bindings directly.
3434
/// Instead, use the corresponding higher-level APIs that expose the bindings'
35-
/// functionality in a widget-oriented way.
35+
/// functionality in a widget-oriented way; see [PlatformActions] for some.
3636
///
3737
/// This piece of architecture is modelled on the "binding" classes in Flutter
3838
/// itself. For discussion, see [BindingBase], [WidgetsFlutterBinding], and

lib/widgets/action_sheet.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import '../model/emoji.dart';
1515
import '../model/internal_link.dart';
1616
import '../model/narrow.dart';
1717
import 'actions.dart';
18-
import 'clipboard.dart';
1918
import 'color.dart';
2019
import 'compose_box.dart';
2120
import 'dialog.dart';
@@ -910,7 +909,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton {
910909

911910
if (!pageContext.mounted) return;
912911

913-
copyWithPopup(context: pageContext,
912+
PlatformActions.copyWithPopup(context: pageContext,
914913
successContent: Text(zulipLocalizations.successMessageTextCopied),
915914
data: ClipboardData(text: rawContent));
916915
}
@@ -936,7 +935,7 @@ class CopyMessageLinkButton extends MessageActionSheetMenuItemButton {
936935
nearMessageId: message.id,
937936
);
938937

939-
copyWithPopup(context: pageContext,
938+
PlatformActions.copyWithPopup(context: pageContext,
940939
successContent: Text(zulipLocalizations.successMessageLinkCopied),
941940
data: ClipboardData(text: messageLink.toString()));
942941
}

lib/widgets/actions.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import 'dart:async';
22

33
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
45

56
import '../api/exception.dart';
67
import '../api/model/model.dart';
78
import '../api/model/narrow.dart';
89
import '../api/route/messages.dart';
910
import '../generated/l10n/zulip_localizations.dart';
11+
import '../model/binding.dart';
1012
import '../model/narrow.dart';
1113
import 'dialog.dart';
1214
import 'store.dart';
@@ -239,3 +241,44 @@ abstract final class ZulipAction {
239241
}
240242
}
241243
}
244+
245+
/// Methods that act through platform APIs and show feedback in the UI.
246+
///
247+
/// The static methods on this class can be thought of as higher-level wrappers
248+
/// for some of the platform binding methods in [ZulipBinding].
249+
/// But they don't belong there, because they also interact with widgets
250+
/// in order to present success or error feedback to the user through the UI.
251+
abstract final class PlatformActions {
252+
/// Copies [data] to the clipboard and shows a popup on success.
253+
///
254+
/// Must have a [Scaffold] ancestor.
255+
///
256+
/// On newer Android the popup is defined and shown by the platform. On older
257+
/// Android and on iOS, shows a [Snackbar] with [successContent].
258+
///
259+
/// In English, the text in [successContent] should be short, should start with
260+
/// a capital letter, and should have no ending punctuation: "{noun} copied".
261+
static void copyWithPopup({
262+
required BuildContext context,
263+
required ClipboardData data,
264+
required Widget successContent,
265+
}) async {
266+
await Clipboard.setData(data);
267+
final deviceInfo = await ZulipBinding.instance.deviceInfo;
268+
269+
if (!context.mounted) return;
270+
271+
final shouldShowSnackbar = switch (deviceInfo) {
272+
// Android 13+ shows its own popup on copying to the clipboard,
273+
// so we suppress ours, following the advice at:
274+
// https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
275+
// TODO(android-sdk-33): Simplify this and dartdoc
276+
AndroidDeviceInfo(:var sdkInt) => sdkInt <= 32,
277+
_ => true,
278+
};
279+
if (shouldShowSnackbar) {
280+
ScaffoldMessenger.of(context).showSnackBar(
281+
SnackBar(behavior: SnackBarBehavior.floating, content: successContent));
282+
}
283+
}
284+
}

lib/widgets/clipboard.dart

Lines changed: 0 additions & 37 deletions
This file was deleted.

lib/widgets/lightbox.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import '../api/model/model.dart';
99
import '../generated/l10n/zulip_localizations.dart';
1010
import '../log.dart';
1111
import '../model/binding.dart';
12+
import 'actions.dart';
1213
import 'content.dart';
1314
import 'dialog.dart';
1415
import 'page.dart';
15-
import 'clipboard.dart';
1616
import 'store.dart';
1717

1818
// TODO(#44): Add index of the image preview in the message, to not break if
@@ -82,7 +82,7 @@ class _CopyLinkButton extends StatelessWidget {
8282
tooltip: zulipLocalizations.lightboxCopyLinkTooltip,
8383
icon: const Icon(Icons.copy),
8484
onPressed: () async {
85-
copyWithPopup(context: context,
85+
PlatformActions.copyWithPopup(context: context,
8686
successContent: Text(zulipLocalizations.successLinkCopied),
8787
data: ClipboardData(text: url.toString()));
8888
});

test/widgets/actions_test.dart

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@ import 'dart:convert';
22

33
import 'package:checks/checks.dart';
44
import 'package:flutter/material.dart';
5+
import 'package:flutter/services.dart';
56
import 'package:flutter_test/flutter_test.dart';
67
import 'package:http/http.dart' as http;
78
import 'package:zulip/api/model/initial_snapshot.dart';
89
import 'package:zulip/api/model/model.dart';
910
import 'package:zulip/api/model/narrow.dart';
1011
import 'package:zulip/api/route/messages.dart';
12+
import 'package:zulip/model/binding.dart';
1113
import 'package:zulip/model/localizations.dart';
1214
import 'package:zulip/model/narrow.dart';
1315
import 'package:zulip/model/store.dart';
1416
import 'package:zulip/widgets/actions.dart';
1517

1618
import '../api/fake_api.dart';
1719
import '../example_data.dart' as eg;
20+
import '../flutter_checks.dart';
1821
import '../model/binding.dart';
1922
import '../model/unreads_checks.dart';
2023
import '../stdlib_checks.dart';
24+
import '../test_clipboard.dart';
2125
import 'dialog_checks.dart';
2226
import 'test_app.dart';
2327

@@ -340,4 +344,75 @@ void main() {
340344
});
341345
});
342346
});
347+
348+
group('PlatformActions', () {
349+
TestZulipBinding.ensureInitialized();
350+
TestWidgetsFlutterBinding.ensureInitialized();
351+
352+
setUp(() async {
353+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
354+
SystemChannels.platform,
355+
MockClipboard().handleMethodCall,
356+
);
357+
});
358+
359+
tearDown(() async {
360+
testBinding.reset();
361+
});
362+
363+
group('copyWithPopup', () {
364+
Future<void> call(WidgetTester tester, {required String text}) async {
365+
await tester.pumpWidget(TestZulipApp(
366+
child: Scaffold(
367+
body: Builder(builder: (context) => Center(
368+
child: ElevatedButton(
369+
onPressed: () async {
370+
PlatformActions.copyWithPopup(context: context,
371+
successContent: const Text('Text copied'),
372+
data: ClipboardData(text: text));
373+
},
374+
child: const Text('Copy')))))));
375+
await tester.pump();
376+
await tester.tap(find.text('Copy'));
377+
await tester.pump(); // copy
378+
await tester.pump(Duration.zero); // await platform info (awkwardly async)
379+
}
380+
381+
Future<void> checkSnackBar(WidgetTester tester, {required bool expected}) async {
382+
if (!expected) {
383+
check(tester.widgetList(find.byType(SnackBar))).isEmpty();
384+
return;
385+
}
386+
final snackBar = tester.widget<SnackBar>(find.byType(SnackBar));
387+
check(snackBar.behavior).equals(SnackBarBehavior.floating);
388+
tester.widget(find.descendant(matchRoot: true,
389+
of: find.byWidget(snackBar.content), matching: find.text('Text copied')));
390+
}
391+
392+
Future<void> checkClipboardText(String expected) async {
393+
check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expected);
394+
}
395+
396+
testWidgets('iOS', (tester) async {
397+
testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0');
398+
await call(tester, text: 'asdf');
399+
await checkClipboardText('asdf');
400+
await checkSnackBar(tester, expected: true);
401+
});
402+
403+
testWidgets('Android', (tester) async {
404+
testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 33, release: '13');
405+
await call(tester, text: 'asdf');
406+
await checkClipboardText('asdf');
407+
await checkSnackBar(tester, expected: false);
408+
});
409+
410+
testWidgets('Android <13', (tester) async {
411+
testBinding.deviceInfoResult = const AndroidDeviceInfo(sdkInt: 32, release: '12');
412+
await call(tester, text: 'asdf');
413+
await checkClipboardText('asdf');
414+
await checkSnackBar(tester, expected: true);
415+
});
416+
});
417+
});
343418
}

test/widgets/clipboard_test.dart

Lines changed: 0 additions & 81 deletions
This file was deleted.

0 commit comments

Comments
 (0)