Feb 19, 2026

WebView-App: Payment Flows, State Sync und Plattform-Hacks (Teil 2)

TL;DR: In Teil 1 habe ich gezeigt, warum "einfach die Website einbetten" bei einem Schmuck-E-Commerce-Projekt mit Shopify-Backend komplexer war als gedacht. Jetzt zeige ich: Das war kein Einzelfall. Ein völlig anderes Projekt — Schweizer Elektronik-Retailer, Custom Backend, andere Payment-Provider — kämpft mit denselben strukturellen Problemen. Plus einer ganzen neuen Schicht: Payment-Flows über App-Grenzen hinaus, native State-Synchronisation via API-Interception, und Plattform-Berechtigungen, die zwischen iOS und Android komplett unterschiedlich funktionieren.

WebView ist eine Systemkomponente, die es mobilen Apps erlaubt, Webinhalte direkt innerhalb der App anzuzeigen — im Grunde ein Browser ohne Adressleiste. In einer WebView-App werden bestehende Webseiten in eine native Hülle eingebettet, statt sie nativ nachzubauen. Was nach einer einfachen Lösung klingt, führt in der Praxis zu erheblicher Komplexität — wie dieser Artikel anhand von echtem Flutter-Code zeigt.

Anderes Projekt, gleiche Schmerzen

Laut Precedence Research wächst das Segment für Cross-Platform-App-Entwicklung — zu dem auch WebView-basierte Hybrid-Apps gehören — mit einer jährlichen Wachstumsrate von 17,3 % bis 2034. Mehr Hybrid-Apps bedeutet: mehr Teams, die auf dieselben WebView-Probleme stoßen werden.

Nach der Veröffentlichung von Teil 1 hätte man einwenden können: "Klar, das war ein Shopify-Projekt. Shopify hat eben seine Eigenheiten. Mit einem Custom Backend wäre das einfacher."

Spoiler: Nein.

Das zweite Projekt ist eine Flutter-App für einen Schweizer Elektronik-Retailer. Keine Shopify-Abhängigkeit, sondern ein eigenes Backend mit REST-APIs. Eigene Authentifizierung. Eigene Payment-Integration über Datatrans. Und trotzdem: dieselben strukturellen Probleme.

Die Architektur folgt dem gleichen Hybrid-Ansatz: Native Screens für Auth, Home, Suche und Watchlists. WebViews für Produktseiten, Warenkorb, Checkout und Account-Einstellungen. Dasselbe Plugin-System. Dieselbe JavaScript-Bridge (window.mobileApp). Dieselbe shouldOverrideUrlLoading-Logik mit plattformspezifischen Workarounds für iOS und Android.

Aber dieses Projekt hat ein Element, das im Shopify-Projekt nicht existierte: einen Payment-Flow, der die WebView verlässt. Und genau da wird es richtig interessant.

In Zahlen: 648 Zeilen WebView-Container, 223 Zeilen Payment-Browser, 436 Zeilen Checkout-Seite, 10 Plugins. Für "einfach die Website einbetten".

Payment Flows: Wenn der WebView nicht mehr reicht

Im Shopify-Projekt lief der gesamte Checkout innerhalb des WebViews. Hier nicht. Der Payment-Flow ist ein eigenes Biest.

Das Problem: Drei Welten statt zwei

