본문 바로가기
Flutter for Beginners

Flutter 디지털 시계 + 나침반 + 날씨정보

by Andrew's Akashic Records 2025. 3. 25.
728x90

기상청 단기예보 API 활용 신청하기

회원 가입후 활용신청 가능 합니다.

 

기상청_단기예보 ((구)_동네예보) 조회서비스 | 공공데이터포털

 

기상청_단기예보 ((구)_동네예보) 조회서비스

초단기실황, 초단기예보, 단기((구)동네)예보, 예보버전 정보를 조회하는 서비스입니다. 초단기실황정보는 예보 구역에 대한 대표 AWS 관측값을, 초단기예보는 예보시점부터 6시간까지의 예보를,

www.data.go.kr

프로젝트 구조

Android, Windows, Web 만 적용합니다.iOS는 나중에..

 

Flutter Dependencies 추가

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  intl: '^0.20.2'
  flutter_compass: '^0.8.1'
  permission_handler: '^11.4.0'
  http: '^1.3.0'
  geolocator: '^13.0.3'

 

1. intl: ^0.20.2

  • 날짜, 시간, 숫자, 통화 등의 국제화/지역화(i18n) 기능 제공
  • Dart의 DateFormat, NumberFormat 등의 클래스 포함
  • initializeDateFormatting()을 통해 로케일 데이터를 초기화해야 요일 등 한글로 표시 가능
DateFormat('yyyy년 MM월 dd일 (E)', 'ko_KR').format(DateTime.now());

 

2. flutter_compass: ^0.8.1

  • 기기의 나침반 센서에서 방위(degree) 값을 실시간으로 가져올 수 있음
  • 기기 방향(0~359도)을 감지해서 동서남북 출력 가능
  • Android 6.0+에서는 위치 권한이 필수
  • 에뮬레이터에서는 센서가 없어서 작동 안 됨
FlutterCompass.events?.listen((event) {
  print('Heading: ${event.heading}');
});

 

3. permission_handler: ^11.4.0

  • Flutter에서 런타임 권한 요청을 손쉽게 처리하는 패키지
  • 위치, 카메라, 마이크, 저장소 등 다양한 퍼미션을 제어
  • Android 13+부터는 블루투스, 알림 등도 별도 퍼미션 필요
  • iOS는 Info.plist에 이유 설명 문구를 추가해야 함
var status = await Permission.location.request();
if (status.isGranted) {
  // 권한 허용됨
}

 

4. http: ^1.3.0

  • Flutter/Dart에서 REST API 호출을 위한 HTTP 클라이언트
  • GET, POST 등 다양한 요청 가능
  • JSON 파싱을 위해 dart:convert의 jsonDecode() 사용
  • 네트워크 호출은 항상 async/await 사용
final response = await http.get(Uri.parse('https://example.com/api'));
if (response.statusCode == 200) {
  final data = jsonDecode(response.body);
}

 

5. geolocator: ^13.0.3

  • 현재 위치 정보, 거리 계산, 위치 스트리밍 등 위치 기반 서비스를 제공
  • Android/iOS에서 모두 동작
  • ACCESS_FINE_LOCATION 권한이 필수
  • Android에서는 Google Play Services 필요
  • iOS에서는 NSLocationWhenInUseUsageDescription 설정 필요
Position position = await Geolocator.getCurrentPosition();
print(position.latitude);

안드로이드 권한 부여

app/src/main/AndroidManifest.xml

    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

 

1. <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

  • 설명: 대략적인 위치 (Wi-Fi, 셀룰러 타워 기반)를 얻기 위한 권한
  • 정확도: 수백 미터 ~ 1km 정도
  • 사용 예:
    • 날씨 정보 요청 시, 정밀한 위치가 필요하지 않을 때
    • 실내 위치 기반 서비스
  • 기타: Android 10 이상에서는 ACCESS_FINE_LOCATION과 함께 쓰는 게 일반적

