webview_plus 0.8.6 copy "webview_plus: ^0.8.6" to clipboard
webview_plus: ^0.8.6 copied to clipboard

A cross-platform Flutter plugin that directly encapsulates native WebViews (Android, iOS/macOS, Windows, Linux andWeb) with no WebView dependencies.

example/lib/main.dart

import 'dart:io'; // AJOUTÉ pour manipuler les fichiers locaux
import 'package:flutter/services.dart'; // AJOUTÉ pour lire les assets de l'application
import 'package:path/path.dart' as p; // AJOUTÉ pour manipuler les chemins
import 'package:path_provider/path_provider.dart'; // AJOUTÉ pour récupérer le dossier de l'app
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:webview_plus/webview_plus.dart';

void main() {
  runApp(const MyApp());
}

/// Contrôle le thème clair/sombre depuis n'importe où dans l'arbre de
/// widgets (voir le bouton de bascule dans l'`AppBar`).
final ValueNotifier<ThemeMode> themeModeNotifier =
    ValueNotifier<ThemeMode>(ThemeMode.system);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  static const _seedColor = Color(0xFF6750A4);

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: themeModeNotifier,
      builder: (context, mode, _) {
        return MaterialApp(
          title: 'Webview Plus — Démo',
          themeMode: mode,
          theme: ThemeData(
            colorSchemeSeed: _seedColor,
            useMaterial3: true,
            brightness: Brightness.light,
            visualDensity: VisualDensity.standard,
          ),
          darkTheme: ThemeData(
            colorSchemeSeed: _seedColor,
            useMaterial3: true,
            brightness: Brightness.dark,
          ),
          debugShowCheckedModeBanner: false,
          home: const WebviewDemoPage(),
        );
      },
    );
  }
}

class WebviewDemoPage extends StatefulWidget {
  const WebviewDemoPage({super.key});

  @override
  State<WebviewDemoPage> createState() => _WebviewDemoPageState();
}