Beim Checkout passiert Folgendes:

  1. Der Nutzer sieht den Warenkorb (WebView #1)

  2. Er geht zum Checkout (WebView #1 navigiert zur Checkout-URL)

  3. Er wählt eine Zahlungsmethode und klickt "Bezahlen"

  4. Der Payment-Provider (Datatrans) öffnet seine eigene Seite

  5. Je nach Zahlungsmethode öffnet sich eine externe App (TWINT, Google Pay)

  6. Nach der Zahlung muss der Nutzer zurück in die App — auf die Bestätigungsseite

Das sind nicht zwei Welten (nativ + Web), sondern drei: nativ + WebView + externe Payment-Apps. Und jeder Übergang ist eine potenzielle Fehlerquelle.

InAppBrowser: Ein separater Browser für Payments

Die Lösung: Ein InAppBrowser — kein InAppWebView. Der Unterschied ist entscheidend: Ein InAppBrowser ist ein eigenständiges Browserfenster mit eigener Toolbar, das über der App schwebt. Ein InAppWebView ist ein Widget, das in den Flutter-Widget-Tree eingebettet ist.

Warum ein separater Browser? Weil Payment-Provider wie Datatrans URLs aufrufen, die aus dem normalen WebView herausnavigieren müssen — zu TWINT, zu Kreditkarten-Verifizierungsseiten, zu externen Apps. Ein eingebetteter WebView kann das nicht zuverlässig.

// Checkout-Seite: Payment-Flow starten
Future<void> launchPaymentBrowser(Uri paymentUri) async {
  final paymentBrowser = PaymentBrowser(
    ref,
    checkoutWebviewController: webviewController,
    paymentSuccessCallback: () async {
      // Nach erfolgreicher Zahlung: zurück zur Startseite
      context.router.popUntilRoot();
      await context.router.navigate(const HomeTab());
    },
  );

  await paymentBrowser.openUrlRequest(
    urlRequest: URLRequest(url: WebUri.uri(paymentUri)),
    settings: InAppBrowserClassSettings(
      browserSettings: InAppBrowserSettings(
        hideUrlBar: true,
        hideToolbarBottom: true,
        hideDefaultMenuItems: true,
        // iOS: eigener Close-Button-Text
        closeButtonCaption: 'Zurück zum Shop',
        // Android: Close-Button als Menü-Item in der Toolbar
        // (siehe unten)
      ),
    ),
  );
}
// Checkout-Seite: Payment-Flow starten
Future<void> launchPaymentBrowser(Uri paymentUri) async {
  final paymentBrowser = PaymentBrowser(
    ref,
    checkoutWebviewController: webviewController,
    paymentSuccessCallback: () async {
      // Nach erfolgreicher Zahlung: zurück zur Startseite
      context.router.popUntilRoot();
      await context.router.navigate(const HomeTab());
    },
  );

  await paymentBrowser.openUrlRequest(
    urlRequest: URLRequest(url: WebUri.uri(paymentUri)),
    settings: InAppBrowserClassSettings(
      browserSettings: InAppBrowserSettings(
        hideUrlBar: true,
        hideToolbarBottom: true,
        hideDefaultMenuItems: true,
        // iOS: eigener Close-Button-Text
        closeButtonCaption: 'Zurück zum Shop',
        // Android: Close-Button als Menü-Item in der Toolbar
        // (siehe unten)
      ),
    ),
  );
}
// Checkout-Seite: Payment-Flow starten
Future<void> launchPaymentBrowser(Uri paymentUri) async {
  final paymentBrowser = PaymentBrowser(
    ref,
    checkoutWebviewController: webviewController,
    paymentSuccessCallback: () async {
      // Nach erfolgreicher Zahlung: zurück zur Startseite
      context.router.popUntilRoot();
      await context.router.navigate(const HomeTab());
    },
  );

  await paymentBrowser.openUrlRequest(
    urlRequest: URLRequest(url: WebUri.uri(paymentUri)),
    settings: InAppBrowserClassSettings(
      browserSettings: InAppBrowserSettings(
        hideUrlBar: true,
        hideToolbarBottom: true,
        hideDefaultMenuItems: true,
        // iOS: eigener Close-Button-Text
        closeButtonCaption: 'Zurück zum Shop',
        // Android: Close-Button als Menü-Item in der Toolbar
        // (siehe unten)
      ),
    ),
  );
}

Und schon fängt es an: iOS und Android brauchen unterschiedliche Close-Buttons. Auf iOS gibt es einen nativen closeButtonCaption. Auf Android muss man einen Menü-Item in die Toolbar injizieren:

// Android: Close-Button als Menü-Item
if (Platform.isAndroid) {
  browser.addMenuItem(
    InAppBrowserMenuItem(
      id: 1,
      title: 'Zurück zum Shop',
      showAsAction: true,
      order: 0,
      onClick: () {
        browser.close();
        browser.onExit();
      },
    ),
  );
}
// Android: Close-Button als Menü-Item
if (Platform.isAndroid) {
  browser.addMenuItem(
    InAppBrowserMenuItem(
      id: 1,
      title: 'Zurück zum Shop',
      showAsAction: true,
      order: 0,
      onClick: () {
        browser.close();
        browser.onExit();
      },
    ),
  );
}
// Android: Close-Button als Menü-Item
if (Platform.isAndroid) {
  browser.addMenuItem(
    InAppBrowserMenuItem(
      id: 1,
      title: 'Zurück zum Shop',
      showAsAction: true,
      order: 0,
      onClick: () {
        browser.close();
        browser.onExit();
      },
    ),
  );
}

TWINT und intent:// — Wenn URLs keine URLs mehr sind

Dann kommt TWINT. TWINT ist die Schweizer Mobile-Payment-Lösung, und sie nutzt Custom URL-Schemes: twint://, twint-issuer1:// bis twint-issuer39://, twint-extended://. 40 verschiedene URL-Schemes für einen Payment-Provider.

Der Payment-Browser muss diese Schemes erkennen und an das Betriebssystem weiterleiten, damit die TWINT-App geöffnet wird:

bool isCustomAppScheme(String url) {
  if (url.isEmpty) return false;

  final uri = Uri.parse(url);
  final scheme = uri.scheme.toLowerCase();

  // TWINT: twint-issuer1 bis twint-issuer39, twint-extended
  if (scheme.startsWith('twint')) {
    return true;
  }

  // Generische App-Links
  if (url.contains('://applinks/')) {
    return true;
  }

  return false;
}
bool isCustomAppScheme(String url) {
  if (url.isEmpty) return false;

  final uri = Uri.parse(url);
  final scheme = uri.scheme.toLowerCase();

  // TWINT: twint-issuer1 bis twint-issuer39, twint-extended
  if (scheme.startsWith('twint')) {
    return true;
  }

  // Generische App-Links
  if (url.contains('://applinks/')) {
    return true;
  }

  return false;
}
bool isCustomAppScheme(String url) {
  if (url.isEmpty) return false;

  final uri = Uri.parse(url);
  final scheme = uri.scheme.toLowerCase();

  // TWINT: twint-issuer1 bis twint-issuer39, twint-extended
  if (scheme.startsWith('twint')) {
    return true;
  }

  // Generische App-Links
  if (url.contains('://applinks/')) {
    return true;
  }

  return false;
}

Auf Android gibt es zusätzlich intent://-URLs. Das sind spezielle Android-URIs, die eine App direkt öffnen können — mit Fallback auf den Play Store, wenn die App nicht installiert ist. Der Payment-Browser muss das erkennen und an die Android Intent API weiterleiten:

// Android: intent:// URLs an die Intent API übergeben
if (Platform.isAndroid && url.startsWith('intent://')) {
  try {
    await AndroidIntent.parseAndLaunch(uri.toString());
    return NavigationActionPolicy.CANCEL;
  } catch (e) {
    log('Error launching Android Intent: $e');
    return NavigationActionPolicy.ALLOW;
  }
}
// Android: intent:// URLs an die Intent API übergeben
if (Platform.isAndroid && url.startsWith('intent://')) {
  try {
    await AndroidIntent.parseAndLaunch(uri.toString());
    return NavigationActionPolicy.CANCEL;
  } catch (e) {
    log('Error launching Android Intent: $e');
    return NavigationActionPolicy.ALLOW;
  }
}
// Android: intent:// URLs an die Intent API übergeben
if (Platform.isAndroid && url.startsWith('intent://')) {
  try {
    await AndroidIntent.parseAndLaunch(uri.toString());
    return NavigationActionPolicy.CANCEL;
  } catch (e) {
    log('Error launching Android Intent: $e');
    return NavigationActionPolicy.ALLOW;
  }
}

iOS hat intent:// nicht — dort werden Custom URL-Schemes direkt über launchUrl geöffnet. Wieder zwei Code-Pfade für zwei Plattformen.

Fehler-Erkennung und Bestätigungs-Seiten

Der Payment-Browser muss wissen, ob die Zahlung erfolgreich war. Kein Callback, kein Event — nur die URL. Der Browser trackt jede besuchte URL in einer Queue und prüft bei jedem Seitenwechsel:

class PaymentBrowser extends InAppBrowser {
  final visitedUrls = Queue<Uri>();

  @override
  void onLoadStart(WebUri? url) {
    // Datatrans signalisiert Fehler über URL-Parameter
    if (url.toString().contains('datatrans=error')) {
      close();
      return;
    }
    if (url != null) visitedUrls.add(url);
  }

  @override
  Future<void> onExit() async {
    // Warenkorb refreshen
    await ref.read(cartControllerProvider.notifier).refresh();

    // Letzte URL prüfen: Bestätigungsseite?
    if (lastVisitedUrl != null) {
      final isConfirmation = await isCheckoutConfirmationUrl(lastVisitedUrl!);
      if (isConfirmation) {
        await paymentSuccessCallback();  // → Home navigieren
      } else {
        await checkoutWebviewController?.reload();  // → Checkout neu laden
      }
    }
  }
}
class PaymentBrowser extends InAppBrowser {
  final visitedUrls = Queue<Uri>();

  @override
  void onLoadStart(WebUri? url) {
    // Datatrans signalisiert Fehler über URL-Parameter
    if (url.toString().contains('datatrans=error')) {
      close();
      return;
    }
    if (url != null) visitedUrls.add(url);
  }

  @override
  Future<void> onExit() async {
    // Warenkorb refreshen
    await ref.read(cartControllerProvider.notifier).refresh();

    // Letzte URL prüfen: Bestätigungsseite?
    if (lastVisitedUrl != null) {
      final isConfirmation = await isCheckoutConfirmationUrl(lastVisitedUrl!);
      if (isConfirmation) {
        await paymentSuccessCallback();  // → Home navigieren
      } else {
        await checkoutWebviewController?.reload();  // → Checkout neu laden
      }
    }
  }
}
class PaymentBrowser extends InAppBrowser {
  final visitedUrls = Queue<Uri>();

  @override
  void onLoadStart(WebUri? url) {
    // Datatrans signalisiert Fehler über URL-Parameter
    if (url.toString().contains('datatrans=error')) {
      close();
      return;
    }
    if (url != null) visitedUrls.add(url);
  }

  @override
  Future<void> onExit() async {
    // Warenkorb refreshen
    await ref.read(cartControllerProvider.notifier).refresh();

    // Letzte URL prüfen: Bestätigungsseite?
    if (lastVisitedUrl != null) {
      final isConfirmation = await isCheckoutConfirmationUrl(lastVisitedUrl!);
      if (isConfirmation) {
        await paymentSuccessCallback();  // → Home navigieren
      } else {
        await checkoutWebviewController?.reload();  // → Checkout neu laden
      }
    }
  }
}

Das heißt: Wenn der Nutzer den Payment-Browser schließt (über den Close-Button oder die System-Geste), entscheidet die App anhand der letzten besuchten URL, ob die Zahlung erfolgreich war. Keine API-Abfrage, kein Server-Event — nur URL-Matching gegen Remote-Config-Werte.

Und die Bestätigungs-URL kommt nicht hardcoded aus dem Code, sondern aus Firebase Remote Config. Weil sich Payment-URLs ändern können, ohne dass ein App-Update nötig sein soll.

Die Checkout State Machine

Der Checkout selbst hat seine eigene State Machine. Ein cart_mutation-Parameter in der URL signalisiert dem Backend, ob der Warenkorb seit dem letzten Checkout geändert wurde:

// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?
bool shouldInitCart(WidgetRef ref, AsyncValue<CartData> cartState) {
  final cartWebviewState = ref.watch(cartWebviewControllerProvider);
  final currentCartHash = cartState.value.hashCode;
  final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;

  // Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisieren
  return lastCheckoutCartHash == null ||
         currentCartHash != lastCheckoutCartHash;
}

// URL mit cart_mutation-Flag
final checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';
// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?
bool shouldInitCart(WidgetRef ref, AsyncValue<CartData> cartState) {
  final cartWebviewState = ref.watch(cartWebviewControllerProvider);
  final currentCartHash = cartState.value.hashCode;
  final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;

  // Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisieren
  return lastCheckoutCartHash == null ||
         currentCartHash != lastCheckoutCartHash;
}

// URL mit cart_mutation-Flag
final checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';
// Cart-Hash vergleichen: Hat sich der Warenkorb geändert?
bool shouldInitCart(WidgetRef ref, AsyncValue<CartData> cartState) {
  final cartWebviewState = ref.watch(cartWebviewControllerProvider);
  final currentCartHash = cartState.value.hashCode;
  final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;

  // Wenn kein Hash gespeichert oder Hash unterschiedlich → neu initialisieren
  return lastCheckoutCartHash == null ||
         currentCartHash != lastCheckoutCartHash;
}

// URL mit cart_mutation-Flag
final checkoutUrl = '${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}';

Warum? Weil der Checkout-WebView zwischen Tab-Wechseln nicht zerstört wird. Wenn der Nutzer zum Warenkorb wechselt, ein Produkt hinzufügt, und zurück zum Checkout geht, muss die App wissen, ob der Checkout neu initialisiert werden muss oder ob der bestehende noch gültig ist.

Dazu gibt es eine URL-Blacklist aus Remote Config:

// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnen
final blacklistHit = shopConfig
    .checkoutBlacklistRules
    .matchUri(uri);

if (blacklistHit) {
  // In separatem WebView öffnen statt im Checkout
  context.router.push(StandaloneWebViewRoute(url: uri.toString()));
  return NavigationActionPolicy.CANCEL;
}
// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnen
final blacklistHit = shopConfig
    .checkoutBlacklistRules
    .matchUri(uri);

if (blacklistHit) {
  // In separatem WebView öffnen statt im Checkout
  context.router.push(StandaloneWebViewRoute(url: uri.toString()));
  return NavigationActionPolicy.CANCEL;
}
// Remote Config: Bestimmte URLs sollen nicht im Checkout-WebView öffnen
final blacklistHit = shopConfig
    .checkoutBlacklistRules
    .matchUri(uri);

if (blacklistHit) {
  // In separatem WebView öffnen statt im Checkout
  context.router.push(StandaloneWebViewRoute(url: uri.toString()));
  return NavigationActionPolicy.CANCEL;
}

Das erlaubt es, zur Laufzeit — ohne App-Update — bestimmte URLs aus dem Checkout-Flow herauszunehmen. Wenn ein Payment-Provider eine neue Redirect-Seite einführt, die den Checkout bricht, kann das Backend-Team die URL in die Blacklist aufnehmen, und die App öffnet sie in einem separaten WebView.

Android: CSS-Injection im Payment-Browser

Und dann ist da noch das: Android rendert den Payment-Browser ohne ausreichend Abstand zur Toolbar. Die Lösung? CSS-Injection:

@override
Future<void> onLoadStop(WebUri? url) async {
  // Android: Padding oben hinzufügen, weil die Toolbar den Content überlappt
  if (Platform.isAndroid) {
    await webViewController?.evaluateJavascript(source: """
      (function() {
        var style = document.createElement('style');
        style.textContent = 'body { padding-top: 60px !important; }';
        document.head.appendChild(style);
      })();
    """);
  }
}
@override
Future<void> onLoadStop(WebUri? url) async {
  // Android: Padding oben hinzufügen, weil die Toolbar den Content überlappt
  if (Platform.isAndroid) {
    await webViewController?.evaluateJavascript(source: """
      (function() {
        var style = document.createElement('style');
        style.textContent = 'body { padding-top: 60px !important; }';
        document.head.appendChild(style);
      })();
    """);
  }
}
@override
Future<void> onLoadStop(WebUri? url) async {
  // Android: Padding oben hinzufügen, weil die Toolbar den Content überlappt
  if (Platform.isAndroid) {
    await webViewController?.evaluateJavascript(source: """
      (function() {
        var style = document.createElement('style');
        style.textContent = 'body { padding-top: 60px !important; }';
        document.head.appendChild(style);
      })();
    """);
  }
}

60 Pixel padding-top per JavaScript-Injection, weil der Payment-Browser auf Android die Toolbar über den Content legt. Auf iOS passiert das nicht. Wieder ein plattformspezifischer Workaround.

Native State-Synchronisation: Die unsichtbare Komplexität

Im Shopify-Projekt gab es eine JavaScript-Bridge, über die die Webseite die App aktiv benachrichtigt hat ("Hey, der Warenkorb hat sich geändert!"). Das setzt voraus, dass die Webseite kooperiert — dass auf Web-Seite Code eingebaut wird, der die Bridge aufruft.

In diesem Projekt gibt es einen zusätzlichen, passiven Ansatz: Die App beobachtet die API-Aufrufe des WebViews.

onLoadResource: Der stille Beobachter

Jedes Mal, wenn der WebView eine Ressource lädt — Bilder, Scripts, API-Aufrufe — feuert der onLoadResource-Callback. Die App nutzt das, um bestimmte API-Endpunkte zu erkennen und den nativen State zu aktualisieren:

onLoadResource: (controller, resource) {
  // Preiseinstellungen: Brutto/Netto-Anzeige synchronisieren
  handlePricePreferenceChange(resource, ref);

  // Watchlist: Änderungen erkennen und nativen State invalidieren
  handleWatchlistChanges(resource, ref);

  // Vergleichsliste: Änderungen erkennen
  handleCompareListChange(resource, ref);
},
onLoadResource: (controller, resource) {
  // Preiseinstellungen: Brutto/Netto-Anzeige synchronisieren
  handlePricePreferenceChange(resource, ref);

  // Watchlist: Änderungen erkennen und nativen State invalidieren
  handleWatchlistChanges(resource, ref);

  // Vergleichsliste: Änderungen erkennen
  handleCompareListChange(resource, ref);
},
onLoadResource: (controller, resource) {
  // Preiseinstellungen: Brutto/Netto-Anzeige synchronisieren
  handlePricePreferenceChange(resource, ref);

  // Watchlist: Änderungen erkennen und nativen State invalidieren
  handleWatchlistChanges(resource, ref);

  // Vergleichsliste: Änderungen erkennen
  handleCompareListChange(resource, ref);
},

Konkret sieht das so aus:

void handlePricePreferenceChange(LoadedResource resource, WidgetRef ref) {
  final isPricesEndpoint = resource.url
      .toString()
      .endsWith('api/accounts/settings/prices');

  // Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer
  // möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.
  // → Session-State refreshen, damit die native UI konsistent bleibt.
  if (isPricesEndpoint) {
    ref.read(sessionControllerProvider.notifier).refreshSessionState();
  }
}

void handleWatchlistChanges(LoadedResource resource, WidgetRef ref) {
  // Watchlist-Übersicht: /api/watchlists
  final isWatchlistOverview = resource.url
      .toString()
      .endsWith('/api/watchlists');

  if (isWatchlistOverview) {
    ref.invalidate(watchlistOverviewControllerProvider);
  }

  // Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}
  final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');
  final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
  final isItemChange = resource.url.toString().contains(addItemRegex) ||
                        resource.url.toString().contains(deleteItemRegex);

  if (isItemChange) {
    // Watchlist-ID aus der URL extrahieren und gezielt refreshen
    final watchlistId = resource.url
        .toString()
        .split('/api/watchlists/')[1]
        .split('/')[0];
    ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();
    ref.invalidate(watchlistOverviewControllerProvider);
  }
}
void handlePricePreferenceChange(LoadedResource resource, WidgetRef ref) {
  final isPricesEndpoint = resource.url
      .toString()
      .endsWith('api/accounts/settings/prices');

  // Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer
  // möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.
  // → Session-State refreshen, damit die native UI konsistent bleibt.
  if (isPricesEndpoint) {
    ref.read(sessionControllerProvider.notifier).refreshSessionState();
  }
}

void handleWatchlistChanges(LoadedResource resource, WidgetRef ref) {
  // Watchlist-Übersicht: /api/watchlists
  final isWatchlistOverview = resource.url
      .toString()
      .endsWith('/api/watchlists');

  if (isWatchlistOverview) {
    ref.invalidate(watchlistOverviewControllerProvider);
  }

  // Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}
  final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');
  final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
  final isItemChange = resource.url.toString().contains(addItemRegex) ||
                        resource.url.toString().contains(deleteItemRegex);

  if (isItemChange) {
    // Watchlist-ID aus der URL extrahieren und gezielt refreshen
    final watchlistId = resource.url
        .toString()
        .split('/api/watchlists/')[1]
        .split('/')[0];
    ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();
    ref.invalidate(watchlistOverviewControllerProvider);
  }
}
void handlePricePreferenceChange(LoadedResource resource, WidgetRef ref) {
  final isPricesEndpoint = resource.url
      .toString()
      .endsWith('api/accounts/settings/prices');

  // Wenn die Web-UI den Preis-Endpunkt aufruft, hat der Nutzer
  // möglicherweise zwischen Brutto- und Netto-Anzeige gewechselt.
  // → Session-State refreshen, damit die native UI konsistent bleibt.
  if (isPricesEndpoint) {
    ref.read(sessionControllerProvider.notifier).refreshSessionState();
  }
}

void handleWatchlistChanges(LoadedResource resource, WidgetRef ref) {
  // Watchlist-Übersicht: /api/watchlists
  final isWatchlistOverview = resource.url
      .toString()
      .endsWith('/api/watchlists');

  if (isWatchlistOverview) {
    ref.invalidate(watchlistOverviewControllerProvider);
  }

  // Einzelne Items: /api/watchlists/{id}/items oder /items/{itemId}
  final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');
  final deleteItemRegex = RegExp(r'/api/watchlists/(.+)/items/(.+)$');
  final isItemChange = resource.url.toString().contains(addItemRegex) ||
                        resource.url.toString().contains(deleteItemRegex);

  if (isItemChange) {
    // Watchlist-ID aus der URL extrahieren und gezielt refreshen
    final watchlistId = resource.url
        .toString()
        .split('/api/watchlists/')[1]
        .split('/')[0];
    ref.read(watchlistControllerFamily(watchlistId).notifier).refresh();
    ref.invalidate(watchlistOverviewControllerProvider);
  }
}

Das ist gleichzeitig elegant und fragil. Elegant, weil es keine Änderung an der Web-Codebase erfordert — die App beobachtet einfach, was der WebView tut. Fragil, weil jede Änderung an den API-Endpunkten die Pattern-Erkennung brechen kann.

Und es gibt noch ein Subtilität: onLoadResource feuert für alle Ressourcen — Bilder, CSS, JavaScript, Fonts, API-Aufrufe. Die App filtert nach URL-Patterns und reagiert nur auf relevante Endpunkte. Das funktioniert, solange die Endpunkt-Struktur stabil bleibt.

Plattform-Berechtigungen: Kamera, 2FA und User-Agent-Lügen

HeyLight Pay: Wenn der Payment-Provider die Kamera braucht

Ein Payment-Provider namens HeyLight (Buy-Now-Pay-Later) braucht Zugriff auf die Kamera des Geräts — für die Identitätsverifizierung. In einem normalen Browser kein Problem. In einem WebView innerhalb einer App? Ein Alptraum.

Problem 1: HeyLight erkennt den WebView. HeyLight macht Browser-Detection und blockiert alles, was nicht Safari oder Chrome ist. Die Standard-User-Agent-Kennung eines Flutter-WebViews wird abgelehnt.

Die Lösung — wie auch im Shopify-Projekt: User-Agent-Spoofing. Aber hier nur auf iOS, weil HeyLight speziell auf Safari prüft:

// Hardcoded Safari User Agent — nur für iOS
// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser
// TODO: Investigate and replace this workaround
userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/26.0 Mobile/15E148 Safari/604.1'
    : null,  // Android: Standard-User-Agent reicht aus
// Hardcoded Safari User Agent — nur für iOS
// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser
// TODO: Investigate and replace this workaround
userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/26.0 Mobile/15E148 Safari/604.1'
    : null,  // Android: Standard-User-Agent reicht aus
// Hardcoded Safari User Agent — nur für iOS
// HeyLight prüft Browser-Detection und blockiert nicht-Safari-Browser
// TODO: Investigate and replace this workaround
userAgent: Platform.isIOS
    ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) '
      'AppleWebKit/605.1.15 (KHTML, like Gecko) '
      'Version/26.0 Mobile/15E148 Safari/604.1'
    : null,  // Android: Standard-User-Agent reicht aus

Problem 2: iOS-Kamera-Berechtigungen doppelt anfragen crasht. Wenn die WebView auf iOS eine Kamera-Berechtigung anfragt, zeigt iOS automatisch den System-Dialog. Wenn die App zusätzlich Permission.camera.request() aufruft, gibt es Probleme — der Dialog erscheint doppelt, oder die Berechtigung wird stillschweigend verweigert.

onPermissionRequest: (controller, request) async {
  final resources = request.resources;

  if (resources.contains(PermissionResourceType.CAMERA)) {
    final isGranted = await Permission.camera.isGranted;

    // iOS: Die WebView zeigt den Permission-Dialog selbst.
    // Nochmals Permission.camera.request() aufrufen verursacht Probleme.
    if (Platform.isIOS) {
      return PermissionResponse(
        resources: resources,
        action: isGranted
            ? PermissionResponseAction.GRANT
            : PermissionResponseAction.DENY,
      );
    }

    // Android: Permission-Dialog manuell triggern
    var status = await Permission.camera.request();
    if (!status.isGranted) {
      // Dialog zeigen: "Bitte gehe in die Einstellungen..."
      await showDialog(
        context: context,
        builder: (_) => const CameraPermissionDeniedDialog(),
      );
    }

    status = await Permission.camera.status;
    return PermissionResponse(
      resources: resources,
      action: status.isGranted
          ? PermissionResponseAction.GRANT
          : PermissionResponseAction.DENY,
    );
  }
  return PermissionResponse(resources: resources);
},
onPermissionRequest: (controller, request) async {
  final resources = request.resources;

  if (resources.contains(PermissionResourceType.CAMERA)) {
    final isGranted = await Permission.camera.isGranted;

    // iOS: Die WebView zeigt den Permission-Dialog selbst.
    // Nochmals Permission.camera.request() aufrufen verursacht Probleme.
    if (Platform.isIOS) {
      return PermissionResponse(
        resources: resources,
        action: isGranted
            ? PermissionResponseAction.GRANT
            : PermissionResponseAction.DENY,
      );
    }

    // Android: Permission-Dialog manuell triggern
    var status = await Permission.camera.request();
    if (!status.isGranted) {
      // Dialog zeigen: "Bitte gehe in die Einstellungen..."
      await showDialog(
        context: context,
        builder: (_) => const CameraPermissionDeniedDialog(),
      );
    }

    status = await Permission.camera.status;
    return PermissionResponse(
      resources: resources,
      action: status.isGranted
          ? PermissionResponseAction.GRANT
          : PermissionResponseAction.DENY,
    );
  }
  return PermissionResponse(resources: resources);
},
onPermissionRequest: (controller, request) async {
  final resources = request.resources;

  if (resources.contains(PermissionResourceType.CAMERA)) {
    final isGranted = await Permission.camera.isGranted;

    // iOS: Die WebView zeigt den Permission-Dialog selbst.
    // Nochmals Permission.camera.request() aufrufen verursacht Probleme.
    if (Platform.isIOS) {
      return PermissionResponse(
        resources: resources,
        action: isGranted
            ? PermissionResponseAction.GRANT
            : PermissionResponseAction.DENY,
      );
    }

    // Android: Permission-Dialog manuell triggern
    var status = await Permission.camera.request();
    if (!status.isGranted) {
      // Dialog zeigen: "Bitte gehe in die Einstellungen..."
      await showDialog(
        context: context,
        builder: (_) => const CameraPermissionDeniedDialog(),
      );
    }

    status = await Permission.camera.status;
    return PermissionResponse(
      resources: resources,
      action: status.isGranted
          ? PermissionResponseAction.GRANT
          : PermissionResponseAction.DENY,
    );
  }
  return PermissionResponse(resources: resources);
},

Drei verschiedene Verhaltensweisen: iOS zeigt den Dialog selbst, Android muss ihn manuell triggern, und wenn die Berechtigung permanent verweigert wurde, braucht der Nutzer einen Hinweis, dass er in die Geräteeinstellungen gehen muss. Alles für einen Payment-Provider, der die Kamera braucht.

TOTP/2FA: Wenn die WebView eine andere App öffnen muss

Die Web-Authentifizierung unterstützt Zwei-Faktor-Authentifizierung über TOTP (Time-Based One-Time Passwords). Wenn der Nutzer 2FA einrichtet, generiert die Webseite einen otpauth://-Link, den eine Authenticator-App (Google Authenticator, Authy, etc.) öffnen soll.

In einem normalen Browser öffnet sich die Authenticator-App automatisch. In einem WebView? Der Link wird als Navigation behandelt — und schlägt fehl.

// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnen
if (url.startsWith('otpauth:')) {
  await handleTotpUrl(url);
  return NavigationActionPolicy.CANCEL;
}
// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnen
if (url.startsWith('otpauth:')) {
  await handleTotpUrl(url);
  return NavigationActionPolicy.CANCEL;
}
// shouldOverrideUrlLoading: otpauth:// abfangen und extern öffnen
if (url.startsWith('otpauth:')) {
  await handleTotpUrl(url);
  return NavigationActionPolicy.CANCEL;
}
Future<void> handleTotpUrl(String totpUrl) async {
  try {
    final uri = Uri.parse(totpUrl);
    final canLaunch = await canLaunchUrl(uri);

    if (canLaunch) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      // Keine Authenticator-App installiert
      showWarning(
        title: 'Keine Authenticator-App gefunden',
        subtitle: 'Bitte installiere eine Authenticator-App...',
      );
    }
  } catch (e) {
    showWarning(
      title: 'Fehler',
      subtitle: 'Der QR-Code konnte nicht verarbeitet werden.',
    );
  }
}
Future<void> handleTotpUrl(String totpUrl) async {
  try {
    final uri = Uri.parse(totpUrl);
    final canLaunch = await canLaunchUrl(uri);

    if (canLaunch) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      // Keine Authenticator-App installiert
      showWarning(
        title: 'Keine Authenticator-App gefunden',
        subtitle: 'Bitte installiere eine Authenticator-App...',
      );
    }
  } catch (e) {
    showWarning(
      title: 'Fehler',
      subtitle: 'Der QR-Code konnte nicht verarbeitet werden.',
    );
  }
}
Future<void> handleTotpUrl(String totpUrl) async {
  try {
    final uri = Uri.parse(totpUrl);
    final canLaunch = await canLaunchUrl(uri);

    if (canLaunch) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      // Keine Authenticator-App installiert
      showWarning(
        title: 'Keine Authenticator-App gefunden',
        subtitle: 'Bitte installiere eine Authenticator-App...',
      );
    }
  } catch (e) {
    showWarning(
      title: 'Fehler',
      subtitle: 'Der QR-Code konnte nicht verarbeitet werden.',
    );
  }
}

