vivienda_webview_sdk 0.2.1
vivienda_webview_sdk: ^0.2.1 copied to clipboard
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',
),
),
);
}
}