Publish `web_embedding` (#1777)

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-devrel
channel on [Discord].

<!-- Links -->
[Flutter Style Guide]:
https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo
[CLA]: https://cla.developers.google.com/
[Discord]: https://github.com/flutter/flutter/wiki/Chat
[Contributors Guide]:
https://github.com/flutter/samples/blob/main/CONTRIBUTING.md


Co-authored-by: Mark Thompson
<2554588+MarkTechson@users.noreply.github.com>
Co-authored-by: David Iglesias <ditman@gmail.com>

Co-authored-by: Mark Thompson <2554588+MarkTechson@users.noreply.github.com>
Co-authored-by: David Iglesias <ditman@gmail.com>
pull/1779/head
Brett Morgan 2 years ago committed by GitHub
parent b703f1f3f9
commit 91cb685d1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,11 @@
# web_embedding
This directory contains examples of how to embed Flutter in web apps (without iframes):
* **element_embedding_demo**: Modifies the index.html of a flutter app so it is
launched in a custom `hostElement`. This is the most basic embedding example.
* **ng-flutter**: A simple Angular app (and component) that replicates the above
example, but in an Angular style.
Check the `README.md` of each example for more details on how to run it, and the
"Points of Interest" it may contain.

@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# Keeping the repo
.metadata
pubspec.lock
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

@ -0,0 +1,25 @@
# element_embedding_demo
This package contains the application used to demonstrate the
upcoming Flutter web feature: "Element Embedding".
This was first shown on the Flutter Forward event in Nairobi (Kenya), by Tim
Sneath. [See the replay here](https://www.youtube.com/watch?v=zKQYGKAe5W8&t=5799s).
## Running the demo
The demo is a Flutter web app, so it can be run as:
```terminal
$ flutter run -d chrome
```
## Points of Interest
* Check the new JS Interop:
* Look at `lib/main.dart`, find the `@js.JSExport()` annotation.
* Find the JS code that interacts with Dart in `web/js/demo-js-interop.js`.
* See how the Flutter web application is embedded into the page now:
* Find `hostElement` in `web/index.html`.
_(Built by @ditman, @kevmoo and @malloc-error)_

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

@ -0,0 +1,335 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:js/js.dart' as js;
import 'package:js/js_util.dart' as js_util;
void main() {
runApp(const MyApp());
}
enum DemoScreen { counter, textField, custom }
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
@js.JSExport()
class _MyAppState extends State<MyApp> {
final _streamController = StreamController<void>.broadcast();
DemoScreen _currentDemoScreen = DemoScreen.counter;
int _counterScreenCount = 0;
@override
void initState() {
super.initState();
final export = js_util.createDartExport(this);
js_util.setProperty(js_util.globalThis, '_appState', export);
js_util.callMethod<void>(js_util.globalThis, '_stateSet', []);
}
@override
void dispose() {
_streamController.close();
super.dispose();
}
@js.JSExport()
void increment() {
if (_currentDemoScreen == DemoScreen.counter) {
setState(() {
_counterScreenCount++;
_streamController.add(null);
});
}
}
@js.JSExport()
void addHandler(void Function() handler) {
_streamController.stream.listen((event) {
handler();
});
}
@js.JSExport()
int get count => _counterScreenCount;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Element embedding',
theme: ThemeData(
primarySwatch: Colors.blue,
),
debugShowCheckedModeBanner: false,
home: demoScreenRouter(_currentDemoScreen),
);
}
Widget demoScreenRouter(DemoScreen which) {
switch (which) {
case DemoScreen.counter:
return CounterDemo(
title: 'Counter',
numToDisplay: _counterScreenCount,
incrementHandler: increment,
);
case DemoScreen.textField:
return const TextFieldDemo(title: 'Note to Self');
case DemoScreen.custom:
return const CustomDemo(title: 'Character Counter');
}
}
@js.JSExport()
void changeDemoScreenTo(String screenString) {
setState(() {
switch (screenString) {
case 'counter':
_currentDemoScreen = DemoScreen.counter;
break;
case 'textField':
_currentDemoScreen = DemoScreen.textField;
break;
case 'custom':
_currentDemoScreen = DemoScreen.custom;
break;
default:
_currentDemoScreen = DemoScreen.counter;
break;
}
});
}
}
class CounterDemo extends StatefulWidget {
final String title;
final int numToDisplay;
final VoidCallback incrementHandler;
const CounterDemo({
super.key,
required this.title,
required this.numToDisplay,
required this.incrementHandler,
});
@override
State<CounterDemo> createState() => _CounterDemoState();
}
class _CounterDemoState extends State<CounterDemo> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${widget.numToDisplay}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: widget.incrementHandler,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
class TextFieldDemo extends StatelessWidget {
const TextFieldDemo({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: const Center(
child: Padding(
padding: EdgeInsets.all(14.0),
child: TextField(
maxLines: null,
decoration: InputDecoration(
border: OutlineInputBorder(),
// hintText: 'Text goes here!',
),
),
),
),
);
}
}
class CustomDemo extends StatefulWidget {
final String title;
const CustomDemo({super.key, required this.title});
@override
State<CustomDemo> createState() => _CustomDemoState();
}
class _CustomDemoState extends State<CustomDemo> {
final double textFieldHeight = 80;
final Color colorPrimary = const Color(0xff027dfd);
// const Color(0xffd43324);
// const Color(0xff6200ee);
// const Color.fromARGB(255, 255, 82, 44);
final TextEditingController _textController = TextEditingController();
late FocusNode textFocusNode;
int totalCharCount = 0;
@override
void initState() {
super.initState();
textFocusNode = FocusNode();
textFocusNode.requestFocus();
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
toolbarHeight: MediaQuery.of(context).size.height - textFieldHeight,
flexibleSpace: Container(
color: colorPrimary,
height: MediaQuery.of(context).size.height - textFieldHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'COUNT WITH DASH!',
style: TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(
height: 26,
),
Container(
width: 98,
height: 98,
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.white),
shape: BoxShape.circle,
),
child: Center(
child: Container(
width: 90,
height: 90,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/dash.png'),
fit: BoxFit.cover,
),
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
const SizedBox(height: 20),
Text(
totalCharCount.toString(),
style: const TextStyle(color: Colors.white, fontSize: 52),
),
// const Text(
// 'characters typed',
// style: TextStyle(color: Colors.white, fontSize: 14),
// ),
],
),
),
),
body: Column(
children: [
SizedBox(
height: textFieldHeight,
child: Center(
child: Padding(
padding: const EdgeInsets.only(left: 18, right: 18),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
focusNode: textFocusNode,
onSubmitted: (value) {
textFocusNode.requestFocus();
},
onChanged: (value) {
handleChange();
},
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
),
const SizedBox(
width: 12,
),
Center(
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: colorPrimary,
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.refresh),
color: Colors.white,
onPressed: () {
handleClear();
},
),
),
),
],
),
),
),
),
],
),
);
}
void handleChange() {
setState(() {
totalCharCount = _textController.value.text.toString().length;
});
}
void handleClear() {
setState(() {
_textController.clear();
totalCharCount = 0;
});
textFocusNode.requestFocus();
}
}