Ein otpauth://-Link ist technisch eine URL, aber keine Webseite. Er enthält den Secret Key und Accountnamen im URL-Format: otpauth://totp/Account?secret=ABCDEF&issuer=MyApp. Die App muss erkennen, dass das kein normaler Link ist, die Navigation abbrechen, und den Link an das Betriebssystem weiterleiten.

Und was passiert, wenn der Nutzer keine Authenticator-App installiert hat? Dann muss die App eine verständliche Fehlermeldung zeigen — statt eines kryptischen "URL konnte nicht geöffnet werden"-Fehlers.

CSS-Injection und andere kreative Lösungen

Pull-to-Refresh: 101vh

Pull-to-Refresh im WebView hat einen Bug: Wenn der Seiteninhalt nicht höher als der Viewport ist, funktioniert die Geste nicht. Die Lösung? CSS-Injection, die den Content künstlich 1% höher macht:

// Plugin: Pull-to-Refresh-Workaround
class WebviewPullToRefreshEnablerPlugin implements IWebViewPlugin {
  @override
  List<UserScript>? get userScripts => [
    UserScript(
      source: '''
        (function() {
          var style = document.createElement('style');
          style.innerHTML = 'main { min-height: 101vh; }';
          document.head.appendChild(style);
        })();
      ''',
      injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
    ),
  ];
}
// Plugin: Pull-to-Refresh-Workaround
class WebviewPullToRefreshEnablerPlugin implements IWebViewPlugin {
  @override
  List<UserScript>? get userScripts => [
    UserScript(
      source: '''
        (function() {
          var style = document.createElement('style');
          style.innerHTML = 'main { min-height: 101vh; }';
          document.head.appendChild(style);
        })();
      ''',
      injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
    ),
  ];
}
// Plugin: Pull-to-Refresh-Workaround
class WebviewPullToRefreshEnablerPlugin implements IWebViewPlugin {
  @override
  List<UserScript>? get userScripts => [
    UserScript(
      source: '''
        (function() {
          var style = document.createElement('style');
          style.innerHTML = 'main { min-height: 101vh; }';
          document.head.appendChild(style);
        })();
      ''',
      injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
    ),
  ];
}

