[Flutter]BlueTooth Scanner 만들기

2026. 2. 10. 14:02Just do IT/Flutter

반응형

Nordic사에서 만든 nfcConnect라는 App처럼 주변의 BT장치나 Beacon을 스캔하는 App을 구현하고자 합니다.

 

1. BT 권한설정

블루투스는 보안 권한이 까다롭습니다. Android와 ios에 각각 아래와 같이 설정을 진행합니다.

 

1) Android - android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Bluetooth Permissions을 위해 추가 -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <!-- Bluetooth Permissions을 위해 추가 -->

위 경로의 파일에 위 소스와 같이 설정을 추가 합니다.

※ 태그 내부 금지 : <application>이나 <activity> 태그 안쪽에 넣으면 권한이 작동하지 않습니다. 
※ 순서 : 다른 태그들과 섞여도 큰 문제는 없지만, 보통 관례상 가장 윗부분에 모아둡니다.
※ 오타 주의 : android.permission.BLUETOOTH_SCAN 처럼 대문자와 점(.) 위치를 정확히 확인하세요. 안드로이드 블루투스 권한 공식 문서를 참고하면 더 정확합니다.

 

2) iOS - ios/Runner/Info.plist

아래와 같이 위 경로의 파일에 NSBluetoothAlwaysUsageDescription와 NSBluetoothPeripheralUsageDescription 키를 추가하고 설명 문구를 넣어야 합니다.

<dict>

    <!-- ✅ 블루투스 권한 설정 추가 -->
    <key>NSBluetoothAlwaysUsageDescription</key>
    <string>주변 블루투스 장치를 검색하고 연결하기 위해 권한이 필요합니다.</string>
    <key>NSBluetoothPeripheralUsageDescription</key>
    <string>블루투스 주변 기기 기능을 사용하기 위해 권한이 필요합니다.</string>
	<!-- ✅ 블루투스 권한 설정 추가 -->

※ 설명 문구 (<string>): 여기에 적은 문구가 실제 아이폰에서 "이 앱이 블루투스 권한을 요청합니다"라는 팝업이 뜰 때 사용자에게 보입니다. 너무 짧으면 애플 심사에서 거절될 수 있으니 구체적으로 적는 것이 좋습니다. 애플 공식 문서: 블루투스 권한 설명
※ 위치: 반드시 <dict>와 </dict> 사이에 위치해야 합니다. 태그 밖으로 나가면 앱 빌드가 되지 않습니다.

2. 패키지 추가

Cursor 왼쪽 파일 탐색기에서 프로젝트 최상위 폴더에 있는 pubspec.yaml 파일을 클릭해서 엽니다. 내용을 내려보다 보면 dependencies:라는 글자가 보일 겁니다.

아래 코드를 pubspec.yaml에 추가 후 flutter pub get 하십시요. 파일을 저장하면 자동으로 "Running flutter pub get..."이라는 메시지가 뜨면서 설치가 진행됩니다.

dependencies:
  flutter:
    sdk: flutter

  # ✅ 여기에 추가합니다 (앞에 스페이스 2칸!)
  flutter_blue_plus: ^1.31.0 
  # ✅ 여기에 추가합니다 (앞에 스페이스 2칸!)

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8

패키지 설치가 정상적으로 성공 시: Process finished with exit code 0이라는 메시지가 나옵니다.

 

**exit code 1**은 오류가 발생하여 비정상적으로 종료되었다는 뜻입니다. 

 

1) 패키지 설치에 오류가 나는 가장 흔한 경우는 들여쓰기 오류입니다.

    반드시 위 소스코드 처럼 위에있는 flutter: 와 세로 줄을 맞춰주셔야 합니다.

2) 윈도우 '개발자 모드'가 꺼져 있어서 심볼릭 링크(Symlink)를 만들지 못하는 문제일 수 있습니다. 이것만 해결하면 바로 정상 작동할 것입니다.
  1. 윈도우 개발자 모드 켜기 (필수)
     키보드의 윈도우 키를 누르고 **"개발자 설정"**을 검색하여 실행합니다.
     [개발자 모드] 항목을 **'켬'**으로 바꿉니다. (경고창이 뜨면 '예'를 누르세요.) 설정 창을 닫습니다.
  2. 다시 패키지 설치 시도