class _WebviewDemoPageState extends State<WebviewDemoPage>
    with SingleTickerProviderStateMixin {
  WebviewPlusController? _controller;
  late final TabController _tabController =
      TabController(length: 4, vsync: this);

  // -- Barre d'adresse & inputs ---------------------------------------------
  final TextEditingController _urlFieldController =
      TextEditingController(text: 'https://flutter.dev');
  final TextEditingController _jsFieldController =
      TextEditingController(text: "document.body.style.backgroundColor = 'red'");
  final TextEditingController _filePathController = TextEditingController();
  final TextEditingController _uaSuffixController =
      TextEditingController(text: 'MyFlutterApp/1.0');
  final TextEditingController _userAgentController = TextEditingController();
  final TextEditingController _initialCssController = TextEditingController(
    text: 'body { font-family: system-ui; }\n::selection { background: orange; }',
  );
  final TextEditingController _rawJsController =
      TextEditingController(text: "alert('Injecté via injectJsData !')");
  final TextEditingController _rawCssController = TextEditingController(
    text: 'body { outline: 4px dashed hotpink; }',
  );

  // -- État / logs affichés en bas d'écran ---------------------------------
  String _status = 'Inactif';
  bool _isLoading = false;
  final List<String> _log = <String>[];
  String _localImagePath = ''; // AJOUTÉ pour stocker l'URL du fichier copié

  // -- Général (toutes plateformes) ----------------------------------------
  bool _javaScriptEnabled = true;
  bool _domStorageEnabled = true;
  bool _supportZoom = true;
  bool _mediaPlaybackRequiresUserGesture = true;
  bool _transparentBackground = false;
  Color? _initialBackgroundColor = Colors.cyan;
  bool _isInspectable = true;
  bool _cacheEnabled = true;
  bool _incognito = false;
  bool _javaScriptCanOpenWindowsAutomatically = false;
  bool _hideNativeScrollbars = false;
  bool _disableKeyboardResize = false;

  // -- Menu contextuel & sélection ------------------------------------------
  bool _disableContextMenu = false;
  bool _disableLongPressLinks = false;
  Color? _selectionTextColor = Colors.pink;
  Color? _selectionHandleColor = Colors.orange;
  final Set<DefaultContextMenuItem> _disabledDefaultItems = <DefaultContextMenuItem>{};
  bool _disableLinkHoverPreview = true;
  bool _disablePrinting = false;

  // -- Android ---------------------------------------------------------------
  bool _allowFileAccess = true;
  bool _allowContentAccess = true;
  bool _builtInZoomControls = true;
  bool _displayZoomControls = false;
  int _textZoom = 100;
  bool _geolocationEnabled = true;
  bool _thirdPartyCookiesEnabled = true;
  bool _forceDarkMode = false;
  OverScrollMode _overScrollMode = OverScrollMode.ifContentScrolls;
  int _initialScale = 0; // 0 = valeur par défaut / non défini
  bool _safeBrowsingEnabled = true;
  bool _allowMixedContent = false;
  AndroidPlatformViewType _androidPlatformViewType =
      AndroidPlatformViewType.surfaceComposition;
  WebviewContentMode _webviewContentMode = WebviewContentMode.recommended;

  // -- iOS / macOS ------------------------------------------------------------
  int _minimumFontSize = 8;
  bool _allowsInlineMediaPlayback = true;
  bool _allowsPictureInPicture = true;
  bool _bounces = true;
  bool _allowsBackForwardNavigationGestures = false;
  bool _allowsLinkPreview = false;
  bool _allowFileAccessFromFileURLs = true; // CHANGÉ À TRUE pour autoriser les images locales en file://
  bool _allowUniversalAccessFromFileURLs = true; // CHANGÉ À TRUE pour autoriser l'accès aux fichiers

  // -- Scrollbars (Windows / macOS / Linux) -----------------------------------
  DesktopScrollbarThemeMode _scrollbarThemeMode = DesktopScrollbarThemeMode.auto;
  Color _scrollbarTrackColor = Colors.grey.shade300;
  Color _scrollbarThumbColor = Colors.grey.shade600;
  double _scrollbarWidth = 10.0;

  // -- Cache & Debug (globaux, indépendants de la Webview affichée) ----------
  bool _webContentsDebuggingEnabled = true;
  bool _cacheActionInProgress = false;

  int _webviewGeneration = 0;

  @override
  void initState() {
    super.initState();
    _prepareLocalImage(); // AJOUTÉ : Copie de l'image dès le chargement du widget
  }

  /// AJOUTÉ : Cette fonction extrait image.jpg des assets et l'écrit dans le dossier de l'app
  Future<void> _prepareLocalImage() async {
    try {
      final directory = await getApplicationDocumentsDirectory();
      final filePath = p.join(directory.path, 'image.jpg');
      final file = File(filePath);

      // Charge le fichier depuis les assets et l'écrit en local
      final ByteData data = await rootBundle.load('assets/image.jpg');
      final List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
      await file.writeAsBytes(bytes);

      setState(() {
        // Pour les WebViews, il faut utiliser le préfixe file://
        _localImagePath = file.uri.toString(); 
      });
      _pushLog('Asset copié avec succès dans le dossier de l\'application : $filePath');
    } catch (e) {
      _pushLog('Erreur lors de la copie de l\'asset : $e');
    }
  }

  void _pushLog(String line) {
    if (!mounted) return; // Sécurité si le widget est détruit entre-temps

    final now = DateTime.now();
    final timestamp = '${now.hour.toString().padLeft(2, '0')}:'
                      '${now.minute.toString().padLeft(2, '0')}:'
                      '${now.second.toString().padLeft(2, '0')}';

    setState(() {
      _log.insert(0, '$timestamp · $line');
      if (_log.length > 40) _log.removeLast();
    });
  }

  // Construit l'objet WebviewSettings avec l'intégralité des paramètres
  // exposés par le package.
  WebviewSettings get _currentSettings => WebviewSettings(
        // Général
        javaScriptEnabled: _javaScriptEnabled,
        domStorageEnabled: _domStorageEnabled,
        supportZoom: _supportZoom,
        mediaPlaybackRequiresUserGesture: _mediaPlaybackRequiresUserGesture,
        transparentBackground: _transparentBackground,
        initialBackgroundColor: _initialBackgroundColor,
        userAgent: _userAgentController.text.trim().isEmpty
            ? null
            : _userAgentController.text.trim(),
        isInspectable: _isInspectable,
        cacheEnabled: _cacheEnabled,
        incognito: _incognito,
        applicationNameForUserAgent: _uaSuffixController.text.trim().isEmpty
            ? null
            : _uaSuffixController.text.trim(),
        javaScriptCanOpenWindowsAutomatically:
            _javaScriptCanOpenWindowsAutomatically,
        hideNativeScrollbars: _hideNativeScrollbars,
        disableKeyboardResize: _disableKeyboardResize,

        // Menu contextuel & sélection
        disableContextMenu: _disableContextMenu,
        disableLongPressContextMenuOnLinks: _disableLongPressLinks,
        selectionTextColor: _selectionTextColor,
        selectionHandleColor: _selectionHandleColor,
        disabledDefaultContextMenuItems: _disabledDefaultItems,
        disableLinkHoverPreview: _disableLinkHoverPreview,
        disablePrinting: _disablePrinting,

        // Android
        allowFileAccess: _allowFileAccess,
        allowContentAccess: _allowContentAccess,
        builtInZoomControls: _builtInZoomControls,
        displayZoomControls: _displayZoomControls,
        textZoom: _textZoom,
        geolocationEnabled: _geolocationEnabled,
        thirdPartyCookiesEnabled: _thirdPartyCookiesEnabled,
        forceDarkMode: _forceDarkMode,
        overScrollMode: _overScrollMode,
        initialScale: _initialScale > 0 ? _initialScale : null,
        safeBrowsingEnabled: _safeBrowsingEnabled,
        allowMixedContent: _allowMixedContent,
        androidPlatformViewType: _androidPlatformViewType,
        webviewContentMode: _webviewContentMode,

        // iOS / macOS
        minimumFontSize: _minimumFontSize,
        allowsInlineMediaPlayback: _allowsInlineMediaPlayback,
        allowsPictureInPicture: _allowsPictureInPicture,
        bounces: _bounces,
        allowsBackForwardNavigationGestures:
            _allowsBackForwardNavigationGestures,
        allowsLinkPreview: _allowsLinkPreview,
        allowFileAccessFromFileURLs: _allowFileAccessFromFileURLs,
        allowUniversalAccessFromFileURLs: _allowUniversalAccessFromFileURLs,

        // Scrollbars (Windows / macOS / Linux)
        windowsScrollbarTheme: _windowsScrollbarTheme,
      );

  DesktopScrollbarTheme get _windowsScrollbarTheme => DesktopScrollbarTheme(
        themeMode: _scrollbarThemeMode,
        trackColor: _scrollbarTrackColor,
        thumbColor: _scrollbarThumbColor,
        width: _scrollbarWidth,
      );

  List<ContextMenuItem> get _contextMenuItems => <ContextMenuItem>[
        ContextMenuItem(
          id: 'search',
          name: 'Rechercher',
          action: (selectedText) {
            _pushLog('Menu perso [Rechercher] texte="$selectedText"');
          },
        ),
        ContextMenuItem(
          id: 'translate',
          name: 'Traduire',
          action: (selectedText) {
            _pushLog('Menu perso [Traduire] texte="$selectedText"');
          },
        ),
      ];

  void _registerHandlers(WebviewPlusController controller) {
    controller.addJavaScriptHandler(
      handlerName: 'fromJs',
      callback: (args) async {
        _pushLog('JS → Dart [fromJs] args=$args');
        return <String, dynamic>{
          'ok': true,
          'reçu': args,
          'horodatage': DateTime.now().toIso8601String(),
          'message': 'Bonjour depuis le Dart !',
        };
      },
    );
  }

  // -- Actions : navigation / chargement --------------------------------------

  Future<void> _go() async {
    final url = _urlFieldController.text.trim();
    if (url.isEmpty) return;
    await _controller?.loadUrl(
      url.startsWith('http') ? url : 'https://$url',
    );
  }

  Future<void> _runJs() async {
    final code = _jsFieldController.text.trim();
    if (code.isEmpty) return;
    final result = await _controller?.evaluateJavascript(code);
    _pushLog('evaluateJavascript("$code") → $result');
  }

  Future<void> _getHtml() async {
    final html = await _controller?.getHtml();
    if (!mounted) return;
    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('HTML de la page'),
        content: SizedBox(
          width: 420,
          height: 320,
          child: SingleChildScrollView(
            child: SelectableText(
              html == null
                  ? '(vide)'
                  : (html.length > 3000 ? '${html.substring(0, 3000)}…' : html),
              style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('Fermer'),
          ),
        ],
      ),
    );
  }

  Future<void> _loadHtmlFile() async {
    await _controller?.loadFlutterAsset('assets/index.html');
  }

  // MODIFIÉ : Injecte le HTML brut en intégrant l'image locale copiée
  Future<void> _loadRawData() async {
    // Imaginons que votre chemin d'image local soit : "file:///var/folders/.../image.jpg"
    // ou simplement un chemin brut sans protocole.
    String baseImagePath = _localImagePath; 

    // Sécurité : On s'assure que le préfixe file:// est bien remplacé par app-assets://
    String customImageUrl;
    if (baseImagePath.startsWith('file://')) {
      customImageUrl = baseImagePath.replaceFirst('file://', 'app-assets://');
    } else {
      customImageUrl = 'app-assets://$baseImagePath';
    }

    // Votre code HTML mis à jour avec le bon schéma d'URL
    String htmlContent = """
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Test Affichage Image</title>
        <style>
            body { font-family: sans-serif; text-align: center; padding: 20px; }
            img { max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
        </style>
    </head>
    <body>
        <h1>Test Image Locale</h1>
        <p>Cette image est interceptée par le code natif grâce au schéma personnalisé.</p>
        
        <!-- L'URL utilise app-assets:// au lieu de file:// -->
        <img src="$customImageUrl" alt="Image locale chargée via app-assets" /> 
    </body>
    </html>
    """;

    await _controller?.loadData(
      htmlContent,
      mimeType: 'text/html',
      encoding: 'utf8',
    );
  }

  Future<void> _loadFile() async {
    final path = _filePathController.text.trim();
    if (path.isEmpty) return;
    await _controller?.loadFile(path);
  }

  // -- Actions : injection distante (fichier via URL) -------------------------

  Future<void> _injectRemoteJs() async {
    await _controller?.injectJavascriptFileFromUrl(
      'https://code.jquery.com/jquery-3.7.1.min.js',
    );
    await Future<void>.delayed(const Duration(milliseconds: 800));
    final result = await _controller?.evaluateJavascript('typeof window.jQuery');
    _pushLog('Injection JS distante → typeof jQuery = $result');
  }

  Future<void> _injectRemoteCss() async {
    await _controller?.injectCSSFileFromUrl(
      'data:text/css;base64,Ym9keSB7IGZpbHRlcjogaW52ZXJ0KDEpOyB9',
    );
    _pushLog('CSS distant injecté (inversion des couleurs de la page).');
  }

  // -- Actions : injection brute (injectJsData / injectCssData) ---------------

  Future<void> _injectRawJs() async {
    final code = _rawJsController.text.trim();
    if (code.isEmpty) return;
    await _controller?.injectJsData(code);
    _pushLog('injectJsData("$code") exécuté.');
  }

  Future<void> _injectRawCss() async {
    final css = _rawCssController.text.trim();
    if (css.isEmpty) return;
    await _controller?.injectCssData(css);
    _pushLog('injectCssData(...) appliqué (${css.length} caractères).');
  }

  // -- Cache & Debug (API globales, indépendantes de la Webview affichée) -----

  Future<void> _runCacheAction(String label, Future<void> Function() action) async {
    setState(() => _cacheActionInProgress = true);
    try {
      await action();
      _pushLog('WebviewCacheManager.$label() : ok.');
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('$label : effectué.'), duration: const Duration(seconds: 2)),
      );
    } catch (e) {
      _pushLog('WebviewCacheManager.$label() : erreur — $e');
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('$label : erreur — $e'), duration: const Duration(seconds: 3)),
      );
    } finally {
      if (mounted) setState(() => _cacheActionInProgress = false);
    }
  }

  Future<void> _toggleWebContentsDebugging(bool enabled) async {
    setState(() => _webContentsDebuggingEnabled = enabled);
    await WebviewBaseController.setWebContentsDebuggingEnabled(enabled);
    _pushLog('setWebContentsDebuggingEnabled($enabled)');
  }

  void _applySettingsAndRebuild() {
    setState(() {
      _webviewGeneration++; // force la recréation de la Webview native
      _status = 'Réglages appliqués, Webview recréée.';
    });
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Webview recréée avec les nouveaux réglages.'), duration: Duration(seconds: 2)),
    );
  }

  // -- Sélecteur de couleur -----------------------------------------------

  static const List<Color> _palette = [
    Colors.red,
    Colors.pink,
    Colors.purple,
    Colors.deepPurple,
    Colors.indigo,
    Colors.blue,
    Colors.lightBlue,
    Colors.cyan,
    Colors.teal,
    Colors.green,
    Colors.lightGreen,
    Colors.lime,
    Colors.yellow,
    Colors.amber,
    Colors.orange,
    Colors.deepOrange,
    Colors.brown,
    Colors.grey,
    Colors.blueGrey,
    Colors.black,
    Colors.white,
  ];

  Future<Color?> _pickColor(String title, Color? current, {bool nullable = false}) async {
    return showDialog<Color?>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: SizedBox(
          width: 320,
          child: Wrap(
            spacing: 10,
            runSpacing: 10,
            children: [
              if (nullable)
                _ColorSwatch(
                  color: null,
                  selected: current == null,
                  onTap: () => Navigator.of(context).pop<Color?>(null),
                ),
              for (final c in _palette)
                _ColorSwatch(
                  color: c,
                  selected: current?.value == c.value,
                  onTap: () => Navigator.of(context).pop<Color?>(c),
                ),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('Annuler'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _tabController.dispose();
    _urlFieldController.dispose();
    _jsFieldController.dispose();
    _filePathController.dispose();
    _uaSuffixController.dispose();
    _userAgentController.dispose();
    _initialCssController.dispose();
    _rawJsController.dispose();
    _rawCssController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final initialDataDemo = WebviewInitialData(
      '''
        <html>
          <body style="font-family: system-ui; padding: 20px;">
            <h2>Données Initiales (WebviewInitialData)</h2>
            <p>Chargées par défaut via les paramètres de création du widget.</p>
          </body>
        </html>
      ''',
      mimeType: 'text/html',
      encoding: 'utf8',
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('Webview Plus — Démo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.arrow_back_rounded),
            tooltip: 'Précédent',
            onPressed: () => _controller?.goBack(),
          ),
          IconButton(
            icon: const Icon(Icons.arrow_forward_rounded),
            tooltip: 'Suivant',
            onPressed: () => _controller?.goForward(),
          ),
          IconButton(
            icon: const Icon(Icons.refresh_rounded),
            tooltip: 'Recharger',
            onPressed: () => _controller?.reload(),
          ),
          ValueListenableBuilder<ThemeMode>(
            valueListenable: themeModeNotifier,
            builder: (context, mode, _) => IconButton(
              tooltip: 'Basculer le thème',
              icon: Icon(
                mode == ThemeMode.dark ? Icons.dark_mode_rounded : Icons.light_mode_rounded,
              ),
              onPressed: () {
                themeModeNotifier.value =
                    mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
              },
            ),
          ),
          const SizedBox(width: 4),
        ],
      ),
      body: Column(
        children: [
          // -- Barre d'adresse + statut ---------------------------------
          Padding(
            padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _urlFieldController,
                    decoration: InputDecoration(
                      isDense: true,
                      filled: true,
                      hintText: 'https://exemple.com',
                      prefixIcon: const Icon(Icons.public_rounded),
                      border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
                      contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
                    ),
                    onSubmitted: (_) => _go(),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton.icon(
                  onPressed: _go,
                  icon: const Icon(Icons.send_rounded, size: 18),
                  label: const Text('Go'),
                  style: FilledButton.styleFrom(
                    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
                  ),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                _StatusChip(isLoading: _isLoading, status: _status),
              ],
            ),
          ),
          const SizedBox(height: 6),

          if (_isLoading) const LinearProgressIndicator(minHeight: 2),

          // -- Webview ------------------------------------------------------
          Expanded(
            flex: 3,
            child: ClipRRect(
              child: WebviewWidget(
                key: ValueKey(_webviewGeneration),
                initialData: initialDataDemo,
                initialCss: _initialCssController.text,
                initialSettings: _currentSettings,
                contextMenuItems: _contextMenuItems,
                onWebViewCreated: (controller) {
                  _controller = controller;
                  _registerHandlers(controller);
                  setState(() => _status = 'Webview créée.');
                },
                onLoadStart: (controller, url) {
                  setState(() {
                    _isLoading = true;
                    _status = 'Chargement…';
                  });
                },
                onLoadStop: (controller, url) {
                  final time = DateTime.now().millisecondsSinceEpoch;
                  if (kDebugMode) {
                    debugPrint('WEBVIEW LOADING onLoadStop $time $url');
                  }
                  setState(() {
                    _isLoading = false;
                    _status = 'Chargé';
                    _urlFieldController.text = url.toString();
                  });
                },
                onDOMContentLoaded: (controller, url) {
                  final time = DateTime.now().millisecondsSinceEpoch;
                  if (kDebugMode) {
                    debugPrint('WEBVIEW LOADING onDOMContentLoaded $time $url');
                  }
                  setState(() => _status = 'DOM chargé');
                },
                onFontsIsLoaded: (controller, loadedFontFamilies) {
                  for(String font in loadedFontFamilies) {
                    if (kDebugMode) {
                      print(font);
                    }
                  }
                },
                onLoadResource: (controller, url , data) async {
                  if (kDebugMode) {
                    print('url: $url');
                    print('data: $data');
                  }

                  // L'application attendra que cette ligne soit atteinte avant de continuer côté Swift !
                  return null;
                },
                onReceivedError: (controller, url, code, description) {
                  setState(() {
                    _isLoading = false;
                    _status = 'Erreur ($code)';
                  });
                  _pushLog('Erreur ($code) sur $url : $description');
                },
                onWindowFocus: (controller) => setState(() => _status = 'Focus'),
                onWindowBlur: (controller) => setState(() => _status = 'Perte de focus'),
                onNavigationRequest: (controller, url) async {
                  final bloque = url.toString().contains('bloque.com');
                  if (bloque) _pushLog('Navigation bloquée : $url');
                  return !bloque;
                },
                onMessageReceived: (controller, message) {
                  _pushLog('postMessage : $message');
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
                  );
                },
              ),
            ),
          ),

          const Divider(height: 1),

          // -- Panneau de test ---------------------------------------------
          Expanded(
            flex: 2,
            child: Column(
              children: [
                Material(
                  color: Theme.of(context).colorScheme.surfaceContainerHighest,
                  child: TabBar(
                    controller: _tabController,
                    tabs: const [
                      Tab(icon: Icon(Icons.bolt_rounded), text: 'Actions'),
                      Tab(icon: Icon(Icons.tune_rounded), text: 'Réglages'),
                      Tab(icon: Icon(Icons.cleaning_services_rounded), text: 'Cache & Debug'),
                      Tab(icon: Icon(Icons.article_rounded), text: 'Journal'),
                    ],
                  ),
                ),
                Expanded(
                  child: TabBarView(
                    controller: _tabController,
                    children: [
                      _buildActionsTab(),
                      _buildSettingsTab(),
                      _buildCacheDebugTab(),
                      _buildLogTab(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  // ==========================================================================
  // Onglet : Actions
  // ==========================================================================

  Widget _buildActionsTab() {
    return ListView(
      padding: const EdgeInsets.all(12),
      children: [
        _CardSection(
          icon: Icons.description_outlined,
          title: 'Chargement de contenu',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _actionButton(Icons.folder_open_rounded, 'Asset (index.html)', _loadHtmlFile),
                _actionButton(Icons.data_object_rounded, 'HTML brut (loadData)', _loadRawData),
                _actionButton(Icons.code_rounded, 'Voir le HTML (getHtml)', _getHtml),
              ],
            ),
            const SizedBox(height: 10),
            Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _filePathController,
                    decoration: const InputDecoration(
                      isDense: true,
                      border: OutlineInputBorder(),
                      labelText: 'Chemin absolu (loadFile)',
                      hintText: '/storage/emulated/0/Download/page.html',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton.tonal(onPressed: _loadFile, child: const Text('Charger')),
              ],
            ),
          ],
        ),
        _CardSection(
          icon: Icons.terminal_rounded,
          title: 'Exécuter du JavaScript',
          children: [
            Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _jsFieldController,
                    decoration: const InputDecoration(
                      isDense: true,
                      border: OutlineInputBorder(),
                      labelText: 'Code JS (evaluateJavascript)',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton(onPressed: _runJs, child: const Text('Exécuter')),
              ],
            ),
          ],
        ),
        _CardSection(
          icon: Icons.publish_rounded,
          title: 'Injection distante (fichier via URL)',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _actionButton(Icons.javascript_rounded, 'JS distant (jQuery)', _injectRemoteJs),
                _actionButton(Icons.style_rounded, 'CSS distant (inversion)', _injectRemoteCss),
              ],
            ),
          ],
        ),
        _CardSection(
          icon: Icons.flash_on_rounded,
          title: 'Injection brute (injectJsData / injectCssData)',
          subtitle: 'Exécutée immédiatement sur la page en cours, sur les 5 plateformes.',
          children: [
            TextField(
              controller: _rawJsController,
              maxLines: 2,
              decoration: const InputDecoration(
                isDense: true,
                border: OutlineInputBorder(),
                labelText: 'JS brut (injectJsData)',
              ),
            ),
            const SizedBox(height: 8),
            Align(
              alignment: Alignment.centerRight,
              child: FilledButton.tonal(onPressed: _injectRawJs, child: const Text('Injecter le JS')),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _rawCssController,
              maxLines: 2,
              decoration: const InputDecoration(
                isDense: true,
                border: OutlineInputBorder(),
                labelText: 'CSS brut (injectCssData)',
              ),
            ),
            const SizedBox(height: 8),
            Align(
              alignment: Alignment.centerRight,
              child: FilledButton.tonal(onPressed: _injectRawCss, child: const Text('Injecter le CSS')),
            ),
          ],
        ),
        _CardSection(
          icon: Icons.style_outlined,
          title: 'CSS initial (WebviewWidget.initialCss)',
          subtitle: 'Réappliqué automatiquement à chaque chargement de page. Nécessite de recréer la Webview.',
          children: [
            TextField(
              controller: _initialCssController,
              maxLines: 3,
              decoration: const InputDecoration(
                isDense: true,
                border: OutlineInputBorder(),
                labelText: 'CSS initial',
              ),
              onChanged: (_) => setState(() {}),
            ),
            const SizedBox(height: 8),
            Align(
              alignment: Alignment.centerRight,
              child: OutlinedButton.icon(
                onPressed: _applySettingsAndRebuild,
                icon: const Icon(Icons.refresh_rounded, size: 18),
                label: const Text('Recréer la Webview'),
              ),
            ),
          ],
        ),
      ],
    );
  }

  // ==========================================================================
  // Onglet : Réglages
  // ==========================================================================

  Widget _buildSettingsTab() {
    return ListView(
      padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
      children: [
        Card(
          color: Theme.of(context).colorScheme.primaryContainer,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                Icon(Icons.info_outline_rounded, color: Theme.of(context).colorScheme.onPrimaryContainer),
                const SizedBox(width: 10),
                Expanded(
                  child: Text(
                    'Ces réglages ne s\'appliquent qu\'à la création de la Webview : '
                    'cliquez sur "Appliquer" pour la recréer.',
                    style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton.icon(
                  onPressed: _applySettingsAndRebuild,
                  icon: const Icon(Icons.refresh_rounded, size: 18),
                  label: const Text('Appliquer'),
                ),
              ],
            ),
          ),
        ),
        const SizedBox(height: 12),

        _SettingsGroup(
          icon: Icons.public_rounded,
          title: 'Général (toutes plateformes)',
          initiallyExpanded: true,
          children: [
            SwitchListTile(
              title: const Text('JavaScript activé'),
              value: _javaScriptEnabled,
              onChanged: (v) => setState(() => _javaScriptEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Stockage DOM (localStorage / IndexedDB)'),
              value: _domStorageEnabled,
              onChanged: (v) => setState(() => _domStorageEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Zoom (pincement) autorisé'),
              value: _supportZoom,
              onChanged: (v) => setState(() => _supportZoom = v),
            ),
            SwitchListTile(
              title: const Text('Lecture média nécessite un geste utilisateur'),
              value: _mediaPlaybackRequiresUserGesture,
              onChanged: (v) => setState(() => _mediaPlaybackRequiresUserGesture = v),
            ),
            SwitchListTile(
              title: const Text('Cache activé'),
              value: _cacheEnabled,
              onChanged: (v) => setState(() => _cacheEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Navigation privée (incognito)'),
              value: _incognito,
              onChanged: (v) => setState(() => _incognito = v),
            ),
            SwitchListTile(
              title: const Text('window.open() autorisé sans geste utilisateur'),
              value: _javaScriptCanOpenWindowsAutomatically,
              onChanged: (v) => setState(() => _javaScriptCanOpenWindowsAutomatically = v),
            ),
            SwitchListTile(
              title: const Text('Masquer les barres de défilement natives'),
              value: _hideNativeScrollbars,
              onChanged: (v) => setState(() => _hideNativeScrollbars = v),
            ),
            SwitchListTile(
              title: const Text('Ne pas redimensionner au clavier (Android/iOS)'),
              value: _disableKeyboardResize,
              onChanged: (v) => setState(() => _disableKeyboardResize = v),
            ),
            SwitchListTile(
              title: const Text('Fond transparent'),
              value: _transparentBackground,
              onChanged: (v) => setState(() => _transparentBackground = v),
            ),
            SwitchListTile(
              title: const Text('Inspection à distance (isInspectable)'),
              subtitle: const Text('Par instance : voir aussi setWebContentsDebuggingEnabled (global)'),
              value: _isInspectable,
              onChanged: (v) => setState(() => _isInspectable = v),
            ),
            _colorTile(
              'Couleur d\'arrière-plan initiale',
              _initialBackgroundColor,
              nullable: true,
              onChanged: (c) => setState(() => _initialBackgroundColor = c),
            ),
            _textTile(
              'User-Agent complet (remplace celui par défaut)',
              _userAgentController,
              hint: 'Laisser vide pour garder le défaut système',
            ),
            _textTile(
              'Suffixe ajouté au User-Agent',
              _uaSuffixController,
            ),
          ],
        ),

        _SettingsGroup(
          icon: Icons.ads_click_rounded,
          title: 'Menu contextuel & sélection',
          children: [
            SwitchListTile(
              title: const Text('Désactiver le menu contextuel'),
              value: _disableContextMenu,
              onChanged: (v) => setState(() => _disableContextMenu = v),
            ),
            SwitchListTile(
              title: const Text('Désactiver le menu sur les liens (appui long)'),
              value: _disableLongPressLinks,
              onChanged: (v) => setState(() => _disableLongPressLinks = v),
            ),
            SwitchListTile(
              title: const Text('Masquer l\'URL survolée (desktop)'),
              value: _disableLinkHoverPreview,
              onChanged: (v) => setState(() => _disableLinkHoverPreview = v),
            ),
            SwitchListTile(
              title: const Text('Désactiver l\'impression (Ctrl/Cmd+P)'),
              value: _disablePrinting,
              onChanged: (v) => setState(() => _disablePrinting = v),
            ),
            _colorTile(
              'Couleur du texte sélectionné',
              _selectionTextColor,
              nullable: true,
              onChanged: (c) => setState(() => _selectionTextColor = c),
            ),
            _colorTile(
              'Couleur des poignées de sélection (Android)',
              _selectionHandleColor,
              nullable: true,
              onChanged: (c) => setState(() => _selectionHandleColor = c),
            ),
            const Padding(
              padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: Text('Éléments par défaut à désactiver (Android/iOS)'),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Wrap(
                spacing: 8,
                children: DefaultContextMenuItem.values.map((item) {
                  final selected = _disabledDefaultItems.contains(item);
                  return FilterChip(
                    label: Text(_defaultContextMenuItemLabel(item)),
                    selected: selected,
                    onSelected: (v) => setState(() {
                      if (v) {
                        _disabledDefaultItems.add(item);
                      } else {
                        _disabledDefaultItems.remove(item);
                      }
                    }),
                  );
                }).toList(),
              ),
            ),
            const SizedBox(height: 12),
          ],
        ),

        _SettingsGroup(
          icon: Icons.android_rounded,
          title: 'Android',
          children: [
            SwitchListTile(
              title: const Text('Accès aux fichiers (file://)'),
              value: _allowFileAccess,
              onChanged: (v) => setState(() => _allowFileAccess = v),
            ),
            SwitchListTile(
              title: const Text('Accès aux Content Providers (content://)'),
              value: _allowContentAccess,
              onChanged: (v) => setState(() => _allowContentAccess = v),
            ),
            SwitchListTile(
              title: const Text('Contrôles de zoom intégrés'),
              value: _builtInZoomControls,
              onChanged: (v) => setState(() => _builtInZoomControls = v),
            ),
            SwitchListTile(
              title: const Text('Afficher les boutons de zoom'),
              value: _displayZoomControls,
              onChanged: (v) => setState(() => _displayZoomControls = v),
            ),
            SwitchListTile(
              title: const Text('Géolocalisation matérielle'),
              value: _geolocationEnabled,
              onChanged: (v) => setState(() => _geolocationEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Cookies tiers autorisés'),
              value: _thirdPartyCookiesEnabled,
              onChanged: (v) => setState(() => _thirdPartyCookiesEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Forcer le mode sombre'),
              value: _forceDarkMode,
              onChanged: (v) => setState(() => _forceDarkMode = v),
            ),
            SwitchListTile(
              title: const Text('Google Safe Browsing'),
              value: _safeBrowsingEnabled,
              onChanged: (v) => setState(() => _safeBrowsingEnabled = v),
            ),
            SwitchListTile(
              title: const Text('Autoriser le contenu mixte (HTTP dans HTTPS)'),
              value: _allowMixedContent,
              onChanged: (v) => setState(() => _allowMixedContent = v),
            ),
            _sliderTile(
              'Zoom du texte',
              '$_textZoom %',
              value: _textZoom.toDouble(),
              min: 50,
              max: 200,
              divisions: 15,
              onChanged: (v) => setState(() => _textZoom = v.toInt()),
            ),
            _sliderTile(
              'Échelle initiale (0 = par défaut)',
              _initialScale == 0 ? 'Par défaut' : '$_initialScale %',
              value: _initialScale.toDouble(),
              min: 0,
              max: 200,
              divisions: 20,
              onChanged: (v) => setState(() => _initialScale = v.toInt()),
            ),
            _dropdownTile<OverScrollMode>(
              'Effet d\'overscroll',
              value: _overScrollMode,
              items: OverScrollMode.values,
              labelBuilder: (m) => m.name,
              onChanged: (v) => setState(() => _overScrollMode = v),
            ),
            _dropdownTile<AndroidPlatformViewType>(
              'Mode de rendu (PlatformView)',
              value: _androidPlatformViewType,
              items: AndroidPlatformViewType.values,
              labelBuilder: (m) => m.name,
              onChanged: (v) => setState(() => _androidPlatformViewType = v),
            ),
            _dropdownTile<WebviewContentMode>(
              'Mode de contenu (Desktop/Mobile)',
              value: _webviewContentMode,
              items: WebviewContentMode.values,
              labelBuilder: (m) => m.name,
              onChanged: (v) => setState(() => _webviewContentMode = v),
            ),
          ],
        ),

        _SettingsGroup(
          icon: Icons.phone_iphone_rounded,
          title: 'iOS & macOS',
          children: [
            SwitchListTile(
              title: const Text('Lecture média inline (sans plein écran auto.)'),
              value: _allowsInlineMediaPlayback,
              onChanged: (v) => setState(() => _allowsInlineMediaPlayback = v),
            ),
            SwitchListTile(
              title: const Text('Picture-in-Picture autorisé'),
              value: _allowsPictureInPicture,
              onChanged: (v) => setState(() => _allowsPictureInPicture = v),
            ),
            SwitchListTile(
              title: const Text('Rebond de défilement (bounces, iOS)'),
              value: _bounces,
              onChanged: (v) => setState(() => _bounces = v),
            ),
            SwitchListTile(
              title: const Text('Navigation par balayage (swipe, iOS)'),
              value: _allowsBackForwardNavigationGestures,
              onChanged: (v) => setState(() => _allowsBackForwardNavigationGestures = v),
            ),
            SwitchListTile(
              title: const Text('Aperçu de lien (Peek & Pop, iOS)'),
              value: _allowsLinkPreview,
              onChanged: (v) => setState(() => _allowsLinkPreview = v),
            ),
            SwitchListTile(
              title: const Text('Accès file:// → file:// (allowFileAccessFromFileURLs)'),
              subtitle: const Text('À activer si un fichier local ne charge pas ses ressources relatives'),
              value: _allowFileAccessFromFileURLs,
              onChanged: (v) => setState(() => _allowFileAccessFromFileURLs = v),
            ),
            SwitchListTile(
              title: const Text('Accès file:// → toute origine (allowUniversalAccessFromFileURLs)'),
              subtitle: const Text('Plus permissif : à réserver à du contenu local de confiance'),
              value: _allowUniversalAccessFromFileURLs,
              onChanged: (v) => setState(() => _allowUniversalAccessFromFileURLs = v),
            ),
            _sliderTile(
              'Taille minimale de police',
              '$_minimumFontSize px',
              value: _minimumFontSize.toDouble(),
              min: 1,
              max: 30,
              divisions: 29,
              onChanged: (v) => setState(() => _minimumFontSize = v.toInt()),
            ),
          ],
        ),

        _SettingsGroup(
          icon: Icons.view_column_rounded,
          title: 'Barres de défilement (Windows / macOS / Linux)',
          children: [
            _dropdownTile<DesktopScrollbarThemeMode>(
              'Mode',
              value: _scrollbarThemeMode,
              items: DesktopScrollbarThemeMode.values,
              labelBuilder: (m) => m.name,
              onChanged: (v) => setState(() => _scrollbarThemeMode = v),
            ),
            _colorTile(
              'Couleur du fond (track)',
              _scrollbarTrackColor,
              onChanged: (c) => setState(() => _scrollbarTrackColor = c ?? _scrollbarTrackColor),
            ),
            _colorTile(
              'Couleur de la poignée (thumb)',
              _scrollbarThumbColor,
              onChanged: (c) => setState(() => _scrollbarThumbColor = c ?? _scrollbarThumbColor),
            ),
            _sliderTile(
              'Largeur',
              '${_scrollbarWidth.toInt()} px',
              value: _scrollbarWidth,
              min: 4,
              max: 30,
              onChanged: (v) => setState(() => _scrollbarWidth = v),
            ),
          ],
        ),
        const SizedBox(height: 12),
      ],
    );
  }

  // ==========================================================================
  // Onglet : Cache & Debug
  // ==========================================================================

  Widget _buildCacheDebugTab() {
    return ListView(
      padding: const EdgeInsets.all(12),
      children: [
        _CardSection(
          icon: Icons.cleaning_services_rounded,
          title: 'WebviewCacheManager',
          subtitle: 'Agit sur toutes les Webviews de l\'app, indépendamment de celle affichée ci-dessus.',
          children: [
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonalIcon(
                  onPressed: _cacheActionInProgress
                      ? null
                      : () => _runCacheAction('clearCache', WebviewCacheManager.clearCache),
                  icon: const Icon(Icons.storage_rounded, size: 18),
                  label: const Text('Vider le cache HTTP'),
                ),
                FilledButton.tonalIcon(
                  onPressed: _cacheActionInProgress
                      ? null
                      : () => _runCacheAction('clearCookies', WebviewCacheManager.clearCookies),
                  icon: const Icon(Icons.cookie_rounded, size: 18),
                  label: const Text('Vider les cookies'),
                ),
                FilledButton.icon(
                  onPressed: _cacheActionInProgress
                      ? null
                      : () => _runCacheAction('clearAllData', WebviewCacheManager.clearAllData),
                  icon: const Icon(Icons.delete_forever_rounded, size: 18),
                  label: const Text('Tout effacer'),
                  style: FilledButton.styleFrom(
                    backgroundColor: Theme.of(context).colorScheme.errorContainer,
                    foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
                  ),
                ),
              ],
            ),
            if (_cacheActionInProgress) ...[
              const SizedBox(height: 12),
              const LinearProgressIndicator(minHeight: 3),
            ],
          ],
        ),
        _CardSection(
          icon: Icons.bug_report_rounded,
          title: 'Inspection distante globale',
          subtitle: 'WebviewPlusController.setWebContentsDebuggingEnabled() — toutes les Webviews, existantes et futures.',
          children: [
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('DevTools / Web Inspector activés'),
              value: _webContentsDebuggingEnabled,
              onChanged: _toggleWebContentsDebugging,
            ),
          ],
        ),
      ],
    );
  }

  // ==========================================================================
  // Onglet : Journal
  // ==========================================================================

  Widget _buildLogTab() {
    if (_log.isEmpty) {
      return const Center(child: Text('Aucun événement pour le moment.'));
    }
    return ListView.separated(
      padding: const EdgeInsets.all(8),
      itemCount: _log.length,
      separatorBuilder: (_, __) => const Divider(height: 8),
      itemBuilder: (context, index) => Text(
        _log[index],
        style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
      ),
    );
  }

  // ==========================================================================
  // Petits constructeurs de widgets réutilisés
  // ==========================================================================

  Widget _actionButton(IconData icon, String label, VoidCallback onPressed) {
    return OutlinedButton.icon(
      onPressed: onPressed,
      icon: Icon(icon, size: 18),
      label: Text(label),
    );
  }

  Widget _colorTile(String title, Color? color, {bool nullable = false, required ValueChanged<Color?> onChanged}) {
    return ListTile(
      title: Text(title),
      trailing: GestureDetector(
        onTap: () async {
          final chosen = await _pickColor(title, color, nullable: nullable);
          if (chosen != color) onChanged(chosen);
        },
        child: color == null
            ? const CircleAvatar(child: Icon(Icons.block_rounded, size: 16))
            : CircleAvatar(backgroundColor: color),
      ),
    );
  }

  Widget _textTile(String label, TextEditingController controller, {String? hint}) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: TextField(
        controller: controller,
        decoration: InputDecoration(
          isDense: true,
          border: const OutlineInputBorder(),
          labelText: label,
          hintText: hint,
        ),
        onChanged: (_) => setState(() {}),
      ),
    );
  }

  Widget _sliderTile(
    String title,
    String valueLabel, {
    required double value,
    required double min,
    required double max,
    int? divisions,
    required ValueChanged<double> onChanged,
  }) {
    return ListTile(
      title: Text(title),
      subtitle: Slider(
        value: value,
        min: min,
        max: max,
        divisions: divisions,
        label: valueLabel,
        onChanged: onChanged,
      ),
      trailing: SizedBox(width: 56, child: Text(valueLabel, textAlign: TextAlign.right)),
    );
  }

  Widget _dropdownTile<T>(
    String title, {
    required T value,
    required List<T> items,
    required String Function(T) labelBuilder,
    required ValueChanged<T> onChanged,
  }) {
    return ListTile(
      title: Text(title),
      trailing: DropdownButton<T>(
        value: value,
        onChanged: (v) {
          if (v != null) onChanged(v);
        },
        items: items
            .map((item) => DropdownMenuItem<T>(value: item, child: Text(labelBuilder(item))))
            .toList(),
      ),
    );
  }

  String _defaultContextMenuItemLabel(DefaultContextMenuItem item) {
    switch (item) {
      case DefaultContextMenuItem.copy:
        return 'Copier';
      case DefaultContextMenuItem.cut:
        return 'Couper';
      case DefaultContextMenuItem.paste:
        return 'Coller';
      case DefaultContextMenuItem.selectAll:
        return 'Tout sélectionner';
    }
  }
}

// ============================================================================
// Widgets utilitaires
// ============================================================================

class _StatusChip extends StatelessWidget {
  const _StatusChip({required this.isLoading, required this.status});

  final bool isLoading;
  final String status;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      decoration: BoxDecoration(
        color: scheme.secondaryContainer,
        borderRadius: BorderRadius.circular(20),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (isLoading)
            SizedBox(
              width: 12,
              height: 12,
              child: CircularProgressIndicator(strokeWidth: 2, color: scheme.onSecondaryContainer),
            )
          else
            Icon(Icons.check_circle_rounded, size: 14, color: scheme.onSecondaryContainer),
          const SizedBox(width: 6),
          Text(
            status,
            style: TextStyle(fontSize: 12, color: scheme.onSecondaryContainer),
          ),
        ],
      ),
    );
  }
}