2. <uses-permission android:name="android.permission.BLUETOOTH" />

  • 설명: 블루투스 기능 사용을 위한 기본 권한
  • 사용 예:
    • 기기 간 블루투스 통신
  • 주의: Android 12(API 31)+에서는 BLUETOOTH_CONNECT 같은 세분화된 권한이 추가되었기 때문에 이 권한만으론 부족할 수 있음

3. <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

  • 설명: 블루투스 설정 변경 등 고급 기능 제어 권한
  • 사용 예:
    • 블루투스 ON/OFF
    • 페어링 제어 등
  • 추가: Android 12부터는 대부분의 기능이 BLUETOOTH_CONNECT로 대체됨

4. <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

  • Android 12 이상 필수
  • 설명: 블루투스 장치와 연결하거나 이름, MAC 주소 등을 식별하는 데 필요한 권한
  • 사용 예:
    • 블루투스 기기 연결 (예: 웨어러블, Beacon)
  • 중요: Android 12(API 31)+에서는 반드시 명시적 사용자 허용이 필요

5. <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

  • 설명: 정확한 위치(GPS 기반) 를 얻기 위한 권한
  • 정확도: 수 미터 이내
  • 사용 예:
    • 현재 위치 기반 날씨, 지도, 방향 감지 (나침반 포함)
    • Geolocator 또는 flutter_compass 사용 시 필수
728x90

전체 코드

1. services/ClockService.dart

import 'dart:async'; // Timer 사용을 위한 import

/// 시계 기능을 분리하여 제공하는 서비스 클래스.
/// 1초마다 현재 시간을 콜백 함수로 전달함.
class ClockService {
  /// startClock()
  /// - 1초마다 현재 시간을 전달하는 타이머를 생성함
  /// - [onTick]은 매초 현재 시간을 전달받는 콜백 함수
  /// - 반환값은 [Timer] 객체로, 필요시 타이머를 종료할 수 있음
  Timer startClock(void Function(DateTime) onTick) {
    return Timer.periodic(
      const Duration(seconds: 1), // 1초마다 반복
      (_) {
        // 현재 시간(DateTime.now())을 콜백 함수로 전달
        onTick(DateTime.now());
      },
    );
  }
}

2. services/CompassService.dart

import 'package:flutter/material.dart';
import 'package:flutter_compass/flutter_compass.dart'; // 나침반 센서 패키지
import 'package:permission_handler/permission_handler.dart'; // 권한 요청을 위한 패키지

/// 기기의 나침반 센서를 초기화하고 방위각(heading)을 감지하는 서비스 클래스
class CompassService {
  /// initCompass()
  /// - 위치 권한을 요청하고, 허용되면 나침반 센서를 구독 시작
  /// - [onHeadingChanged]는 방위각(0~359.9도)을 콜백으로 전달받음
  /// - 방위각은 북쪽 기준 (0도: 북, 90도: 동, 180도: 남, 270도: 서)
  void initCompass(void Function(double?) onHeadingChanged) async {
    // 위치 권한 요청
    var status = await Permission.locationWhenInUse.request();

    // 권한이 허용되었을 경우
    if (status.isGranted) {
      // FlutterCompass의 스트림을 구독하여 heading 값 수신
      FlutterCompass.events?.listen((event) {
        // heading 값이 null일 수도 있으므로 안전하게 전달
        onHeadingChanged(event.heading);
      });
    } else {
      // 권한이 거부된 경우 디버그 메시지 출력
      debugPrint('위치 권한 거부됨.');
    }
  }
}

3. services/WeatherService.dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';               // 날짜 형식화
import 'package:geolocator/geolocator.dart';   // 위치 정보
import 'package:http/http.dart' as http;       // API 요청용

/// 기상청 초단기실황 API를 통해 현재 위치 기반 날씨 정보를 가져오는 서비스
class WeatherService {
  /// 공공데이터포털에서 발급받은 기상청 API 서비스 키 (URL 인코딩됨)
  final String _serviceKey =
      'H5ONUP4Pu4N7SA9ayhHIgJYVKRKKv6IwqXKr8M9%2F%2B%2F9L8eM5NAH%2BwJMY8sH1jk1Mn15FO%2BkzjVlA22XDvE%2FUMQ%3D%3D';

