> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/tradingview-webview-javascript-bridge.md).

# TradingView in a Flutter WebView

How to package TradingView in a Flutter WebView with local assets, a versioned JavaScript bridge, and safe Android/iOS lifecycle handling

## Outcome

In my app, the financial chart is not rendered by a Flutter chart widget. I package the web app together with its HTML, JavaScript, CSS, and chart library as Flutter assets, then display them in a WebView on Android and iOS.

Flutter is responsible for:

* Selecting the initial symbol, interval, and theme.
* Loading assets with the appropriate mechanism for each platform.
* Sending commands such as adding or removing annotations.
* Receiving events from JavaScript and deciding which native actions are allowed.
* Restoring the chart when the app returns to the foreground.

The web app is responsible for mounting the chart, connecting the datafeed, and handling chart-specific commands.

The complete flow looks like this:

```
Flutter page        WebView shell          Chart web app        Native router
     │                    │                      │                    │
     ├── create ─────────►│                      │                    │
     │                    ├── local index.html ─►│                    │
     │                    ├── bootstrap(v1) ────►│ document start     │
     │                    │                      ├── mount chart      │
     │                    │◄── chartReady(v1) ───┤                    │
     ├── command ────────►│── JavaScript ───────►│                    │
     │                    │◄── ack(requestId) ───┤                    │
     │                    │◄── openAppLink ──────┤                    │
     │                    ├── validate/allowlist ────────────────────►│
     │                    │                      │                    │
     ├── app resumed ────►│ reload/resync        │                    │
     │                    │◄── chartReady ────────┤                    │
```

`onLoadStop` only proves that the document has loaded. Flutter sends commands only after the web app itself reports `chartReady`.

The current source confirms that the local chart is used by multiple runtime pages and already has bootstrap data, Flutter-to-JavaScript commands, navigation interception, and lifecycle reload. The message schema, ready handshake, and strict allowlist in this article are how I harden that architecture; the current source does not yet contain all of these safeguards.

This article targets Android and iOS. It does not explain how to obtain or distribute TradingView Advanced Charts. You must have valid usage rights and preserve attribution according to your TradingView agreement.

## Problem

When I first embedded the chart, creating the WebView was the short part. The difficult problems appeared at the boundaries around it.

The HTML must find every relative JavaScript chunk, stylesheet, font, and asset. Android and iOS do not load local files in the same way. The chart needs bootstrap data before the web app starts, but commands such as drawing an annotation can only be sent after the chart library has mounted.

In the current source, callers wait for a fixed delay after page-finished or reload before sending a command. That can work on a warm device and still lose the message when the WebView starts more slowly.

A WebView is not a trusted code region by default. JavaScript may request navigation, open a native screen, or call device functionality. If the app only parses JSON and executes the requested action, a page outside the allowlist may reach a bridge with more privileges than it needs.

I split the problem into three layers:

| Layer           | Owns                                                | Question to answer                                                                    |
| --------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Flutter shell   | Assets, lifecycle, loading UI, navigation, gestures | When should the WebView be created or reloaded, and which native actions are allowed? |
| Bridge contract | Version, schema, ready/ack, serialization           | How do both sides know which messages are valid and have been handled?                |
| Chart web app   | Chart mounting, datafeed, theme, annotations        | When is the chart actually ready to receive commands?                                 |

If these three layers are combined in one widget, the code quickly becomes a collection of timing-dependent `evaluateJavascript()` strings that are difficult to test.

## Solution

### Preparing dependencies and assets

The example uses `flutter_inappwebview` 6.1.5, the stable version verified in the source app. I declare `mime` directly because the path handler imports it to return the correct Content-Type:

```yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_inappwebview: ^6.1.5
  mime: ^2.0.0

flutter:
  assets:
    - assets/chart/index.html
    - assets/chart/app/
    - assets/chart/vendor-chart/
```

I keep the public bundle structure neutral:

```
assets/chart/
├── index.html
├── app/
│   ├── main.<hash>.js
│   └── main.<hash>.css
└── vendor-chart/        # only when the app has a valid license
```

Before building the app, CI should verify that:

* Every `src` and `href` in `index.html` exists.
* JavaScript, CSS, fonts, and images use the correct extension and MIME type.
* The bundle does not contain `.env`, tokens, private domains, or unintended build metadata.
* Attribution and licensing remain valid after each vendor library update.

TradingView distributes Advanced Charts through an authorized repository. The library must not be redistributed in a public repository, and its internal files should be treated as a black box. This documentation repository therefore contains only illustrative code, not the vendor bundle or screenshots from the real app.

### Injecting bootstrap data at document start

The web app needs the symbol, interval, and theme as it starts. I serialize a small object with `jsonEncode` and inject it at `AT_DOCUMENT_START`:

```dart
import 'dart:convert';

final class ChartBootstrap {
  const ChartBootstrap({
    required this.symbol,
    required this.theme,
    required this.interval,
    required this.sessionTicket,
  });

  final String symbol;
  final String theme;
  final String interval;
  final String sessionTicket;

  Map<String, Object?> toJson() => {
        'schemaVersion': 1,
        'symbol': symbol,
        'theme': theme,
        'interval': interval,
        'sessionTicket': sessionTicket,
      };
}

String makeBootstrapScript(ChartBootstrap bootstrap) {
  final encoded = jsonEncode(bootstrap.toJson());
  return 'window.chartBootstrap = $encoded;';
}
```

Then pass the script to the WebView:

```dart
initialUserScripts: UnmodifiableListView<UserScript>([
  UserScript(
    source: makeBootstrapScript(bootstrap),
    injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
  ),
]),
```

Do not interpolate a symbol or user-provided text directly into JavaScript. `jsonEncode` keeps the payload as data instead of turning it into code.

In the current source, the bootstrap also contains credentials and an account identifier. For the public example, I replace them with a short-lived, narrowly scoped, revocable `sessionTicket`. If the web app only needs a few APIs, the ticket should grant only those permissions.

`AT_DOCUMENT_START` helps the script run before page resources. However, the `flutter_inappwebview` documentation warns that when Android System WebView does not support `DOCUMENT_START_SCRIPT`, the plugin can only inject the script as early as possible. The web app must still validate the bootstrap and show a clear error when required data is missing.

### Loading assets on Android with a local HTTPS origin

Android provides `WebViewAssetLoader` to map Flutter assets to an HTTPS origin. This works with the Same-Origin Policy and loads subresources without opening broad `file://` access.

The path handler reads only files inside `assets/chart/`:

```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:mime/mime.dart';

final class ChartAssetHandler extends CustomPathHandler {
  ChartAssetHandler() : super(path: '/assets/');

  @override
  Future<WebResourceResponse?> handle(String path) async {
    try {
      final decodedPath = Uri.decodeComponent(path);
      if (decodedPath.contains('..')) {
        return WebResourceResponse(data: null);
      }

      final assetPath = decodedPath.replaceFirst('flutter_assets/', '');
      if (!assetPath.startsWith('assets/chart/')) {
        return WebResourceResponse(data: null);
      }

      final data = await rootBundle.load(assetPath);
      return WebResourceResponse(
        contentType: lookupMimeType(assetPath),
        data: data.buffer.asUint8List(),
      );
    } catch (error, stackTrace) {
      debugPrint('Chart asset failed: $error\n$stackTrace');
      return WebResourceResponse(data: null);
    }
  }
}
```

The initial Android URL uses the platform's reserved domain:

```dart
const androidChartUrl =
    'https://appassets.androidplatform.net/'
    'assets/flutter_assets/assets/chart/index.html';

final settings = InAppWebViewSettings(
  javaScriptEnabled: true,
  useShouldOverrideUrlLoading: true,
  allowFileAccess: false,
  allowContentAccess: false,
  allowFileAccessFromFileURLs: false,
  allowUniversalAccessFromFileURLs: false,
  webViewAssetLoader: WebViewAssetLoader(
    pathHandlers: [ChartAssetHandler()],
  ),
);
```

A missing asset should return an error instead of falling back to the network. When the chart is blank on Android, I inspect the Network tab and the chunk's MIME type before changing the Flutter widget.

