본문 바로가기
Flutter/Study

Flutter 실시간 경로 시각화: 조깅 앱에 Google Maps 적용하기

by Maccrey Coding 2025. 2. 12.
반응형

 

안녕하세요!

지난 포스트에서 다룬 백그라운드 위치 추적 기능을 바탕으로, 오늘은 실시간으로 사용자의 운동 경로를 지도에 표시하는 방법을 알아보겠습니다.

dependencies:
  google_maps_flutter: ^2.3.0
  flutter_polyline_points: ^1.0.0
  latlong2: ^0.9.0

🗺 Google Maps 설정하기

먼저 Google Maps API 키를 설정해야 합니다.

Android 설정

android/app/src/main/AndroidManifest.xml

<manifest ...>
    <application ...>
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="YOUR_API_KEY"/>
    </application>
</manifest>

iOS 설정

ios/Runner/AppDelegate.swift

import UIKit
import Flutter
import GoogleMaps

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GMSServices.provideAPIKey("YOUR_API_KEY")
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

💻 코드 구현

1️⃣ 위치 데이터 모델 확장

@HiveType(typeId: 0)
class LocationRecord extends HiveObject {
  // 기존 필드들...
  
  @HiveField(3)
  final double speed;  // 현재 속도
  
  @HiveField(4)
  final double distance;  // 누적 거리
  
  LocationRecord({
    required this.latitude,
    required this.longitude,
    required this.timestamp,
    required this.speed,
    required this.distance,
  });
  
  LatLng toLatLng() => LatLng(latitude, longitude);
}

 

2️⃣ 지도 뷰모델 구현

final mapViewModelProvider = StateNotifierProvider<MapViewModel, MapState>((ref) {
  return MapViewModel(ref.watch(locationServiceProvider));
});

class MapState {
  final List<LocationRecord> locations;
  final bool isTracking;
  final double totalDistance;
  final double currentSpeed;
  
  MapState({
    required this.locations,
    required this.isTracking,
    required this.totalDistance,
    required this.currentSpeed,
  });
}

class MapViewModel extends StateNotifier<MapState> {
  final LocationService _locationService;
  StreamSubscription? _locationSubscription;
  
  MapViewModel(this._locationService) : super(MapState(
    locations: [],
    isTracking: false,
    totalDistance: 0,
    currentSpeed: 0,
  ));
  
  void startTracking() {
    _locationSubscription = _locationService.locationStream.listen((location) {
      final newLocations = [...state.locations, location];
      final distance = _calculateTotalDistance(newLocations);
      
      state = MapState(
        locations: newLocations,
        isTracking: true,
        totalDistance: distance,
        currentSpeed: location.speed,
      );
    });
  }
  
  void stopTracking() {
    _locationSubscription?.cancel();
    state = state.copyWith(isTracking: false);
  }
  
  double _calculateTotalDistance(List<LocationRecord> locations) {
    if (locations.length < 2) return 0;
    
    double totalDistance = 0;
    for (int i = 0; i < locations.length - 1; i++) {
      totalDistance += _calculateDistance(
        locations[i].toLatLng(),
        locations[i + 1].toLatLng(),
      );
    }
    return totalDistance;
  }
  
  double _calculateDistance(LatLng start, LatLng end) {
    // Haversine 공식을 사용한 거리 계산
    const double radius = 6371e3;  // 지구 반경 (미터)
    
    final lat1 = start.latitude * pi / 180;
    final lat2 = end.latitude * pi / 180;
    final dLat = (end.latitude - start.latitude) * pi / 180;
    final dLon = (end.longitude - start.longitude) * pi / 180;
    
    final a = sin(dLat / 2) * sin(dLat / 2) +
        cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2);
    final c = 2 * atan2(sqrt(a), sqrt(1 - a));
    
    return radius * c;
  }
}

 

3️⃣ 지도 위젯 구현

class TrackingMapPage extends ConsumerWidget {
  final CameraPosition initialPosition = CameraPosition(
    target: LatLng(37.5665, 126.9780),  // 서울 시청
    zoom: 15,
  );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final mapState = ref.watch(mapViewModelProvider);
    