@ -0,0 +1,23 @@
name: element_embedding_demo
description: A small app to be embedded into a HTML element (see web/index.html)
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0-0 <4.0.0'
dependencies:
cupertino_icons: ^1.0.2
flutter:
sdk: flutter
js: ^0.6.6
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true
assets:
- assets/dash.png

@ -0,0 +1,260 @@
@font-face {
font-family: "DM Sans";
src: url(../fonts/DMSans-Regular.ttf);
font-weight: normal;
}
@font-face {
font-family: "DM Sans";
src: url(../fonts/DMSans-Bold.ttf);
font-weight: 700;
}
/** Reset */
* {
box-sizing: border-box;
font-family: "DM Sans", sans-serif;
}
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
}
body {
background-color: #fff;
background-image: radial-gradient(
ellipse at bottom,
#fafafa 5%,
transparent 60%
),
linear-gradient(136deg, transparent, #eee 290%),
linear-gradient(115deg, #fafafa, transparent 40%),
linear-gradient(180deg, transparent 0, #ddd 70%),
radial-gradient(ellipse at -70% -180%, transparent 80%, #eee 0),
radial-gradient(ellipse at bottom, #71c7ee 40%, transparent 80%),
radial-gradient(ellipse at 5% 340%, transparent 80%, #ddd 0);
background-repeat: no-repeat;
color: #555;
}
/** Layout **/
body { display: flex; flex-direction: column; }
section.contents {
flex: 1 1 auto;
flex-direction: row;
display: flex;
}
section.contents aside {
flex: 0;
display: flex;
flex-direction: column;
order: -1;
}
section.contents aside fieldset {
display: flex;
flex-flow: wrap;
justify-content: space-between;
align-items: flex-end;
}
section.contents aside .align-top {
align-self: flex-start;
}
section.contents article {
flex: 1;
margin-top: 50px;
display: flex;
justify-content: center;
}
/** Title */
h1 {
font-weight: 700;
font-size: 48px;
padding: 0;
line-height: .9em;
letter-spacing: -2px;
margin: 0 0 30px 0;
}
/** Controls for the demo (left column) */
#demo_controls {
background: linear-gradient(90deg, rgba(255,255,255,1) 10%, rgba(255,255,255,0) 100%);
padding: 40px 20px 0px 20px;
z-index: 10;
}
#demo_controls fieldset {
padding: 0;
border: none;
width: 210px;
}
#demo_controls legend {
text-align: center;
font-size: 20px;
line-height: 40px;
margin-bottom: 3px;
}
#demo_controls select.screen {
display: block;
width: 120px;
padding: 4px;
text-align: center;
margin-bottom: 10px;
}
#demo_controls input {
display: block;
width: 100px;
margin: 0 0 10px 0;
text-align: center;
}
/** Keep controls that */
#demo_controls .tight input {
margin: 0px;
}
#demo_controls input[type="button"] {
line-height: 10px;
font-size: 14px;
border-radius: 15px;
border: 1px solid #aaa;
border-style: outset;
background-color: #fff;
height: 30px;
color: #555;
transition: all 100ms ease-in-out;
cursor: pointer;
}
#demo_controls input[type="button"]:hover {
/* .active:hover background-color: #96B6E3;*/
border-color: #1c68d4;
background-color: #1c68d4;
color: white;
}
#demo_controls input[type="button"].active {
border-color: #1c68d4;
background-color: #1c68d4;
color: white;
}
#demo_controls input#value {
font-size: 32px;
line-height: 1em;
min-height: 30px;
color: #888;
}
#demo_controls input#increment {
/* Center vertically next to taller input#value */
position: relative;
top: -6px;
}
#demo_controls .disabled {
pointer-events: none;
opacity: .5;
}
/** The style for the DIV where flutter will be rendered, and the CSS fx */
#flutter_target {
border: 1px solid #aaa;
width: 320px;
height: 480px;
border-radius: 0px;
transition: all 150ms ease-in;
}
#flutter_target.resize {
width: 480px;
height: 320px;
}
#flutter_target.spin { animation: spin 6400ms ease-in-out infinite; }
#flutter_target.shadow { position: relative; }
#flutter_target.shadow::before {
content: "";
position: absolute;
display: block;
width: 100%;
top: calc(100% - 1px);
left: 0;
height: 1px;
background-color: black;
border-radius: 50%;
z-index: -1;
transform: rotateX(80deg);
box-shadow: 0px 0px 60px 38px rgb(0 0 0 / 25%);
}
#flutter_target.mirror {
-webkit-box-reflect: below 0px linear-gradient(to bottom, rgba(0,0,0,0.0), rgba(0,0,0,0.4));
}
@keyframes spin {
0% {
transform: perspective(1000px) rotateY(0deg);
animation-timing-function: ease-in;
}
15% {
transform: perspective(1000px) rotateY(165deg);
animation-timing-function: linear;
}
75% {
transform: perspective(1000px) rotateY(195deg);
animation-timing-function: linear;
}
90% {
transform: perspective(1000px) rotateY(359deg);
animation-timing-function: ease-out;
}
100% {
transform: perspective(1000px) rotateY(359deg);
animation-timing-function: linear;
}
}
/** "Handheld"/Device mode container */
#handheld::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: url(../icons/unsplash-x9WGMWwp1NM.png) no-repeat;
background-size: 1000px;
background-position: top right;
opacity: 1;
transition: opacity 200ms ease-out;
}
#handheld::after {
content: "";
position: absolute;
display: block;
width: 77px;
height: 67px;
top: 534px;
right: 573px;
background: url(../icons/nail.png) no-repeat;
background-size: 77px;
opacity: 1;
transition: opacity 200ms ease-out;
}
#handheld.hidden::before,
#handheld.hidden::after {
opacity: 0;
}
#flutter_target.handheld {
position: absolute;
right: 0px;
transform-origin: 0px 0px 0px;
transform: rotate(-14.1deg) scale(0.80) translate(-539px, -45px);
width: 316px;
height: 678px;
border-radius: 34px;
border: 1px solid #000;
overflow: hidden;
}
.imageAttribution {
position: absolute;
bottom: 6px;
right: 6px;
font-size: 10px;
}
.imageAttribution, .imageAttribution a { color: #fff; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1,93 @@
Copyright 2014-2017 Indian Type Foundry (info@indiantypefoundry.com). Copyright 2019 Google LLC.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<base href="/" />
<meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="A Flutter Web Element embedding demo." />
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Flutter Element embedding" />
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
<link rel="preload" as="image" href="icons/unsplash-x9WGMWwp1NM.png" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="icons/favicon.png" />
<title>Element embedding</title>
<link rel="manifest" href="manifest.json" />
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
<link rel="stylesheet" type="text/css" href="css/style.css" />
</head>
<body>
<section class="contents">
<article>
<div id="flutter_target"></div>
</article>
<aside id="demo_controls">
<h1>Element embedding</h1>
<fieldset id="fx">
<legend>Effects</legend>
<input value="Shadow" data-fx="shadow" type="button" class="fx" />
<input value="Mirror 🧪" data-fx="mirror" type="button" class="fx" />
<input value="Resize" data-fx="resize" type="button" class="fx align-top" />
<div class="tight">
<input value="Spin" data-fx="spin" type="button" class="fx" />
<input type="range" value="0" min="-180" max="180" list="markers" id="rotation" class="tight" />
<datalist id="markers">
<option value="-180"></option>
<option value="-135"></option>
<option value="-45"></option>
<option value="0"></option>
<option value="45"></option>
<option value="135"></option>
<option value="180"></option>
</datalist>
</div>
<input value="Device" data-fx="handheld" type="button" class="fx" />
</fieldset>
<fieldset id="interop">
<legend>JS Interop</legend>
<label for="screen-selector">
Screen
<select name="screen-select" id="screen-selector" class="screen">
<option value="counter">Counter</option>
<option value="textField">TextField</option>
<option value="custom">Custom App</option>
</select>
</label>
<label for="value">
Value
<input id="value" value="" type="text" readonly />
</label>
<input
id="increment"
value="Increment"
type="button"
/>
</fieldset>
</aside>
</section>
<script>
window.addEventListener("load", function (ev) {
// Embed flutter into div#flutter_target
let target = document.querySelector("#flutter_target");
_flutter.loader.loadEntrypoint({
onEntrypointLoaded: async function (engineInitializer) {
let appRunner = await engineInitializer.initializeEngine({
hostElement: target,
});
await appRunner.runApp();
},
});
});
</script>
<script src="js/demo-js-interop.js" defer></script>
<script src="js/demo-css-fx.js" defer></script>
</body>
</html>

@ -0,0 +1,82 @@
// Manages toggling the VFX of the buttons
(function () {
"use strict";
let handheld;
let fxButtons = document.querySelector("#fx");
let flutterTarget = document.querySelector("#flutter_target");
let attribution = document.createElement("span");
attribution.className = "imageAttribution";
attribution.innerHTML = "Photo by <a href='https://unsplash.com/photos/x9WGMWwp1NM' rel='noopener noreferrer' target='_blank'>Nathana Rebouças</a> on Unsplash";
// (Re)moves the flutterTarget inside a div#handheld.
function handleHandHeld(fx) {
resetRotation();
if (!handheld) {
handheld = document.createElement("div");
handheld.id = "handheld";
handheld.classList.add("hidden");
// Inject before the flutterTarget
flutterTarget.parentNode.insertBefore(handheld, flutterTarget);
handheld.append(flutterTarget);
handheld.append(attribution);
window.setTimeout(function () {
handheld.classList.remove("hidden");
}, 100);
// Disable all effects on the flutter container
flutterTarget.className = "";
setOtherFxEnabled(false);
} else {
handheld.classList.add("hidden");
window.setTimeout(function () {
handheld.parentNode.insertBefore(flutterTarget, handheld);
handheld.remove();
handheld = null;
}, 210);
setOtherFxEnabled(true);
}
window.requestAnimationFrame(function () {
// Let the browser flush the DOM...
flutterTarget.classList.toggle(fx);
});
}
// Sets a rotation style on the flutterTarget (in degrees).
function handleRotation(degrees) {
flutterTarget.style.transform = `perspective(1000px) rotateY(${degrees}deg)`;
}
// Removes the inline style from the flutterTarget.
function resetRotation() {
flutterTarget.style = null;
}
// Enables/disables the buttons that are not compatible with the "handheld" mode.
function setOtherFxEnabled(enabled) {
fxButtons.querySelectorAll('input').forEach((btn) => {
if (btn.dataset.fx != 'handheld') {
btn.classList.toggle('disabled', !enabled);
}
});
}
// Handles clicks on the buttons inside #fx.
fxButtons.addEventListener("click", (event) => {
let fx = event.target.dataset.fx;
if (fx === "handheld") {
handleHandHeld(fx);
return;
}
flutterTarget.classList.toggle(fx);
});
fxButtons.addEventListener("input", (event) => {
if (event.target.id === "rotation") {
flutterTarget.classList.toggle("spin", false);
handleRotation(event.target.value);
}
})
})();

@ -0,0 +1,43 @@
// Sets up a channel to JS-interop with Flutter
(function() {
"use strict";
// This function will be called from Flutter when it prepares the JS-interop.
window._stateSet = function () {
window._stateSet = function () {
console.log("Call _stateSet only once!");
};
// The state of the flutter app, see `class _MyAppState` in lib/main.dart.
let appState = window._appState;
let valueField = document.querySelector("#value");
let updateState = function () {
valueField.value = appState.count;
};
// Register a callback to update the HTML field from Flutter.
appState.addHandler(updateState);
// Render the first value (0).
updateState();
let incrementButton = document.querySelector("#increment");
incrementButton.addEventListener("click", (event) => {
appState.increment();
});
let screenSelector = document.querySelector("#screen-selector");
screenSelector.addEventListener("change", (event) => {
appState.changeDemoScreenTo(event.target.value);
setJsInteropControlsEnabled(event.target.value === 'counter');
});
// Enables/disables the Value/Increment controls.
function setJsInteropControlsEnabled(enabled) {
let elements = document.querySelectorAll("#increment, label[for='value']");
elements.forEach((el) => {
el.classList.toggle('disabled', !enabled);
})
}
};
}());

@ -0,0 +1,35 @@
{
"name": "element_embedding_demo",
"short_name": "element_embedding",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "An example of how to embed a Flutter Web app into any HTML Element of a page.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

@ -0,0 +1,42 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

@ -0,0 +1,178 @@
# ng-flutter
This Angular project is a simple example of how Angular and Flutter
web apps could be integrated, and have them interop.
## Points of Interest
### Angular
This repository is a quite standard Angular app. The following changes were made
to be able to use (and interop) with a Flutter web application:
* `package.json` has a custom `prebuild` script that builds the
Flutter web app, so Angular can find it later.
* `flutter.js` is added as a `"scripts"` entry in `angular.json`.
Angular takes care of minimizing and injecting it as any other script.
* The rest of the flutter app `flutter/build/web/` is registered
as an `"assets"` entry in `angular.json`, and moved to `/flutter`.
* The `ng-flutter` component takes care of embedding Flutter web, and yielding
control to Angular through an `appLoaded` `EventEmitter`. The object yielded
by this emitter is a state controller exposed by flutter via a JS custom
event!
### Flutter
The embedded Flutter application lives in the `flutter` directory of this repo.
That application is a standard web app, that doesn't need to be aware that it's
going to be embedded in another framework.
* Flutter uses new `@staticInterop` methods to allow certain Dart functions to
be called from JavaScript.
* Look at how `createDartExport` and `broadcastAppEvent` work together to make
the `_state` controller of the Flutter app available to Angular!
## How to build the app
### Requirements
If you want to build and run this demo on your machine, you'll need
a moderately recent version of Angular:
```console
$ ng version
Angular CLI: 15.2.4
Node: 18.13.0
Package Manager: npm 9.4.2
OS: linux x64
```
And Flutter:
```
$ flutter --version
Flutter 3.10.0-12.0.pre.38 • channel master
Framework • revision 1a51dc2131 (8 days ago) • 2023-04-24 12:25:21 -0700
Engine • revision 5fbde6c0fc
Tools • Dart 3.1.0 (build 3.1.0-35.0.dev) • DevTools 2.23.1
```
**Ensure `npm`, `ng` and `flutter` are present in your `$PATH`.**
### Building the app
This repository is a moderately standard Angular app. It integrates
Flutter web by making it part of the Angular `assets`.
In order to build this app, first fetch its `npm` dependencies:
```console
$ npm install
npm WARN deprecated @npmcli/move-file@2.0.1: This functionality has been moved to @npmcli/fs
added 963 packages, and audited 964 packages in 17s
93 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
```
Then run the `build` script. It'll take care of building Flutter
automatically:
```console
$ npm run build
> ng-flutter@0.0.0 prebuild
... Flutter web build output ...
Compiling lib/main.dart for the Web...
> ng-flutter@0.0.0 build
> ng build
... Angular build output ...
✔ Browser application bundle generation complete.
✔ Copying assets complete.
✔ Index html generation complete.
```
### Local Angular development
Once you've reached this point, you should be able to work with
your Angular application normally, for example to run a local web
server:
```console
$ npm run start
> ng-flutter@0.0.0 start
> ng serve
✔ Browser application bundle generation complete.
Initial Chunk Files | Names | Raw Size
vendor.js | vendor | 4.38 MB |
... Angular build output...
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
✔ Compiled successfully.
```
Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of its Angular source files.
### Local Flutter web development
The Flutter app lives inside the `flutter` directory, and can be
developed independently. Just do any changes on Flutter web as you'd
normally do. It even includes a small `web/index.html` so you can see
changes to your app without running the whole Angular setup.
> **Note**
> For now, Angular does _not_ auto-detect changes to your Flutter web
app, so once you're happy with your Flutter web app, make sure to
call `npm run build` so everything rebuilds and gets placed into its
correct location.
### Deploying the app
After `npm run build`, you should have a deployable Angular + Flutter
web app in the `dist` directory of this Angular project.
Your built app can can be deployed anywhere, but do check
[Firebase hosting](https://firebase.google.com/docs/hosting) for a
super-easy deployment experience!
## Troubleshooting
### Flutter
Ensure your flutter app is properly rebuilt after any changes.
* Run `npm run build` to re-build the Flutter app.
If you encounter error messages like:
```
Error: Can't resolve 'flutter/build/web/flutter.js' in '/my/checkout/of/ng-flutter'
```
You definitely need to run `npm run build`!
## Reach out to the team(s)!
Have you had any problem not covered in this README? Do you want
to see other embedding examples?
Let us know by [creating an issue](https://github.com/flutter/samples/issues/new) or opening a new pull request.
Thanks!

@ -0,0 +1,114 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ng-flutter": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/ng-flutter",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets",
{
"input": "flutter/build/web/",
"glob": "**/*",
"output": "/flutter/"
}
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": [
{
"input": "flutter/build/web/flutter.js",
"inject": true,
"bundleName": "flutter"
}
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "ng-flutter:build:production"
},
"development": {
"browserTarget": "ng-flutter:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ng-flutter:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": "0ff9b6e8-2034-4f87-9ac7-46dbd612ebad"
}
}

@ -0,0 +1,44 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
channel: master
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: android
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: ios
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: linux
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: macos
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: web
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
- platform: windows
create_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
base_revision: f41ae4f4c925336400b11dc02986c1b4d78a173c
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

@ -0,0 +1,16 @@
# ng_companion
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

@ -0,0 +1,29 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@ -0,0 +1,73 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'package:flutter/material.dart';
import 'pages/counter.dart';
import 'pages/dash.dart';
import 'pages/text.dart';
import 'src/js_interop.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final ValueNotifier<DemoScreen> _screen = ValueNotifier<DemoScreen>(DemoScreen.counter);
final ValueNotifier<int> _counter = ValueNotifier<int>(0);
final ValueNotifier<String> _text = ValueNotifier<String>('');
late final DemoAppStateManager _state;
@override
void initState() {
super.initState();
_state = DemoAppStateManager(
screen: _screen,
counter: _counter,
text: _text,
);
final export = createDartExport(_state);
// Emit this through the root object of the flutter app :)
broadcastAppEvent('flutter-initialized', export);
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Element embedding',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: ValueListenableBuilder<DemoScreen>(
valueListenable: _screen,
builder: (context, value, child) => demoScreenRouter(value),
),
);
}
Widget demoScreenRouter(DemoScreen which) {
switch (which) {
case DemoScreen.counter:
return CounterDemo(counter: _counter);
case DemoScreen.text:
return TextFieldDemo(text: _text);
case DemoScreen.dash:
return DashDemo(text: _text);
}
}
}

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class CounterDemo extends StatefulWidget {
final ValueNotifier<int> counter;
const CounterDemo({
super.key,
required this.counter,
});
@override
State<CounterDemo> createState() => _CounterDemoState();
}
class _CounterDemoState extends State<CounterDemo> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Counter'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
ValueListenableBuilder(
valueListenable: widget.counter,
builder: (context, value, child) => Text(
'$value',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () { widget.counter.value++; },
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
class DashDemo extends StatefulWidget {
final ValueNotifier<String> text;
const DashDemo({super.key, required this.text});
@override
State<DashDemo> createState() => _DashDemoState();
}
class _DashDemoState extends State<DashDemo> {
final double textFieldHeight = 80;
final Color colorPrimary = Colors.blue.shade700;
late TextEditingController textController;
int totalCharCount = 0;
@override
void initState() {
super.initState();
// Initial value of the text box
totalCharCount = widget.text.value.length;
textController = TextEditingController.fromValue(
TextEditingValue(
text: widget.text.value,
selection: TextSelection.collapsed(offset: widget.text.value.length)
)
);
// Report changes
textController.addListener(_onTextControllerChange);
// Listen to changes from the outside
widget.text.addListener(_onTextStateChanged);
}
void _onTextControllerChange() {
widget.text.value = textController.text;
setState(() {
totalCharCount = textController.text.length;
});
}
void _onTextStateChanged() {
textController.value = TextEditingValue(
text: widget.text.value,
selection: TextSelection.collapsed(offset: widget.text.value.length),
);
}
@override
void dispose() {
super.dispose();
textController.dispose();
widget.text.removeListener(_onTextStateChanged);
}
void _handleClear() {
textController.value = TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: widget.text.value.length),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
color: colorPrimary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'COUNT WITH DASH!',
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
),
),
// Bordered dash avatar
Padding(
padding: const EdgeInsets.all(12),
child: ClipOval(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(2),
child: ClipOval(
child: Container(
color: colorPrimary,
padding: const EdgeInsets.all(2),
child: const CircleAvatar(
radius: 45,
backgroundColor: Colors.white,
foregroundImage: AssetImage('assets/dash.png'),
)
),
)
),
),
),
Text(
'$totalCharCount',
style: Theme.of(context).textTheme.displayLarge!.copyWith(
color: Colors.white,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: textController,
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Type something!',
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12),
child: Ink(
decoration: ShapeDecoration(
color: colorPrimary,
shape: const CircleBorder(),
),
child: IconButton(
icon: const Icon(Icons.refresh),
color: Colors.white,
onPressed: _handleClear,
),
),
),
],
),
),
],
),
);
}
}

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
class TextFieldDemo extends StatefulWidget {
const TextFieldDemo({super.key, required this.text});
final ValueNotifier<String> text;
@override
State<TextFieldDemo> createState() => _TextFieldDemoState();
}
class _TextFieldDemoState extends State<TextFieldDemo> {
late TextEditingController textController;
@override
void initState() {
super.initState();
// Initial value of the text box
textController = TextEditingController.fromValue(
TextEditingValue(
text: widget.text.value,
selection: TextSelection.collapsed(offset: widget.text.value.length)
)
);
// Report changes
textController.addListener(_onTextControllerChange);
// Listen to changes from the outside
widget.text.addListener(_onTextStateChanged);
}
void _onTextControllerChange() {
widget.text.value = textController.text;
}
void _onTextStateChanged() {
textController.value = TextEditingValue(
text: widget.text.value,
selection: TextSelection.collapsed(offset: widget.text.value.length),
);
}
@override
void dispose() {
super.dispose();
textController.dispose();
widget.text.removeListener(_onTextStateChanged);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Text Field'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(14.0),
child: TextField(
controller: textController,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Type something!',
),
),
),
),
);
}
}