### Loading the local chart on iOS

`WebViewAssetLoader` is Android-only. In the current app, iOS loads `assets/chart/index.html` through `initialFile`:

```dart
InAppWebView(
  initialFile: 'assets/chart/index.html',
  initialSettings: InAppWebViewSettings(
    javaScriptEnabled: true,
    useShouldOverrideUrlLoading: true,
    allowFileAccessFromFileURLs: false,
    allowUniversalAccessFromFileURLs: false,
  ),
  // callbacks...
)
```

I do not enable `allowFileAccessFromFileURLs` or `allowUniversalAccessFromFileURLs` just to work around a CORS error. These options expand what one local file can read, and the plugin documentation recommends keeping them `false`.

If the iOS bundle cannot find a relative chunk, inspect how the file was copied into the app bundle and restrict read access to the chart directory. Do not move the entire asset bundle to the network or enable arbitrary file access before identifying the failed request.

Both platforms can be combined in one widget:

```dart
import 'dart:collection';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

class ChartWebView extends StatelessWidget {
  const ChartWebView({
    required this.bootstrap,
    required this.onWebViewCreated,
    required this.onNavigation,
    super.key,
  });

  final ChartBootstrap bootstrap;
  final void Function(InAppWebViewController) onWebViewCreated;
  final Future<NavigationActionPolicy> Function(WebUri? url) onNavigation;

  @override
  Widget build(BuildContext context) {
    return InAppWebView(
      initialUserScripts: UnmodifiableListView<UserScript>([
        UserScript(
          source: makeBootstrapScript(bootstrap),
          injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
        ),
      ]),
      initialSettings: InAppWebViewSettings(
        javaScriptEnabled: true,
        useShouldOverrideUrlLoading: true,
        allowFileAccess: false,
        allowContentAccess: false,
        allowFileAccessFromFileURLs: false,
        allowUniversalAccessFromFileURLs: false,
        webViewAssetLoader: Platform.isAndroid
            ? WebViewAssetLoader(
                pathHandlers: [ChartAssetHandler()],
              )
            : null,
      ),
      initialFile: Platform.isIOS ? 'assets/chart/index.html' : null,
      initialUrlRequest: Platform.isAndroid
          ? URLRequest(url: WebUri(androidChartUrl))
          : null,
      onWebViewCreated: onWebViewCreated,
      shouldOverrideUrlLoading: (controller, action) async {
        return await onNavigation(action.request.url);
      },
    );
  }
}
```

`useHybridComposition` is an Android option, and plugin 6.1.5 enables it by default. I do not set this option from `Platform.isIOS` because it does not configure how iOS embeds `WKWebView`.

### Defining a versioned message contract

I do not let Flutter and JavaScript infer several unrelated object shapes. Every message uses one envelope:

```json
{
  "version": 1,
  "id": "request-42",
  "action": "annotation.upsert",
  "payload": {
    "annotationId": "demo-line",
    "price": 123.45,
    "kind": "reference"
  }
}
```

The Flutter parser limits message size and validates required fields before dispatching:

```dart
final class ChartBridgeMessage {
  const ChartBridgeMessage({
    required this.version,
    required this.id,
    required this.action,
    required this.payload,
  });

  final int version;
  final String id;
  final String action;
  final Map<String, dynamic> payload;

  factory ChartBridgeMessage.parse(Object? raw) {
    if (raw is! String || raw.length > 16 * 1024) {
      throw const FormatException('Invalid bridge message size');
    }

    final decoded = jsonDecode(raw);
    if (decoded is! Map<String, dynamic>) {
      throw const FormatException('Bridge message must be an object');
    }

    final version = decoded['version'];
    final id = decoded['id'];
    final action = decoded['action'];
    final payload = decoded['payload'];

    if (version is! int ||
        version != 1 ||
        id is! String ||
        id.isEmpty ||
        action is! String ||
        payload is! Map<String, dynamic>) {
      throw const FormatException('Invalid bridge message schema');
    }

    return ChartBridgeMessage(
      version: version,
      id: id,
      action: action,
      payload: payload,
    );
  }
}
```