  /// 현재 위치 기반으로 날씨 정보(온도, 습도, 바람)를 가져오는 메서드
  /// 반환: 온도(temp), 습도(humid), 바람(wind)를 담은 Map (또는 null)
  Future<Map<String, dynamic>?> fetchWeather() async {
    // 위치 권한 확인 및 요청
    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied ||
        permission == LocationPermission.deniedForever) {
      await Geolocator.requestPermission();
    }

    // 현재 GPS 위치 가져오기
    final position = await Geolocator.getCurrentPosition();

    // 위도, 경도를 기상청 격자 좌표(nx, ny)로 변환
    final grid = _convertToGrid(position.latitude, position.longitude);

    // 현재 날짜와 시간을 기준으로 기상청에 요청할 base_date, base_time 설정
    final now = DateTime.now();
    final baseDate = DateFormat('yyyyMMdd').format(now);                    // ex: 20250325
    final baseTime = DateFormat('HH00').format(now.subtract(const Duration(hours: 1))); // ex: 0900

    // 초단기실황 API 요청 URL 구성
    final url = Uri.parse(
      'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst'
      '?serviceKey=$_serviceKey&numOfRows=100&dataType=JSON'
      '&base_date=$baseDate&base_time=$baseTime&nx=${grid.x}&ny=${grid.y}'
    );

    // HTTP GET 요청 전송
    final response = await http.get(url);

    // 응답 성공 시(JSON 파싱 후 필요한 값 추출)
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      final items = data['response']['body']['items']['item'];

      // 필요한 날씨 데이터 초기화
      double? temp, humid, wind;

      // 항목을 순회하며 T1H(기온), REH(습도), WSD(풍속) 추출
      for (var item in items) {
        switch (item['category']) {
          case 'T1H': // 기온
            temp = double.tryParse(item['obsrValue'].toString());
            break;
          case 'REH': // 습도
            humid = double.tryParse(item['obsrValue'].toString());
            break;
          case 'WSD': // 풍속
            wind = double.tryParse(item['obsrValue'].toString());
            break;
        }
      }

      // 추출한 데이터 반환
      return {'temp': temp, 'humid': humid, 'wind': wind};
    } else {
      // 응답 실패 시 로그 출력
      debugPrint('기상청 API 오류: ${response.body}');
      return null;
    }
  }

  /// 위도, 경도를 기상청 격자 좌표(nx, ny)로 변환하는 함수
  GridXY _convertToGrid(double lat, double lon) {
    // 기상청에서 정의한 변환 상수
    const double RE = 6371.00877; // 지구 반지름 (km)
    const double GRID = 5.0; // 격자 간격 (km)
    const double SLAT1 = 30.0; // 표준 위도 1
    const double SLAT2 = 60.0; // 표준 위도 2
    const double OLON = 126.0; // 기준점 경도
    const double OLAT = 38.0;  // 기준점 위도
    const double XO = 43; // 기준점 X 좌표
    const double YO = 136; // 기준점 Y 좌표

    // 변환 수식 계산
    double DEGRAD = pi / 180.0;
    double re = RE / GRID;
    double slat1 = SLAT1 * DEGRAD;
    double slat2 = SLAT2 * DEGRAD;
    double olon = OLON * DEGRAD;
    double olat = OLAT * DEGRAD;

    double sn = tan(pi * 0.25 + slat2 * 0.5) / tan(pi * 0.25 + slat1 * 0.5);
    sn = log(cos(slat1) / cos(slat2)) / log(sn);
    double sf = tan(pi * 0.25 + slat1 * 0.5);
    sf = pow(sf, sn) * cos(slat1) / sn;
    double ro = tan(pi * 0.25 + olat * 0.5);
    ro = re * sf / pow(ro, sn);

    double ra = tan(pi * 0.25 + lat * DEGRAD * 0.5);
    ra = re * sf / pow(ra, sn);
    double theta = lon * DEGRAD - olon;
    if (theta > pi) theta -= 2.0 * pi;
    if (theta < -pi) theta += 2.0 * pi;
    theta *= sn;

    // 최종 격자 좌표 계산
    int x = (ra * sin(theta) + XO + 0.5).floor();
    int y = (ro - ra * cos(theta) + YO + 0.5).floor();
    return GridXY(x, y);
  }
}

