Skip to main content

vivienda_webview_sdk 0.2.1 copy "vivienda_webview_sdk: ^0.2.1" to clipboard
vivienda_webview_sdk: ^0.2.1 copied to clipboard

PlatformAndroid

SDK Flutter (wrapper de WebView) para Vivienda IA / FGE Cotizador. Abre el flujo embebido, inyecta runtime config por bridge y reenvía los eventos vivienda:* a callbacks Dart.

example/lib/main.dart

import 'dart:io';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:open_filex/open_filex.dart';
import 'package:vivienda_webview_sdk/vivienda_webview_sdk.dart';

import 'vivienda_example_profiles.dart';

/// Guarda el PDF con `dart:io` en un directorio temporal app-specific.
///
/// Usamos `Directory.systemTemp` (en Android es la cache privada de la app) en vez
/// de `path_provider`: ese plugin arrastra `path_provider_foundation` →
/// `objective_c`, cuyo build de native-assets puede fallar en Windows
/// cuando la ruta del proyecto tiene espacios. `open_filex`
/// NO depende de objective_c, así que la apertura automática se conserva.
Future<String> _examplePdfSaver(List<int> bytes, String fileName) async {
  final dir = Directory('${Directory.systemTemp.path}/vivienda');
  if (!dir.existsSync()) {
    dir.createSync(recursive: true);
  }
  final file = File('${dir.path}/$fileName');
  await file.writeAsBytes(bytes, flush: true);
  return file.path;
}

/// No etiquetar las imágenes como PDF: el Intent debe usar su MIME real.
String? exampleDownloadMime(String path) =>
    switch (path.split('.').last.toLowerCase()) {
      'pdf' => 'application/pdf',
      'png' => 'image/png',
      'jpg' || 'jpeg' => 'image/jpeg',
      'svg' => 'image/svg+xml',
      'webp' => 'image/webp',
      _ => null,
    };

/// Abre el archivo con el visor del sistema. `false` ⇒ no hay app compatible.
Future<bool> _examplePdfOpener(String path) async {
  final result = await OpenFilex.open(path, type: exampleDownloadMime(path));
  if (result.type == ResultType.done) return true;
  if (result.type == ResultType.noAppToOpen) return false;
  throw StateError('El visor rechazó la apertura');
}

/// Sprint 6C · Error 10 — selector de archivos para el `<input type="file">` de
/// subir croquis/plano. El SDK lo invoca al disparar el input en Android; abre el
/// selector nativo (SAF / Photo Picker, sin permisos) y devuelve URIs `file://…`.
///
/// Devuelve `[]` si el usuario cancela (no rompe el flujo web). El frontend vuelve
/// a validar tipo/tamaño en su `onChange`, así que basta con acotar a los formatos
/// que acepta el croquis.
Future<List<String>> _exampleFileSelector(
  ViviendaFileSelectorParams params,
) async {
  final allowedExtensions = _croquisExtensions(params.acceptTypes);
  try {
    final result = await FilePicker.platform.pickFiles(
      allowMultiple: params.allowMultiple,
      type: FileType.custom,
      allowedExtensions: allowedExtensions,
    );
    if (result == null) {
      return const <String>[]; // cancelado
    }
    final uris = result.files
        .map(_filePickerUri)
        .whereType<String>()
        .where((uri) => uri.isNotEmpty)
        .toList();
    if (uris.isEmpty) {
      throw StateError('El selector no entregó un archivo accesible');
    }
    return uris;
  } catch (_) {
    // El SDK distingue fallo del selector de una cancelación voluntaria y
    // notifica a la WebView para ofrecer un reintento visible.
    rethrow;
  }
}

String? _filePickerUri(PlatformFile file) {
  final path = file.path;
  if (path != null && path.isNotEmpty) {
    return Uri.file(path).toString();
  }
  final identifier = file.identifier;
  if (identifier != null && identifier.isNotEmpty) {
    return identifier;
  }
  return null;
}

