FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

Flutter + Firebaseで位置情報共有機能を作る

この記事は「フリュー Advent Calendar 2023」11日目の記事です。

はじめに

メリークリスマス、ピクトリンク事業部開発部開発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ヶ月以上ログインしていない彼氏
あれ、私と位置情報共有してくれるって言ったよね?なんで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の更新が容易なことを実感できました。
メリークリスマスそして良いお年を!