An unknown `version` or `action` is rejected. I do not fall back to old behavior because fallback lets both sides believe they speak the same protocol when their schemas have already diverged.

### Waiting for `chartReady` instead of a fixed delay

Flutter can register a JavaScript handler in `onWebViewCreated`, but it should not call `evaluateJavascript()` at that point. The plugin documentation says that the WebView is not ready to run scripts in `onWebViewCreated` or `onLoadStart`.

The distinction is:

* Registering a handler in `onWebViewCreated`: correct, because the handler must exist before the page sends a message.
* Running chart-control JavaScript in `onWebViewCreated`: timing is unsafe.

The web app waits until both the plugin bridge and the chart are ready:

```javascript
const readyState = {
  platform: false,
  chart: false,
  sent: false,
};

async function notifyReady() {
  if (!readyState.platform || !readyState.chart || readyState.sent) return;
  readyState.sent = true;

  await window.flutter_inappwebview.callHandler(
    'ChartBridge',
    JSON.stringify({
      version: 1,
      id: `${Date.now()}-${Math.random()}`,
      action: 'chart.ready',
      payload: {},
    }),
  );
}

window.addEventListener('flutterInAppWebViewPlatformReady', () => {
  readyState.platform = true;
  notifyReady();
});

mountChart(window.chartBootstrap).then(() => {
  readyState.chart = true;
  notifyReady();
});
```

`mountChart()` is a web-app adapter, not a TradingView API. The adapter resolves only after the chart library has mounted and the command listener has been registered.

### Queueing commands in Flutter until the chart is ready

The bridge retains commands that arrive early and flushes them after `chart.ready`:

```dart
import 'dart:collection';
import 'dart:convert';

import 'package:flutter_inappwebview/flutter_inappwebview.dart';

final class ChartCommandBridge {
  InAppWebViewController? _controller;
  bool _ready = false;
  final Queue<Map<String, Object?>> _pending = Queue();

  void attach(InAppWebViewController controller) {
    _controller = controller;
    _ready = false;
  }

  void beginReload() {
    _ready = false;
  }

  Future<void> markReady() async {
    _ready = true;
    while (_pending.isNotEmpty) {
      await _dispatch(_pending.removeFirst());
    }
  }

  Future<void> send(Map<String, Object?> command) async {
    if (!_ready) {
      _pending.addLast(command);
      return;
    }
    await _dispatch(command);
  }

  Future<void> _dispatch(Map<String, Object?> command) async {
    final controller = _controller;
    if (controller == null) {
      throw StateError('Chart WebView is not attached');
    }

    final encoded = jsonEncode(command);
    await controller.evaluateJavascript(
      source: 'window.chartBridge.onNativeCommand($encoded);',
    );
  }

  void dispose() {
    _pending.clear();
    _controller = null;
    _ready = false;
  }
}
```

The web app receives commands through one function:

```javascript
window.chartBridge = {
  onNativeCommand(message) {
    if (message?.version !== 1 || typeof message?.action !== 'string') {
      throw new Error('Invalid native chart command');
    }

    switch (message.action) {
      case 'annotation.upsert':
        return annotationController.upsert(message.payload);
      case 'annotation.remove':
        return annotationController.remove(message.payload);
      default:
        throw new Error(`Unsupported action: ${message.action}`);
    }
  },
};
```

In production, each command should include an `id`, and the web app should return an `ack` with the same `id`. When the document reloads, I increment its generation and reject acknowledgements or messages from an older generation so a late callback cannot update the new state.

### Validating JavaScript before running native actions

The handler is registered as soon as the controller is created:

```dart
Future<Map<String, Object?>> handleBridgeMessage(
  InAppWebViewController controller,
  List<dynamic> arguments,
  ChartCommandBridge bridge,
) async {
  try {
    final currentUrl = await controller.getUrl();
    if (!isTrustedChartUrl(currentUrl)) {
      return {'ok': false, 'error': 'untrusted_document'};
    }

    final raw = arguments.length == 1 ? arguments.single : null;
    final message = ChartBridgeMessage.parse(raw);

    switch (message.action) {
      case 'chart.ready':
        await bridge.markReady();
        return {'ok': true, 'id': message.id};
      case 'navigation.open':
        final path = message.payload['path'];
        if (path is! String || !allowedNativeRoutes.contains(path)) {
          return {'ok': false, 'error': 'route_not_allowed'};
        }
        await openNativeRoute(path);
        return {'ok': true, 'id': message.id};
      default:
        return {'ok': false, 'error': 'action_not_supported'};
    }
  } on FormatException {
    return {'ok': false, 'error': 'invalid_message'};
  }
}
```

The helpers keep policy in one place:

```dart
const allowedNativeRoutes = <String>{
  '/help',
  '/settings',
};

bool isTrustedChartUrl(WebUri? url) {
  if (url == null) return false;

  if (Platform.isAndroid) {
    return url.scheme == 'https' &&
        url.host == 'appassets.androidplatform.net' &&
        url.path.startsWith(
          '/assets/flutter_assets/assets/chart/',
        );
  }

  final decodedPath = Uri.decodeComponent(url.path);
  return url.scheme == 'file' &&
      !decodedPath.contains('..') &&
      decodedPath.endsWith('/assets/chart/index.html');
}

Future<void> openNativeRoute(String path) async {
  // Map the allowed path to the app's router.
}
```

Register the handler:

```dart
onWebViewCreated: (controller) {
  bridge.attach(controller);
  controller.addJavaScriptHandler(
    handlerName: 'ChartBridge',
    callback: (arguments) {
      return handleBridgeMessage(
        controller,
        arguments,
        bridge,
      );
    },
  );
},
```

Plugin 6.1.5 does not expose origin/main-frame metadata in the stable callback type used here. Checking `currentUrl()` adds a layer of defense, but it is not equivalent to verifying the frame that sent the message. When I upgrade to a stable version that supports `JavaScriptHandlerFunctionData` and an origin allowlist, I will restrict the bridge to the main frame and validate the origin natively.

Actions with side effects need their own policies:

* Navigation accepts only a route enum or allowlist, not an arbitrary URL.
* Download/share validates the HTTPS host, Content-Type, and size.
* Vibration and repeated actions are rate-limited.
* Logs contain only request ID, action, and error code; they never contain the bootstrap payload or credentials.

### Blocking navigation with an allowlist

The current source already intercepts deep links and blocks some URLs, but remaining URLs are allowed. For a WebView with a privileged bridge, I invert that policy: block by default and allow only the local chart origin.

```dart
Future<NavigationActionPolicy> decideNavigation(
  WebUri? webUri,
) async {
  if (webUri == null) return NavigationActionPolicy.CANCEL;
  if (isTrustedChartUrl(webUri)) return NavigationActionPolicy.ALLOW;

  final uri = Uri.tryParse(webUri.toString());
  if (uri != null &&
      uri.scheme == 'https' &&
      uri.host == 'example.com' &&
      uri.path.startsWith('/app/')) {
    await openAppLink(uri);
  }

  return NavigationActionPolicy.CANCEL;
}

Future<void> openAppLink(Uri uri) async {
  // Send the allowed app link to the native router.
}
```

Do not load remote help, advertising, or legal pages inside the same document that owns the privileged bridge. If an external page must be opened, validate its HTTPS host and send it to the system browser or to a separate WebView without the chart bridge.

### Restoring the chart with the app lifecycle

In the current source, a lifecycle widget wraps the chart. When the app returns to `resumed`, the WebView reloads and the caller sends its annotations again.

I keep recovery in the Flutter layer, but replace the fixed delay with the bridge queue:

```dart
Future<void> restoreAfterResume({
  required InAppWebViewController controller,
  required ChartCommandBridge bridge,
  required Map<String, Object?> restoreCommand,
}) async {
  bridge.beginReload();
  await bridge.send(restoreCommand);
  await controller.reload();
}
```

`send()` only queues the command because the bridge has just moved to not-ready. Once the new document has loaded, the web app sends `chart.ready`, and only then is the restore command flushed.