@ -0,0 +1,8 @@
/// Exposes useful functions to interop with JS from our Flutter app.
library example_js_interop;
export 'js_interop/counter_state_manager.dart';
export 'js_interop/helper.dart' show broadcastAppEvent;
export 'package:js/js_util.dart' show createDartExport;

@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import 'package:js/js.dart';
enum DemoScreen {
counter('counter'),
text('text'),
dash('dash');
const DemoScreen(String screen) : _screen = screen;
final String _screen;
@override
String toString() => _screen;
}
/// This is the bit of state that JS is able to see.
///
/// It contains getters/setters/operations and a mechanism to
/// subscribe to change notifications from an incoming [notifier].
@JSExport()
class DemoAppStateManager {
// Creates a DemoAppStateManager wrapping a ValueNotifier.
DemoAppStateManager({
required ValueNotifier<DemoScreen> screen,
required ValueNotifier<int> counter,
required ValueNotifier<String> text,
}) : _counter = counter, _text = text, _screen = screen;
final ValueNotifier<DemoScreen> _screen;
final ValueNotifier<int> _counter;
final ValueNotifier<String> _text;
// _counter
int getClicks() {
return _counter.value;
}
void setClicks(int value) {
_counter.value = value;
}
void incrementClicks() {
_counter.value++;
}
void decrementClicks() {
_counter.value--;
}
// _text
void setText(String text) {
_text.value = text;
}
String getText() {
return _text.value;
}
// _screen
void setScreen(String screen) {
_screen.value = DemoScreen.values.byName(screen);
}
String getScreen() {
return _screen.value.toString();
}
// Allows clients to subscribe to changes to the wrapped value.
void onClicksChanged(VoidCallback f) {
_counter.addListener(f);
}
void onTextChanged(VoidCallback f) {
_text.addListener(f);
}
void onScreenChanged(VoidCallback f) {
_screen.addListener(f);
}
}