/// Deriva extensiones desde el `accept` del input; si no hay pistas, cae al set
/// soportado por el análisis de croquis (jpg/png/webp/pdf).
List<String> _croquisExtensions(List<String> acceptTypes) {
  const fallback = <String>['png', 'jpg', 'jpeg', 'webp', 'pdf'];
  if (acceptTypes.isEmpty) {
    return fallback;
  }
  final exts = <String>{};
  for (final raw in acceptTypes) {
    final type = raw.trim().toLowerCase();
    if (type == 'image/*') {
      exts.addAll(['png', 'jpg', 'jpeg', 'webp']);
    } else if (type == 'image/png' || type == '.png') {
      exts.add('png');
    } else if (type == 'image/jpeg' || type == '.jpg' || type == '.jpeg') {
      exts.addAll(['jpg', 'jpeg']);
    } else if (type == 'image/webp' || type == '.webp') {
      exts.add('webp');
    } else if (type == 'application/pdf' || type == '.pdf') {
      exts.add('pdf');
    }
  }
  return exts.isEmpty ? fallback : exts.toList();
}

// --- Perfil seleccionado (prioridad 2) ---
const _profileName = String.fromEnvironment(
  'VIVIENDA_PROFILE',
  defaultValue: 'localAndroid',
);

// --- Overrides directos por dart-define (prioridad 1). '' = no provisto. ---
const _webUrlOverride = String.fromEnvironment('VIVIENDA_WEB_URL');
const _baseUrlOverride = String.fromEnvironment('VIVIENDA_BASE_URL');
const _clientDpiOverride = String.fromEnvironment('VIVIENDA_CLIENT_DPI');
const _sourceAppOverride = String.fromEnvironment('VIVIENDA_SOURCE_APP');
const _showDevLogsOverride = String.fromEnvironment('VIVIENDA_SHOW_DEV_LOGS');
const _allowedOriginsOverride =
    String.fromEnvironment('VIVIENDA_ALLOWED_ORIGINS');

// --- Tokens: SIEMPRE por dart-define, nunca hardcodeados. ---
const _sdkToken = String.fromEnvironment('VIVIENDA_SDK_TOKEN');
const _embedSessionToken =
    String.fromEnvironment('VIVIENDA_EMBED_SESSION_TOKEN');
// Permite comparar un proyecto existente sin forzar una sesión de creación.
// Son ajustes del host de ejemplo; no cambian la API ni la versión del SDK.
const _projectIdOverride = String.fromEnvironment('VIVIENDA_PROJECT_ID');
const _modeOverride = String.fromEnvironment('VIVIENDA_MODE');

ViviendaMode resolveExampleMode([String mode = _modeOverride]) =>
    switch (mode) {
      'view' => ViviendaMode.view,
      'edit' => ViviendaMode.edit,
      _ => ViviendaMode.create,
    };

// --- Promotor: UUID del asesor dueño de la sesión. NO es un token (no es
// secreto), pero es obligatorio cuando se crea la sesión con X-SDK-Token
// (Opción B). Prioridad de resolución: dart-define > perfil > vacío. ---
const _promoterIdOverride = String.fromEnvironment('VIVIENDA_PROMOTER_ID');
const _employeeCodeOverride = String.fromEnvironment('VIVIENDA_EMPLOYEE_CODE');
const _gformCodeOverride = String.fromEnvironment('VIVIENDA_GFORM_CODE');
const _codPromotorOverride = String.fromEnvironment('VIVIENDA_COD_PROMOTOR');

/// Resuelve el `promoterId` efectivo: el override por dart-define gana; si no se
/// pasó, cae al `promoterId` del perfil. Los perfiles públicos lo dejan vacío;
/// proporciona la identidad que admita tu backend al solicitar una sesión.
String resolvePromoterId(ViviendaExampleProfile profile) {
  if (_promoterIdOverride.isNotEmpty) {
    return _promoterIdOverride;
  }
  return profile.promoterId;
}

String resolveEmployeeCode() => _employeeCodeOverride.trim();

String resolveGformCode() => _gformCodeOverride.trim();

String resolveCodPromotor() => _codPromotorOverride.trim();

/// Modo de autenticación resuelto para el example.
enum ExampleAuthMode {
  /// Se pasó un `embedSessionToken` listo (Opción A).
  embedSessionToken,

  /// Hay `sdkToken`: el SDK pedirá la sesión embed automáticamente (Opción B).
  sdkTokenAuto,

  /// Sin tokens. Solo abrible en modo debug.
  none,
}

