2026. 2. 10. 14:02ㆍJust 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파일의 경로를 보여 줍니다.

'Just do IT > Flutter' 카테고리의 다른 글
| [Flutter]이론 말고 실전!! - Cursor에 Flutter 개발 환경 구축하기 (0) | 2026.02.10 |
|---|---|
| [Flutter]Widget Tree? (0) | 2023.09.24 |
| [Flutter]Widget? StatelessWidget, StatefulWidget (0) | 2023.09.24 |