The restoration order should be deterministic:

```
Reload document
      │
      ▼
Bootstrap symbol + theme + interval
      │
      ▼
chartReady
      │
      ▼
Restore annotations + layout
```

If reload is expensive, ping the chart first and reload only when the datafeed or document is no longer healthy. Whichever strategy you choose, the lifecycle callback must not send JavaScript before the chart is ready.

To prevent messages from an old document from updating a new document, a production bridge should include `generation` in the bootstrap, commands, and acknowledgements. Increment the generation when a reload starts and discard queued commands that are no longer valid.

### Resolving gestures between the chart and Flutter

The chart needs horizontal pan, pinch, and long press. The Flutter page may need vertical scrolling. I let the WebView receive horizontal drags after the gesture passes `kTouchSlop`, while vertical drags remain with the parent page.

```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';

final chartGestures = <Factory<OneSequenceGestureRecognizer>>{
  Factory<HorizontalDragGestureRecognizer>(
    HorizontalDragGestureRecognizer.new,
  ),
};
```

Then pass `chartGestures` to the `gestureRecognizers` property of `InAppWebView`.

This is not the correct configuration for every layout. A full-screen chart may own vertical gestures as well. When the chart is inside a `ListView`, test horizontal pan, vertical scroll, pinch, and long press on real devices. TradingView also has mobile feature flags for horizontal and vertical touch drag, so the WebView gesture policy and chart configuration must be verified together.

### Using a state machine for loading and errors

WebView progress does not prove that the chart has mounted. I keep the loading state visible until `chart.ready`:

```
bootstrapping → documentLoading → chartMounting → ready
       │                │                │
       └────────────── error ◄───────────┘
                            │
                          retry
```

`onLoadStart` moves to `documentLoading`. `onLoadStop` moves to `chartMounting`. Only the `chart.ready` handler moves to `ready`.

`onReceivedError` and `onReceivedHttpError` should move the UI to the error state when the main document or a required asset fails. The error UI should provide retry and a neutral correlation ID without displaying a private URL or bootstrap payload.

### Verifying the result

The bridge contract can be tested without a real WebView:

* The parser rejects malformed JSON, unknown versions, unknown actions, and oversized payloads.
* The serializer still produces valid JSON when a symbol or label contains quotes, newlines, or Unicode.
* A command sent before `chart.ready` is queued and flushed exactly once.
* Reload moves the bridge back to not-ready.
* An old generation cannot acknowledge a command after reload.
* The navigation policy allows only the local chart origin and known app links.

Widget tests should verify state instead of trying to render the native WebView:

* `documentLoading` and `chartMounting` keep the loading UI visible.
* `chart.ready` hides loading.
* A timeout moves to error/retry.
* `resumed` triggers one restoration.
* `dispose` removes the lifecycle observer and clears the command queue.

Finally, I verify the integration on one Android device and one iPhone:

1. Open the chart in light and dark themes and confirm that bootstrap data exists before the web app mounts.
2. Verify the MIME type of the HTML, JavaScript chunks, CSS, fonts, and images.
3. Send a command before ready and confirm that the message is not lost.
4. Background and resume the app, then confirm that annotations are restored once.
5. Try an allowed app link, a URL outside the allowlist, `http:`, `file:`, and `javascript:`.
6. Try malformed JSON, an unknown action, an oversized payload, and repeated actions.
7. Test horizontal pan, vertical scroll, pinch, and long press.
8. Inspect console and network activity with Chrome DevTools or Safari Web Inspector using fake data.

The current source contains widget tests for the guide page and feature flag, but no direct test for the chart asset loader, bridge, readiness, or lifecycle. During research, the related suite also could not build because the workspace dependency cache and generated code were not ready. I therefore do not consider the current source to have complete automated coverage for the WebView bridge.

### Common issues and trade-offs

#### Blank chart on Android

**Common causes:** a missing chunk, incorrect MIME type, or an Android System WebView that does not support the JavaScript required by the bundle.

**How to diagnose:** open Chrome DevTools, inspect which request returned 404, and check the Content-Type of `.js` and `.css` files. TradingView recommends updating Android WebView when an integration displays a white screen because ES6 support is missing.