extension ExampleAuthModeLabel on ExampleAuthMode {
  String get label => switch (this) {
        ExampleAuthMode.embedSessionToken => 'embedSessionToken',
        ExampleAuthMode.sdkTokenAuto => 'sdkToken-auto',
        ExampleAuthMode.none => 'none',
      };
}

/// Resuelve el perfil efectivo aplicando overrides de dart-define sobre el
/// perfil seleccionado. Prioridad: dart-define > perfil > defaults del perfil.
ViviendaExampleProfile resolveEffectiveProfile() {
  final base = resolveViviendaExampleProfile(_profileName);
  return base.copyWith(
    webUrl: _webUrlOverride.isEmpty ? null : _webUrlOverride,
    baseUrl: _baseUrlOverride.isEmpty ? null : _baseUrlOverride,
    clientDpi: _clientDpiOverride.isEmpty ? null : _clientDpiOverride,
    sourceApp: _sourceAppOverride.isEmpty ? null : _sourceAppOverride,
    showDevLogs: _showDevLogsOverride.isEmpty
        ? null
        : _showDevLogsOverride.toLowerCase() == 'true',
    allowedOrigins: _allowedOriginsOverride.isEmpty
        ? null
        : _allowedOriginsOverride
            .split(',')
            .map((e) => e.trim())
            .where((e) => e.isNotEmpty)
            .toList(),
  );
}

ExampleAuthMode resolveAuthMode() {
  if (_embedSessionToken.isNotEmpty) {
    return ExampleAuthMode.embedSessionToken;
  }
  if (_sdkToken.isNotEmpty) {
    return ExampleAuthMode.sdkTokenAuto;
  }
  return ExampleAuthMode.none;
}

void main() => runApp(const ExampleApp());

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

  @override
  Widget build(BuildContext context) {
    final profile = resolveEffectiveProfile();
    final authMode = resolveAuthMode();
    return MaterialApp(
      title: 'Vivienda SDK Example',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: EmbedScreen(profile: profile, authMode: authMode),
    );
  }
}

class EmbedScreen extends StatefulWidget {
  const EmbedScreen({
    super.key,
    required this.profile,
    required this.authMode,
  });

  final ViviendaExampleProfile profile;
  final ExampleAuthMode authMode;

  @override
  State<EmbedScreen> createState() => _EmbedScreenState();
}

class _EmbedScreenState extends State<EmbedScreen> {
  final List<String> _events = <String>[];
  final Set<String> _seenEvents = <String>{};
  ViviendaDownloadResult? _lastDownload;

  /// En modo `none` solo abrimos la WebView si el usuario fuerza debug.
  bool _forceOpenWithoutToken = false;

  bool get _showDevLogs => widget.profile.showDevLogs;

  bool get _canOpen =>
      widget.authMode != ExampleAuthMode.none || _forceOpenWithoutToken;

  void _rememberEvent(ViviendaLogEvent event) {
    _rememberEventName(event.name);
  }

