Skip to main content

knct_lib 0.1.6 copy "knct_lib: ^0.1.6" to clipboard
knct_lib: ^0.1.6 copied to clipboard

SDK da KNCT para Flutter. Detecção de presença por Bluetooth de baixa energia, com operação em segundo plano. Este é o pacote a adicionar no aplicativo.

example/lib/main.dart

/// App de exemplo do plugin Flutter knct_lib (BND-01).
///
/// Espelha a ferramenta de debug nativa (`android/apps/knct-tool-android`): as mesmas três
/// abas — Devices, Telemetria, Logs — lendo do mesmo SDK.
///
/// O que um integrador precisa entender antes de desenhar qualquer coisa: **detecção não
/// acontece em Dart**. Quando o SO acorda o app em background não existe isolate rodando.
/// Por isso "ao vivo" e "fila durável" ficam lado a lado na aba Logs — divergência entre as
/// duas não é bug, é a plataforma.
library;

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:knct_lib/knct_lib.dart';
import 'package:permission_handler/permission_handler.dart';

import 'sdk/bootstrap.dart';

/// Mesmas cores por tipo das ferramentas nativas (Palette.kt / Palette.swift).
const _kindColors = <KnctDeviceKind, Color>{
  KnctDeviceKind.anchor: Color(0xFF3B9EFF),
  KnctDeviceKind.peerAndroid: Color(0xFF2EE6A8),
  KnctDeviceKind.peerIos: Color(0xFFB07CFF),
  KnctDeviceKind.peerIosBg: Color(0xFFB07CFF),
  KnctDeviceKind.knctUuidOnly: Color(0xFFE8A317),
  KnctDeviceKind.other: Color(0xFF5D6373),
};

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // O SDK sobe AQUI, antes da primeira tela — o análogo do `Application.onCreate`.
  await bootstrapKnct();

  runApp(const KnctExampleApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'knct lib · Flutter',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2F6FD0)),
          useMaterial3: true,
        ),
        darkTheme: ThemeData(
          colorScheme: ColorScheme.fromSeed(
            seedColor: const Color(0xFF2F6FD0),
            brightness: Brightness.dark,
          ),
          useMaterial3: true,
        ),
        home: const HomePage(),
      );
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with WidgetsBindingObserver {

  int _aba = 0;
  KnctRuntimeStatus? _status;
  KnctNearbyScan? _scan;
  KnctCapabilities? _caps;
  List<String> _logs = const [];
  List<KnctDetection> _fila = const [];
  final List<KnctDetection> _live = [];
  String _installId = '—';
  Map<Permission, bool> _perms = const {};

  StreamSubscription<KnctDetection>? _subscription;
  Timer? _poll;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    // Só entrega com o engine vivo; o passado está em pendingEvents().
    _subscription = KnctLib.detections.listen((d) {
      setState(() => _live.insert(0, d));
      if (_live.length > 20) _live.removeLast();
    });

    KnctLib.installId().then((v) => setState(() => _installId = v));

    /*
     * A aba Devices nasce selecionada, e `_trocaAba` só dispara quando alguém TOCA numa
     * aba — então o scan nunca subia no primeiro launch e o chip ficava "parado" com a
     * lista aberta na frente. Ligar aqui é o que casa o estado inicial com o que a tela
     * está mostrando.
     */
    KnctLib.startNearbyScan();

    _refresh();
    _poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _subscription?.cancel();
    _poll?.cancel();
    // Se a aba Devices ficou aberta, o scan de diagnóstico precisa cair junto.
    KnctLib.stopNearbyScan();
    super.dispose();
  }

  /*
   * `dispose` não cobre o caso que importa: ir para segundo plano não destrói o State, e o
   * scan de diagnóstico continuava ligado com o app na bandeja. Medido no Xiaomi
   * (2026-08-25): ele se recuperava a cada ~20s e tomava `MIUI PERMISSION DENIED` em toda
   * tentativa — bateria gasta num scan que o SO já disse que não vai entregar.
   *
   * A presença não depende disto. Quem detecta em background é o scan por PendingIntent, que
   * segue armado sem tela nenhuma.
   */
  @override
  void didChangeAppLifecycleState(AppLifecycleState estado) {
    super.didChangeAppLifecycleState(estado);
    if (estado == AppLifecycleState.resumed) {
      if (_aba == 0) KnctLib.startNearbyScan();
    } else {
      KnctLib.stopNearbyScan();
    }
  }

  Future<void> _refresh() async {
    final status = await KnctLib.runtimeStatus();
    final scan = await KnctLib.nearbyScan();
    final caps = await KnctLib.capabilities();
    // Ler não consome a fila: quem remove é o upload confirmado pelo backend.
    final fila = await KnctLib.pendingEvents();
    final logs = await KnctLib.recentLogs();
    final perms = await _lerPermissoes();

    if (!mounted) return;
    setState(() {
      _status = status;
      _scan = scan;
      _caps = caps;
      _fila = fila;
      _logs = logs;
      _perms = perms;
    });
  }

  /// Dart não pede permissão sozinho, e sem localização o scan BLE do Android retorna
  /// **zero resultado, sem erro nenhum**. É a causa número um de "o SDK não acha nada".
  ///
  /// `Permission.locationAlways` não está aqui: o núcleo do SDK deixou de declarar
  /// ACCESS_BACKGROUND_LOCATION, e presença em background passou a vir de BLUETOOTH_SCAN com
  /// `neverForLocation` (API 31+). Consultar uma permissão fora do manifesto devolve sempre
  /// negado, e a tela apontaria para a causa errada.
  Future<Map<Permission, bool>> _lerPermissoes() async {
    final alvos = [
      Permission.locationWhenInUse,
      Permission.bluetoothScan,
      Permission.bluetoothAdvertise,
      Permission.bluetoothConnect,
      Permission.notification,
    ];
    final out = <Permission, bool>{};
    for (final p in alvos) {
      out[p] = await p.isGranted;
    }
    return out;
  }

  Future<void> _pedirPermissoes() async {
    await [
      Permission.locationWhenInUse,
      Permission.bluetoothScan,
      Permission.bluetoothAdvertise,
      Permission.bluetoothConnect,
      Permission.notification,
    ].request();
    await _refresh();
  }

  /*
   * O scan de diagnóstico vive com a aba Devices, não com o app.
   *
   * Ele mantém scan de baixa latência e conexões GATT — não é o caminho de presença, é
   * bancada, e custa bateria. Deixar ligado o tempo todo seria ensinar um anti-padrão a
   * quem copiar este exemplo.
   */
  void _trocaAba(int i) {
    if (i == 0) {
      KnctLib.startNearbyScan();
    } else if (_aba == 0) {
      KnctLib.stopNearbyScan();
    }
    setState(() => _aba = i);
  }

  @override
  Widget build(BuildContext context) {
    final rodando = _status?.started == true && _status?.sdkEnabled == true;

    return Scaffold(
      appBar: AppBar(
        title: const Text('knct lib · Flutter'),
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 16),
            child: Center(child: _StatusDot(ativo: rodando, label: rodando ? 'rodando' : 'parado')),
          ),
        ],
      ),
      body: switch (_aba) {
        0 => _DevicesTab(scan: _scan),
        1 => _TelemetriaTab(status: _status, caps: _caps, perms: _perms, installId: _installId, onPedir: _pedirPermissoes),
        _ => _LogsTab(logs: _logs, live: _live, fila: _fila, onLimpar: () async {
              await KnctLib.clearLogs();
              await _refresh();
            }),
      },
      bottomNavigationBar: _FloatingTabBar(selecionada: _aba, onSelect: _trocaAba),
    );
  }
}

/// Ponto pulsante + rótulo, no formato das ferramentas nativas.
class _StatusDot extends StatelessWidget {
  const _StatusDot({required this.ativo, required this.label, this.cor});

  final bool ativo;
  final String label;
  final Color? cor;

  @override
  Widget build(BuildContext context) {
    final tema = Theme.of(context);
    final c = ativo ? (cor ?? const Color(0xFF2EE6A8)) : tema.colorScheme.onSurfaceVariant;
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(width: 9, height: 9, decoration: BoxDecoration(color: c, shape: BoxShape.circle)),
        const SizedBox(width: 6),
        Text(label, style: TextStyle(fontSize: 13, color: tema.colorScheme.onSurfaceVariant)),
      ],
    );
  }
}

/// Barra de abas flutuante, no formato do iOS 26.
///
/// `NavigationBar` do Material cola na borda e ocupa a largura toda; a cápsula destacada é
/// o que as quatro telas passaram a usar. Com todas mostrando a mesma informação, navegação
/// que parece diferente faz quem compara achar que o comportamento também é.
class _FloatingTabBar extends StatelessWidget {
  const _FloatingTabBar({required this.selecionada, required this.onSelect});

  final int selecionada;
  final ValueChanged<int> onSelect;

  /// Ícones escolhidos para casar com os SF Symbols da ferramenta iOS.
  static const _itens = [
    (Icons.sensors, 'Devices'),
    (Icons.info_outline, 'Telemetria'),
    (Icons.description_outlined, 'Logs'),
  ];

  @override
  Widget build(BuildContext context) {
    final tema = Theme.of(context);
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(24, 10, 24, 10),
        // `Row` centralizado, e não `Center`: dentro de `bottomNavigationBar` o `Center`
        // expande para a altura disponível e a cápsula sobe para o meio da tela, comendo o
        // conteúdo. O `Row` se ajusta ao filho.
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
            padding: const EdgeInsets.all(6),
            decoration: BoxDecoration(
              color: tema.colorScheme.surfaceContainerHigh,
              borderRadius: BorderRadius.circular(28),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                for (var i = 0; i < _itens.length; i++)
                  GestureDetector(
                    onTap: () => onSelect(i),
                    child: Container(
                      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
                      decoration: BoxDecoration(
                        color: i == selecionada
                            ? tema.colorScheme.primary.withValues(alpha: 0.14)
                            : Colors.transparent,
                        borderRadius: BorderRadius.circular(22),
                      ),
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(
                            _itens[i].$1,
                            size: 22,
                            color: i == selecionada
                                ? tema.colorScheme.primary
                                : tema.colorScheme.onSurfaceVariant,
                          ),
                          const SizedBox(height: 2),
                          Text(
                            _itens[i].$2,
                            style: TextStyle(
                              fontSize: 11,
                              fontWeight:
                                  i == selecionada ? FontWeight.w700 : FontWeight.w500,
                              color: i == selecionada
                                  ? tema.colorScheme.primary
                                  : tema.colorScheme.onSurfaceVariant,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
              ],
            ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Devices: a lista viva do que o rádio ouve.
///
/// É scan de DIAGNÓSTICO, não o caminho de presença — presença é decidida em background,
/// sem engine de Dart vivo. Por isso a lista só enche com a aba aberta.
class _DevicesTab extends StatelessWidget {
  const _DevicesTab({required this.scan});

  final KnctNearbyScan? scan;

  @override
  Widget build(BuildContext context) {
    final s = scan;
    final knct = s?.devices.length ?? 0;

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Row(
          children: [
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('$knct devices Knct',
                      style: Theme.of(context).textTheme.headlineSmall),
                  Text(
                    (s?.resolving ?? 0) > 0
                        ? '${s?.totalSeen ?? 0} no ar · ${s?.resolving} resolvendo por GATT'
                        : '${s?.totalSeen ?? 0} no ar no total',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ],
              ),
            ),
            _StatusDot(
              ativo: s?.scanning == true,
              cor: const Color(0xFF3B82F6),
              label: s?.scanning == true ? 'escaneando' : 'parado',
            ),
          ],
        ),
        const SizedBox(height: 12),
        if (s?.status != null)
          Card(
            child: ListTile(
              title: const Text('Scan parado'),
              subtitle: Text(s!.status!),
            ),
          ),
        if (s != null && s.devices.isEmpty && s.status == null)
          Card(
            child: ListTile(
              title: const Text('Nada no ar ainda'),
              subtitle: Text(
                s.silenceMs > 8000
                    ? 'Scan de pé há ${(s.silenceMs / 1000).round()}s e o rádio não entregou '
                        'NENHUM advert. Não é ar vazio: verifique a Localização do sistema '
                        'e o autostart do app.'
                    : 'Aproxime uma âncora ou outro aparelho com o SDK.',
              ),
            ),
          ),
        for (final d in s?.devices ?? const <KnctNearbyDevice>[]) _DeviceCard(d: d),
      ],
    );
  }
}

/// Card no mesmo formato das ferramentas nativas: faixa de acento por tipo, três linhas de
/// texto e o medidor de sinal à direita.
class _DeviceCard extends StatelessWidget {
  const _DeviceCard({required this.d});

  final KnctNearbyDevice d;

  @override
  Widget build(BuildContext context) {
    final accent = _kindColors[d.kind] ?? const Color(0xFF5D6373);
    final tema = Theme.of(context);

    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Row(
          children: [
            Container(
              width: 4,
              height: 38,
              decoration: BoxDecoration(
                color: accent,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Âncora cadastrada mostra o nome da loja; o id desce para o detalhe.
                  Text(
                    d.establishment ?? d.identity ?? d.key,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      fontSize: 15,
                      fontWeight: FontWeight.w600,
                      fontFamily: d.establishment != null ? null : 'monospace',
                      color: tema.colorScheme.onSurface,
                    ),
                  ),
                  Text(
                    d.establishment != null && d.identity != null
                        ? '${_rotulo(d.kind)}  ·  ${d.identity}'
                        : _rotulo(d.kind),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w500,
                      color: accent,
                    ),
                  ),
                  Text(
                    d.address,
                    maxLines: 1,
                    style: TextStyle(
                      fontSize: 11,
                      fontFamily: 'monospace',
                      color: tema.colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 12),
            _RssiMeter(rssi: d.rssi),
          ],
        ),
      ),
    );
  }
}

/// Barra de sinal + dBm. Verde perto, âmbar médio, vermelho longe — igual às ferramentas.
class _RssiMeter extends StatelessWidget {
  const _RssiMeter({required this.rssi});

  final int rssi;

  @override
  Widget build(BuildContext context) {
    final frac = ((rssi + 100).clamp(0, 70)) / 70;
    final cor = rssi >= -60
        ? const Color(0xFF2EE6A8)
        : rssi >= -80
            ? const Color(0xFFE8A317)
            : const Color(0xFFD0402F);

    return SizedBox(
      width: 64,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          Text('$rssi',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: cor)),
          Text('dBm',
              style: TextStyle(
                  fontSize: 10, color: Theme.of(context).colorScheme.onSurfaceVariant)),
          const SizedBox(height: 4),
          // `FractionallySizedBox` em vez de dois `Expanded`: com `flex` arredondando para
          // 0, o `Expanded` colapsa e a barra some — foi o que aconteceu no aparelho.
          Container(
            width: 56,
            height: 5,
            decoration: BoxDecoration(
              color: Theme.of(context).colorScheme.surfaceContainerHighest,
              borderRadius: BorderRadius.circular(3),
            ),
            child: FractionallySizedBox(
              alignment: Alignment.centerLeft,
              widthFactor: frac.clamp(0.02, 1.0),
              child: DecoratedBox(
                decoration: BoxDecoration(
                  color: cor,
                  borderRadius: BorderRadius.circular(3),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// Mesmos rótulos da ferramenta nativa — a classificação vem do SDK, não daqui.
String _rotulo(KnctDeviceKind kind) => switch (kind) {
      KnctDeviceKind.anchor => 'âncora',
      KnctDeviceKind.peerAndroid => 'android',
      KnctDeviceKind.peerIos => 'iOS',
      KnctDeviceKind.peerIosBg => 'iOS em background',
      KnctDeviceKind.knctUuidOnly => 'Knct (sem payload)',
      KnctDeviceKind.other => '—',
    };

/// Telemetria: flags e config, lidos do nativo. Nada aqui é decidido em Dart.
class _TelemetriaTab extends StatelessWidget {
  const _TelemetriaTab({
    required this.status,
    required this.caps,
    required this.perms,
    required this.installId,
    required this.onPedir,
  });

  final KnctRuntimeStatus? status;
  final KnctCapabilities? caps;
  final Map<Permission, bool> perms;
  final String installId;
  final VoidCallback onPedir;

  @override
  Widget build(BuildContext context) {
    final s = status;
    if (s == null) {
      return const Center(child: Text('Lendo o estado do nativo…'));
    }

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Cartao('Runtime', 'estado real do SDK, lido do nativo', [
          ('modo', s.mode),
          ('iniciado', _sn(s.started)),
          ('SDK habilitado', _sn(s.sdkEnabled)),
          ('foreground service', _sn(s.fgsActive)),
          ('fila de presença', s.queueCount < 0 ? 'ilegível' : '${s.queueCount}'),
          ('flags v', '${s.flagsVersion}'),
          ('zonas mortas', '${s.zonesCount}'),
          ('em zona morta', s.emZonaMorta ? 'sim — rádio suspenso' : 'não'),
        ]),
        _Cartao('Identidade', null, [
          ('SDK', s.sdkVersion),
          ('installId', installId),
          ('servidor', s.configBaseUrl ?? '—'),
        ]),
        _Cartao('Flags em vigor', '${s.flags.length} chave(s) · publicadas pelo servidor',
            [for (final f in s.flags) (f.key, f.value)]),
        Card(
          child: Column(
            children: [
              const ListTile(
                title: Text('Permissões'),
                subtitle: Text('o que segura o app vivo em background'),
              ),
              for (final e in perms.entries)
                ListTile(
                  dense: true,
                  title: Text(e.key.toString().split('.').last),
                  trailing: Text(e.value ? 'concedida' : 'faltando'),
                ),
              Padding(
                padding: const EdgeInsets.all(8),
                child: Row(children: [
                  FilledButton.tonal(onPressed: onPedir, child: const Text('Pedir permissões')),
                  const SizedBox(width: 8),
                  OutlinedButton(onPressed: openAppSettings, child: const Text('Ajustes')),
                ]),
              ),
            ],
          ),
        ),
        if (caps != null)
          _Cartao('Capacidades desta plataforma', 'o que o binding declara não fazer', [
            ('isenção de bateria', _sn(caps!.batteryOptimizationExemption)),
            ('overlay', _sn(caps!.overlayPermission)),
            ('foreground service', _sn(caps!.foregroundService)),
            ('gatilho por lugar', _sn(caps!.placeTrigger)),
          ]),
      ],
    );
  }
}

/// Logs: o ring buffer do SDK, mais a fila e as detecções ao vivo.
///
/// `recentLogs()` vem do buffer nativo, não do logcat — ler logcat exige `READ_LOGS`, que é
/// permissão de sistema.
class _LogsTab extends StatelessWidget {
  const _LogsTab({
    required this.logs,
    required this.live,
    required this.fila,
    required this.onLimpar,
  });

  final List<String> logs;
  final List<KnctDetection> live;
  final List<KnctDetection> fila;
  final VoidCallback onLimpar;

  @override
  Widget build(BuildContext context) {
    final recentes = logs.reversed.toList();

    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _Cartao('Fila durável', '${fila.length} pendente(s) · nativa, sobrevive ao processo',
            [for (final d in fila) ('${d.establishmentId} · ${d.transition.name}', '${d.rssi} dBm')]),
        _Cartao('Detecções ao vivo', '${live.length} nesta sessão · só com o engine vivo',
            [for (final d in live) ('${d.establishmentId} · ${d.transition.name}', '${d.rssi} dBm')]),
        Card(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              ListTile(
                title: const Text('Logs'),
                subtitle: Text('${logs.length} linha(s) · buffer interno do SDK'),
                trailing: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    // Copiar é o que faz o log sair do aparelho. A alternativa é
                    // `adb logcat`, que exige cabo e máquina com SDK — inútil quando quem
                    // está com o telefone na loja é outra pessoa. Vai em ordem cronológica,
                    // que é a ordem em que se lê um incidente.
                    OutlinedButton(
                      onPressed: logs.isEmpty
                          ? null
                          : () => Clipboard.setData(
                                ClipboardData(text: logs.join('\n')),
                              ),
                      child: const Text('Copiar'),
                    ),
                    const SizedBox(width: 8),
                    OutlinedButton(onPressed: onLimpar, child: const Text('Limpar')),
                  ],
                ),
              ),
              for (final linha in recentes)
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
                  child: Text(linha,
                      style: const TextStyle(fontFamily: 'monospace', fontSize: 11)),
                ),
            ],
          ),
        ),
      ],
    );
  }
}

class _Cartao extends StatelessWidget {
  const _Cartao(this.titulo, this.subtitulo, this.linhas);

  final String titulo;
  final String? subtitulo;
  final List<(String, String)> linhas;

  @override
  Widget build(BuildContext context) => Card(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ListTile(
              title: Text(titulo),
              subtitle: subtitulo == null ? null : Text(subtitulo!),
            ),
            if (linhas.isEmpty)
              const Padding(
                padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
                child: Text('—'),
              ),
            for (final (k, v) in linhas)
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
                child: Row(
                  children: [
                    // O peso vai no VALOR, não na chave: com `Expanded` na chave, um valor
                    // longo (a URL do servidor) a espremia até quebrar no meio da palavra.
                    Text(k, style: Theme.of(context).textTheme.bodySmall, maxLines: 1),
                    const SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        v,
                        textAlign: TextAlign.end,
                        style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
                      ),
                    ),
                  ],
                ),
              ),
            const SizedBox(height: 8),
          ],
        ),
      );
}

String _sn(bool v) => v ? 'sim' : 'não';
0
likes
0
points
140
downloads

Publisher

unverified uploader

Weekly Downloads

SDK da KNCT para Flutter. Detecção de presença por Bluetooth de baixa energia, com operação em segundo plano. Este é o pacote a adicionar no aplicativo.

Homepage
Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, knct_lib_android, knct_lib_ios, knct_lib_platform_interface

More

Packages that depend on knct_lib

Packages that implement knct_lib