101% der Viewport-Höhe. Weil 100% nicht reicht, um dem WebView zu signalisieren, dass gescrollt werden kann — und ohne Scroll-Möglichkeit feuert Pull-to-Refresh nicht.

PDF-Downloads: Abfangen und teilen

WebViews können keine PDFs herunterladen — sie versuchen, sie zu rendern oder navigieren zu einer leeren Seite. Die Lösung: den Download abfangen, die Datei mit dem authentifizierten HTTP-Client herunterladen, und über den nativen Share-Sheet teilen.

onDownloadStartRequest: (controller, request) async {
  if (request.mimeType == 'application/pdf') {
    await controller.stopLoading();  // Navigation stoppen
    final pdfUrl = request.url.toString();

    // Mit authentifiziertem Client herunterladen
    await downloadAndSharePdf(pdfUrl, ref, context: context);

    // Warten, bis der Share-Dialog geschlossen ist
    await Future.delayed(const Duration(milliseconds: 300));
    await context.maybePop();
  }
},
onDownloadStartRequest: (controller, request) async {
  if (request.mimeType == 'application/pdf') {
    await controller.stopLoading();  // Navigation stoppen
    final pdfUrl = request.url.toString();

    // Mit authentifiziertem Client herunterladen
    await downloadAndSharePdf(pdfUrl, ref, context: context);

    // Warten, bis der Share-Dialog geschlossen ist
    await Future.delayed(const Duration(milliseconds: 300));
    await context.maybePop();
  }
},
onDownloadStartRequest: (controller, request) async {
  if (request.mimeType == 'application/pdf') {
    await controller.stopLoading();  // Navigation stoppen
    final pdfUrl = request.url.toString();

    // Mit authentifiziertem Client herunterladen
    await downloadAndSharePdf(pdfUrl, ref, context: context);

    // Warten, bis der Share-Dialog geschlossen ist
    await Future.delayed(const Duration(milliseconds: 300));
    await context.maybePop();
  }
},