@ -0,0 +1,55 @@
import 'dart:js_interop';
import 'package:js/js.dart';
import 'package:js/js_util.dart' as js_util;
/// This is a little bit of JS-interop code so this Flutter app can dispatch
/// a custom JS event (to be deprecated by package:web)
@JS('CustomEvent')
@staticInterop
class DomCustomEvent {
external factory DomCustomEvent.withType(JSString type);
external factory DomCustomEvent.withOptions(JSString type, JSAny options);
factory DomCustomEvent._(String type, [Object? options]) {
if (options != null) {
return DomCustomEvent.withOptions(type.toJS, js_util.jsify(options) as JSAny);
}
return DomCustomEvent.withType(type.toJS);
}
}
dispatchCustomEvent(DomElement target, String type, Object data) {
final DomCustomEvent event = DomCustomEvent._(type, <String, Object>{
'bubbles': true,
'composed': true,
'detail': data,
});
target.dispatchEvent(event);
}
@JS()
@staticInterop
class DomEventTarget {}
extension DomEventTargetExtension on DomEventTarget {
@JS('dispatchEvent')
external JSBoolean _dispatchEvent(DomCustomEvent event);
bool dispatchEvent(DomCustomEvent event) => _dispatchEvent(event).toDart;
}
@JS()
@staticInterop
class DomElement extends DomEventTarget {}
extension DomElementExtension on DomElement {
@JS('querySelector')
external DomElement? _querySelector(JSString selectors);
DomElement? querySelector(String selectors) => _querySelector(selectors.toJS);
}
@JS()
@staticInterop
class DomDocument extends DomElement {}
@JS()
@staticInterop
external DomDocument get document;

