-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathappcues_flutter.dart
403 lines (353 loc) · 14.4 KB
/
appcues_flutter.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
/// A set of options that can be configured when initializing the Appcues
/// plugin.
class AppcuesOptions {
/// Determines whether logging is enabled.
bool? logging;
/// The API host path to be used for Appcues requests.
String? apiHost;
/// The timeout value, in seconds, used to determine if a new session is
/// started upon the application returning to the foreground.
///
/// The default value is 1800 seconds (30 minutes).
int? sessionTimeout;
/// The number of analytics requests that can be stored on the local device
/// and retried later, in the case of the device network connection being
/// unavailable.
///
/// Only the most recent requests, up to this count, are retained.
/// The default and maximum value is 25.
int? activityStorageMaxSize;
/// The duration, in seconds, that an analytics request can be stored on
/// the local device and retried later, in the case of the device network
/// connection being unavailable.
///
/// Only requests that are more recent than the max age will be retried.
/// There is no max age limitation if this value is left unset.
int? activityStorageMaxAge;
/// Applies to iOS only. When enabled, the iOS SDK will pass potential
/// universal links back to the host application AppDelegate function
/// `application(_:continue:restorationHandler:)`. The host
/// application is responsible for returning true if the link was handled
/// as a deep link into a screen in the app, or false if not. By default,
/// universal link support is disabled for Flutter applications, since the
/// default FlutterAppDelegate template always returns a true value from
/// `application(_:continue:restorationHandler:)`and blocks subsequent link
/// handling.
bool? enableUniversalLinks;
}
/// Captures the details about analytics events that have been reported.
class AppcuesAnalytic {
/// Indicates the type of the analytic.
///
/// Value is one of: EVENT, SCREEN, IDENTIFY, or GROUP.
String analytic;
/// Contains the primary value of the analytic being tracked.
///
/// For events - the event name, for screens - the screen title,
/// for identify - the user ID, for group - the group ID.
String value;
/// Indicates if the analytic was internally generated by the SDK,
/// as opposed to passed in from the host application.
bool isInternal;
/// Contains the properties that provide additional context about the
/// analytic.
Map<String, Object> properties;
AppcuesAnalytic._internal(
this.analytic, this.value, this.isInternal, this.properties);
}
/// A SemanticsTag that can be used to identify elements for targeting
/// Appcues content.
class AppcuesView extends SemanticsTag {
/// The identifier used to locate a view element for targeting content.
final String identifier;
/// Initialize the AppcuesView with the given [identifier].
const AppcuesView(this.identifier) : super(identifier);
}
/// This widget can be used to optionally host Appcues embedded experiences.
class AppcuesFrameView extends StatefulWidget {
/// The frame identifier used to locate this view, if embedded content
/// is eligible for rendering in this view.
final String frameId;
/// Initialize the AppcuesFrameView with the given [frameId]
const AppcuesFrameView(this.frameId, {Key? key}) : super(key: key);
@override
_AppcuesFrameViewState createState() => _AppcuesFrameViewState();
}
class _AppcuesFrameViewState extends State<AppcuesFrameView> {
// A non-zero size is needed to ensure that the native view
// layoutSubviews is called at least once. Then, the intrinsic size of
// the native view will control the SizedBox dimensions here to auto
// size contents or set to zero if hidden.
double _height = 1;
double _width = 1;
StreamSubscription? _sizeStream;
@override
Widget build(BuildContext context) {
// construct the correct native view based on platform ios / android
var nativeView = _nativeView(
viewType: 'AppcuesFrameView',
creationParams: {"frameId": widget.frameId},
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(
() => HorizontalDragGestureRecognizer(),
)
},
onPlatformViewCreated: (id) {
_sizeStream = EventChannel("com.appcues.flutter/frame/$id")
.receiveBroadcastStream()
.listen((size) => setState(() {
_height = size['height'];
_width = size['width'];
}));
});
// use the SizedBox with the height update listener (above) to auto
// size the content
return SizedBox(height: _height, width: _width, child: nativeView);
}
Widget _nativeView(
{required String viewType,
required Map<String, dynamic> creationParams,
required Set<Factory<OneSequenceGestureRecognizer>>? gestureRecognizers,
required PlatformViewCreatedCallback? onPlatformViewCreated}) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return AndroidView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
gestureRecognizers: gestureRecognizers,
onPlatformViewCreated: onPlatformViewCreated);
case TargetPlatform.iOS:
return UiKitView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
gestureRecognizers: gestureRecognizers,
onPlatformViewCreated: onPlatformViewCreated);
default:
throw UnsupportedError('Unsupported platform view');
}
}
@override
void dispose() {
// It is important that we stop listening to height updates from a
// native view, if this widget is disposed - cancel the StreamSubscription.
_sizeStream?.cancel();
super.dispose();
}
}
/// The main entry point of the Appcues plugin.
class Appcues {
static SemanticsHandle? _semanticsHandle;
static const MethodChannel _methodChannel = MethodChannel('appcues_flutter');
static const EventChannel _analyticsChannel =
EventChannel('appcues_analytics');
/// Initialize the plugin.
///
/// To initialize appcues, provide the [accountId] and [applicationId] for
/// the application using the plugin. Optionally, provide [options] to
/// configure the plugin.
static Future<void> initialize(String accountId, String applicationId,
[AppcuesOptions? options]) async {
// convert options to a Map to send to platform code
Map<String, Object?> nativeOptions = <String, Object?>{
"logging": options?.logging,
"apiHost": options?.apiHost,
"sessionTimeout": options?.sessionTimeout,
"activityStorageMaxSize": options?.activityStorageMaxSize,
"activityStorageMaxAge": options?.activityStorageMaxAge,
"enableUniversalLinks": options?.enableUniversalLinks,
};
await _methodChannel.invokeMethod('initialize', {
'accountId': accountId,
'applicationId': applicationId,
'options': nativeOptions,
'additionalAutoProperties': <String, Object>{
'_applicationFramework': 'flutter',
'_dartVersion': Platform.version
}
});
}
static void enableElementTargeting() {
_semanticsHandle ??= RendererBinding.instance.pipelineOwner
.ensureSemantics(listener: _semanticsChanged);
}
static void disableElementTargeting() {
_semanticsHandle?.dispose();
_semanticsHandle = null;
_methodChannel.invokeMethod('setTargetElements', {'viewElements': []});
}
static Stream<AppcuesAnalytic> get onAnalyticEvent {
return _analyticsChannel.receiveBroadcastStream().map((event) =>
// this repackages the platform level event from the channel
// into a formatted object for the host application to
// observe, via the Stream.
AppcuesAnalytic._internal(
event["analytic"],
event["value"],
event["isInternal"],
Map<String, Object>.from(event["properties"])));
}
/// Identify a user in the application.
///
/// To identify a known user, pass the [userId] and optionally specify
/// any additional custom [properties]
static Future<void> identify(String userId,
[Map<String, Object>? properties]) async {
return await _methodChannel
.invokeMethod('identify', {'userId': userId, 'properties': properties});
}
/// Identify a group for the current user.
///
/// To specify that the current user belongs to a certain group, pass
/// the [groupId] and optionally specify any additional custom group
/// [properties] to update. A null value for [groupId] clears any previous
/// group.
static Future<void> group(String? groupId,
[Map<String, Object>? properties]) async {
return await _methodChannel
.invokeMethod('group', {'groupId': groupId, 'properties': properties});
}
/// Track an event for an action taken by a user.
///
/// Specify any [name] for the event and optionally any [properties] that
/// supply more context about the event.
static Future<void> track(String name,
[Map<String, Object>? properties]) async {
return await _methodChannel
.invokeMethod('track', {'name': name, 'properties': properties});
}
/// Track a screen viewed by a user.
///
/// Specify the [title] of the screen and optionally any [properties] that
/// provide additional context about the screen view.
static Future<void> screen(String title,
[Map<String, Object>? properties]) async {
return await _methodChannel
.invokeMethod('screen', {'title': title, 'properties': properties});
}
/// Generate a unique ID for the current user when there is not a known
/// identity to use in the [Appcues.identify] call.
///
/// This will cause the plugin to begin tracking activity and checking for
/// qualified content.
static Future<void> anonymous() async {
return await _methodChannel.invokeMethod('anonymous');
}
/// Clear out the current user in this session.
///
/// This can be used when the user logs out of your application.
static Future<void> reset() async {
return await _methodChannel.invokeMethod('reset');
}
/// Returns the current version of the Appcues SDK.
static Future<String> version() async {
return await _methodChannel.invokeMethod('version');
}
/// Launch the Appcues debugger over your app's UI.
static Future<void> debug() async {
return await _methodChannel.invokeMethod('debug');
}
/// Forces a specific Appcues experience to appear for the current user by
/// passing in the [experienceId].
///
/// If the experience was not able to be shown, and error is raised.
/// This function ignores any targeting that is set on the experience.
static Future<void> show(String experienceId) async {
return await _methodChannel
.invokeMethod('show', {'experienceId': experienceId});
}
/// Verifies if an incoming [url] value is intended for the Appcues SDK.
///
/// Returns `true` if the [url] matches the Appcues scheme or `false` if
/// the [url] is not known by the Appcues SDK and should be handled by
/// your application. If the [url] is an Appcues URL, this function may
/// launch an experience or otherwise alter the UI state.
static Future<bool> didHandleURL(Uri url) async {
return await _methodChannel
.invokeMethod('didHandleURL', {'url': url.toString()});
}
// runs every time the SemanticsNode tree updates, capturing the known
// layout information that can be used for Appcues element targeting
static void _semanticsChanged() {
var rootSemanticNode = RendererBinding
.instance.pipelineOwner.semanticsOwner?.rootSemanticsNode;
var dpr = WidgetsBinding.instance.window.devicePixelRatio;
var dprScaleMatrix = Matrix4(
1/dpr, 0, 0, 0,
0, 1/dpr, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
);
List<Map<String, dynamic>> viewElements = [];
if (rootSemanticNode != null) {
// this function runs on each node in the tree, looking for
// identifiable elements.
bool visitor(SemanticsNode node) {
// by default, we use the generated label, if non-empty
var identifier = node.label;
var tags = node.tags;
// look through tags for a more specific AppcuesView identifier and
// use that if possible.
if (tags != null) {
for (var tag in tags) {
if (tag is AppcuesView) {
identifier = tag.identifier;
}
}
}
if (identifier.isNotEmpty) {
// do the transform to global logical pixel coordinates
var rect = MatrixUtils
.transformRect(dprScaleMatrix, _transformToRoot(node.rect, node));
// add this item to the set of captured views
viewElements.add({
'x': rect.left,
'y': rect.top,
'width': rect.width,
'height': rect.height,
'type': 'SemanticsNode',
'identifier': identifier,
});
}
// run the visitor on down through the tree
node.visitChildren(visitor);
return true;
}
// start the tree inspection
rootSemanticNode.visitChildren(visitor);
// pass the target elements found to the native side to capture
// the current known set of views for element targeting
_methodChannel
.invokeMethod('setTargetElements', {'viewElements': viewElements});
}
}
// the SemanticsNode rect is in local coordinates. This helper
// will recursively walk the ancestors and transform the rect
// into global coordinates for the screen.
static Rect _transformToRoot(Rect rect, SemanticsNode? node) {
var transform = node?.transform;
var parent = node?.parent;
if (transform == null) {
if (parent != null) {
return _transformToRoot(rect, parent);
} else {
return rect;
}
}
var transformed = rect;
if (!MatrixUtils.isIdentity(transform)) {
transformed = MatrixUtils.transformRect(transform, rect);
}
return _transformToRoot(transformed, parent);
}
}