#### Chart loads without configuration

**Cause:** bootstrap data was injected too late, or the web app read the wrong schema version.

**Fix:** use `UserScriptInjectionTime.AT_DOCUMENT_START`, validate `window.chartBootstrap` before mounting, and display an error if required fields are missing.

#### The first command is lost

**Cause:** Flutter sent JavaScript in `onWebViewCreated`, `onLoadStart`, or immediately after `onLoadStop` before the chart mounted.

**Fix:** the web app sends `chart.ready`, and Flutter queues commands until ready.

#### A deep link opens inside the chart

**Cause:** navigation uses a default-allow policy or blocks only a few known URLs.

**Fix:** cancel by default, allow the local origin, and forward a valid app link to the native router.

#### Web content can call too many native actions

**Cause:** the handler only runs `jsonDecode` and dispatches a string provided by JavaScript.

**Fix:** validate the current document, version, action, payload, and route allowlist; limit size and rate; never download an arbitrary URL.

#### Resume loses annotations

**Cause:** the WebView reloads, but state exists only in the old document.

**Fix:** Flutter owns the state to restore, starts a new generation, and sends it again after `chart.ready`.

#### Page scrolling and chart panning compete

**Cause:** the WebView receives every pointer, or the parent page always wins the gesture arena.

**Fix:** assign gesture ownership by axis and test it together with the chart's mobile feature flags.

#### A local bundle increases app size

Packaging the chart keeps the web version aligned with the app and avoids a hosting dependency for static files. The trade-off is that every bundle update requires a new app release and increases app size. Remote hosting may fit better when the web app must update independently, but it also requires CORS, caching, CSP, an origin allowlist, and rollback design.

### Related article

Flutter must resolve `ThemeMode` to `light` or `dark` before sending it to the web app. The Dark Mode article owns theme management in Flutter; this article covers only the boundary where the resolved theme enters the bootstrap payload.

{% content-ref url="/pages/DDwBamhA1zlGZaY4h0sH" %}
[Dark Mode](/flutter/my-flutter/ui-media/dark-mode.md)
{% endcontent-ref %}

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11 to before 4.0.
* `flutter_inappwebview`: 6.1.5.
* `mime`: 2.0.0 in the verified dependency graph; the example declares it directly.
* Platforms: Android and iOS.
* Source research: 2026-09-02.

### References

* [TradingView — Mobile app development](https://www.tradingview.com/charting-library-docs/latest/mobile_specifics/)
* [TradingView — Get started](https://www.tradingview.com/charting-library-docs/latest/getting_started/quick-start/)
* [TradingView — Best practices](https://www.tradingview.com/charting-library-docs/latest/resources/Best-Practices/)
* [InAppWebView — WebView Asset Loader](https://inappwebview.dev/docs/webview/webview-asset-loader/)
* [InAppWebView — User Scripts](https://inappwebview.dev/docs/webview/javascript/user-scripts/)
* [InAppWebView — JavaScript communication](https://inappwebview.dev/docs/webview/javascript/communication/)
* [InAppWebView — JavaScript injection](https://inappwebview.dev/docs/webview/javascript/injection/)
* [InAppWebView — Load local content](https://inappwebview.dev/docs/webview/in-app-webview/#load-local-content)

## Conclusion

Embedding TradingView in Flutter is more than placing a WebView in the widget tree. A local chart needs the correct asset origin and MIME types, bootstrap data must exist before the web app mounts, and commands must wait until the chart reports that it is ready.

I keep asset loading and lifecycle handling in Flutter, chart behavior in the web app, and connect both sides with a versioned message contract. Native actions and navigation pass through allowlists because a JavaScript bridge is a trust boundary, not a harmless internal helper.

This approach fits an app that ships a fixed web-chart version on Android and iOS and needs two-way communication with the native layer. If you only display a remote chart with no native actions, the bridge can be smaller. If the web app must update independently, move to remote hosting but add CORS, CSP, caching, and rollback instead of removing the controls described in this article.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