@ -0,0 +1,10 @@
import 'dom.dart' as dom;
/// Locates the root of the flutter app (for now, the first element that has
/// a flt-renderer tag), and dispatches a JS event named [name] with [data].
void broadcastAppEvent(String name, Object data) {
final dom.DomElement? root = dom.document.querySelector('[flt-renderer]');
assert(root != null, 'Flutter root element cannot be found!');
dom.dispatchCustomEvent(root!, name, data);
}

@ -0,0 +1,23 @@
name: ng_companion
description: A flutter app with a counter that can be manipulated from JS.
publish_to: 'none'
version: 1.0.0
environment:
sdk: '>=3.0.0-322.0.dev <4.0.0'
dependencies:
cupertino_icons: ^1.0.2
flutter:
sdk: flutter
js: ^0.6.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true
assets:
- assets/

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="ng_companion">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>ng_companion</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
// Listen until Flutter tells us it's ready to rumble
window.addEventListener('flutter-initialized', function (event) {
const state = event.detail;
window['_debugCounter'] = state;
state.onClicksChanged(() => {
console.log('New clicks value: ', state.getClicks());
});
});
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: async function(engineInitializer) {
await engineInitializer.autoStart();
}
});
});
</script>
</body>
</html>

@ -0,0 +1,35 @@
{
"name": "ng_companion",
"short_name": "ng_companion",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,41 @@
{
"name": "ng-flutter",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"prebuild": "pushd flutter && flutter clean && flutter build web && popd",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^15.2.0",
"@angular/cdk": "^15.2.5",
"@angular/common": "^15.2.0",
"@angular/compiler": "^15.2.0",
"@angular/core": "^15.2.0",
"@angular/forms": "^15.2.0",
"@angular/material": "^15.2.5",
"@angular/platform-browser": "^15.2.0",
"@angular/platform-browser-dynamic": "^15.2.0",
"@angular/router": "^15.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.12.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.2.4",
"@angular/cli": "~15.2.4",
"@angular/compiler-cli": "^15.2.0",
"@types/jasmine": "~4.3.0",
"jasmine-core": "~4.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"typescript": "~4.9.4"
}
}