/// 기상청 API에 사용되는 격자 좌표 (nx, ny)
class GridXY {
  final int x;
  final int y;
  GridXY(this.x, this.y);
}

4. screen/DigitalClockScreen.dart

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

import 'package:digital_clock/services/ClockService.dart';    // 시계 타이머 서비스
import 'package:digital_clock/services/CompassService.dart';  // 나침반 센서 서비스
import 'package:digital_clock/services/WeatherService.dart';  // 기상청 날씨 서비스

/// 디지털 시계 + 날씨 + 나침반을 보여주는 화면
class DigitalClockScreen extends StatefulWidget {
  const DigitalClockScreen({super.key});

  @override
  State<DigitalClockScreen> createState() => _DigitalClockScreenState();
}

class _DigitalClockScreenState extends State<DigitalClockScreen> {
  // 서비스 객체 선언
  final ClockService _clockService = ClockService();
  final CompassService _compassService = CompassService();
  final WeatherService _weatherService = WeatherService();

  // 상태 변수들
  DateTime _now = DateTime.now();          // 현재 시각
  double? _heading;                        // 나침반 각도
  Map<String, dynamic>? _weather;          // 날씨 데이터
  Timer? _timer;                           // 시계 타이머

  @override
  void initState() {
    super.initState();
    _startClock();     // 시계 시작
    _initCompass();    // 나침반 시작
    _fetchWeather();   // 날씨 정보 불러오기
  }

  /// 1초마다 현재 시간을 갱신하는 타이머 시작
  void _startClock() {
    _timer = _clockService.startClock((now) {
      setState(() {
        _now = now;
      });
    });
  }

  /// 나침반 센서 초기화 및 heading 값 수신
  void _initCompass() {
    _compassService.initCompass((heading) {
      setState(() {
        _heading = heading;
      });
    });
  }

  /// 현재 위치 기반으로 날씨 정보 가져오기
  void _fetchWeather() async {
    final weather = await _weatherService.fetchWeather();
    if (weather != null) {
      setState(() {
        _weather = weather;
      });
    }
  }

  /// 타이머 해제 (리소스 정리)
  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  /// heading(각도)을 방향 문자열로 변환 (N, NE, E, ... 등)
  String _getDirection(double? degree) {
    if (degree == null) return "방향 알 수 없음";
    if (degree >= 337.5 || degree < 22.5) return "북 (N)";
    if (degree >= 22.5 && degree < 67.5) return "북동 (NE)";
    if (degree >= 67.5 && degree < 112.5) return "동 (E)";
    if (degree >= 112.5 && degree < 157.5) return "남동 (SE)";
    if (degree >= 157.5 && degree < 202.5) return "남 (S)";
    if (degree >= 202.5 && degree < 247.5) return "남서 (SW)";
    if (degree >= 247.5 && degree < 292.5) return "서 (W)";
    if (degree >= 292.5 && degree < 337.5) return "북서 (NW)";
    return "알 수 없음";
  }

  @override
  Widget build(BuildContext context) {
    final direction = _getDirection(_heading);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Andrew 나침반'), // 앱 상단 타이틀
        centerTitle: true,
      ),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              // 📅 날짜 표시
              Text(
                DateFormat('yyyy년 MM월 dd일 (E)', 'ko_KR').format(_now),
                style: const TextStyle(fontSize: 24),
                textAlign: TextAlign.center,
              ),

              const SizedBox(height: 20),

              // 🕐 현재 시간 표시 (시:분:초)
              FittedBox(
                fit: BoxFit.scaleDown,
                child: Text(
                  DateFormat('HH:mm:ss').format(_now),
                  style: const TextStyle(
                    fontSize: 100,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 4,
                  ),
                ),
              ),

              const SizedBox(height: 30),