Drei Zeilen wären es, wenn WebViews PDFs nativ unterstützen würden. Stattdessen: Download abfangen, Loading stoppen, separaten HTTP-Request mit Cookies, temporäre Datei erstellen, Share-Sheet öffnen, temporäre Datei löschen, und eine künstliche Verzögerung, weil sonst die Seite navigiert, bevor der Share-Dialog erscheint.

Bot-Detection: Ein Cookie für die Entwicklung

Analytics-Dienste wie Tealium haben Bot-Detection. In der Entwicklungsumgebung — wo ein Entwickler hunderte Mal pro Tag dieselbe Seite lädt — wird der WebView als Bot erkannt und blockiert.

// Debug-Modus: Bot-Detection umgehen
if (kDebugMode)
  Cookie(name: 'teal_bdwl', value: '1'),
// Debug-Modus: Bot-Detection umgehen
if (kDebugMode)
  Cookie(name: 'teal_bdwl', value: '1'),
// Debug-Modus: Bot-Detection umgehen
if (kDebugMode)
  Cookie(name: 'teal_bdwl', value: '1'),

Ein einziges Cookie. Aber ohne es funktioniert die App in der Entwicklung nicht.

Das Muster erkennen

Zwei Projekte. Unterschiedliche Branchen (Schmuck vs. Elektronik). Unterschiedliche Backends (Shopify vs. Custom). Unterschiedliche Payment-Provider (Shop Pay vs. Datatrans/TWINT). Und trotzdem dieselben Muster:

Muster

Projekt 1 (Schmuck/Shopify)

Projekt 2 (Elektronik/Custom)

User-Agent-Spoofing

Shopify blockiert WebView-UA

HeyLight blockiert nicht-Safari-UA

iOS vs. Android Navigation

NavigationType.BACK_FORWARD vs. isRedirect

onDidReceiveServerRedirectForProvisionalNavigation vs. isRedirect

WebView zerstören & neu erstellen

ValueKey mit Auth-Token

rebuildIndex mit Cookie-Changes

Künstliche Verzögerungen

700ms für Back-Navigation-Reload

300ms für Share-Dialog-Timing

CSS/JS-Injection

Consent-Daten, Height-Messung

Pull-to-Refresh 101vh, Padding-Fix

Remote Config

Whitelisted Domains

Checkout-Blacklist, Payment-URLs

Das sind keine zufälligen Überschneidungen. Das sind strukturelle Konsequenzen des WebView-Ansatzes. Jedes Projekt, das WebViews in einer nativen App einsetzt, wird früher oder später auf diese Muster stoßen:

  1. Plattform-Divergenz: iOS und Android behandeln WebView-Events grundlegend anders

  2. Grenz-Management: Jeder Übergang zwischen nativ und Web erfordert Synchronisation

  3. Workaround-Kultur: Jeder dritte Fix ist ein Hack mit einem TODO-Kommentar

  4. Externe Abhängigkeiten: Payment-Provider, Analytics-Dienste und andere Dritte haben ihre eigenen Regeln, die im WebView-Kontext brechen

