bearound_flutter_sdk 3.6.2 copy "bearound_flutter_sdk: ^3.6.2" to clipboard
bearound_flutter_sdk: ^3.6.2 copied to clipboard

Bearound Flutter SDK - Beacon detection and proximity tracking

example/lib/main.dart

import 'dart:async';
import 'dart:io' show Platform;

import 'package:bearound_flutter_sdk/bearound_flutter_sdk.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

import 'events.dart';
import 'eye_card.dart';
import 'log_modal.dart';
import 'settings_page.dart';
import 'two_eyes_modal.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bearound SDK Example',
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: const Color(0xFF0B0B0F),
        cardColor: const Color(0xFF141414),
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF111111),
          foregroundColor: Colors.white,
        ),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF1976D2),
          surface: Color(0xFF141414),
        ),
      ),
      home: const BeaconHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  State<BeaconHomePage> createState() => _BeaconHomePageState();
}

class _BeaconHomePageState extends State<BeaconHomePage>
    with WidgetsBindingObserver {
  // ---- SDK config ----
  static const _businessToken = 'ee2ec9c46d2b2ad99bddcdd0afe224e6';

  // ---- Permission state ----
  // NOTE: push is app-level now — the SDK no longer manages notification
  // permission or posting, so the example doesn't request it either.
  // Real, separately-read statuses. On Android 12+ the BLE-scan gate is
  // BLUETOOTH_SCAN ("Nearby devices"), NOT location — so we surface both
  // instead of collapsing them into one misleading "Localização" row.
  bool _locationGranted = false;
  bool _bluetoothScanGranted = false;
  bool _notificationsGranted = false;
  BluetoothState _bluetoothState = BluetoothState.unknown;

  // ---- Scanning state ----
  bool _isScanning = false;
  bool _useForegroundService =
      true; // Android: FGS (true) vs oportunista (false)
  String _status = 'Pronto';
  ScanPrecision _scanPrecision = ScanPrecision.high;
  MaxQueuedPayloads _maxQueuedPayloads = MaxQueuedPayloads.medium;
  String _sdkVersion = '';

  // ---- Beacons / errors ----
  List<Beacon> _detectedBeacons = [];
  DateTime? _lastScanTime;
  String? _lastError;

  // ---- Sync info ----
  DateTime? _lastSyncTime;
  int _lastSyncCount = 0;
  Duration? _lastSyncDuration;
  bool? _lastSyncSuccess;
  DateTime? _syncStartedAt;

  // ---- Geofence debug ----
  bool _isInBeaconRegion = false;
  DateTime? _lastEnteredRegionAt;
  DateTime? _lastExitedRegionAt;
  bool _isActiveScanRunning = false;
  final List<GeofenceEventEntry> _geofenceEvents = [];

  // ---- Two eyes state ----
  int _locationEnterCount = 0;
  bool _isInBluetoothZone = false;
  DateTime? _lastBtEnterAt;
  DateTime? _lastBtExitAt;
  int _btZoneEnterCount = 0;
  BluetoothScanMode _btScanMode = BluetoothScanMode.idle;
  DateTime? _btNextIdleScanAt;
  final Set<String> _locationKeys = {};
  final Set<String> _bluetoothKeys = {};

  // ---- Tick for "X seg atrás" UI ----
  Timer? _tickTimer;
  DateTime _nowTick = DateTime.now();

  // ---- Stream subscriptions ----
  StreamSubscription<List<Beacon>>? _beaconsSub;
  StreamSubscription<bool>? _scanningSub;
  StreamSubscription<BearoundError>? _errorSub;
  StreamSubscription<SyncLifecycleEvent>? _syncSub;
  StreamSubscription<BackgroundDetectionEvent>? _bgDetectionSub;
  StreamSubscription<BeaconRegionEvent>? _regionSub;
  StreamSubscription<ActiveScanEvent>? _activeScanSub;
  StreamSubscription<BluetoothZoneEvent>? _btZoneSub;
  StreamSubscription<BluetoothScanModeEvent>? _btScanModeSub;
  StreamSubscription<BluetoothState>? _btStateSub;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _startListening();
    _bootstrap();
    _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) setState(() => _nowTick = DateTime.now());
    });
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _tickTimer?.cancel();
    _beaconsSub?.cancel();
    _scanningSub?.cancel();
    _errorSub?.cancel();
    _syncSub?.cancel();
    _bgDetectionSub?.cancel();
    _regionSub?.cancel();
    _activeScanSub?.cancel();
    _btZoneSub?.cancel();
    _btScanModeSub?.cancel();
    _btStateSub?.cancel();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    super.didChangeAppLifecycleState(state);
    if (state == AppLifecycleState.resumed) {
      _refreshState();
    }
  }

  /// Whether the SDK can actually run a scan, mirroring its native gate:
  /// Android 12+ needs BLUETOOTH_SCAN granted + the radio on; iOS needs
  /// location + the radio on. (Android <12 uses location, but that path is
  /// legacy — the example targets the 12+ reality.)
  bool get _canScan {
    if (Platform.isAndroid) {
      return _bluetoothScanGranted &&
          _bluetoothState == BluetoothState.poweredOn;
    }
    return _locationGranted && _bluetoothState == BluetoothState.poweredOn;
  }

  // ---------------------------------------------------------------------------
  // Boot
  // ---------------------------------------------------------------------------

  /// Reads the REAL status of each permission independently. Crucially it does
  /// NOT use the return of `requestPermissions()` for the location row — on
  /// Android 12+ that call returns the BLUETOOTH_SCAN gate, not location, which
  /// made the UI show "Localização: Negada" while location was actually granted.
  Future<void> _refreshPermissions() async {
    final location = await Permission.location.status;
    // BLUETOOTH_SCAN is the Android 12+ scan gate; on iOS it is not the model,
    // so fall back to the location status there.
    final scan = Platform.isAndroid
        ? await Permission.bluetoothScan.status
        : location;
    final notif = await Permission.notification.status;
    if (!mounted) return;
    setState(() {
      _locationGranted = location.isGranted;
      _bluetoothScanGranted = scan.isGranted;
      _notificationsGranted = notif.isGranted;
    });
  }

  Future<void> _bootstrap() async {
    // Solicita permissões (Location + BT). Não bloqueia — o SDK nativo nunca
    // gateia scan; qualquer olho disponível ativa.
    await BearoundFlutterSdk.requestPermissions();

    // Lê o status REAL de cada permissão (location, nearby-devices, notificação)
    // — sem confundir o gate de scan (BLUETOOTH_SCAN no 12+) com location.
    await _refreshPermissions();

    // Bluetooth state inicial.
    final bt = await BearoundFlutterSdk.getBluetoothState();
    setState(() => _bluetoothState = bt);

    // SDK version (iOS retorna, Android retorna '').
    final version = await BearoundFlutterSdk.getSdkVersion();
    setState(() => _sdkVersion = version);

    await _applyConfig();
    await _startScan();
    await _refreshState();
  }

  Future<void> _applyConfig() async {
    try {
      await BearoundFlutterSdk.configure(
        businessToken: _businessToken,
        scanPrecision: _scanPrecision,
        maxQueuedPayloads: _maxQueuedPayloads,
      );
    } catch (e) {
      setState(() => _lastError = e.toString());
    }
  }

  Future<void> _refreshState() async {
    final running = await BearoundFlutterSdk.isScanning();
    if (!mounted) return;
    setState(() {
      _isScanning = running;
      _status = running ? 'Scaneando…' : 'Parado';
    });
    // Re-read permissions on resume: the user may have changed them in Settings
    // (e.g. granted "Nearby devices" or Location) while the app was backgrounded.
    await _refreshPermissions();
  }

  // ---------------------------------------------------------------------------
  // Listeners
  // ---------------------------------------------------------------------------

  void _startListening() {
    _beaconsSub = BearoundFlutterSdk.beaconsStream.listen(_onBeacons);
    _scanningSub = BearoundFlutterSdk.scanningStream.listen(_onScanning);
    _errorSub = BearoundFlutterSdk.errorStream.listen(_onError);
    _syncSub = BearoundFlutterSdk.syncLifecycleStream.listen(_onSync);
    _bgDetectionSub = BearoundFlutterSdk.backgroundDetectionStream.listen(
      _onBackground,
    );
    _regionSub = BearoundFlutterSdk.beaconRegionStream.listen(_onRegion);
    _activeScanSub = BearoundFlutterSdk.activeScanStream.listen(_onActiveScan);
    _btZoneSub = BearoundFlutterSdk.bluetoothZoneStream.listen(_onBtZone);
    _btScanModeSub = BearoundFlutterSdk.bluetoothScanModeStream.listen(
      _onBtScanMode,
    );
    _btStateSub = BearoundFlutterSdk.bluetoothStateStream.listen((state) {
      if (mounted) setState(() => _bluetoothState = state);
    });
  }

  void _onBeacons(List<Beacon> beacons) {
    if (!mounted) return;
    setState(() {
      _detectedBeacons = beacons;
      _lastScanTime = DateTime.now();
      if (beacons.isNotEmpty) _status = '${beacons.length} beacon(s)';

      // Acumula keys por olho (iOS discoverySources).
      for (final b in beacons) {
        final key = '${b.major}.${b.minor}';
        if (b.discoverySources.contains(BeaconDiscoverySource.coreLocation)) {
          _locationKeys.add(key);
        }
        if (b.discoverySources.contains(BeaconDiscoverySource.serviceUUID) ||
            b.discoverySources.contains(BeaconDiscoverySource.name)) {
          _bluetoothKeys.add(key);
        }
      }
    });
  }

  void _onScanning(bool running) {
    if (!mounted) return;
    setState(() {
      _isScanning = running;
      _status = running ? 'Scaneando…' : 'Parado';
      if (!running) _detectedBeacons = [];
    });
  }

  void _onError(BearoundError error) {
    if (!mounted) return;
    setState(() {
      _lastError = error.message;
      _status = 'Erro';
    });
  }

  void _onSync(SyncLifecycleEvent event) {
    if (!mounted) return;
    if (event.isStarted) {
      setState(() {
        _syncStartedAt = DateTime.now();
        _lastSyncCount = event.beaconCount;
      });
    } else if (event.isCompleted) {
      final startedAt = _syncStartedAt;
      final duration = startedAt != null
          ? DateTime.now().difference(startedAt)
          : Duration.zero;
      setState(() {
        _lastSyncTime = DateTime.now();
        _lastSyncCount = event.beaconCount;
        _lastSyncDuration = duration;
        _lastSyncSuccess = event.success;
        _syncStartedAt = null;
        if (event.success == true) {
          _lastError = null;
        } else if (event.error != null) {
          _lastError = event.error;
        }
      });
    }
  }

  void _onBackground(BackgroundDetectionEvent event) {
    // NOTE: push is app-level now — the SDK no longer posts notifications.
    // The host app can react to this event and post its own notification.
    if (!mounted) return;
    setState(() {
      _status = '${event.beaconCount} beacon(s) em background';
    });
  }

  void _onRegion(BeaconRegionEvent event) {
    if (!mounted) return;
    setState(() {
      _isInBeaconRegion = event.isEnter;
      if (event.isEnter) {
        _lastEnteredRegionAt = DateTime.now();
        _locationEnterCount += 1;
      } else {
        _lastExitedRegionAt = DateTime.now();
      }
    });
    _pushGeofenceEvent(
      event.isEnter
          ? GeofenceEventKind.regionEnter
          : GeofenceEventKind.regionExit,
      event.isEnter
          ? 'iOS/Android reportou entrada na zona do beacon'
          : 'Saiu da zona do beacon',
    );
  }

  void _onActiveScan(ActiveScanEvent event) {
    if (!mounted) return;
    setState(() => _isActiveScanRunning = event.isActive);
    _pushGeofenceEvent(
      event.isActive
          ? GeofenceEventKind.scanActive
          : GeofenceEventKind.scanPaused,
      event.isActive
          ? 'Scan ativo (ranging + BLE) LIGADO'
          : 'Scan ativo DESLIGADO — só region monitoring',
    );
  }

  void _onBtZone(BluetoothZoneEvent event) {
    if (!mounted) return;
    setState(() {
      _isInBluetoothZone = event.isEnter;
      if (event.isEnter) {
        _lastBtEnterAt = DateTime.now();
        _btZoneEnterCount += 1;
      } else {
        _lastBtExitAt = DateTime.now();
      }
    });
    _pushGeofenceEvent(
      event.isEnter
          ? GeofenceEventKind.btZoneEnter
          : GeofenceEventKind.btZoneExit,
      event.isEnter
          ? 'BLE detectou beacon (CBCentralManager)'
          : 'Zona BLE vazia (graça expirou)',
    );
  }

  void _onBtScanMode(BluetoothScanModeEvent event) {
    if (!mounted) return;
    setState(() {
      _btScanMode = event.mode;
      _btNextIdleScanAt = event.nextIdleScanAt != null
          ? DateTime.fromMillisecondsSinceEpoch(event.nextIdleScanAt!)
          : null;
    });
  }

  void _pushGeofenceEvent(GeofenceEventKind kind, String detail) {
    if (!mounted) return;
    setState(() {
      _geofenceEvents.insert(
        0,
        GeofenceEventEntry(
          id: '${DateTime.now().millisecondsSinceEpoch}-${_geofenceEvents.length}',
          kind: kind,
          timestamp: DateTime.now(),
          detail: detail,
        ),
      );
      if (_geofenceEvents.length > 30) {
        _geofenceEvents.removeRange(30, _geofenceEvents.length);
      }
    });
  }

  // ---------------------------------------------------------------------------
  // Actions
  // ---------------------------------------------------------------------------

  Future<void> _startScan() async {
    try {
      await BearoundFlutterSdk.startScanning();

      // Android: foreground service (opcional) para sobreviver em background.
      // Desligue _useForegroundService para o modo oportunista (sem FGS/vídeo).
      // Sem config: a notificação mostra só o nome do app (sem subtítulo).
      if (Platform.isAndroid && _useForegroundService) {
        await BearoundFlutterSdk.enableForegroundScanning().catchError(
          (_) => null,
        );
      }

      setState(() {
        _geofenceEvents.clear();
        _lastEnteredRegionAt = null;
        _lastExitedRegionAt = null;
        _locationEnterCount = 0;
        _btZoneEnterCount = 0;
        _isInBluetoothZone = false;
        _lastBtEnterAt = null;
        _lastBtExitAt = null;
        _locationKeys.clear();
        _bluetoothKeys.clear();
      });
    } catch (e) {
      setState(() => _lastError = e.toString());
    }
  }

  Future<void> _stopScan() async {
    try {
      await BearoundFlutterSdk.stopScanning();
      if (Platform.isAndroid) {
        await BearoundFlutterSdk.disableForegroundScanning().catchError(
          (_) => null,
        );
      }
    } catch (e) {
      setState(() => _lastError = e.toString());
    }
    setState(() {
      _detectedBeacons = [];
      _lastError = null;
    });
  }

  Future<void> _openSettings() async {
    final result = await Navigator.push<SdkSettings>(
      context,
      MaterialPageRoute(
        builder: (context) => SettingsPage(
          initialScanPrecision: _scanPrecision,
          initialMaxQueuedPayloads: _maxQueuedPayloads,
          sdkVersion: _sdkVersion,
        ),
      ),
    );
    if (result != null) {
      setState(() {
        _scanPrecision = result.scanPrecision;
        _maxQueuedPayloads = result.maxQueuedPayloads;
      });
      if (_isScanning) await _applyConfig();
    }
  }

  Future<void> _openTwoEyes() async {
    await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => TwoEyesModal(
          now: _nowTick,
          location: EyeCardData(
            available: true,
            isInZone: _isInBeaconRegion,
            lastEnter: _lastEnteredRegionAt,
            lastExit: _lastExitedRegionAt,
            enterCount: _locationEnterCount,
            beaconsNow: _detectedBeacons
                .where(
                  (b) => b.discoverySources.contains(
                    BeaconDiscoverySource.coreLocation,
                  ),
                )
                .length,
            totalDetected: _locationKeys.length,
            modeLabel: _isActiveScanRunning ? 'RANGING' : 'REGION',
            modeIsActive: _isActiveScanRunning,
            cadenceLabel: _isActiveScanRunning ? '~1Hz' : 'kernel-level',
          ),
          bluetooth: EyeCardData(
            available: Platform.isIOS,
            isInZone: _isInBluetoothZone,
            lastEnter: _lastBtEnterAt,
            lastExit: _lastBtExitAt,
            enterCount: _btZoneEnterCount,
            beaconsNow: _detectedBeacons
                .where(
                  (b) => b.discoverySources.any(
                    (s) =>
                        s == BeaconDiscoverySource.serviceUUID ||
                        s == BeaconDiscoverySource.name,
                  ),
                )
                .length,
            totalDetected: _bluetoothKeys.length,
            modeLabel: _btScanMode == BluetoothScanMode.active
                ? 'ATIVO'
                : 'STANDBY',
            modeIsActive: _btScanMode == BluetoothScanMode.active,
            cadenceLabel: _btScanMode == BluetoothScanMode.active
                ? '10s tick'
                : '5min cycle',
            nextScanAt: _btNextIdleScanAt,
          ),
          events: _geofenceEvents,
          onClearLog: () => setState(() => _geofenceEvents.clear()),
        ),
      ),
    );
  }

  Future<void> _openLog() async {
    await Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => const LogModal()),
    );
  }

  // ---------------------------------------------------------------------------
  // UI
  // ---------------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'BeAroundScan',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            Text(
              _status,
              style: const TextStyle(fontSize: 11, color: Colors.grey),
            ),
          ],
        ),
        actions: [
          IconButton(
            icon: Icon(
              _isScanning ? Icons.wifi_tethering : Icons.wifi_off,
              color: _isScanning ? Colors.green : Colors.grey,
            ),
            onPressed: null,
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _headerActions(),
          if (_lastError != null) _errorBox(),
          _permissionsCard(),
          if (_isScanning) ...[
            const SizedBox(height: 12),
            _scanInfoCard(),
            const SizedBox(height: 12),
            _syncInfoCard(),
            const SizedBox(height: 12),
            _geofenceDebugCard(),
          ],
          const SizedBox(height: 12),
          _controlsCard(),
          const SizedBox(height: 12),
          if (_lastScanTime != null)
            Center(
              child: Text(
                'Última atualização: ${_fmtTime(_lastScanTime!)}',
                style: const TextStyle(color: Colors.grey),
              ),
            ),
          const SizedBox(height: 12),
          if (_detectedBeacons.isEmpty)
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 32),
              child: Center(
                child: Column(
                  children: [
                    Text(
                      'Aguardando próximos scans',
                      style: TextStyle(
                        color: Colors.grey,
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ],
                ),
              ),
            )
          else
            for (final beacon in _detectedBeacons) _beaconCard(beacon),
        ],
      ),
    );
  }

  Widget _headerActions() {
    return Padding(
      padding: const EdgeInsets.only(bottom: 16),
      child: Row(
        children: [
          Expanded(
            child: FilledButton.icon(
              onPressed: _openTwoEyes,
              icon: const Text('👁 👁', style: TextStyle(fontSize: 14)),
              label: const Text('Abrir Dois Olhos'),
            ),
          ),
          const SizedBox(width: 8),
          IconButton.outlined(
            onPressed: _openLog,
            icon: const Icon(Icons.list_alt),
          ),
          IconButton.outlined(
            onPressed: _openSettings,
            icon: const Icon(Icons.settings),
          ),
        ],
      ),
    );
  }

  Widget _errorBox() {
    return Container(
      margin: const EdgeInsets.only(bottom: 12),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.red.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text(
        _lastError ?? '',
        style: const TextStyle(color: Colors.redAccent),
      ),
    );
  }

  Widget _permissionsCard() {
    final btLabel = _bluetoothLabel(_bluetoothState);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Permissões',
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
            ),
            const SizedBox(height: 12),
            // Android 12+: THE scan gate. Labeled as the OS labels it.
            if (Platform.isAndroid)
              _permRow(
                'Nearby devices (BT Scan)',
                _bluetoothScanGranted ? 'Concedida' : 'Negada',
                _bluetoothScanGranted ? Colors.green : Colors.red,
              ),
            _permRow(
              'Localização',
              _locationGranted ? 'Concedida' : 'Negada',
              _locationGranted ? Colors.green : Colors.red,
            ),
            _permRow(
              'Bluetooth (rádio)',
              btLabel,
              _bluetoothColor(_bluetoothState),
            ),
            _permRow(
              'Notificações',
              _notificationsGranted ? 'Concedida' : 'Negada',
              _notificationsGranted ? Colors.green : Colors.red,
            ),
            const SizedBox(height: 8),
            Text(
              _canScan
                  ? '✓ Pronto para detectar'
                  : (Platform.isAndroid
                        ? '⚠ Conceda "Nearby devices" e ligue o Bluetooth'
                        : '⚠ Conceda Localização e ligue o Bluetooth'),
              style: TextStyle(
                color: _canScan ? Colors.green : Colors.orange,
                fontWeight: FontWeight.bold,
                fontSize: 12,
              ),
            ),
            const SizedBox(height: 8),
            TextButton.icon(
              onPressed: () async {
                await BearoundFlutterSdk.requestPermissions();
                await Permission.notification.request();
                final bt = await BearoundFlutterSdk.getBluetoothState();
                if (mounted) setState(() => _bluetoothState = bt);
                await _refreshPermissions();
              },
              icon: const Icon(Icons.lock_open),
              label: const Text('Solicitar permissões'),
            ),
          ],
        ),
      ),
    );
  }

  Widget _permRow(String label, String value, Color color) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          Container(
            width: 8,
            height: 8,
            decoration: BoxDecoration(color: color, shape: BoxShape.circle),
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(label, style: const TextStyle(color: Colors.grey)),
          ),
          Text(
            value,
            style: TextStyle(
              color: color,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }

  Widget _scanInfoCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Informações do Scan',
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
            ),
            const SizedBox(height: 8),
            _infoRow('Precisão', _scanPrecision.value),
            _infoRow('Fila Retry', '${_maxQueuedPayloads.value}'),
            const Divider(),
            const Text(
              '✨ Sync automático: eventos em syncLifecycleStream',
              style: TextStyle(
                color: Colors.grey,
                fontSize: 12,
                fontStyle: FontStyle.italic,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _syncInfoCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Informações do Sync',
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
            ),
            const SizedBox(height: 8),
            _infoRow(
              'Último sync',
              _lastSyncTime != null ? _fmtTime(_lastSyncTime!) : '--',
            ),
            _infoRow('Beacons sincronizados', '$_lastSyncCount'),
            _infoRow(
              'Duração',
              _lastSyncDuration == null
                  ? '--'
                  : _lastSyncDuration!.inMilliseconds >= 1000
                  ? '${(_lastSyncDuration!.inMilliseconds / 1000).toStringAsFixed(1)}s'
                  : '${_lastSyncDuration!.inMilliseconds}ms',
            ),
            _infoRow(
              'Resultado',
              _lastSyncSuccess == true
                  ? 'Sucesso'
                  : _lastSyncSuccess == false
                  ? 'Falha'
                  : 'Aguardando…',
              valueColor: _lastSyncSuccess == true
                  ? Colors.green
                  : _lastSyncSuccess == false
                  ? Colors.red
                  : Colors.grey,
            ),
          ],
        ),
      ),
    );
  }

  Widget _geofenceDebugCard() {
    final inZone = _isInBeaconRegion;
    final bg = inZone ? const Color(0xFF0F2B14) : const Color(0xFF2A0F0F);
    final border = inZone ? const Color(0xFF2E7D32) : const Color(0xFFC62828);
    final titleColor = inZone
        ? const Color(0xFFA5D6A7)
        : const Color(0xFFEF9A9A);
    final emoji = inZone ? '✅' : '⛔';
    final title = inZone ? 'NA ZONA DO BEACON' : 'FORA DA ZONA';
    final body = inZone
        ? 'Beacon detectado — dentro da zona monitorada.'
        : 'Sem beacon detectado no momento.';

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                const Expanded(
                  child: Text(
                    'Debug Geofence',
                    style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                  ),
                ),
                if (_geofenceEvents.isNotEmpty)
                  TextButton(
                    onPressed: () => setState(() => _geofenceEvents.clear()),
                    child: const Text(
                      'Limpar',
                      style: TextStyle(color: Colors.grey, fontSize: 12),
                    ),
                  ),
              ],
            ),
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: bg,
                border: Border.all(color: border),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      Text(emoji, style: const TextStyle(fontSize: 18)),
                      const SizedBox(width: 8),
                      Text(
                        title,
                        style: TextStyle(
                          color: titleColor,
                          fontWeight: FontWeight.bold,
                          fontSize: 14,
                        ),
                      ),
                    ],
                  ),
                  Padding(
                    padding: const EdgeInsets.only(top: 4),
                    child: Text(
                      body,
                      style: TextStyle(color: titleColor, fontSize: 12),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 8),
            _infoRow(
              'Zona do beacon',
              inZone ? 'DENTRO' : 'fora',
              valueColor: inZone ? Colors.green : Colors.grey,
            ),
            if (_lastEnteredRegionAt != null)
              _infoRow(
                'Entrou às',
                '${_fmtTime(_lastEnteredRegionAt!)} (${_fmtAge(_nowTick.difference(_lastEnteredRegionAt!))})',
              ),
            if (_lastExitedRegionAt != null)
              _infoRow(
                'Saiu às',
                '${_fmtTime(_lastExitedRegionAt!)} (${_fmtAge(_nowTick.difference(_lastExitedRegionAt!))})',
              ),
            _infoRow(
              'Scan ativo',
              _isActiveScanRunning ? 'LIGADO' : 'desligado',
              valueColor: _isActiveScanRunning ? Colors.green : Colors.grey,
            ),
            if (_geofenceEvents.isNotEmpty) ...[
              const Divider(),
              const Text(
                'Eventos recentes',
                style: TextStyle(
                  color: Colors.grey,
                  fontSize: 12,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 6),
              for (final event in _geofenceEvents.take(10))
                _geofenceEventTile(event),
            ],
          ],
        ),
      ),
    );
  }

  Widget _geofenceEventTile(GeofenceEventEntry event) {
    final color = geofenceEventColor(event.kind);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            margin: const EdgeInsets.only(top: 4),
            width: 8,
            height: 8,
            decoration: BoxDecoration(color: color, shape: BoxShape.circle),
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Expanded(
                      child: Text(
                        geofenceEventTitle(event.kind),
                        style: TextStyle(
                          color: color,
                          fontSize: 11,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                    Text(
                      '${_fmtTime(event.timestamp)} · ${_fmtAge(_nowTick.difference(event.timestamp))}',
                      style: const TextStyle(
                        color: Colors.grey,
                        fontFamily: 'monospace',
                        fontSize: 10,
                      ),
                    ),
                  ],
                ),
                Text(event.detail, style: const TextStyle(fontSize: 11)),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _controlsCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Controle',
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
            ),
            const SizedBox(height: 12),
            if (!_isScanning)
              FilledButton.icon(
                onPressed: _startScan,
                style: FilledButton.styleFrom(
                  backgroundColor: const Color(0xFF1976D2),
                ),
                icon: const Icon(Icons.play_arrow),
                label: const Text('Iniciar Scan'),
              )
            else
              FilledButton.icon(
                onPressed: _stopScan,
                style: FilledButton.styleFrom(
                  backgroundColor: const Color(0xFFC0392B),
                ),
                icon: const Icon(Icons.stop),
                label: const Text('Parar Scan'),
              ),
            if (Platform.isAndroid)
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Foreground service'),
                subtitle: Text(
                  _useForegroundService
                      ? 'Contínuo · sobrevive app fechado · exige vídeo no Play'
                      : 'Oportunista · sem vídeo no Play · pode perder detecções',
                  style: const TextStyle(fontSize: 11, color: Colors.grey),
                ),
                value: _useForegroundService,
                onChanged: (v) async {
                  setState(() => _useForegroundService = v);
                  if (_isScanning) {
                    await _stopScan();
                    await _startScan();
                  }
                },
              ),
            const SizedBox(height: 16),
            const Text('Scan Precision', style: TextStyle(color: Colors.grey)),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: [
                for (final precision in ScanPrecision.values)
                  ChoiceChip(
                    label: Text(precision.value),
                    selected: _scanPrecision == precision,
                    onSelected: (_) async {
                      setState(() => _scanPrecision = precision);
                      if (_isScanning) await _applyConfig();
                    },
                  ),
              ],
            ),
            const SizedBox(height: 12),
            const Text(
              'Max Queued Payloads',
              style: TextStyle(color: Colors.grey),
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: [
                for (final queue in MaxQueuedPayloads.values)
                  ChoiceChip(
                    label: Text('${queue.value}'),
                    selected: _maxQueuedPayloads == queue,
                    onSelected: (_) async {
                      setState(() => _maxQueuedPayloads = queue);
                      if (_isScanning) await _applyConfig();
                    },
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _beaconCard(Beacon beacon) {
    final m = beacon.metadata;
    final sources = beacon.discoverySources;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Beacon ${beacon.major}.${beacon.minor}',
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 15,
                        ),
                      ),
                      Text(
                        beacon.uuid,
                        style: const TextStyle(
                          color: Colors.grey,
                          fontSize: 11,
                        ),
                      ),
                    ],
                  ),
                ),
                Text(
                  '${beacon.rssi} dB',
                  style: const TextStyle(color: Colors.grey),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Wrap(
              spacing: 8,
              children: [
                _chip(
                  _proximityLabel(beacon.proximity),
                  _proximityColor(beacon.proximity),
                ),
                if (beacon.accuracy > 0)
                  _chip('${beacon.accuracy.toStringAsFixed(1)}m', Colors.grey),
                if (beacon.isStale) _chip('stale', Colors.orange),
                if (beacon.alreadySynced) _chip('✓ synced', Colors.green),
              ],
            ),
            if (sources.isNotEmpty) ...[
              const SizedBox(height: 6),
              Wrap(
                spacing: 6,
                children: [
                  for (final source in sources)
                    Container(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 6,
                        vertical: 1,
                      ),
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(4),
                        border: Border.all(color: _sourceColor(source)),
                      ),
                      child: Text(
                        _sourceLabel(source),
                        style: TextStyle(
                          fontSize: 10,
                          color: _sourceColor(source),
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                ],
              ),
            ],
            if (m != null) ...[
              const SizedBox(height: 6),
              Wrap(
                spacing: 8,
                children: [
                  Text(
                    '🔋 ${m.batteryLevel}mV',
                    style: TextStyle(
                      color: _batteryColor(m.batteryLevel),
                      fontSize: 11,
                    ),
                  ),
                  Text(
                    '🌡 ${m.temperature}°C',
                    style: const TextStyle(color: Colors.grey, fontSize: 11),
                  ),
                  Text(
                    '🚶 ${m.movements}',
                    style: const TextStyle(color: Colors.grey, fontSize: 11),
                  ),
                  Text(
                    'v${m.firmwareVersion}',
                    style: const TextStyle(color: Colors.grey, fontSize: 11),
                  ),
                  if (beacon.txPower != null)
                    Text(
                      'tx ${beacon.txPower}',
                      style: const TextStyle(color: Colors.grey, fontSize: 11),
                    ),
                ],
              ),
            ],
            if (beacon.rssiSamples != null) ...[
              const SizedBox(height: 4),
              Text(
                'RSSI avg ${beacon.rssiSamples!.avg.round()} dBm (${beacon.rssiSamples!.min}…${beacon.rssiSamples!.max}, n=${beacon.rssiSamples!.count})',
                style: const TextStyle(color: Colors.grey, fontSize: 11),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _infoRow(String label, String value, {Color? valueColor}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        children: [
          Expanded(
            child: Text(label, style: const TextStyle(color: Colors.grey)),
          ),
          Text(
            value,
            style: TextStyle(
              color: valueColor ?? Colors.white,
              fontWeight: FontWeight.w600,
            ),
          ),
        ],
      ),
    );
  }

  Widget _chip(String text, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        text,
        style: TextStyle(
          color: color,
          fontSize: 11,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  String _bluetoothLabel(BluetoothState s) {
    switch (s) {
      case BluetoothState.poweredOn:
        return 'Ligado';
      case BluetoothState.poweredOff:
        return 'Desligado';
      case BluetoothState.unauthorized:
        return 'Não autorizado';
      case BluetoothState.unsupported:
        return 'Não suportado';
      case BluetoothState.resetting:
        return 'Reiniciando';
      case BluetoothState.unknown:
        return 'Verificando…';
    }
  }

  Color _bluetoothColor(BluetoothState s) {
    switch (s) {
      case BluetoothState.poweredOn:
        return Colors.green;
      case BluetoothState.poweredOff:
      case BluetoothState.unauthorized:
      case BluetoothState.unsupported:
        return Colors.red;
      case BluetoothState.resetting:
      case BluetoothState.unknown:
        return Colors.orange;
    }
  }

  Color _batteryColor(int mV) =>
      mV > 2800 ? Colors.green : (mV > 2400 ? Colors.orange : Colors.red);

  String _proximityLabel(BeaconProximity p) {
    switch (p) {
      case BeaconProximity.immediate:
        return 'Imediato';
      case BeaconProximity.near:
        return 'Perto';
      case BeaconProximity.far:
        return 'Longe';
      case BeaconProximity.bt:
        return 'Bluetooth';
      case BeaconProximity.unknown:
        return 'Desconhecido';
    }
  }

  Color _proximityColor(BeaconProximity p) {
    switch (p) {
      case BeaconProximity.immediate:
        return Colors.green;
      case BeaconProximity.near:
        return Colors.orange;
      case BeaconProximity.far:
        return Colors.red;
      case BeaconProximity.bt:
        return Colors.blue;
      case BeaconProximity.unknown:
        return Colors.grey;
    }
  }

  String _sourceLabel(BeaconDiscoverySource source) {
    switch (source) {
      case BeaconDiscoverySource.serviceUUID:
        return 'Service UUID';
      case BeaconDiscoverySource.name:
        return 'Name';
      case BeaconDiscoverySource.coreLocation:
        return 'iBeacon';
    }
  }

  Color _sourceColor(BeaconDiscoverySource source) {
    switch (source) {
      case BeaconDiscoverySource.serviceUUID:
        return const Color(0xFF7E57C2);
      case BeaconDiscoverySource.name:
        return const Color(0xFF26A69A);
      case BeaconDiscoverySource.coreLocation:
        return const Color(0xFF5C6BC0);
    }
  }

  String _fmtTime(DateTime t) {
    String pad(int v) => v.toString().padLeft(2, '0');
    return '${pad(t.hour)}:${pad(t.minute)}:${pad(t.second)}';
  }

  String _fmtAge(Duration d) {
    final sec = d.inSeconds.clamp(0, 1 << 30);
    if (sec < 60) return '${sec}s atrás';
    if (sec < 3600) return '${sec ~/ 60}min ${sec % 60}s atrás';
    return '${sec ~/ 3600}h ${(sec % 3600) ~/ 60}min atrás';
  }
}