lighthouse_north_client 0.2.0
lighthouse_north_client: ^0.2.0 copied to clipboard
SDK Flutter oficial para integrar o app do cliente à plataforma Lighthouse North de disparo push: registro de device token com evidência de consentimento (LGPD), heartbeat e opt-out fácil e gratuito. [...]
example/lib/main.dart
// App mínimo de integração com o SDK (codinome) LighthouseNorth — fatia 3.2.
//
// Demonstra o FLUXO LEGAL completo que todo app cliente deve ter:
// 1. OPT-IN explícito: botão "Ativar push" → permissão do OS → registro com
// evidência de consentimento (a plataforma grava a trilha no contato);
// 2. heartbeat ligado junto do registro (mantém o device vivo/ativo);
// 3. OPT-OUT fácil e gratuito, sempre visível: revoga o consentimento de
// marketing + remove o device — requisito LEGAL BLOQUEANTE do produto.
//
// A APP KEY vem do PAINEL DO TENANT (Apps > Chaves) — NUNCA hardcode no
// fonte. Preencha o campo abaixo com a sua key.
import 'package:lighthouse_north_client/lighthouse_north_client.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'LighthouseNorth SDK — exemplo',
theme: ThemeData(
colorSchemeSeed: const Color(0xFF2563EB), useMaterial3: true),
home: const SdkHomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class SdkHomePage extends StatefulWidget {
const SdkHomePage({super.key});
@override
State<SdkHomePage> createState() => _SdkHomePageState();
}
class _SdkHomePageState extends State<SdkHomePage> {
final TextEditingController _appKey = TextEditingController();
final TextEditingController _apiBaseUrl =
TextEditingController(text: 'http://localhost:3000');
final TextEditingController _contactRef = TextEditingController();
// Desktop/web não têm os handlers nativos deste plugin — o fake é o modo
// dev padrão lá; em android/ios o caminho nativo real é o padrão.
bool _useFakeProvider = !kIsWeb &&
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
LighthouseNorthClient? _client;
final InMemoryLighthouseNorthLogger _logger = InMemoryLighthouseNorthLogger();
bool _busy = false;
@override
void dispose() {
unawaited(_client?.close());
_appKey.dispose();
_apiBaseUrl.dispose();
_contactRef.dispose();
super.dispose();
}
static void unawaited(Future<void>? future) => future?.ignore();
Future<LighthouseNorthClient> _ensureClient() async {
if (_client != null) return _client!;
if (_appKey.text.trim().isEmpty) {
throw const InvalidAppKeyException(
'preencha a app key do painel do tenant (Apps > Chaves)',
);
}
_client = await LighthouseNorthClient.init(
_appKey.text.trim(),
apiBaseUrl: _apiBaseUrl.text.trim(),
// Fake recebe a plataforma REAL do host: a API valida o binding
// app↔plataforma e rejeita registro com plataforma trocada
// (device_platform_not_in_app — provado no simulador iOS 21/10/2026).
tokenProvider:
_useFakeProvider ? FakeTokenProvider(platform: _hostPlatform) : null,
logger: _logger,
);
return _client!;
}
/// Plataforma real do host para o provider fake reportar no registro.
DevicePlatform get _hostPlatform {
if (kIsWeb) return DevicePlatform.web;
return switch (defaultTargetPlatform) {
TargetPlatform.iOS => DevicePlatform.ios,
_ => DevicePlatform.android,
};
}
Future<void> _registerForPush() async {
await _run(() async {
final client = await _ensureClient();
final reg = await client.registerForPush(
contactRef:
_contactRef.text.trim().isEmpty ? null : _contactRef.text.trim(),
policyVersion: '2026-09',
);
await client.startHeartbeat();
_toast(
'Device registrado (${reg.updatedExisting ? 'atualizado' : 'novo'}) '
'· token ${reg.maskedToken}',
);
});
}
Future<void> _optOut() async {
await _run(() async {
final client = await _ensureClient();
final result = await client.optOut();
if (result.fullyOptedOut) {
_toast('Push desativado — consentimento revogado e device removido.');
} else {
// Requisito legal: falha parcial/total NÃO pode parecer sucesso.
_toast(
'Opt-out INCOMPLETO (${result.failures.map((f) => f.step.name).join(', ')}) '
'— toque de novo para re-tentar.',
error: true,
);
}
});
}
Future<void> _run(Future<void> Function() action) async {
if (_busy) return;
setState(() => _busy = true);
try {
await action();
} on PushPermissionDeniedException {
_toast('Permissão de notificação negada — o device NÃO foi registrado.',
error: true);
} on FirebaseNotConfiguredException {
_toast(
'Host sem Firebase — configure o Firebase do app ou ative o provider fake (dev).',
error: true);
} on LighthouseNorthException catch (e) {
_toast('$e', error: true);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _toast(String message, {bool error = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor:
error ? Theme.of(context).colorScheme.errorContainer : null,
duration: const Duration(seconds: 4),
),
);
setState(() {}); // atualiza painel de status/log
}
@override
Widget build(BuildContext context) {
final state = _client?.currentState;
final events = _logger.events.reversed.take(8).toList();
return Scaffold(
appBar: AppBar(title: const Text('LighthouseNorth SDK — exemplo (codinome)')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_section('Configuração (do painel do tenant — nunca hardcode)'),
TextField(
controller: _appKey,
decoration: const InputDecoration(
labelText: 'App key (ak_…)',
hintText: 'Painel do tenant → Apps → Chaves',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _apiBaseUrl,
decoration: const InputDecoration(
labelText: 'API base URL',
helperText: 'http:// só é aceito em localhost (dev/testes)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _contactRef,
decoration: const InputDecoration(
labelText: 'contactRef (external_id do contato — opcional)',
helperText:
'Com contactRef o opt-in cria base legal push (marketing).',
border: OutlineInputBorder(),
),
),
SwitchListTile(
value: _useFakeProvider,
onChanged: (v) => setState(() => _useFakeProvider = v),
title: const Text('Provider fake de token (dev sem Firebase)'),
subtitle: const Text(
'Substitui OS/Firebase por FakeTokenProvider — o resto do fluxo é real.',
),
),
const Divider(height: 32),
_section('Fluxo legal'),
FilledButton.icon(
onPressed: _busy ? null : _registerForPush,
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Ativar push (opt-in + consentimento)'),
),
const SizedBox(height: 8),
// OPT-OUT: sempre visível, fácil e gratuito — requisito legal.
FilledButton.tonalIcon(
onPressed: _busy ? null : _optOut,
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
icon: const Icon(Icons.notifications_off_outlined),
label:
const Text('Desativar push (opt-out — revoga consentimento)'),
),
const Divider(height: 32),
_section('Estado do SDK'),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_kv('Inicializado', _client != null ? 'sim' : 'não'),
_kv('Registrado',
_client?.isRegistered == true ? 'sim' : 'não'),
_kv('Heartbeat',
_client?.isHeartbeatRunning == true ? 'ativo' : 'parado'),
_kv('Token (mascarado)',
state?.token == null ? '—' : redactSecret(state!.token!)),
_kv('contactRef', state?.contactRef ?? '—'),
],
),
),
),
const SizedBox(height: 16),
_section('Eventos do SDK (log estruturado)'),
if (events.isEmpty)
const Text('Nenhum evento ainda — faça o opt-in.')
else
...events.map(
(e) => Text(
'${e.$1.name.padRight(7)} ${e.$2} ${e.$3.isEmpty ? '' : e.$3}',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
const SizedBox(height: 24),
Text(
'Nota: nome de publicação do SDK a definir (codinome). '
'O SDK coleta apenas platform/token/contactRef/consent (minimização LGPD) '
'e não renderiza conteúdo de push (plain-text por construção).',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
);
Widget _kv(String k, String v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(k,
style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(child: Text(v)),
],
),
);
}