Fazit

Wenn ich eines aus diesen beiden Projekten gelernt habe, dann das: Die Komplexität von WebViews ist nicht projektspezifisch — sie ist strukturell.

Es spielt keine Rolle, ob das Backend Shopify oder custom ist. Es spielt keine Rolle, ob der Payment-Provider Shop Pay oder Datatrans heißt. Es spielt keine Rolle, ob die Branche Schmuck oder Elektronik ist. Sobald man eine Website in eine native App einbettet, entstehen dieselben Probleme an denselben Stellen:

  • Auth-Synchronisation über Cookie-Management

  • Plattformspezifische Navigation-Workarounds

  • JavaScript-Bridges für die Kommunikation

  • User-Agent-Spoofing gegen Server-Blocking

  • Und — wie dieses Projekt zeigt — Payment-Flows, die über die Grenzen der App hinausgehen

Das bedeutet nicht, dass WebViews die falsche Wahl sind. Es bedeutet, dass man die Komplexität einplanen muss. Nicht als "machen wir dann, wenn es soweit ist", sondern als fester Bestandteil der Architektur-Planung.

Die Frage an Entscheidungsträger bleibt dieselbe wie in Teil 1: Nicht ob, sondern wo. Welche Screens nativ, welche WebView, und wie kommunizieren sie? Wer diese Frage mit dem Wissen aus zwei realen Projekten beantwortet, trifft bessere Entscheidungen.

