この記事は「フリュー Advent Calendar 2023」11日目の記事です。
- はじめに
- 技術スタック
- 事前準備:Firebaseプロジェクトの作成
- step1. 自分の位置情報を地図に表示する
- step2. 自分の位置情報をFirestoreに反映し続ける
- step3. Firestoreから位置情報を取得して地図に反映
- コード全体
- 完成
- おわりに
はじめに
メリークリスマス、ピクトリンク事業部開発部開発2課のkitajimaです。はやく帰省して富山のシースーが食べたいなあ〜
もうすぐクリスマスなので、彼氏の位置情報を毎秒把握したいというユースケースがあるかと思います。
Flutterで位置情報共有機能を作ってみました。
技術スタック
- Flutter
- Firebase Authentication
- Firebase Cloud Firestore
事前準備:Firebaseプロジェクトの作成
今回はデータベースとしてFirebase Cloud Firestoreを、ユーザを識別するために Firebase Authenticationを使用します。
Flutter アプリに Firebase を追加するを参考に、FirebaseをFlutterアプリに追加してください。
step1. 自分の位置情報を地図に表示する
Google Mapsの表示
google_maps_flutterを使うとGoogle Mapsのwidgetが使えるようになり、簡単に地図を表示することができます。 pageのbuildメソッドはこんな感じです。
@override Widget build(BuildContext context) => Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text("Location Share Page"), ), body: const GoogleMap( initialCameraPosition: CameraPosition( target: LatLng(0, 0), ), myLocationEnabled: true, myLocationButtonEnabled: true, ), );
myLocationEnabled
をtrueにすると、現在地を表示することができます。
myLocationButtonEnabled
をtrueにすると、現在地に移動するボタンが表示されます。
取得した位置情報をMapsの初回レンダリングに反映
位置情報を取得するにはgeolocatorを使います。
以下のコードは、1度取得した位置情報を、GoogleMapsの初期位置を設定するためのCameraPosition
として返却するメソッドです。
Future<CameraPosition> _initCurrentLocation() async { if (!await Geolocator.isLocationServiceEnabled()) { return const CameraPosition(target: LatLng(0, 0), zoom: 14); } if (await Geolocator.checkPermission() == LocationPermission.denied && await Geolocator.requestPermission() == LocationPermission.denied) { return const CameraPosition(target: LatLng(0, 0), zoom: 14); } return Geolocator.getCurrentPosition() .then((value) => CameraPosition( target: LatLng(value.latitude, value.longitude), zoom: 14)) .onError((error, stackTrace) => const CameraPosition(target: LatLng(0, 0), zoom: 14)); }
地図上に現在地が表示されました👏
step2. 自分の位置情報をFirestoreに反映し続ける
次に、Firestoreに自分の位置情報が反映されるようにします。 先ほど使用したgeolocatorは、位置情報を1度取得するだけでなく位置情報の変更をStreamとして扱うことができます。
/// store current location information when location changed void _subscribeToLocationChanges() async { _storeLocationSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings(distanceFilter: 20)) .listen((Position? position) async { if (FirebaseAuth.instance.currentUser == null) { await _loginAnonymously(); return; } FirebaseFirestore.instance .collection('locations') .doc(FirebaseAuth.instance.currentUser?.uid) .set({ 'latitude': position?.latitude, 'longitude': position?.longitude, 'timestamp': FieldValue.serverTimestamp(), }); }); }
上記コードでは、自分の位置情報が更新されるたびにFirestoreに位置情報を保存しています。なお、更新頻度が高くなることを避けるため、distanceFilter
で20m以上の動きがあって初めて検知するようにしています。
また、ユーザの識別にはFirebase Authenticationの匿名認証で得られるuidを利用しています。
Firebase 匿名認証を行う | Firebase ドキュメント
step3. Firestoreから位置情報を取得して地図に反映
そして、Firestoreから位置情報を取得して地図に反映してみます。
Firestoreから位置情報リストを取得
Firestoreから位置情報のStreamを取得します。Firestoreのデータが更新されるたび、つまり誰かが動くたびにStreamに新しいデータが流れてきます。
/// subscribe to location changes from Firestore Stream<Set<Marker>> _fetchMarkersStream() => FirebaseFirestore.instance .collection('locations') .snapshots() .map((snapshot) => snapshot.docs .where((doc) => doc.id != FirebaseAuth.instance.currentUser?.uid) .map(_convertToMarker) .toSet());
各ユーザの情報を表示
おまけに、各ユーザを表すマーカーにユーザ情報を表示させてみましょう。
今回はユーザ名(Firebase匿名ログインのuid)、最終ログイン情報を出してみました。
あれ、私と位置情報共有してくれるって言ったよね?なんで3ヶ月もログインしてないの?(圧)
最終ログイン情報
最終ログインを「〜(minutes | days | months) ago」という形式で表すために、timeagoというライブラリを利用しました。与えられたDateTimeが現在から何分前( or 何日前 etc...)なのかを特定のフォーマットで表現してくれるものです。
timeago.format(now.subtract(const Duration(seconds: 30))); // a moment ago timeago.format(now.subtract(const Duration(minutes: 1))); // a minute ago timeago.format(now.subtract(const Duration(days: 2))); // 2 days ago timeago.format(now.subtract(const Duration(days: 30))); // about a month ago
GoogleMapsのマーカーを表すMarkerをこんな感じに作ってあげました。
return Marker( markerId: MarkerId(user), position: LatLng(latitude, longitude), icon: BitmapDescriptor.defaultMarkerWithHue(color), infoWindow: InfoWindow( title: user, snippet: 'last login: ${timeago.format(dateTime)}', ), );
Streamから取得した位置情報を地図に反映
StreamBuilderというwidgetを使うと、Streamから取得したデータを元にwidgetを再構築することができます。誰かが動くたびに自分の地図上のマーカーも動くわけですね!
StreamBuilder( stream: _fetchMarkersStream(), // private method to fetch markers from Firestore(see above) builder: (BuildContext context, AsyncSnapshot<Set<Marker>> markers) => GoogleMap( initialCameraPosition: const CameraPosition( target: LatLng(0, 0), zoom: 14, ), myLocationEnabled: true, myLocationButtonEnabled: true, markers: markers.data!, ), ),
コード全体
コード全体はこちら(折りたたみコンテンツです)▼
import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:timeago/timeago.dart' as timeago; class LocationSharePage extends StatefulWidget { const LocationSharePage({Key? key}) : super(key: key); @override State<LocationSharePage> createState() => _LocationSharePageState(); } class _LocationSharePageState extends State<LocationSharePage> { StreamSubscription? _storeLocationSubscription; @override void initState() { _fetchMarkersStream(); _subscribeToLocationChanges(); super.initState(); } @override void dispose() { _storeLocationSubscription?.cancel(); super.dispose(); } /// attempt to get current location to initialize map Future<CameraPosition> _initCurrentLocation() async { if (!await Geolocator.isLocationServiceEnabled()) { return const CameraPosition(target: LatLng(0, 0), zoom: 14); } if (await Geolocator.checkPermission() == LocationPermission.denied && await Geolocator.requestPermission() == LocationPermission.denied) { return const CameraPosition(target: LatLng(0, 0), zoom: 14); } return Geolocator.getCurrentPosition() .then((value) => CameraPosition( target: LatLng(value.latitude, value.longitude), zoom: 14)) .onError((error, stackTrace) => const CameraPosition(target: LatLng(0, 0), zoom: 14)); } /// store current location information when location changed void _subscribeToLocationChanges() async { _storeLocationSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings(distanceFilter: 20)) .listen((Position? position) async { if (FirebaseAuth.instance.currentUser == null) { await _loginAnonymously(); return; } FirebaseFirestore.instance .collection('locations') .doc(FirebaseAuth.instance.currentUser?.uid) .set({ 'latitude': position?.latitude, 'longitude': position?.longitude, 'timestamp': FieldValue.serverTimestamp(), }); }); } Future<void> _loginAnonymously() async => await FirebaseAuth.instance.signInAnonymously(); /// subscribe to location changes from Firestore Stream<Set<Marker>> _fetchMarkersStream() => FirebaseFirestore.instance .collection('locations') .snapshots() .map((snapshot) => snapshot.docs .where((doc) => doc.id != FirebaseAuth.instance.currentUser?.uid) .map(_convertToMarker) .toSet()); Marker _convertToMarker(doc) { final data = doc.data(); final user = doc.id; double latitude = data['latitude']; double longitude = data['longitude']; DateTime dateTime = data['timestamp'].toDate(); double color = dateTime.isBefore(DateTime.now().subtract(const Duration(days: 1))) ? BitmapDescriptor.hueYellow : BitmapDescriptor.hueAzure; return Marker( markerId: MarkerId(user), position: LatLng(latitude, longitude), icon: BitmapDescriptor.defaultMarkerWithHue(color), infoWindow: InfoWindow( title: user, snippet: 'last login: ${timeago.format(dateTime)}', ), ); } @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text("Location Share Page"), ), body: FutureBuilder<CameraPosition>( future: _initCurrentLocation(), builder: (BuildContext context, AsyncSnapshot<CameraPosition> cameraPosition) => StreamBuilder( stream: _fetchMarkersStream(), builder: (BuildContext context, AsyncSnapshot<Set<Marker>> markers) => cameraPosition.connectionState == ConnectionState.waiting || markers.connectionState == ConnectionState.waiting ? const Center(child: CircularProgressIndicator()) : GoogleMap( initialCameraPosition: cameraPosition.data!, myLocationEnabled: true, myLocationButtonEnabled: true, markers: markers.data!, ), ), ), ); }
完成
こんな感じになりました。これでクリスマスも安心ですね!
※左の端末の位置情報が右の端末上で赤ピン、右の端末の位置情報が左の端末上で青ピンで表示されています。そして実際はリアルタイムに動いています🫶
おわりに
Firebase Cloud Firestoreを使えばリアルタイムで変更を簡単に検知できること、そしてFlutterではStreamを使えばUIの更新が容易なことを実感できました。
メリークリスマスそして良いお年を!