    return Scaffold(
      body: Stack(
        children: [
          GoogleMap(
            initialCameraPosition: initialPosition,
            myLocationEnabled: true,
            myLocationButtonEnabled: true,
            polylines: _createPolylines(mapState.locations),
            onMapCreated: (GoogleMapController controller) {
              // 현재 위치로 카메라 이동
              if (mapState.locations.isNotEmpty) {
                final lastLocation = mapState.locations.last;
                controller.animateCamera(
                  CameraUpdate.newLatLng(lastLocation.toLatLng()),
                );
              }
            },
          ),
          Positioned(
            top: 50,
            left: 0,
            right: 0,
            child: _buildStatsCard(mapState),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          final viewModel = ref.read(mapViewModelProvider.notifier);
          mapState.isTracking ? viewModel.stopTracking() : viewModel.startTracking();
        },
        child: Icon(mapState.isTracking ? Icons.stop : Icons.play_arrow),
      ),
    );
  }
  
  Set<Polyline> _createPolylines(List<LocationRecord> locations) {
    if (locations.isEmpty) return {};
    
    return {
      Polyline(
        polylineId: PolylineId('route'),
        color: Colors.blue,
        width: 5,
        points: locations.map((loc) => loc.toLatLng()).toList(),
      ),
    };
  }
  
  Widget _buildStatsCard(MapState state) {
    return Card(
      margin: EdgeInsets.symmetric(horizontal: 16),
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            _buildStat('거리', '${(state.totalDistance / 1000).toStringAsFixed(2)} km'),
            _buildStat('속도', '${state.currentSpeed.toStringAsFixed(1)} m/s'),
            _buildStat('시간', _formatDuration(state.locations)),
          ],
        ),
      ),
    );
  }
  
  Widget _buildStat(String label, String value) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(label, style: TextStyle(fontSize: 12, color: Colors.grey)),
        SizedBox(height: 4),
        Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
      ],
    );
  }
  
  String _formatDuration(List<LocationRecord> locations) {
    if (locations.isEmpty) return '00:00';
    
    final duration = locations.last.timestamp.difference(locations.first.timestamp);
    final minutes = duration.inMinutes;
    final seconds = duration.inSeconds % 60;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
  }
}

🔍 주요 기능 설명

  1. 실시간 경로 표시
    • Polyline을 사용하여 이동 경로를 선으로 표시
    • 새로운 위치가 추가될 때마다 자동으로 업데이트
  2. 통계 정보 표시
    • 총 이동 거리
    • 현재 속도
    • 운동 시간
  3. 지도 기능
    • 현재 위치 표시
    • 자동 카메라 이동
    • 경로 추적 시작/중지

🎯 성능 최적화

  1. 메모리 관리
    • StreamSubscription을 적절히 취소하여 메모리 누수 방지
    • 불필요한 상태 업데이트 최소화
  2. 배터리 최적화
    • 위치 업데이트 필터링으로 불필요한 데이터 수집 방지
    • 화면이 꺼져있을 때는 지도 업데이트 주기 조절

💡 추가 개선 아이디어

  1. 히트맵 표시
    • 속도나 고도에 따른 경로 색상 변경
    • 운동 강도 시각화
  2. 경로 분석
    • 구간별 페이스 계산
    • 고도 변화 그래프 표시
  3. 경로 공유
    • 완료된 운동 경로 이미지 생성
    • SNS 공유 기능

마치며

이것으로 Flutter를 사용한 실시간 경로 시각화 구현 방법을 알아보았습니다.

이 구현을 바탕으로 다양한 기능을 추가하여 더 풍부한 운동 경험을 제공할 수 있습니다.

 

다음 포스트에서는 운동 데이터 분석과 통계 기능을 추가하는 방법에 대해 다루어보도록 하겠습니다.

궁금하신 점이나 제안사항이 있으시다면 댓글로 남겨주세요!

 

구독!! 공감과 댓글,

광고 클릭은 저에게 큰 힘이 됩니다.

 

Starting Google Play App Distribution! "Tester Share" for Recruiting 20 Testers for a Closed Test.

 

Tester Share [테스터쉐어] - Google Play 앱

Tester Share로 Google Play 앱 등록을 단순화하세요.

play.google.com

 

반응형