  void _rememberEventName(String name) {
    // En modo limpio no se traza nada en UI ni se reconstruye el árbol: el panel
    // de eventos no existe. Los callbacks de negocio (p. ej. snackbar de proyecto
    // creado) se siguen ejecutando porque viven fuera de este método.
    if (!_showDevLogs) return;
    if (!mounted) return;
    final shortName = name.replaceFirst('vivienda:', '');
    final next = '${TimeOfDay.now().format(context)}  $shortName';
    setState(() {
      _seenEvents.add(shortName);
      _events.insert(0, next);
      if (_events.length > 100) {
        _events.removeRange(100, _events.length);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Vivienda IA · Nuevo proyecto')),
      body: SafeArea(
        top: false,
        child: Column(
          children: [
            // Modo debug: cabecera técnica con el perfil resuelto. En modo limpio
            // (showDevLogs=false) no se muestra para dejar la WebView lo más
            // limpia posible.
            if (_showDevLogs)
              _ProfileHeader(
                profile: widget.profile,
                authMode: widget.authMode,
                promoterIdSet: resolvePromoterId(widget.profile).isNotEmpty,
                employeeCodeSet: resolveEmployeeCode().isNotEmpty ||
                    resolveGformCode().isNotEmpty ||
                    resolveCodPromotor().isNotEmpty,
              ),
            Expanded(
              child: _canOpen
                  ? _buildWebView(context)
                  : _MissingTokenView(
                      showDevLogs: _showDevLogs,
                      onForceOpen: _showDevLogs
                          ? () => setState(() => _forceOpenWithoutToken = true)
                          : null,
                    ),
            ),
            if (_lastDownload case final result?)
              Container(
                width: double.infinity,
                padding:
                    const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                color: Colors.indigo.shade50,
                child: Row(children: [
                  Expanded(
                      child: Text('Guardado en Vivienda: ${result.fileName}',
                          style: const TextStyle(fontSize: 12))),
                  TextButton(
                    onPressed: () => _saveDownloadCopy(result),
                    child: const Text('Guardar copia'),
                  ),
                  IconButton(
                    tooltip: 'Ocultar aviso',
                    onPressed: () => setState(() => _lastDownload = null),
                    icon: const Icon(Icons.close, size: 18),
                  ),
                ]),
              ),
            // Modo debug: panel de eventos vivienda:*. Oculto en modo limpio.
            if (_showDevLogs)
              _EventPanel(events: _events, seenEvents: _seenEvents),
          ],
        ),
      ),
    );
  }

  Widget _buildWebView(BuildContext context) {
    final profile = widget.profile;
    final promoterId = resolvePromoterId(profile);
    final employeeCode = resolveEmployeeCode();
    final gformCode = resolveGformCode();
    final codPromotor = resolveCodPromotor();
    return ViviendaWebViewSdk(
      webUrl: profile.webUrl,
      baseUrl: profile.baseUrl,
      // El dashboard "Mis proyectos" no requiere clientDpi: una sesión sin DPI
      // queda acotada solo al promotor (puede ver/editar/archivar TODOS sus
      // proyectos). Si se envía, prellena el flujo de creación. Empty → null.
      clientDpi: profile.clientDpi.isEmpty ? null : profile.clientDpi,
      sourceApp: profile.sourceApp,
      showDevLogs: profile.showDevLogs,
      allowedOrigins: profile.allowedOrigins,
      mode: resolveExampleMode(),
      projectId: _projectIdOverride.isEmpty ? null : _projectIdOverride,
      initialStep: ViviendaInitialStep.client,
      // Promotor dueño de la sesión (obligatorio con X-SDK-Token / Opción B).
      promoterId: promoterId.isEmpty ? null : promoterId,
      employeeCode: employeeCode.isEmpty ? null : employeeCode,
      gformCode: gformCode.isEmpty ? null : gformCode,
      codPromotor: codPromotor.isEmpty ? null : codPromotor,
      // Opción A: token embed listo.
      embedSessionToken: _embedSessionToken.isEmpty ? null : _embedSessionToken,
      // Opción B: pedir la sesión embed con X-SDK-Token si no hay token directo.
      sdkToken: _sdkToken.isEmpty ? null : _sdkToken,
      autoRequestEmbedSession: widget.authMode == ExampleAuthMode.sdkTokenAuto,
      embedSessionTtlSeconds: 900,
      onReady: () {
        _rememberEventName(ViviendaEventNames.bridgeReady);
        if (_showDevLogs) debugPrint('[example] bridge ready');
      },
      onRuntimeApplied: () {
        _rememberEventName(ViviendaEventNames.runtimeApplied);
        if (_showDevLogs) debugPrint('[example] runtime applied');
      },
      onClientLoaded: (e) {
        _rememberEventName(ViviendaEventNames.clientLoaded);
        if (_showDevLogs) debugPrint('[example] client ${e.clientId}');
      },
      onProjectCreated: (e) {
        _rememberEventName(ViviendaEventNames.projectCreated);
        if (_showDevLogs) debugPrint('[example] project ${e.projectId}');
        if (!mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Proyecto creado: ${e.projectId}')),
        );
      },
      onError: (e) {
        _rememberEventName(ViviendaEventNames.error);
        if (_showDevLogs) debugPrint('[example] error ${e.code}: ${e.message}');
      },
      onDownloadRequested: (req) {
        _rememberEventName(ViviendaEventNames.downloadRequested);
        if (_showDevLogs) {
          debugPrint('[example] download requested ${req.fileName}');
        }
        _showSnack('Descargando ${req.fileName ?? 'PDF'}…');
      },
      onDownloadCompleted: (result) {
        if (_showDevLogs) {
          debugPrint('[example] download completed ${result.filePath}');
        }
        _offerSavedDownload(result);
      },
      onDownloadError: (result) {
        if (_showDevLogs) {
          debugPrint(
              '[example] download error ${result.code}: ${result.message}');
        }
        _showSnack(result.message ?? 'No se pudo descargar el PDF.');
      },
      onClosed: _handleClosed,
      onEvent: _rememberEvent,
      // El SDK no incluye plugins nativos: el example inyecta guardado (path_provider)
      // y apertura (open_filex) del PDF.
      pdfSaver: _examplePdfSaver,
      pdfOpener: _examplePdfOpener,
      // Sprint 6C · Error 10 — selector de archivos para subir croquis/plano en
      // Android WebView. Sin esto, tocar "Elegir archivo" no abre nada.
      fileSelector: _exampleFileSelector,
    );
  }

  void _showSnack(String message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text(message)));
  }