@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'ng-flutter'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('ng-flutter');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content span')?.textContent).toContain('ng-flutter app is running!');
});
});

@ -0,0 +1,171 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { NgFlutterComponent } from './ng-flutter/ng-flutter.component';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { MatSidenavModule } from '@angular/material/sidenav';
import { CommonModule } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatCardModule } from '@angular/material/card';
import { MatSliderModule } from '@angular/material/slider';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@Component({
standalone: true,
selector: 'app-root',
template: `
<mat-toolbar color="primary">
<button
aria-label="Toggle sidenav"
mat-icon-button
(click)="drawer.toggle()">
<mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
</button>
<span>Angular 🤝 Flutter</span>
<span class="toolbar-spacer"></span>
<mat-icon aria-hidden="true">flutter_dash</mat-icon>
</mat-toolbar>
<mat-sidenav-container [hasBackdrop]=false class="sidenav-container">
<mat-sidenav #drawer mode="side" [opened]=false class="sidenav">
<mat-nav-list autosize>
<section>
<h2>Effects</h2>
<div class="button-list">
<button class="mb-control" mat-stroked-button color="primary"
(click)="container.classList.toggle('fx-shadow')">Shadow</button>
<button class="mb-control" mat-stroked-button color="primary"
(click)="container.classList.toggle('fx-mirror')">Mirror</button>
<button class="mb-control" mat-stroked-button color="primary"
(click)="container.classList.toggle('fx-resize')">Resize</button>
<button class="mb-control" mat-stroked-button color="primary"
(click)="container.classList.toggle('fx-spin')">Spin</button>
</div>
</section>
<section>
<h2>JS Interop</h2>
<mat-form-field appearance="outline">
<mat-label>Screen</mat-label>
<mat-select
(valueChange)="this.flutterState?.setScreen($event)"
[value]="this.flutterState?.getScreen()">
<mat-option value="counter">Counter</mat-option>
<mat-option value="text">TextField</mat-option>
<mat-option value="dash">Custom App</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" *ngIf="this.flutterState?.getScreen() == 'counter'">
<mat-label>Clicks</mat-label>
<input type="number" matInput (input)="onCounterSet($event)" [value]="this.flutterState?.getClicks()" />
</mat-form-field>
<mat-form-field appearance="outline" *ngIf="this.flutterState?.getScreen() != 'counter'">
<mat-label>Text</mat-label>
<input type="text" matInput (input)="onTextSet($event)" [value]="this.flutterState?.getText()" />
<button *ngIf="this.flutterState?.getText()" matSuffix mat-icon-button aria-label="Clear" (click)="this.flutterState?.setText('')">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</section>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="sidenav-content">
<div class="flutter-app" #container>
<ng-flutter
src="flutter/main.dart.js"
assetBase="/flutter/"
(appLoaded)="onFlutterAppLoaded($event)"></ng-flutter>
</div>
</mat-sidenav-content>
</mat-sidenav-container>
`,
styles: [`
:host{
display: flex;
height: 100%;
flex-direction: column;
}
.toolbar-spacer {
flex: 1 1 auto;
}
.sidenav-container {
flex: 1;
}
.sidenav {
width: 300px;
padding: 10px;
}
.button-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 20px;
}
.button-list button {
min-width: 130px;
}
.sidenav-content {
display: flex;
justify-content: center;
align-items: center;
}
.flutter-app {
border: 1px solid #eee;
border-radius: 5px;
height: 480px;
width: 320px;
transition: all 150ms ease-in-out;
overflow: hidden;
}
`],
imports: [
NgFlutterComponent,
MatToolbarModule,
MatSidenavModule,
MatSidenavModule,
MatIconModule,
CommonModule,
MatListModule,
MatCardModule,
MatSliderModule,
MatButtonModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
],
})
export class AppComponent {
title = 'ng-flutter';
flutterState?: any;
constructor(private changeDetectorRef: ChangeDetectorRef, private breakpointObserver: BreakpointObserver) { }
onFlutterAppLoaded(state: any) {
this.flutterState = state;
this.flutterState.onClicksChanged(() => { this.onCounterChanged() });
this.flutterState.onTextChanged(() => { this.onTextChanged() });
}
onCounterSet(event: Event) {
let clicks = parseInt((event.target as HTMLInputElement).value, 10) || 0;
this.flutterState.setClicks(clicks);
}
onTextSet(event: Event) {
this.flutterState.setText((event.target as HTMLInputElement).value || '');
}
// I need to force a change detection here. When clicking on the "Decrement"
// button, everything works fine, but clicking on Flutter doesn't trigger a
// repaint (even though this method is called)
onCounterChanged() {
this.changeDetectorRef.detectChanges();
}
onTextChanged() {
this.changeDetectorRef.detectChanges();
}
}

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NgFlutterComponent } from './ng-flutter.component';
describe('NgFlutterComponent', () => {
let component: NgFlutterComponent;
let fixture: ComponentFixture<NgFlutterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NgFlutterComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(NgFlutterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,65 @@
import { Component, AfterViewInit, SimpleChanges, ViewChild, ElementRef, Input, EventEmitter, Output } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
// The global _flutter namespace
declare var _flutter: any;
declare var window: {
_debug: any
};
@Component({
selector: 'ng-flutter',
standalone: true,
template: `
<div #flutterTarget>
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
</div>
`,
styles: [`
:host div {
width: 100%;
height: 100%;
}
.spinner {
display: flex;
justify-content: center;
align-items: center;
}`,
],
imports: [
MatProgressSpinnerModule,
],
})
export class NgFlutterComponent implements AfterViewInit {
// The target that will host the Flutter app.
@ViewChild('flutterTarget') flutterTarget!: ElementRef;
@Input() src: String = 'main.dart.js';
@Input() assetBase: String = '';
@Output() appLoaded: EventEmitter<Object> = new EventEmitter<Object>();
ngAfterViewInit(): void {
const target: HTMLElement = this.flutterTarget.nativeElement;
_flutter.loader.loadEntrypoint({
entrypointUrl: this.src,
onEntrypointLoaded: async (engineInitializer: any) => {
let appRunner = await engineInitializer.initializeEngine({
hostElement: target,
assetBase: this.assetBase,
});
await appRunner.runApp();
}
});
target.addEventListener("flutter-initialized", (event: Event) => {
let state = (event as CustomEvent).detail;
window._debug = state;
this.appLoaded.emit(state);
}, {
once: true,
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NgFlutter</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

@ -0,0 +1,14 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { AppComponent } from './app/app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { importProvidersFrom } from '@angular/core';
const appRoutes: Routes = [];
bootstrapApplication(AppComponent, {
providers: [
provideRouter(appRoutes),
importProvidersFrom(BrowserAnimationsModule)
]
})

@ -0,0 +1,54 @@
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
/* FX */
.fx-resize {
width: 480px !important;
height: 320px !important;
}
.fx-spin { animation: spin 6400ms ease-in-out infinite; }
.fx-shadow { position: relative; overflow: visible !important; }
.fx-shadow::before {
content: "";
position: absolute;
display: block;
width: 100%;
top: calc(100% - 1px);
left: 0;
height: 1px;
background-color: black;
border-radius: 50%;
z-index: -1;
transform: rotateX(80deg);
box-shadow: 0px 0px 60px 38px rgb(0 0 0 / 25%);
}
.fx-mirror {
-webkit-box-reflect: below 0px linear-gradient(to bottom, rgba(0,0,0,0.0), rgba(0,0,0,0.4));
}
@keyframes spin {
0% {
transform: perspective(1000px) rotateY(0deg);
animation-timing-function: ease-in-out;
}
10% {
transform: perspective(1000px) rotateY(0deg);
animation-timing-function: ease-in-out;
}
40% {
transform: perspective(1000px) rotateY(180deg);
animation-timing-function: ease-in-out;
}
60% {
transform: perspective(1000px) rotateY(180deg);
animation-timing-function: ease-in-out;
}
90% {
transform: perspective(1000px) rotateY(359deg);
animation-timing-function: ease-in-out;
}
100% {
transform: perspective(1000px) rotateY(360deg);
animation-timing-function: ease-in-out;
}
}

@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

@ -0,0 +1,33 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

@ -0,0 +1,14 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
Loading…
Cancel
Save