From 5635de12fcaadc8276d26f39877b82480a0a37d3 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 3 Jun 2020 09:00:18 -0700 Subject: [PATCH 1/2] [web] Introduce the Link widget --- .../url_launcher/example/lib/main.dart | 147 ++++++++- .../url_launcher/lib/url_launcher.dart | 3 + .../url_launcher/url_launcher/pubspec.yaml | 6 +- .../lib/link.dart | 133 ++++++++ .../url_launcher_web/lib/link.dart | 295 ++++++++++++++++++ .../lib/url_launcher_web.dart | 7 + .../url_launcher_web/pubspec.yaml | 6 +- 7 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 packages/url_launcher/url_launcher_platform_interface/lib/link.dart create mode 100644 packages/url_launcher/url_launcher_web/lib/link.dart diff --git a/packages/url_launcher/url_launcher/example/lib/main.dart b/packages/url_launcher/url_launcher/example/lib/main.dart index f7d90c4bef65..759ed2c01f85 100644 --- a/packages/url_launcher/url_launcher/example/lib/main.dart +++ b/packages/url_launcher/url_launcher/example/lib/main.dart @@ -6,7 +6,8 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Link; +import 'package:flutter/rendering.dart'; import 'package:url_launcher/url_launcher.dart'; void main() { @@ -21,20 +22,25 @@ class MyApp extends StatelessWidget { theme: ThemeData( primarySwatch: Colors.blue, ), - home: MyHomePage(title: 'URL Launcher'), + initialRoute: '/links', + routes: { + '/links': (BuildContext context) => LinksPage(title: 'Links'), + '/url_launcher': (BuildContext context) => + UrlLauncherPage(title: 'URL Launcher'), + }, ); } } -class MyHomePage extends StatefulWidget { - MyHomePage({Key key, this.title}) : super(key: key); +class UrlLauncherPage extends StatefulWidget { + UrlLauncherPage({Key key, this.title}) : super(key: key); final String title; @override - _MyHomePageState createState() => _MyHomePageState(); + _UrlLauncherPageState createState() => _UrlLauncherPageState(); } -class _MyHomePageState extends State { +class _UrlLauncherPageState extends State { Future _launched; String _phone = ''; @@ -204,3 +210,132 @@ class _MyHomePageState extends State { ); } } + +class LinksPage extends StatefulWidget { + LinksPage({Key key, this.title}) : super(key: key); + final String title; + + @override + _LinksPageState createState() => _LinksPageState(); +} + +class _LinksPageState extends State { + Future _launched; + String _phone = ''; + + Widget _launchStatus(BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + return const Text(''); + } + } + + @override + Widget build(BuildContext context) { + final Uri fullUri = Uri.parse('https://www.cylog.org/headers/'); + final Uri routeName = Uri.parse('/url_launcher'); + + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: ListView( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + onChanged: (String text) => setState(() { + _phone = text; + }), + decoration: const InputDecoration( + hintText: 'Input the phone number to launch')), + ), + Link( + uri: Uri.parse('tel:$_phone'), + builder: (BuildContext context, FollowLink followLink) => + RaisedButton( + onPressed: followLink, + child: const Text('Make phone call'), + ), + ), + Padding( + padding: EdgeInsets.only(top: 32.0, bottom: 16.0), + child: Text('fullUri: $fullUri'), + ), + Link( + uri: fullUri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink followLink) { + return RaisedButton( + onPressed: () => setState(() { + _launched = followLink(); + }), + child: const Text('Launch in blank'), + ); + }, + ), + const Padding(padding: EdgeInsets.all(16.0)), + Link( + uri: fullUri, + target: LinkTarget.self, + builder: (BuildContext context, FollowLink followLink) { + return RaisedButton( + onPressed: () => setState(() { + _launched = followLink(); + }), + child: const Text('Launch in self'), + ); + }, + ), + Padding( + padding: EdgeInsets.only(top: 32.0, bottom: 16.0), + child: Text('routeName: $routeName'), + ), + Link( + uri: routeName, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink followLink) { + return RaisedButton( + onPressed: () => setState(() { + _launched = followLink(); + }), + child: Text('Push in blank'), + ); + }, + ), + const Padding(padding: EdgeInsets.all(16.0)), + Link( + uri: routeName, + target: LinkTarget.self, + builder: (BuildContext context, FollowLink followLink) { + return RaisedButton( + onPressed: () => setState(() { + _launched = followLink(); + }), + child: Text('Push in self'), + ); + }, + ), + const Padding(padding: EdgeInsets.all(16.0)), + Link( + uri: null, + builder: (BuildContext context, FollowLink followLink) { + return RaisedButton( + onPressed: followLink, + child: Text('Disabled'), + ); + }, + ), + const Padding(padding: EdgeInsets.all(16.0)), + FutureBuilder(future: _launched, builder: _launchStatus), + ], + ), + ], + ), + ); + } +} diff --git a/packages/url_launcher/url_launcher/lib/url_launcher.dart b/packages/url_launcher/url_launcher/lib/url_launcher.dart index ccc6833f0941..0c0a926dff28 100644 --- a/packages/url_launcher/url_launcher/lib/url_launcher.dart +++ b/packages/url_launcher/url_launcher/lib/url_launcher.dart @@ -9,6 +9,9 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +export 'package:url_launcher_platform_interface/link.dart' + show Link, LinkTarget, LinkWidgetBuilder, FollowLink; + /// Parses the specified URL string and delegates handling of it to the /// underlying platform. /// diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index 0b6a50f8b8fd..bd0053801bf2 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -24,13 +24,15 @@ flutter: dependencies: flutter: sdk: flutter - url_launcher_platform_interface: ^1.0.8 + url_launcher_platform_interface: + path: ../url_launcher_platform_interface # The design on https://flutter.dev/go/federated-plugins was to leave # this constraint as "any". We cannot do it right now as it fails pub publish # validation, so we set a ^ constraint. # TODO(amirh): Revisit this (either update this part in the design or the pub tool). # https://github.com/flutter/flutter/issues/46264 - url_launcher_web: ^0.1.3 + url_launcher_web: + path: ../url_launcher_web url_launcher_linux: ^0.0.1 url_launcher_macos: ^0.0.1 url_launcher_windows: ^0.0.1 diff --git a/packages/url_launcher/url_launcher_platform_interface/lib/link.dart b/packages/url_launcher/url_launcher_platform_interface/lib/link.dart new file mode 100644 index 000000000000..98fff345a26d --- /dev/null +++ b/packages/url_launcher/url_launcher_platform_interface/lib/link.dart @@ -0,0 +1,133 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Signature for a function provided by the [Link] widget that instructs it to +/// follow the link. +typedef FollowLink = Future Function(); + +/// Signature for a builder function passed to the [Link] widget to construct +/// the widget tree under it. +typedef LinkWidgetBuilder = Widget Function( + BuildContext context, + FollowLink followLink, +); + +/// Signature for a delegate function to build the [Link] widget. +typedef LinkDelegate = Widget Function(Link linkWidget); + +/// Defines where a Link URL should be open. +/// +/// This is a class instead of an enum to allow future customizability e.g. +/// opening a link in a specific iframe. +class LinkTarget { + /// Const private constructor with a [debugLabel] to allow the creation of + /// multiple distinct const instances. + const LinkTarget._({this.debugLabel}); + + /// Used to distinguish multiple const instances of [LinkTarget]. + final String debugLabel; + + /// Use the default target for each platform. + /// + /// On Android, the default is [blank]. On the web, the default is [self]. + /// + /// iOS, on the other hand, defaults to [self] for web URLs, and [blank] for + /// non-web URLs. + static const defaultTarget = LinkTarget._(debugLabel: 'defaultTarget'); + + /// On the web, this opens the link in the same tab where the flutter app is + /// running. + /// + /// On Android and iOS, this opens the link in a webview within the app. + static const self = LinkTarget._(debugLabel: 'self'); + + /// On the web, this opens the link in a new tab or window (depending on the + /// browser and user configuration). + /// + /// On Android and iOS, this opens the link in the browser or the relevant + /// app. + static const blank = LinkTarget._(debugLabel: 'blank'); +} + +/// Used to override the delegate that builds the link. +set linkDelegate(LinkDelegate delegate) { + Link._linkDelegate = delegate; +} + +/// A widget that renders a real link on the web, and uses WebViews in native +/// platforms to open links. +/// +/// Example link to an external URL: +/// +/// ```dart +/// Link( +/// uri: Uri.parse('https://flutter.dev'), +/// builder: (BuildContext context, FollowLink followLink) => RaisedButton( +/// onPressed: followLink, +/// // ... other properties here ... +/// )}, +/// ); +/// ``` +/// +/// Example link to a route name within the app: +/// +/// ```dart +/// Link( +/// uri: Uri.parse('/home'), +/// builder: (BuildContext context, FollowLink followLink) => RaisedButton( +/// onPressed: followLink, +/// // ... other properties here ... +/// )}, +/// ); +/// ``` +class Link extends StatelessWidget { + /// Called at build time to construct the widget tree under the link. + final LinkWidgetBuilder builder; + + /// The destination that this link leads to. + final Uri uri; + + /// The target indicating where to open the link. + final LinkTarget target; + + /// Whether the link is disabled or not. + bool get isDisabled => uri == null; + + static LinkDelegate _linkDelegate = (Link link) => _DefaultLinkDelegate(link); + + /// Creates a widget that renders a real link on the web, and uses WebViews in + /// native platforms to open links. + Link({ + Key key, + @required this.uri, + LinkTarget target, + @required this.builder, + }) : target = target ?? LinkTarget.defaultTarget, + super(key: key); + + @override + Widget build(BuildContext context) { + return _linkDelegate(this); + } +} + +class _DefaultLinkDelegate extends StatelessWidget { + const _DefaultLinkDelegate(this.link); + + final Link link; + + Future _followLink() { + // The default link delegate uses url launcher to open URIs. + // TODO(mdebbar): use url_launcher. + return Future.value(null); + } + + @override + Widget build(BuildContext context) { + return link.builder(context, link.isDisabled ? null : _followLink); + } +} diff --git a/packages/url_launcher/url_launcher_web/lib/link.dart b/packages/url_launcher/url_launcher_web/lib/link.dart new file mode 100644 index 000000000000..cbd217f6c701 --- /dev/null +++ b/packages/url_launcher/url_launcher_web/lib/link.dart @@ -0,0 +1,295 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html' as html; +import 'dart:js_util'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +import 'package:url_launcher_platform_interface/link.dart'; + +typedef _ClickListener = void Function(html.MouseEvent); + +/// The unique identifier for the view type to be used for link platform views. +const String viewType = '__url_launcher::link'; + +/// The name of the property used to set the viewId on the DOM element. +const String _viewIdProperty = '__url_launcher::link::viewId'; + +// TODO(mdebbar): Should use PlatformViewFactory. +dynamic get webViewFactory => _LinkViewController._viewFactory; + +final MethodCodec _codec = const JSONMethodCodec(); + +/// The delegate for building the [Link] widget on the web. +/// +/// It uses a platform view to render an anchor element in the DOM. +class WebLinkDelegate extends StatefulWidget { + /// Creates a delegate for the given [link]. + const WebLinkDelegate(this.link); + + /// The [Link] widget built by the app. + final Link link; + + @override + _WebLinkDelegateState createState() => _WebLinkDelegateState(); +} + +class _WebLinkDelegateState extends State { + _LinkViewController _controller; + + @override + void didUpdateWidget(WebLinkDelegate oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.link.uri != oldWidget.link.uri) { + _controller?._setUri(widget.link.uri); + } + if (widget.link.target != oldWidget.link.target) { + _controller?._setTarget(widget.link.target); + } + } + + Future _followLink() { + final Completer completer = Completer(); + _LinkViewController._registerHitTest( + _controller, + onClick: (html.MouseEvent event) { + completer.complete(_onDomClick(event)); + }, + ); + return completer.future; + } + + Future _onDomClick(html.MouseEvent event) { + if (!widget.link.uri.hasScheme) { + // A uri that doesn't have a scheme is an internal route name. In this + // case, we push it via Flutter's navigation system instead of letting the + // browser handle it. + event.preventDefault(); + final String routeName = widget.link.uri.toString(); + // TODO(mdebbar): how do we know if this is true or false? + final bool isUsingRouter = false; + // QUESTION: does this update the history entry on the engine side? + final MethodCall call = isUsingRouter + ? MethodCall('pushRouteInformation', { + 'location': routeName, + 'state': null, + }) + : MethodCall('pushRoute', routeName); + return pluginBinaryMessenger.send( + 'flutter/navigation', + _codec.encodeMethodCall(call), + ); + } + + // External links will be handled by the browser, so we don't have to do + // anything. + return Future.value(null); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.link.builder( + context, + widget.link.isDisabled ? null : _followLink, + ), + Positioned.fill( + // child: _LinkHtmlView(uri: link.uri, target: link.target), + child: PlatformViewLink( + viewType: viewType, + onCreatePlatformView: _createController, + surfaceFactory: + (BuildContext context, PlatformViewController controller) { + return PlatformViewSurface( + controller: controller, + gestureRecognizers: + Set>(), + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + ), + ), + ], + ); + } + + _LinkViewController _createController(PlatformViewCreationParams params) { + _controller = _LinkViewController(params.id); + _controller._initialize().then((_) { + params.onPlatformViewCreated(params.id); + }); + return _controller + .._setUri(widget.link.uri) + .._setTarget(widget.link.target); + } +} + +/// Used to control link views. +class _LinkViewController extends PlatformViewController { + _LinkViewController(this.viewId) { + if (_instances.isEmpty) { + // This is the first controller being created, attach the global click + // listener. + _clickSubscribtion = html.window.onClick.listen(_onGlobalClick); + } + _instances[viewId] = this; + } + + static Map _instances = + {}; + + static html.Element _viewFactory(int viewId) { + return _instances[viewId]?._element; + } + + static int _hitTestedViewId; + static _ClickListener _hitTestedClickCallback; + + static StreamSubscription _clickSubscribtion; + + static void _onGlobalClick(html.MouseEvent event) { + final int viewId = _getViewIdFromTarget(event); + _instances[viewId]?._onDomClick(event); + // After the DOM click event has been received, clean up the hit test state + // so we can start fresh on the next click. + _unregisterHitTest(); + } + + /// Call this method to indicated that a hit test has been registered for the + /// given [controller]. + /// + /// The [onClick] callback is invoked when the anchor element receives a + /// `click` from the browser. + static void _registerHitTest( + _LinkViewController controller, { + @required _ClickListener onClick, + }) { + _hitTestedViewId = controller.viewId; + _hitTestedClickCallback = onClick; + } + + static void _unregisterHitTest() { + _hitTestedViewId = null; + _hitTestedClickCallback = null; + } + + @override + final int viewId; + + html.Element _element; + bool get _isInitialized => _element != null; + + Future _initialize() async { + _element = html.Element.tag('a'); + setProperty(_element, _viewIdProperty, viewId); + _element.style + ..opacity = '0' + ..display = 'block' + ..cursor = 'unset' + ; + + final Map args = { + 'id': viewId, + 'viewType': viewType, + }; + await SystemChannels.platform_views.invokeMethod('create', args); + } + + void _onDomClick(html.MouseEvent event) { + final bool isHitTested = _hitTestedViewId == viewId; + if (isHitTested) { + _hitTestedClickCallback(event); + } else { + // There was no hit test registered for this click. This means the click + // landed on the anchor element but not on the underlying widget. In this + // case, we prevent the browser from following the click. + event.preventDefault(); + } + } + + void _setUri(Uri uri) { + assert(_isInitialized); + if (uri == null) { + _element.removeAttribute('href'); + } else { + _element.setAttribute('href', uri.toString()); + } + } + + void _setTarget(LinkTarget target) { + assert(_isInitialized); + _element.setAttribute('target', _getHtmlTarget(target)); + } + + String _getHtmlTarget(LinkTarget target) { + switch (target) { + case LinkTarget.defaultTarget: + case LinkTarget.self: + return '_self'; + case LinkTarget.blank: + return '_blank'; + default: + throw Exception('Unknown LinkTarget value $target.'); + } + } + + @override + Future clearFocus() async { + // Currently this does nothing on Flutter Web. + // TODO(het): Implement this. See https://github.com/flutter/flutter/issues/39496 + } + + @override + Future dispatchPointerEvent(PointerEvent event) async { + // We do not dispatch pointer events to HTML views because they may contain + // cross-origin iframes, which only accept user-generated events. + } + + @override + Future dispose() async { + if (_isInitialized) { + assert(_instances[viewId] == this); + _instances.remove(viewId); + if (_instances.isEmpty) { + await _clickSubscribtion.cancel(); + } + // Asynchronously dispose this view. + await SystemChannels.platform_views.invokeMethod('dispose', viewId); + } + } +} + +int _getViewIdFromTarget(html.Event event) { + final html.Element linkElement = _getLinkElementFromTarget(event); + if (linkElement != null) { + return getProperty(linkElement, _viewIdProperty); + } + return null; +} + +html.Element _getLinkElementFromTarget(html.Event event) { + final html.Element target = event.target; + if (_isLinkElement(target)) { + return target; + } + if (target.shadowRoot != null) { + final html.Element child = target.shadowRoot.lastChild; + if (_isLinkElement(child)) { + return child; + } + } + return null; +} + +bool _isLinkElement(html.Element element) { + return element.tagName == 'A' && hasProperty(element, _viewIdProperty); +} diff --git a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart index 093e06a4d8ed..938280b7de5e 100644 --- a/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart +++ b/packages/url_launcher/url_launcher_web/lib/url_launcher_web.dart @@ -4,10 +4,14 @@ import 'dart:async'; import 'dart:html' as html; +// ignore: undefined_shown_name +import 'dart:ui' as ui show platformViewRegistry; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:meta/meta.dart'; +import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:url_launcher_web/link.dart'; import 'src/third_party/platform_detect/browser.dart'; @@ -43,6 +47,9 @@ class UrlLauncherPlugin extends UrlLauncherPlatform { /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith(Registrar registrar) { UrlLauncherPlatform.instance = UrlLauncherPlugin(); + linkDelegate = (Link link) => WebLinkDelegate(link); + // ignore: undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory(viewType, webViewFactory); } /// Opens the given [url] in the specified [webOnlyWindowName]. diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 957b25757036..bd352403aa0f 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -14,7 +14,8 @@ flutter: fileName: url_launcher_web.dart dependencies: - url_launcher_platform_interface: ^1.0.8 + url_launcher_platform_interface: + path: ../url_launcher_platform_interface flutter: sdk: flutter flutter_web_plugins: @@ -24,7 +25,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - url_launcher: ^5.2.5 + url_launcher: + path: ../url_launcher pedantic: ^1.8.0 mockito: ^4.1.1 integration_test: From 0d67f19ebb21430f18e341084ec57d7962dc6805 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 14 Oct 2020 09:25:02 -0700 Subject: [PATCH 2/2] revert local pubspec changes --- packages/url_launcher/url_launcher/pubspec.yaml | 6 ++---- packages/url_launcher/url_launcher_web/pubspec.yaml | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/url_launcher/url_launcher/pubspec.yaml b/packages/url_launcher/url_launcher/pubspec.yaml index bd0053801bf2..0b6a50f8b8fd 100644 --- a/packages/url_launcher/url_launcher/pubspec.yaml +++ b/packages/url_launcher/url_launcher/pubspec.yaml @@ -24,15 +24,13 @@ flutter: dependencies: flutter: sdk: flutter - url_launcher_platform_interface: - path: ../url_launcher_platform_interface + url_launcher_platform_interface: ^1.0.8 # The design on https://flutter.dev/go/federated-plugins was to leave # this constraint as "any". We cannot do it right now as it fails pub publish # validation, so we set a ^ constraint. # TODO(amirh): Revisit this (either update this part in the design or the pub tool). # https://github.com/flutter/flutter/issues/46264 - url_launcher_web: - path: ../url_launcher_web + url_launcher_web: ^0.1.3 url_launcher_linux: ^0.0.1 url_launcher_macos: ^0.0.1 url_launcher_windows: ^0.0.1 diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index bd352403aa0f..957b25757036 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -14,8 +14,7 @@ flutter: fileName: url_launcher_web.dart dependencies: - url_launcher_platform_interface: - path: ../url_launcher_platform_interface + url_launcher_platform_interface: ^1.0.8 flutter: sdk: flutter flutter_web_plugins: @@ -25,8 +24,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - url_launcher: - path: ../url_launcher + url_launcher: ^5.2.5 pedantic: ^1.8.0 mockito: ^4.1.1 integration_test: