flutter_uwb 1.0.1 copy "flutter_uwb: ^1.0.1" to clipboard
flutter_uwb: ^1.0.1 copied to clipboard

Ultra-wideband proximity for Flutter — cm-level distance, real-time ranging, Precision Find.

example/lib/main.dart

import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'dart:math' as math;

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

import 'brand.dart';
import 'widgets/precision_arrow.dart';
import 'widgets/radar.dart';
import 'widgets/readout_card.dart';

/// BLE profile advertised by Qorvo's QANI firmware on the DWM3001CDK,
/// verified live via a Mac GATT enumeration:
///
///   service  2E938FD0-6A61-11ED-A1EB-0242AC120002
///   write    2E93998A-...  (host → accessory  — Apple FiRa "rx")
///   notify   2E939AF2-...  (accessory → host  — Apple FiRa "tx")
class _QorvoProfile {
  static const serviceUuid = '2E938FD0-6A61-11ED-A1EB-0242AC120002';
  static const rxUuid = '2E93998A-6A61-11ED-A1EB-0242AC120002';
  static const txUuid = '2E939AF2-6A61-11ED-A1EB-0242AC120002';
  static const vendorTag = 'qorvo';
}

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter_uwb',
      theme: buildBrandTheme(),
      home: const _Home(),
    );
  }
}

class _Home extends StatefulWidget {
  const _Home();

  @override
  State<_Home> createState() => _HomeState();
}

class _HomeState extends State<_Home> with WidgetsBindingObserver {
  final FlutterUwb _uwb = FlutterUwb.instance;

  bool? _uwbAvailable;
  bool _bluetoothEnabled = true;
  List<String> _missingPermissions = const [];
  bool _scanning = false;
  String? _error;
  bool _cameraAssist = false;
  bool _extendedDistance = false;
  DeviceCapabilities? _capabilities;
  final Map<String, UwbDevice> _devicesById = {};
  RangingSample? _lastSample;
  String? _activeRangingId;
  String? _pairingId;
  int? _lastHapticBin;
  String? _lastVerb;
  DateTime? _directionLostSince;
  Timer? _directionLostTimer;
  Timer? _scanTimeoutTimer;
  bool _scanNoPeers = false;
  static const _scanGrace = Duration(seconds: 8);
  int _currentTab = 0;

  StreamSubscription<UwbDevice>? _deviceFoundSub;
  StreamSubscription<String>? _deviceLostSub;
  StreamSubscription<RangingSample>? _samplesSub;
  StreamSubscription<String>? _peerLostSub;
  StreamSubscription<RangingErrorEvent>? _errorsSub;
  StreamSubscription<IncomingRequest>? _incomingSub;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _checkReadiness();
    // Register the Qorvo accessory profile so DWM3001CDK boards surface
    // in the same `deviceFound` stream as iOS peers. Accessory mode is
    // iOS-only in flutter_uwb 1.0.0; the call would throw on Android.
    if (Platform.isIOS) {
      _uwb.registerAccessoryProfile(
        serviceUuid: _QorvoProfile.serviceUuid,
        rxUuid: _QorvoProfile.rxUuid,
        txUuid: _QorvoProfile.txUuid,
        vendorTag: _QorvoProfile.vendorTag,
      );
    }
    _deviceFoundSub = _uwb.deviceFound.listen((d) {
      if (!mounted) return;
      _scanTimeoutTimer?.cancel();
      setState(() {
        _devicesById[d.id] = d;
        _scanNoPeers = false;
      });
    });
    _deviceLostSub = _uwb.deviceLost.listen((id) {
      if (!mounted) return;
      setState(() => _devicesById.remove(id));
    });
    _samplesSub = _uwb.rangingSamples.listen(_onSample);
    _peerLostSub = _uwb.peerLost.listen((id) {
      if (!mounted) return;
      developer.log('SNACK peerLost: $id', name: 'flutter_uwb.example');
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text('Peer lost: $id')));
    });
    _errorsSub = _uwb.rangingErrors.listen((e) {
      developer.log(
        'SNACK rangingError: code=${e.code.name} dev=${e.deviceId} msg=${e.message}',
        name: 'flutter_uwb.example',
      );
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Ranging error (${e.deviceId}): ${e.message}')),
      );
    });
    _incomingSub = _uwb.incomingRequests.listen(_handleIncomingRequest);
  }

  void _onSample(RangingSample s) {
    if (!mounted) return;
    final hadDirection = _lastSample?.azimuthDegrees != null;
    final hasDirection = s.azimuthDegrees != null;

    if (_activeRangingId != null && hasDirection) {
      final bin = proximityBin(s.distanceMeters);
      if (_lastHapticBin != null && bin < _lastHapticBin!) {
        HapticFeedback.selectionClick();
      }
      _lastHapticBin = bin;

      final verb = proximityVerb(s.distanceMeters);
      if (verb == 'HERE' && _lastVerb != 'HERE') {
        HapticFeedback.heavyImpact();
      } else if (_lastVerb == 'HERE' && verb != 'HERE') {
        HapticFeedback.lightImpact();
      }
      _lastVerb = verb;
    }

    if (hadDirection && !hasDirection) {
      _directionLostSince ??= DateTime.now();
      // Re-trigger a rebuild after the 800 ms grace window so the body
      // switcher picks the home view if direction never returns.
      _directionLostTimer?.cancel();
      _directionLostTimer = Timer(const Duration(milliseconds: 800), () {
        if (mounted) setState(() {});
      });
    } else if (hasDirection) {
      _directionLostSince = null;
      _directionLostTimer?.cancel();
    }

    setState(() => _lastSample = s);
  }

  bool get _showPrecision {
    if (_activeRangingId == null) return false;
    final s = _lastSample;
    if (s?.azimuthDegrees != null) return true;
    final lostAt = _directionLostSince;
    if (lostAt == null) return false;
    return DateTime.now().difference(lostAt) <
        const Duration(milliseconds: 800);
  }

  Future<void> _handleIncomingRequest(IncomingRequest req) async {
    final id = req.device.id;
    if (mounted) setState(() => _pairingId = id);
    try {
      final myToken = await _uwb.getLocalToken(UwbRole.controlee);
      await _uwb.acceptRequest(id, myToken);
      await _uwb.startRanging(id);
      if (mounted) setState(() => _activeRangingId = id);
    } on UwbException catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Auto-accept failed: ${e.message}')),
      );
    } finally {
      if (mounted) setState(() => _pairingId = null);
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _deviceFoundSub?.cancel();
    _deviceLostSub?.cancel();
    _samplesSub?.cancel();
    _peerLostSub?.cancel();
    _errorsSub?.cancel();
    _incomingSub?.cancel();
    _directionLostTimer?.cancel();
    _scanTimeoutTimer?.cancel();
    if (_activeRangingId != null) _uwb.stopRanging();
    if (_scanning) _uwb.stopDiscovery();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // Recheck on resume so users coming back from system Settings
    // (after granting a permission or enabling Bluetooth) see the
    // banner clear without restarting the app.
    if (state == AppLifecycleState.resumed) {
      _checkReadiness(requestIfDenied: false);
    }
  }

  /// Probe the plugin's readiness, request any missing Android runtime
  /// permissions through `permission_handler`, then snapshot the device
  /// capabilities for the Settings tab.
  Future<void> _checkReadiness({bool requestIfDenied = true}) async {
    try {
      var r = await _uwb.checkReadiness();
      if (!mounted) return;

      // First pass: surface what we have so the banner reflects reality
      // even if the user dismisses the system permission prompt.
      setState(() {
        _uwbAvailable = r.uwbAvailable;
        _bluetoothEnabled = r.bluetoothEnabled;
        _missingPermissions = r.missingPermissions.whereType<String>().toList(
          growable: false,
        );
      });

      // Second pass: ask for anything ungranted that permission_handler
      // knows about. Re-check afterwards so the banner clears once the
      // user accepts.
      if (requestIfDenied && !r.permissionsGranted) {
        final toRequest = _toPermissions(r.missingPermissions);
        if (toRequest.isNotEmpty) {
          await toRequest.request();
          r = await _uwb.checkReadiness();
          if (!mounted) return;
          setState(() {
            _uwbAvailable = r.uwbAvailable;
            _bluetoothEnabled = r.bluetoothEnabled;
            _missingPermissions = r.missingPermissions
                .whereType<String>()
                .toList(growable: false);
          });
        }
      }

      final caps = await _uwb.getDeviceCapabilities();
      if (!mounted) return;
      setState(() {
        _capabilities = caps;
        // Camera assist and extended distance are mutually exclusive on
        // `NINearbyPeerConfiguration` — extended distance trades AoA for
        // range, camera assist supplies AoA via ARKit. Default to camera
        // assist when supported (precision-find UX expects direction);
        // otherwise fall back to extended distance for longer range.
        _cameraAssist = caps.supportsCameraAssist;
        _extendedDistance =
            caps.supportsExtendedDistance && !caps.supportsCameraAssist;
      });
    } on UwbException catch (e) {
      if (!mounted) return;
      setState(() => _error = 'checkReadiness failed: ${e.message}');
    }
  }

  /// Map plugin-reported `android.permission.*` strings to
  /// `permission_handler` Permission objects. Skips entries the package
  /// doesn't model (UWB_RANGING is not a typed Permission in 11.x — the
  /// system prompts on first use of the UWB radio anyway).
  List<Permission> _toPermissions(List<String?> raw) {
    final result = <Permission>[];
    for (final p in raw) {
      switch (p) {
        case 'android.permission.BLUETOOTH_SCAN':
          result.add(Permission.bluetoothScan);
        case 'android.permission.BLUETOOTH_CONNECT':
          result.add(Permission.bluetoothConnect);
        case 'android.permission.BLUETOOTH_ADVERTISE':
          result.add(Permission.bluetoothAdvertise);
        case 'android.permission.ACCESS_FINE_LOCATION':
          result.add(Permission.locationWhenInUse);
        // android.permission.BLUETOOTH / BLUETOOTH_ADMIN are
        // install-time perms on API <31 and don't go through
        // permission_handler.
      }
    }
    return result;
  }

  Future<void> _toggleScan() async {
    if (_scanning) {
      try {
        await _uwb.stopDiscovery();
      } on UwbException catch (e) {
        if (mounted) setState(() => _error = e.message);
      }
      _scanTimeoutTimer?.cancel();
      if (mounted) {
        setState(() {
          _scanning = false;
          _scanNoPeers = false;
          _devicesById.clear();
        });
      }
      return;
    }

    try {
      final name =
          '${Platform.localHostname}-${DateTime.now().millisecondsSinceEpoch % 10000}';
      await _uwb.startDiscovery(name);
      if (mounted) {
        setState(() {
          _scanning = true;
          _scanNoPeers = false;
          _error = null;
        });
        _scanTimeoutTimer?.cancel();
        _scanTimeoutTimer = Timer(_scanGrace, () {
          if (!mounted) return;
          if (_scanning && _activeRangingId == null && _devicesById.isEmpty) {
            setState(() => _scanNoPeers = true);
          }
        });
      }
    } on UwbException catch (e) {
      if (mounted) setState(() => _error = e.message);
    }
  }

  Future<void> _pairAndRange(UwbDevice device) async {
    final id = device.id;
    if (mounted) setState(() => _pairingId = id);
    // Only one ranging session can be active at a time on iOS — a second
    // startRanging while another is live fails with -5887 (sessionFailed)
    // because the AR/camera resources are already claimed. Tear the
    // previous session down first.
    if (_activeRangingId != null && _activeRangingId != id) {
      await _stopRanging();
    }
    final isAccessory = device.platform.startsWith('accessory');
    final cameraAssist = _cameraAssist;
    final extendedDistance = _extendedDistance;
    try {
      if (!isAccessory) {
        await _uwb.pairWith(id);
        if (!mounted) return;
      }
      await _uwb.startRanging(
        id,
        options: RangingOptions(
          cameraAssist: cameraAssist,
          extendedDistance: extendedDistance,
        ),
      );
      if (mounted) setState(() => _activeRangingId = id);
    } on UwbException catch (e) {
      developer.log(
        'SNACK pairAndRange UwbException: dev=$id platform=${device.platform} '
        'cameraAssist=$cameraAssist extDist=$extendedDistance msg=${e.message}',
        name: 'flutter_uwb.example',
      );
      if (!mounted) return;
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text(e.message)));
    } finally {
      if (mounted) setState(() => _pairingId = null);
    }
  }

  Future<void> _stopRanging() async {
    try {
      await _uwb.stopRanging();
    } on UwbException catch (_) {}
    _directionLostTimer?.cancel();
    if (mounted) {
      setState(() {
        _activeRangingId = null;
        _lastSample = null;
        _lastHapticBin = null;
        _lastVerb = null;
        _directionLostSince = null;
      });
    }
  }

  void _setCameraAssist(bool value) {
    setState(() {
      _cameraAssist = value;
      if (value) _extendedDistance = false;
    });
  }

  void _setExtendedDistance(bool value) {
    setState(() {
      _extendedDistance = value;
      if (value) _cameraAssist = false;
    });
  }

  String get _distanceText {
    final m = _lastSample?.distanceMeters;
    return m == null ? '— m' : '${m.toStringAsFixed(2)} m';
  }

  String get _azimuthText {
    final az = _lastSample?.azimuthDegrees;
    if (_capabilities?.supportsDirection == false) return 'n/a';
    return az == null ? '—°' : '${az.toStringAsFixed(0)}°';
  }

  String get _elevationText {
    final el = _lastSample?.elevationDegrees;
    if (_capabilities?.supportsDirection == false) return 'n/a';
    if (el == null) return '—°';
    final sign = el >= 0 ? '+' : '';
    return '$sign${el.toStringAsFixed(0)}°';
  }

  String get _signalText {
    if (!_scanning && _activeRangingId == null && _pairingId == null) {
      return 'idle';
    }
    if (_activeRangingId != null && _lastSample != null) return 'live';
    if (_activeRangingId != null || _pairingId != null) return 'pairing';
    return 'scan';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: IndexedStack(
          index: _currentTab,
          children: [_buildRangingTab(context), _buildSettingsTab(context)],
        ),
      ),
      bottomNavigationBar: NavigationBar(
        backgroundColor: const Color(0xFF13182F),
        indicatorColor: Brand.primary.withValues(alpha: 0.18),
        surfaceTintColor: Colors.transparent,
        height: 64,
        labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
        selectedIndex: _currentTab,
        onDestinationSelected: (i) => setState(() => _currentTab = i),
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.radar_outlined),
            selectedIcon: Icon(Icons.radar),
            label: 'Ranging',
          ),
          NavigationDestination(
            icon: Icon(Icons.tune_outlined),
            selectedIcon: Icon(Icons.tune),
            label: 'Settings',
          ),
        ],
      ),
    );
  }

  Widget _buildRangingTab(BuildContext context) {
    final size = MediaQuery.of(context).size;
    final radarSize = math.min(size.width - 64, 320.0);
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'UWB · RANGING · ${_signalText.toUpperCase()}',
            style: eyebrowStyle(),
          ),
          const SizedBox(height: 18),
          Center(
            child: SizedBox(
              width: radarSize,
              height: radarSize,
              child: AnimatedSwitcher(
                duration: const Duration(milliseconds: 240),
                switchInCurve: Curves.easeOut,
                switchOutCurve: Curves.easeIn,
                child: _showPrecision
                    ? Center(
                        key: const ValueKey('arrow'),
                        child: PrecisionArrow(
                          azimuthDegrees: _lastSample!.azimuthDegrees ?? 0,
                          elevationDegrees: _lastSample!.elevationDegrees,
                          distanceMeters: _lastSample!.distanceMeters,
                          size: radarSize * 0.85,
                        ),
                      )
                    : Radar(
                        key: const ValueKey('radar'),
                        active: _activeRangingId != null,
                      ),
              ),
            ),
          ),
          const SizedBox(height: 18),
          if (_missingPermissions.isNotEmpty)
            _StatusBanner(
              text: 'Missing permissions — open Settings to grant access',
              color: Brand.warm,
            )
          else if (!_bluetoothEnabled)
            _StatusBanner(
              text: 'Bluetooth is off — enable it to discover peers',
              color: Brand.warm,
            )
          else if (_uwbAvailable == false)
            _StatusBanner(
              text: 'UWB hardware not available on this device',
              color: Brand.muted,
            )
          else if (_pairingId != null && _activeRangingId == null)
            _StatusBanner(
              text:
                  'Pairing with ${_devicesById[_pairingId]?.name ?? _pairingId}…',
              color: Brand.primary,
            )
          else if (_uwbAvailable == true &&
              _activeRangingId == null &&
              !_scanning)
            _StatusBanner(text: 'Tap Start Ranging to discover peers')
          else if (_scanning && _activeRangingId == null && _scanNoPeers)
            _StatusBanner(
              text: Platform.isIOS
                  ? 'No peers found yet. On iPhone, allow Local Network access '
                        '(Settings → this app → Local Network), and check the '
                        'other phone is scanning and nearby.'
                  : 'No peers found yet. Check the other phone is scanning '
                        'and nearby.',
              color: Brand.warm,
            )
          else if (_scanning && _activeRangingId == null)
            _StatusBanner(text: 'Scanning for peers…'),
          const SizedBox(height: 12),
          Row(
            children: [
              Expanded(
                child: ReadoutCard(label: 'Distance', value: _distanceText),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: ReadoutCard(label: 'Azimuth', value: _azimuthText),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: ReadoutCard(label: 'Elevation', value: _elevationText),
              ),
            ],
          ),
          const SizedBox(height: 18),
          FilledButton(
            style: FilledButton.styleFrom(
              backgroundColor: Brand.primary,
              foregroundColor: Brand.background,
              padding: const EdgeInsets.symmetric(vertical: 14),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12),
              ),
            ),
            onPressed: _scanning && _activeRangingId == null
                ? _toggleScan
                : (_activeRangingId != null ? _stopRanging : _toggleScan),
            child: Text(
              _activeRangingId != null
                  ? 'Stop Ranging'
                  : (_scanning ? 'Stop Discovery' : 'Start Ranging'),
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
          ),
          if (_devicesById.isNotEmpty) ...[
            const SizedBox(height: 24),
            Text('Discovered peers'.toUpperCase(), style: readoutLabelStyle()),
            const SizedBox(height: 8),
            for (final d in _devicesById.values)
              _PeerTile(
                device: d,
                isActive: _activeRangingId == d.id,
                isPairing: _pairingId == d.id && _activeRangingId != d.id,
                busyElsewhere:
                    _activeRangingId != null && _activeRangingId != d.id,
                onPair: () => _pairAndRange(d),
              ),
          ],
          if (_error != null) ...[
            const SizedBox(height: 16),
            Text(_error!, style: TextStyle(color: Colors.redAccent.shade100)),
          ],
        ],
      ),
    );
  }

  Widget _buildSettingsTab(BuildContext context) {
    final caps = _capabilities;
    final canCamera = caps?.supportsCameraAssist ?? false;
    final canExtended = caps?.supportsExtendedDistance ?? false;
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text('UWB · SETTINGS', style: eyebrowStyle()),
          const SizedBox(height: 18),
          _SettingsSection(
            title: 'Hardware',
            children: [
              _StatusRow(
                label: 'UWB available',
                ok: _uwbAvailable == true,
                value: _uwbAvailable == null
                    ? 'checking…'
                    : (_uwbAvailable! ? 'yes' : 'no'),
              ),
              _StatusRow(
                label: 'Direction (AoA)',
                ok: caps?.supportsDirection ?? false,
                value: (caps?.supportsDirection ?? false) ? 'yes' : 'no',
              ),
              _StatusRow(
                label: 'Camera assist',
                ok: canCamera,
                value: canCamera ? 'supported' : 'unsupported',
              ),
              _StatusRow(
                label: 'Extended distance',
                ok: canExtended,
                value: canExtended ? 'supported' : 'unsupported',
              ),
              _StatusRow(
                label: 'Precise distance',
                ok: caps?.supportsPreciseDistance ?? false,
                value: (caps?.supportsPreciseDistance ?? false) ? 'yes' : 'no',
              ),
              _StatusRow(
                label: 'Raw AoA',
                ok: caps?.supportsAoa ?? false,
                value: (caps?.supportsAoa ?? false) ? 'yes' : 'no',
              ),
            ],
          ),
          const SizedBox(height: 16),
          _SettingsSection(
            title: 'Ranging options',
            subtitle:
                'Applied to every ranging session — both peers and '
                'accessories. Defaults on when the hardware supports it.',
            children: [
              _ToggleRow(
                label: 'Camera assist',
                value: _cameraAssist,
                enabled: canCamera && _activeRangingId == null,
                onChanged: _setCameraAssist,
                hint: canCamera
                    ? 'Enables azimuth + elevation via the U2 chip.'
                    : 'Not supported on this device.',
              ),
              _ToggleRow(
                label: 'Extended distance',
                value: _extendedDistance,
                enabled: canExtended && _activeRangingId == null,
                onChanged: _setExtendedDistance,
                hint: canExtended
                    ? 'Trades update rate for longer range.'
                    : 'Not supported on this device.',
              ),
            ],
          ),
          if (Platform.isIOS) ...[
            const SizedBox(height: 16),
            _SettingsSection(
              title: 'Accessory profiles',
              subtitle: 'BLE service triplets the host scans for.',
              children: [
                _InfoRow(
                  label: _QorvoProfile.vendorTag,
                  value: _QorvoProfile.serviceUuid,
                ),
              ],
            ),
          ],
        ],
      ),
    );
  }
}

class _StatusBanner extends StatelessWidget {
  const _StatusBanner({required this.text, this.color});

  final String text;
  final Color? color;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
      decoration: BoxDecoration(
        color: const Color(0xFF13182F),
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: Brand.muted.withValues(alpha: 0.25)),
      ),
      child: Text(
        text,
        textAlign: TextAlign.center,
        style: TextStyle(color: color ?? Brand.text, fontSize: 13),
      ),
    );
  }
}

class _PeerTile extends StatelessWidget {
  const _PeerTile({
    required this.device,
    required this.isActive,
    required this.isPairing,
    required this.busyElsewhere,
    required this.onPair,
  });

  final UwbDevice device;
  final bool isActive;
  final bool isPairing;
  final bool busyElsewhere;
  final VoidCallback onPair;

  static IconData _iconFor(UwbDevice d, bool isActive) {
    if (isActive) return Icons.radar;
    if (d.platform.startsWith('accessory')) return Icons.sensors;
    if (d.platform == 'ios') return Icons.phone_iphone;
    return Icons.phone_android;
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
        decoration: BoxDecoration(
          color: const Color(0xFF13182F),
          borderRadius: BorderRadius.circular(10),
          border: Border.all(
            color: isActive
                ? Brand.primary
                : Brand.muted.withValues(alpha: 0.25),
          ),
        ),
        child: Row(
          children: [
            Icon(
              _iconFor(device, isActive),
              color: isActive ? Brand.primary : Brand.muted,
              size: 20,
            ),
            const SizedBox(width: 10),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    device.name,
                    style: const TextStyle(
                      color: Brand.text,
                      fontWeight: FontWeight.w500,
                    ),
                  ),
                  Text(
                    device.platform,
                    style: TextStyle(color: Brand.muted, fontSize: 11),
                  ),
                ],
              ),
            ),
            if (isPairing)
              const SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(
                  strokeWidth: 2,
                  color: Brand.primary,
                ),
              )
            else if (!isActive)
              TextButton(
                onPressed: busyElsewhere ? null : onPair,
                style: TextButton.styleFrom(
                  foregroundColor: busyElsewhere ? Brand.muted : Brand.primary,
                ),
                child: const Text('Pair & range'),
              ),
          ],
        ),
      ),
    );
  }
}

class _SettingsSection extends StatelessWidget {
  const _SettingsSection({
    required this.title,
    required this.children,
    this.subtitle,
  });

  final String title;
  final String? subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
      decoration: BoxDecoration(
        color: const Color(0xFF13182F),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: Brand.muted.withValues(alpha: 0.18)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(title.toUpperCase(), style: readoutLabelStyle()),
          if (subtitle != null) ...[
            const SizedBox(height: 4),
            Text(
              subtitle!,
              style: TextStyle(color: Brand.muted, fontSize: 11, height: 1.35),
            ),
          ],
          const SizedBox(height: 8),
          for (final c in children) c,
        ],
      ),
    );
  }
}

class _StatusRow extends StatelessWidget {
  const _StatusRow({
    required this.label,
    required this.ok,
    required this.value,
  });

  final String label;
  final bool ok;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        children: [
          Icon(
            ok ? Icons.check_circle_outline : Icons.remove_circle_outline,
            size: 16,
            color: ok ? Brand.primary : Brand.muted,
          ),
          const SizedBox(width: 8),
          Expanded(
            child: Text(
              label,
              style: const TextStyle(color: Brand.text, fontSize: 13),
            ),
          ),
          Text(value, style: TextStyle(color: Brand.muted, fontSize: 12)),
        ],
      ),
    );
  }
}

class _ToggleRow extends StatelessWidget {
  const _ToggleRow({
    required this.label,
    required this.value,
    required this.enabled,
    required this.onChanged,
    this.hint,
  });

  final String label;
  final bool value;
  final bool enabled;
  final ValueChanged<bool> onChanged;
  final String? hint;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  label,
                  style: TextStyle(
                    color: enabled ? Brand.text : Brand.muted,
                    fontSize: 13,
                  ),
                ),
                if (hint != null)
                  Padding(
                    padding: const EdgeInsets.only(top: 2),
                    child: Text(
                      hint!,
                      style: TextStyle(
                        color: Brand.muted,
                        fontSize: 11,
                        height: 1.3,
                      ),
                    ),
                  ),
              ],
            ),
          ),
          Switch(
            value: value,
            onChanged: enabled ? onChanged : null,
            activeThumbColor: Brand.primary,
          ),
        ],
      ),
    );
  }
}

class _InfoRow extends StatelessWidget {
  const _InfoRow({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: const TextStyle(color: Brand.text, fontSize: 13)),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              value,
              textAlign: TextAlign.right,
              style: TextStyle(
                color: Brand.muted,
                fontSize: 11,
                fontFeatures: const [FontFeature.tabularFigures()],
              ),
            ),
          ),
        ],
      ),
    );
  }
}
8
likes
150
points
260
downloads

Documentation

API reference

Publisher

verified publisherahmedhamdan.com

Weekly Downloads

Ultra-wideband proximity for Flutter — cm-level distance, real-time ranging, Precision Find.

Repository (GitHub)
View/report issues
Contributing

Topics

#uwb #proximity #ranging #bluetooth #location

License

MIT (license)

Dependencies

flutter

More

Packages that depend on flutter_uwb

Packages that implement flutter_uwb