Häufig gestellte Fragen (FAQ)

Kann man Payment-Flows wie TWINT oder Google Pay direkt in einem WebView abwickeln?

Nein — zumindest nicht zuverlässig. Payment-Provider wie TWINT verwenden Custom URL-Schemes (twint://, twint-issuer1:// bis twint-issuer39://), die aus dem WebView heraus nicht direkt geöffnet werden können. Die Lösung ist ein separater InAppBrowser, der diese Schemes ans Betriebssystem weiterleiten kann. Auf Android kommen zusätzlich intent://-URLs hinzu, die eine eigene Behandlung erfordern.

Was ist der Unterschied zwischen InAppWebView und InAppBrowser in Flutter?

Ein InAppWebView ist ein Widget, das in den Flutter-Widget-Tree eingebettet wird — vergleichbar mit einem <iframe> im Web. Ein InAppBrowser dagegen öffnet ein eigenständiges Browserfenster mit eigener Toolbar über der App. Für Payment-Flows wird oft der InAppBrowser benötigt, weil Payment-Provider URLs aufrufen, die aus dem normalen WebView herausnavigieren müssen.

Warum braucht man User-Agent-Spoofing in WebView-Apps?

Viele Server und Dienste erkennen den Standard-User-Agent eines WebViews und verweigern den Zugriff — entweder aus Sicherheitsgründen oder weil sie nur "echte" Browser unterstützen wollen. In der Praxis muss man den User-Agent pro Plattform hardcoden: Shopify blockiert Standard-WebView-UAs, und Payment-Provider wie HeyLight prüfen explizit auf Safari oder Chrome.

Wie synchronisiert man nativen App-State mit einem WebView?

Es gibt zwei Ansätze: aktiv und passiv. Beim aktiven Ansatz injiziert man JavaScript-Code in den WebView, der über eine Bridge (window.mobileApp.functionName) die native App benachrichtigt. Beim passiven Ansatz — wie in diesem Artikel beschrieben — beobachtet die App über onLoadResource die API-Aufrufe des WebViews und aktualisiert den nativen State, wenn bestimmte Endpunkte aufgerufen werden. Beide Ansätze haben Trade-offs: aktiv erfordert Änderungen an der Webseite, passiv ist fragil bei API-Änderungen.

Sind WebView-Probleme framework-spezifisch oder plattformübergreifend?

Die Probleme sind plattformübergreifend und strukturell. Ob Flutter, React Native oder native Entwicklung — die fundamentalen Herausforderungen (Auth-Synchronisation, plattformspezifische Navigation, Payment-Flow-Management, User-Agent-Spoofing) treten in jedem Framework auf, das WebViews einsetzt. Das Framework bestimmt nur die API, über die man die Workarounds implementiert.

Dieser Artikel ist Teil 2 einer Serie über WebView-Komplexität in mobilen Apps. Teil 1 lesen | Du planst eine hybride App und willst die Fallstricke vermeiden? Termin buchen — ich berate bei genau diesen Architektur-Entscheidungen.