3. lib/main.dart에 아래와 같이 코드를 작성합니다.

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BLE 스캐너',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1E88E5),
          brightness: Brightness.dark,
          primary: const Color(0xFF1E88E5),
          surface: const Color(0xFF0D1117),
        ),
        useMaterial3: true,
      ),
      home: const BleScannerPage(),
    );
  }
}

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

  @override
  State<BleScannerPage> createState() => _BleScannerPageState();
}

class _BleScannerPageState extends State<BleScannerPage>
    with SingleTickerProviderStateMixin {
  final Map<DeviceIdentifier, ScanResult> _devices = {};
  bool _isScanning = false;
  String? _errorMessage;
  StreamSubscription<List<ScanResult>>? _scanSubscription;
  DeviceIdentifier? _expandedDeviceId;
  bool _showHelpToast = false;
  AnimationController? _helpToastController;

  @override
  void initState() {
    super.initState();
    _listenToBluetoothState();
    _helpToastController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 280),
    );
    _helpToastController!.addStatusListener((status) {
      if (status == AnimationStatus.dismissed) {
        setState(() => _showHelpToast = false);
      }
    });
  }

  @override
  void dispose() {
    _helpToastController?.dispose();
    _stopScan();
    super.dispose();
  }

  void _listenToBluetoothState() {
    FlutterBluePlus.adapterState.listen((state) {
      if (state == BluetoothAdapterState.off) {
        setState(() {
          _errorMessage = '블루투스를 켜주세요.';
        });
      } else {
        setState(() => _errorMessage = null);
      }
    });
  }

  Future<bool> _requestPermissions() async {
    if (Theme.of(context).platform == TargetPlatform.android) {
      final bluetoothScan = await Permission.bluetoothScan.request();
      final bluetoothConnect = await Permission.bluetoothConnect.request();
      final location = await Permission.locationWhenInUse.request();
      return bluetoothScan.isGranted &&
          bluetoothConnect.isGranted &&
          (location.isGranted || location.isDenied);
    }
    return true;
  }

  Future<void> _startScan() async {
    final ok = await _requestPermissions();
    if (!ok) {
      setState(() {
        _errorMessage = '블루투스 및 위치 권한을 허용해주세요.';
      });
      return;
    }

    setState(() {
      _errorMessage = null;
      _devices.clear();
      _isScanning = true;
    });

    _scanSubscription = FlutterBluePlus.scanResults.listen((results) {
      for (final r in results) {
        setState(() {
          _devices[r.device.remoteId] = r;
        });
      }
    });

    try {
      await FlutterBluePlus.startScan(
        timeout: const Duration(seconds: 30),
        androidUsesFineLocation: true,
      );
    } catch (e) {
      setState(() {
        _errorMessage = '스캔 실패: $e';
        _isScanning = false;
      });
    }
  }

  Future<void> _stopScan() async {
    await FlutterBluePlus.stopScan();
    await _scanSubscription?.cancel();
    _scanSubscription = null;
    setState(() => _isScanning = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0D1117),
      appBar: AppBar(
        title: const Text('BLE Scanner'),
        backgroundColor: const Color(0xFF161B22),
        foregroundColor: Colors.white,
        elevation: 0,
      ),
      body: Stack(
        children: [
          Column(
            children: [
              if (_errorMessage != null) _buildErrorBanner(),
              _buildStatusCard(),
              Expanded(child: _buildDeviceList()),
              _buildCopyright(),
            ],
          ),
          _buildHelpToast(),
        ],
      ),
      floatingActionButton: _buildScanButton(),
    );
  }

  Widget _buildErrorBanner() {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      color: Colors.orange.shade900,
      child: Row(
        children: [
          const Icon(Icons.warning_amber_rounded, color: Colors.white),
          const SizedBox(width: 12),
          Expanded(
            child: Text(
              _errorMessage!,
              style: const TextStyle(color: Colors.white),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildStatusCard() {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Card(
        color: const Color(0xFF161B22),
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Row(
            children: [
              Container(
                width: 48,
                height: 48,
                decoration: BoxDecoration(
                  color: _isScanning
                      ? const Color.fromRGBO(33, 150, 243, 0.3)
                      : const Color.fromRGBO(158, 158, 158, 0.3),
                  shape: BoxShape.circle,
                ),
                child: Icon(
                  _isScanning ? Icons.radar : Icons.bluetooth_searching,
                  color: _isScanning ? Colors.blue : Colors.grey,
                  size: 28,
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      _isScanning ? '스캔 중...' : '대기 중',
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 18,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      '발견된 기기: ${_devices.length}개',
                      style: TextStyle(
                        color: const Color.fromRGBO(255, 255, 255, 0.7),
                        fontSize: 14,
                      ),
                    ),
                  ],
                ),
              ),
              Material(
                color: Colors.transparent,
                child: InkWell(
                  onTap: () {
                    setState(() => _showHelpToast = true);
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      _helpToastController?.forward(from: 0.0);
                    });
                  },
                  customBorder: const CircleBorder(),
                  child: Container(
                    width: 36,
                    height: 36,
                    alignment: Alignment.center,
                    decoration: const BoxDecoration(
                      shape: BoxShape.circle,
                      color: Color.fromRGBO(255, 255, 255, 0.12),
                    ),
                    child: const Icon(
                      Icons.help_outline,
                      color: Color.fromRGBO(255, 255, 255, 0.7),
                      size: 22,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _toastSectionTitle(String text) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Text(
        text,
        style: const TextStyle(
          color: Colors.white,
          fontSize: 13,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }

  Widget _toastItem(String label, String value) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: RichText(
        textAlign: TextAlign.start,
        text: TextSpan(
          style: const TextStyle(
            color: Color.fromRGBO(255, 255, 255, 0.85),
            fontSize: 12,
            height: 1.35,
          ),
          children: [
            TextSpan(
              text: '$label: ',
              style: const TextStyle(
                fontWeight: FontWeight.w600,
                color: Color.fromRGBO(255, 255, 255, 0.95),
              ),
            ),
            TextSpan(text: value),
          ],
        ),
      ),
    );
  }

  Widget _buildHelpToast() {
    if (!_showHelpToast) return const SizedBox.shrink();
    return Positioned(
      left: 0,
      right: 0,
      bottom: 0,
      child: Material(
        elevation: 8,
        color: Colors.transparent,
        child: SlideTransition(
          position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
              .animate(
                CurvedAnimation(
                  parent: _helpToastController!,
                  curve: Curves.easeOutCubic,
                ),
              ),
          child: SafeArea(
            child: Container(
              margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: const Color(0xFF21262D),
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: const Color.fromRGBO(0, 0, 0, 0.3),
                    blurRadius: 12,
                    offset: const Offset(0, 4),
                  ),
                ],
              ),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      const Text(
                        '스캔 안내',
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 16,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      IconButton(
                        onPressed: () => _helpToastController?.reverse(),
                        icon: const Icon(Icons.close),
                        color: Colors.white70,
                        iconSize: 22,
                        padding: EdgeInsets.zero,
                        constraints: const BoxConstraints(
                          minWidth: 32,
                          minHeight: 32,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  ConstrainedBox(
                    constraints: const BoxConstraints(maxHeight: 360),
                    child: SingleChildScrollView(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          _toastSectionTitle('1. 기본 식별 정보'),
                          _toastItem(
                            'Device Name (Local Name)',
                            '장치가 설정한 이름입니다. (예: "Kona-Beacon", "Galaxy Watch")',
                          ),
                          _toastItem(
                            'Remote ID (MAC Address)',
                            '장치의 고유 식별 주소입니다. (iOS는 보안상 랜덤 UUID로 표시됩니다.)',
                          ),
                          _toastItem(
                            'Appearance',
                            '장치의 종류(아이콘)를 결정하는 코드값입니다. (예: 키보드, 심박계, 센서 등)',
                          ),
                          const SizedBox(height: 12),
                          _toastSectionTitle('2. Advertisement Data'),
                          _toastItem(
                            'Service UUIDs',
                            '해당 장치가 제공하는 서비스의 목록입니다. (예: 배터리 서비스, 혈압 측정 서비스 등)',
                          ),
                          _toastItem(
                            'Manufacturer Data',
                            '가장 중요한 부분입니다. 제조사가 임의로 넣은 데이터로, 비콘의 UUID, Major, Minor 값이나 센서의 실시간 측정값(온도, 습도)이 여기에 담깁니다.',
                          ),
                          _toastItem(
                            'Service Data',
                            '특정 서비스와 관련된 추가 데이터를 담고 있습니다.',
                          ),
                          _toastItem(
                            'TX Power Level',
                            '장치가 신호를 보낼 때의 세기입니다. RSSI와 비교하여 장치와의 실제 거리(m)를 계산할 때 사용합니다.',
                          ),
                          const SizedBox(height: 12),
                          _toastSectionTitle('3. Status (Connected)'),
                          _toastItem(
                            'Connectable',
                            '현재 이 장치에 연결이 가능한 상태인지 여부입니다.',
                          ),
                          _toastItem(
                            'MTU Size',
                            '한 번에 주고받을 수 있는 데이터 패킷의 최대 크기입니다.',
                          ),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildDeviceList() {
    final list = _devices.values.toList();
    if (list.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.bluetooth_disabled,
              size: 64,
              color: const Color.fromRGBO(255, 255, 255, 0.3),
            ),
            const SizedBox(height: 16),
            Text(
              _isScanning ? '주변 기기를 검색 중입니다...' : '아래 버튼을 눌러 스캔을 시작하세요',
              style: TextStyle(
                color: const Color.fromRGBO(255, 255, 255, 0.6),
                fontSize: 16,
              ),
            ),
          ],
        ),
      );
    }

    list.sort((a, b) => (b.rssi).compareTo(a.rssi));

    return ListView.builder(
      padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
      itemCount: list.length,
      itemBuilder: (context, index) {
        final result = list[index];
        final isExpanded = _expandedDeviceId == result.device.remoteId;
        return _DeviceTile(
          result: result,
          isExpanded: isExpanded,
          onTap: () {
            setState(() {
              _expandedDeviceId = isExpanded ? null : result.device.remoteId;
            });
          },
        );
      },
    );
  }

  Widget _buildScanButton() {
    return FloatingActionButton.extended(
      onPressed: _isScanning ? _stopScan : _startScan,
      backgroundColor: _isScanning ? Colors.red : const Color(0xFF1E88E5),
      icon: Icon(_isScanning ? Icons.stop : Icons.search),
      label: Text(_isScanning ? '스캔 중지' : '스캔 시작'),
    );
  }

  Widget _buildCopyright() {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 12),
      child: Text(
        '© AtlasSoft',
        style: TextStyle(
          color: Color.fromRGBO(255, 255, 255, 0.45),
          fontSize: 12,
        ),
      ),
    );
  }
}

class _DeviceTile extends StatelessWidget {
  const _DeviceTile({
    required this.result,
    required this.isExpanded,
    required this.onTap,
  });

  final ScanResult result;
  final bool isExpanded;
  final VoidCallback onTap;

  static const _infoFontSize = 11.0;
  static const _detailFontSize = 10.0;

  @override
  Widget build(BuildContext context) {
    final dev = result.device;
    final adv = result.advertisementData;
    final name = dev.platformName.isNotEmpty
        ? dev.platformName
        : (adv.advName.isNotEmpty ? adv.advName : 'No Name');
    final mac = dev.remoteId.str;
    final rssi = result.rssi;
    final connectable = adv.connectable ? 'Y' : 'N';
    final mtuStr = dev.isConnected ? '${dev.mtuNow}' : '—';
    final distanceStr = _distanceFromRssi(rssi, adv.txPowerLevel);

    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      color: const Color(0xFF161B22),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Row(
                children: [
                  CircleAvatar(
                    backgroundColor: const Color.fromRGBO(33, 150, 243, 0.3),
                    child: Icon(
                      isExpanded ? Icons.expand_less : Icons.expand_more,
                      color: Colors.blue,
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Row(
                          crossAxisAlignment: CrossAxisAlignment.baseline,
                          textBaseline: TextBaseline.alphabetic,
                          children: [
                            Flexible(
                              child: Text(
                                name,
                                style: const TextStyle(
                                  color: Colors.white,
                                  fontWeight: FontWeight.w600,
                                  fontSize: 16,
                                ),
                                overflow: TextOverflow.ellipsis,
                              ),
                            ),
                            const SizedBox(width: 8),
                            Text(
                              mac,
                              style: const TextStyle(
                                color: Color.fromRGBO(255, 255, 255, 0.85),
                                fontSize: _infoFontSize,
                                fontFamily: 'monospace',
                              ),
                              overflow: TextOverflow.ellipsis,
                            ),
                          ],
                        ),
                        const SizedBox(height: 2),
                        Row(
                          children: [
                            Icon(
                              Icons.signal_cellular_alt,
                              size: 12,
                              color: _rssiColor(rssi),
                            ),
                            const SizedBox(width: 4),
                            Text(
                              'RSSI: $rssi dBm',
                              style: TextStyle(
                                color: _rssiColor(rssi),
                                fontSize: _infoFontSize,
                              ),
                            ),
                            const SizedBox(width: 10),
                            Text(
                              'distance: $distanceStr',
                              style: const TextStyle(
                                color: Color.fromRGBO(255, 255, 255, 0.85),
                                fontSize: _infoFontSize,
                              ),
                            ),
                          ],
                        ),
                        const SizedBox(height: 4),
                        Row(
                          children: [
                            Expanded(
                              child: Text(
                                'MTU Size: $mtuStr',
                                style: const TextStyle(
                                  color: Color.fromRGBO(255, 255, 255, 0.85),
                                  fontSize: _infoFontSize,
                                ),
                                overflow: TextOverflow.ellipsis,
                              ),
                            ),
                            const SizedBox(width: 10),
                            Text(
                              'Connectable: $connectable',
                              style: const TextStyle(
                                color: Color.fromRGBO(255, 255, 255, 0.85),
                                fontSize: _infoFontSize,
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ],
              ),
              AnimatedCrossFade(
                firstChild: const SizedBox.shrink(),
                secondChild: _buildDetailSection(adv),
                crossFadeState: isExpanded
                    ? CrossFadeState.showSecond
                    : CrossFadeState.showFirst,
                duration: const Duration(milliseconds: 200),
                sizeCurve: Curves.easeOut,
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildDetailSection(AdvertisementData adv) {
    final serviceUuids = adv.serviceUuids.isEmpty
        ? '—'
        : adv.serviceUuids.map((g) => g.toString()).join(', ');
    final manufacturerData = adv.manufacturerData.isEmpty
        ? '—'
        : adv.manufacturerData.entries
              .map(
                (e) =>
                    '0x${e.key.toRadixString(16).toUpperCase().padLeft(4, '0')}: ${e.value.length}B',
              )
              .join(', ');
    final serviceData = adv.serviceData.isEmpty
        ? '—'
        : adv.serviceData.entries
              .map((e) {
                final s = e.key.toString();
                final end = s.length >= 8 ? 8 : s.length;
                return '${s.substring(0, end)}${s.length > 8 ? '...' : ''}: ${e.value.length}B';
              })
              .join(', ');
    final txPower = adv.txPowerLevel != null ? '${adv.txPowerLevel} dBm' : '—';

    const labelStyle = TextStyle(
      color: Color.fromRGBO(255, 255, 255, 0.5),
      fontSize: _detailFontSize,
    );
    const valueStyle = TextStyle(
      color: Color.fromRGBO(255, 255, 255, 0.85),
      fontSize: _detailFontSize,
      fontFamily: 'monospace',
    );

    return Padding(
      padding: const EdgeInsets.only(top: 12),
      child: Container(
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: const Color.fromRGBO(255, 255, 255, 0.25),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Divider(
              height: 1,
              color: Color.fromRGBO(255, 255, 255, 0.12),
            ),
            const SizedBox(height: 10),
            Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(
                  child: _detailItem(
                    'Service UUIDs',
                    serviceUuids,
                    labelStyle,
                    valueStyle,
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: _detailItem(
                    'Manufacturer Data',
                    manufacturerData,
                    labelStyle,
                    valueStyle,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(
                  child: _detailItem(
                    'Service Data',
                    serviceData,
                    labelStyle,
                    valueStyle,
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: _detailItem(
                    'TX Power Level',
                    txPower,
                    labelStyle,
                    valueStyle,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _detailItem(
    String label,
    String value,
    TextStyle labelStyle,
    TextStyle valueStyle,
  ) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(label, style: labelStyle),
        const SizedBox(height: 2),
        Text(
          value,
          style: valueStyle,
          overflow: TextOverflow.ellipsis,
          maxLines: 3,
        ),
      ],
    );
  }

  Color _rssiColor(int rssi) {
    if (rssi >= -50) return Colors.green;
    if (rssi >= -70) return Colors.orange;
    return Colors.red;
  }

  /// TX Power(dBm)와 RSSI(dBm)로 거리(m) 추정. path loss exponent n=2 사용.
  /// TX가 비정상(일반 BLE 범위 -80~-40 dBm 밖)이면 기본값 -59 dBm으로 대체.
  static String _distanceFromRssi(int rssi, int? txPowerDbm) {
    const defaultTx = -59; // BLE 광고 기본 가정값
    final rawTx = txPowerDbm ?? defaultTx;
    final tx = (rawTx > -40 || rawTx < -80) ? defaultTx : rawTx;
    const n = 2.0; // path loss exponent (자유공간 ≈2)
    final exp = (tx - rssi) / (10.0 * n);
    final d = math.pow(10.0, exp);
    if (d < 0.01) return '0.01 m';
    if (d > 999) return '999+ m';
    return '${d.toStringAsFixed(2)} m';
  }
}

4. APK Build하기

apk 빌드를 하여 release 하려면 app의 OS에 맞게 터미널에 아래와 같이 각각 입력합니다.

flutter build apk --release
flutter build ios --release
flutter build web --release

 

※ 지금까지 과정을 그대로 진행하셨다면, Android SDK 에러가 발생하면서 정상적인 Build가 되지 않을 것입니다.

 

1) 우선 아래와 같이 터미널에 명령어를 입력하여 Flutter에게 Andrioid SDK 위치를 정확하게 알려 주어야 합니다.

flutter config --android-sdk "복사한_SDK_경로"

 

2) SDK 위치를 지정하셨다면, 라이센서 동의를 완료해야 정상적으로 Build가 됩니다.

경로 설정 후, 터미널에 아래 명령어를 입력하고 나오는 질문에 모두 y를 눌러 동의하세요.

flutter doctor --android-licenses

 

3) 라인센스 동의도 에러가 날 것입니다. 

이 에러는 안드로이드 SDK를 관리하는 도구인 sdkmanager가 Java(JDK)의 위치를 찾지 못해서 발생하는 전형적인 설정 오류입니다. Flutter Windows 설치 가이드에서도 강조하는 부분이니, 아래 순서대로 JAVA_HOME을 잡아주면 바로 해결됩니다.

 

4) JAVA_HOME 환경 변수 설정 (가장 중요)

윈도우 환경변수 설정을 실행하여, '시스템 변수' 영역에서 **[새로 만들기]**를 누릅니다.
변수 이름: JAVA_HOME
변수 값: 위에서 확인한 Java 폴더 경로 (예: C:\Program Files\Android\Android Studio\jbr)

Path 변수 수정: '시스템 변수'의 Path를 선택하고 [편집] -> [새로 만들기] -> %JAVA_HOME%\bin을 추가합니다.

 

5) 다시 2)번의 라이센서 동의를 진행하면, 정상적으로 진행이 될 것입니다.

 

6) 라이센스 동의까지 완료가 되었다면, apk를 Build해 보도록 합니다.

 

위와 같이 정상적으로 Build가 되고, APK파일의 경로를 보여 줍니다.

 

반응형