  void _offerSavedDownload(ViviendaDownloadResult result) {
    if (!mounted) return;
    setState(() => _lastDownload = result);
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      duration: const Duration(seconds: 15),
      content: Text(result.opened
          ? 'Archivo guardado; apertura enviada al visor: ${result.fileName}'
          : 'Guardado en el almacenamiento temporal de Vivienda. ${result.message ?? ''}'),
      action: result.filePath == null
          ? null
          : SnackBarAction(
              label: 'Guardar copia',
              onPressed: () => _saveDownloadCopy(result),
            ),
    ));
  }

  /// SAF permite al usuario elegir destino sin permisos amplios de almacenamiento.
  Future<void> _saveDownloadCopy(ViviendaDownloadResult result) async {
    final path = result.filePath;
    if (path == null || path.isEmpty) return;
    try {
      final bytes = await File(path).readAsBytes();
      final destination = await FilePicker.platform.saveFile(
        fileName: result.fileName,
        type: FileType.custom,
        allowedExtensions: [path.split('.').last.toLowerCase()],
        bytes: bytes,
      );
      if (destination != null) {
        _showSnack('Copia guardada en la ubicación elegida.');
      }
    } catch (_) {
      _showSnack(
          'No se pudo guardar la copia. El archivo temporal se conserva.');
    }
  }

  /// Cierre real de la pantalla embebida: si hay back stack, hacemos pop; si es la
  /// ruta raíz del APK de prueba, cerramos la Activity con SystemNavigator.pop()
  /// para que el botón X nunca quede "sin acción".
  void _handleClosed() {
    _rememberEventName(ViviendaEventNames.closed);
    final navigator = Navigator.of(context);
    if (navigator.canPop()) {
      navigator.pop();
    } else {
      SystemNavigator.pop();
    }
  }
}

/// Encabezado con la configuración resuelta del perfil.
class _ProfileHeader extends StatelessWidget {
  const _ProfileHeader({
    required this.profile,
    required this.authMode,
    required this.promoterIdSet,
    required this.employeeCodeSet,
  });

  final ViviendaExampleProfile profile;
  final ExampleAuthMode authMode;
  final bool promoterIdSet;
  final bool employeeCodeSet;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Container(
      width: double.infinity,
      color: theme.colorScheme.surfaceContainerHighest,
      padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.tune, size: 16, color: theme.colorScheme.primary),
              const SizedBox(width: 6),
              Text(
                'profile: ${profile.name}',
                style: theme.textTheme.labelLarge
                    ?.copyWith(fontWeight: FontWeight.bold),
              ),
              const Spacer(),
              _Tag(
                text: 'auth: ${authMode.label}',
                highlight: authMode != ExampleAuthMode.none,
              ),
            ],
          ),
          const SizedBox(height: 4),
          _kv('webUrl', profile.webUrl),
          _kv('baseUrl', profile.baseUrl),
          _kv('clientDpi', profile.clientDpi),
          if (authMode == ExampleAuthMode.sdkTokenAuto)
            _kv('promoterId',
                promoterIdSet ? 'set' : 'MISSING (req. SDK token)'),
          if (authMode == ExampleAuthMode.sdkTokenAuto)
            _kv('employeeCode/codPromotor',
                employeeCodeSet ? 'set' : 'not set'),
          _kv('showDevLogs', '${profile.showDevLogs}'),
        ],
      ),
    );
  }

  Widget _kv(String k, String v) {
    return Builder(builder: (context) {
      final style = Theme.of(context).textTheme.bodySmall?.copyWith(
            fontFamily: 'monospace',
          );
      return Padding(
        padding: const EdgeInsets.only(top: 2),
        child: Text('$k: $v',
            style: style, maxLines: 1, overflow: TextOverflow.ellipsis),
      );
    });
  }
}

