knct_lib 0.1.6
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';