class _CardSection extends StatelessWidget {
  const _CardSection({
    required this.icon,
    required this.title,
    required this.children,
    this.subtitle,
  });

  final IconData icon;
  final String title;
  final String? subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(title, style: Theme.of(context).textTheme.titleSmall),
                ),
              ],
            ),
            if (subtitle != null) ...[
              const SizedBox(height: 4),
              Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
            ],
            const SizedBox(height: 10),
            ...children,
          ],
        ),
      ),
    );
  }
}

class _SettingsGroup extends StatelessWidget {
  const _SettingsGroup({
    required this.icon,
    required this.title,
    required this.children,
    this.initiallyExpanded = false,
  });

  final IconData icon;
  final String title;
  final List<Widget> children;
  final bool initiallyExpanded;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      clipBehavior: Clip.antiAlias,
      child: Theme(
        data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
        child: ExpansionTile(
          initiallyExpanded: initiallyExpanded,
          leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
          title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
          childrenPadding: const EdgeInsets.only(bottom: 8),
          children: children,
        ),
      ),
    );
  }
}

class _ColorSwatch extends StatelessWidget {
  const _ColorSwatch({required this.color, required this.selected, required this.onTap});

  final Color? color;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(24),
      child: Container(
        width: 40,
        height: 40,
        decoration: BoxDecoration(
          color: color ?? Colors.transparent,
          shape: BoxShape.circle,
          border: Border.all(
            color: selected ? Theme.of(context).colorScheme.primary : Colors.black26,
            width: selected ? 3 : 1,
          ),
        ),
        child: color == null
            ? const Icon(Icons.block_rounded, size: 18)
            : (selected ? const Icon(Icons.check_rounded, color: Colors.white, size: 18) : null),
      ),
    );
  }
}
3
likes
160
points
928
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A cross-platform Flutter plugin that directly encapsulates native WebViews (Android, iOS/macOS, Windows, Linux andWeb) with no WebView dependencies.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface, web

More

Packages that depend on webview_plus

Packages that implement webview_plus