              // 🌤 날씨 정보 표시 (온도, 습도, 바람)
              if (_weather != null)
                Center(
                  child: Column(
                    children: [
                      Text('🌡️ 온도: ${_weather!["temp"]}°C',
                          style: const TextStyle(fontSize: 18)),
                      Text('💧 습도: ${_weather!["humid"]}%',
                          style: const TextStyle(fontSize: 18)),
                      Text('🌬️ 바람: ${_weather!["wind"]} m/s',
                          style: const TextStyle(fontSize: 18)),
                    ],
                  ),
                )
              else
                const CircularProgressIndicator(), // 날씨 로딩 중

              const SizedBox(height: 30),

              // 🧭 나침반 정보 표시
              if (_heading != null)
                Column(
                  children: [
                    // 방향에 따라 회전하는 나침반 아이콘
                    Transform.rotate(
                      angle: (_heading! * pi / 180 * -1),
                      child: const Icon(Icons.navigation,
                          size: 60, color: Colors.blueAccent),
                    ),
                    const SizedBox(height: 12),
                    // 방향 텍스트 + 각도
                    Text(
                      '$direction\n${_heading!.toStringAsFixed(1)}°',
                      textAlign: TextAlign.center,
                      style: const TextStyle(fontSize: 20),
                    ),
                  ],
                )
              else
                const Text(
                  '나침반 센서를 찾을 수 없습니다.',
                  style: TextStyle(fontSize: 16),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

5. main.dart

import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart'; // 날짜 포맷의 로케일(언어/문화권) 초기화를 위한 패키지

import 'package:digital_clock/screen/DigitalClockScreen.dart'; // 디지털 시계 화면 (UI) import

/// 앱 실행의 진입점 (entry point)
void main() async {
  WidgetsFlutterBinding.ensureInitialized(); // Flutter 바인딩을 미리 초기화 (비동기 작업을 위해 필요)
  
  // intl 패키지 사용 시 한국어 요일, 날짜 포맷 등을 위해 로케일 데이터를 초기화
  await initializeDateFormatting('ko_KR', null);

  // 앱 실행
  runApp(const DigitalClockApp());
}

/// 앱 루트 위젯 (MaterialApp 설정 및 첫 화면 정의)
class DigitalClockApp extends StatelessWidget {
  const DigitalClockApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Andrew 나침반',      // 앱 타이틀 (Android 태스크 스위처나 iOS에서 보일 수 있음)
      theme: ThemeData.dark(),    // 다크 테마 사용
      home: const DigitalClockScreen(), // 첫 화면은 DigitalClockScreen
    );
  }
}

 

넓은 화면 실행 모습
쫍은 화면

안드로이드 앱에 아이콘을 등록하고 빌드

1단계: 앱 아이콘 준비

  • 필수 조건:
    • 정사각형 PNG 이미지
    • 권장 사이즈: 512×512, 배경이 투명하거나 원하는 색상
    • 파일 이름 예: app_icon.png

app_icon.png
0.19MB

2단계: flutter_launcher_icons 패키지 추가

pubspec.yaml 파일에 아래 내용 추가:

dev_dependencies:
  flutter_launcher_icons: ^0.13.1

flutter_icons:
  android: true
  ios: false
  image_path: "assets/app_icon.png"

 

아이콘 이미지 경로는 assets/app_icon.png로 가정하고 있어요. 다르면 맞춰 수정하세요.

3단계: 아이콘 이미지 넣기

  • assets 폴더가 없다면 생성
  • 아이콘 이미지(app_icon.png)를 그 안에 넣기
/my_flutter_app
  └─ /assets
       └─ app_icon.png

4단계: 아이콘 생성 실행

터미널에서 아래 명령어 입력:

flutter pub get
flutter pub run flutter_launcher_icons:main

성공하면 android/app/src/main/res/ 경로 아래에 해상도별 아이콘들이 자동으로 생성됩니다.

5단계: APK 빌드하기

아래 명령어로 릴리즈용 APK를 빌드하세요:

flutter build apk --release
build/app/outputs/flutter-apk/app-release.apk

선택 옵션 (앱 이름도 바꾸고 싶다면)

android/app/src/main/AndroidManifest.xml 안의 label 수정:

<application
    android:label="내 앱 이름"
    ... >
728x90