class _Tag extends StatelessWidget {
  const _Tag({required this.text, required this.highlight});

  final String text;
  final bool highlight;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
      decoration: BoxDecoration(
        color: highlight ? cs.primaryContainer : cs.errorContainer,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text(
        text,
        style: Theme.of(context).textTheme.labelSmall?.copyWith(
              color: highlight ? cs.onPrimaryContainer : cs.onErrorContainer,
              fontFamily: 'monospace',
            ),
      ),
    );
  }
}

/// Vista mostrada cuando no hay token configurado.
class _MissingTokenView extends StatelessWidget {
  const _MissingTokenView({required this.showDevLogs, this.onForceOpen});

  final bool showDevLogs;
  final VoidCallback? onForceOpen;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(Icons.key_off, size: 48, color: Colors.redAccent),
            const SizedBox(height: 16),
            const Text(
              'No se configuró VIVIENDA_SDK_TOKEN ni '
              'VIVIENDA_EMBED_SESSION_TOKEN.',
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            Text(
              'Pásalos por --dart-define al compilar el example.',
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.bodySmall,
            ),
            if (showDevLogs && onForceOpen != null) ...[
              const SizedBox(height: 20),
              OutlinedButton.icon(
                onPressed: onForceOpen,
                icon: const Icon(Icons.bug_report),
                label: const Text('Abrir sin token (debug)'),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class _EventPanel extends StatelessWidget {
  const _EventPanel({required this.events, required this.seenEvents});

  final List<String> events;
  final Set<String> seenEvents;

  static const _trackedEvents = <String>[
    'bridge-ready',
    'runtime-applied',
    'client-loaded',
    'project-created',
    'error',
  ];

  @override
  Widget build(BuildContext context) {
    final visibleEvents = events.isEmpty
        ? const <String>['Esperando eventos vivienda:*']
        : events;

    return SafeArea(
      top: false,
      child: Container(
        width: double.infinity,
        constraints: const BoxConstraints(maxHeight: 170),
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.surface,
          border: Border(
            top: BorderSide(color: Theme.of(context).dividerColor),
          ),
        ),
        padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Eventos recibidos',
              style: Theme.of(context).textTheme.labelLarge,
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: _trackedEvents
                  .map(
                    (name) => _EventChip(
                      name: name,
                      seen: seenEvents.contains(name),
                    ),
                  )
                  .toList(),
            ),
            const SizedBox(height: 6),
            Expanded(
              child: Scrollbar(
                child: ListView.builder(
                  itemCount: visibleEvents.length,
                  itemBuilder: (context, index) => Text(
                    visibleEvents[index],
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                          fontFamily: 'monospace',
                        ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _EventChip extends StatelessWidget {
  const _EventChip({required this.name, required this.seen});

  final String name;
  final bool seen;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: seen ? colorScheme.primaryContainer : colorScheme.surface,
        border: Border.all(
          color: seen ? colorScheme.primary : Theme.of(context).dividerColor,
        ),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text(
        '${seen ? 'ok' : '--'} $name',
        style: Theme.of(context).textTheme.labelSmall?.copyWith(
              color: seen
                  ? colorScheme.onPrimaryContainer
                  : colorScheme.onSurfaceVariant,
              fontFamily: 'monospace',
            ),
      ),
    );
  }
}
0
likes
160
points
430
downloads

Documentation

API reference

Publisher

verified publishermetag.site

Weekly Downloads

SDK Flutter (wrapper de WebView) para Vivienda IA / FGE Cotizador. Abre el flujo embebido, inyecta runtime config por bridge y reenvía los eventos vivienda:* a callbacks Dart.

Repository (GitHub)

License

MIT (license)

Dependencies

flutter, http, webview_flutter, webview_flutter_android

More

Packages that depend on vivienda_webview_sdk