Skip to content

Bab 32 — Google Maps & Proximity

Bab 16 sudah memperkenalkan Google Maps untuk memilih satu lokasi. Bab ini melangkah lebih jauh: menyimpan lokasi ke Firestore, menampilkan banyak penanda sekaligus, dan menyaring data berdasarkan jarak dari pengguna.

Bagian terakhir itu ternyata jauh lebih menarik daripada kelihatannya, karena Firestore tidak bisa melakukan kueri geografis secara bawaan.

GeoPoint: tipe lokasi Firestore

Firestore punya tipe khusus untuk koordinat:

dart
import 'package:cloud_firestore/cloud_firestore.dart';

final lokasi = GeoPoint(-6.9175, 107.6191);   // Bandung

await _db.collection('polls').doc(id).update({
  'lokasi': lokasi,
});

Membacanya kembali:

dart
final data = doc.data()!;
final lokasi = data['lokasi'] as GeoPoint?;

if (lokasi != null) {
  print('${lokasi.latitude}, ${lokasi.longitude}');
}

Keuntungan memakai GeoPoint alih-alih dua field double terpisah: Firestore menampilkannya sebagai peta kecil di console, dan tipenya jelas.

Menyimpan lokasi saat membuat poll

dart
// lib/data/services/layanan_lokasi.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:location/location.dart';

class HasilLokasi {
  const HasilLokasi({
    required this.lintang,
    required this.bujur,
    this.alamat,
  });

  final double lintang;
  final double bujur;
  final String? alamat;

  GeoPoint get geoPoint => GeoPoint(lintang, bujur);
}

class KesalahanLokasi implements Exception {
  KesalahanLokasi(this.pesan);
  final String pesan;

  @override
  String toString() => pesan;
}

class LayananLokasi {
  final _location = Location();

  Future<HasilLokasi> lokasiSekarang() async {
    // 1. Layanan GPS aktif?
    var layananAktif = await _location.serviceEnabled();
    if (!layananAktif) {
      layananAktif = await _location.requestService();
      if (!layananAktif) {
        throw KesalahanLokasi('Aktifkan layanan lokasi terlebih dahulu.');
      }
    }

    // 2. Izin diberikan?
    var izin = await _location.hasPermission();
    if (izin == PermissionStatus.denied) {
      izin = await _location.requestPermission();
    }

    if (izin == PermissionStatus.deniedForever) {
      throw KesalahanLokasi(
        'Izin lokasi ditolak permanen. Aktifkan lewat Pengaturan.',
      );
    }

    if (izin != PermissionStatus.granted) {
      throw KesalahanLokasi('Izin lokasi diperlukan untuk fitur ini.');
    }

    // 3. Ambil koordinat
    final data = await _location.getLocation();

    if (data.latitude == null || data.longitude == null) {
      throw KesalahanLokasi('Gagal membaca koordinat perangkat.');
    }

    return HasilLokasi(
      lintang: data.latitude!,
      bujur: data.longitude!,
    );
  }

  /// Aliran lokasi yang diperbarui terus — untuk pelacakan waktu nyata.
  Stream<HasilLokasi> pantauLokasi() {
    return _location.onLocationChanged
        .where((d) => d.latitude != null && d.longitude != null)
        .map((d) => HasilLokasi(lintang: d.latitude!, bujur: d.longitude!));
  }
}

Perhatikan pemeriksaan dua tahap: layanan GPS aktif, dan izin diberikan. Keduanya bisa gagal secara terpisah, dan aplikasi yang hanya memeriksa izin akan menggantung tanpa penjelasan ketika GPS mati.

Menghitung jarak

Untuk menyaring berdasarkan kedekatan, kamu butuh menghitung jarak antara dua koordinat. Rumus yang dipakai disebut Haversine.

dart
// lib/utils/jarak.dart
import 'dart:math' as math;

/// Menghitung jarak antara dua koordinat dalam kilometer.
/// Memakai rumus Haversine.
double hitungJarakKm({
  required double lintang1,
  required double bujur1,
  required double lintang2,
  required double bujur2,
}) {
  const radiusBumiKm = 6371.0;

  final dLintang = _keRadian(lintang2 - lintang1);
  final dBujur = _keRadian(bujur2 - bujur1);

  final a = math.sin(dLintang / 2) * math.sin(dLintang / 2) +
      math.cos(_keRadian(lintang1)) *
          math.cos(_keRadian(lintang2)) *
          math.sin(dBujur / 2) *
          math.sin(dBujur / 2);

  final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));

  return radiusBumiKm * c;
}

double _keRadian(double derajat) => derajat * math.pi / 180;

/// Format jarak untuk ditampilkan: 850 m, 3,4 km, 12 km
String formatJarak(double km) {
  if (km < 1) return '${(km * 1000).round()} m';
  if (km < 10) return '${km.toStringAsFixed(1).replaceAll('.', ',')} km';
  return '${km.round()} km';
}

Alternatif tanpa menulis rumus sendiri

Paket geolocator menyediakan fungsi ini:

dart
import 'package:geolocator/geolocator.dart';

final meter = Geolocator.distanceBetween(lat1, lon1, lat2, lon2);
final km = meter / 1000;

Menulis Haversine sendiri berguna untuk memahami cara kerjanya, tetapi untuk produksi memakai pustaka yang teruji lebih bijak.

Masalah: Firestore tidak bisa kueri jarak

Ini keterbatasan yang penting dipahami. Kamu tidak bisa menulis:

dart
// ❌ Tidak mungkin — Firestore tidak punya operator ini
_db.collection('polls').where('lokasi', isWithinKm: 10, of: lokasiSaya)

Firestore hanya bisa memfilter dengan perbandingan sederhana pada satu field. Ada tiga pendekatan untuk mengatasinya.

Pendekatan 1: kotak pembatas (bounding box)

Alih-alih lingkaran, filter dengan persegi — lalu saring sisanya di aplikasi.

dart
// lib/data/repositories/poll_lokasi_repository.dart
import 'dart:math' as math;
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/poll.dart';
import '../../utils/jarak.dart';

class PollDenganJarak {
  const PollDenganJarak({required this.poll, required this.jarakKm});

  final Poll poll;
  final double jarakKm;
}

class PollLokasiRepository {
  PollLokasiRepository({FirebaseFirestore? firestore})
      : _db = firestore ?? FirebaseFirestore.instance;

  final FirebaseFirestore _db;

  Future<List<PollDenganJarak>> pollTerdekat({
    required double lintang,
    required double bujur,
    double radiusKm = 10,
    int batas = 50,
  }) async {
    // 1 derajat lintang ≈ 111 km, di mana pun di bumi
    final deltaLintang = radiusKm / 111.0;

    // 1 derajat bujur menyempit mendekati kutub
    final deltaBujur =
        radiusKm / (111.0 * math.cos(lintang * math.pi / 180).abs());

    // Firestore bisa memfilter rentang pada SATU field saja,
    // jadi kita filter lintang di server, bujur di aplikasi.
    final snapshot = await _db
        .collection('polls')
        .where('lokasi', isGreaterThan: GeoPoint(lintang - deltaLintang, -180))
        .where('lokasi', isLessThan: GeoPoint(lintang + deltaLintang, 180))
        .limit(batas * 3)   // ambil lebih banyak, akan disaring
        .get();

    final hasil = <PollDenganJarak>[];

    for (final doc in snapshot.docs) {
      final lokasi = doc.data()['lokasi'] as GeoPoint?;
      if (lokasi == null) continue;

      // Saring bujur di sini
      if ((lokasi.longitude - bujur).abs() > deltaBujur) continue;

      // Hitung jarak sesungguhnya — kotak menjadi lingkaran
      final jarak = hitungJarakKm(
        lintang1: lintang,
        bujur1: bujur,
        lintang2: lokasi.latitude,
        bujur2: lokasi.longitude,
      );

      if (jarak <= radiusKm) {
        hasil.add(
          PollDenganJarak(poll: Poll.dariDokumen(doc), jarakKm: jarak),
        );
      }
    }

    // Urutkan dari yang terdekat
    hasil.sort((a, b) => a.jarakKm.compareTo(b.jarakKm));

    return hasil.take(batas).toList();
  }
}
   Kueri Firestore mengambil KOTAK,
   lalu aplikasi menyaring menjadi LINGKARAN

   ┌─────────────────────────┐
   │  ○         ○            │  ← diambil dari server
   │      ╭─────────╮        │
   │  ○   │    ●    │  ○     │  ● = pengguna
   │      │  ○   ○  │        │  ○ dalam lingkaran = lolos
   │      ╰─────────╯        │  ○ luar lingkaran = dibuang
   │  ○         ○            │
   └─────────────────────────┘

Kelemahan pendekatan ini

Kamu membaca lebih banyak dokumen daripada yang dipakai — dan setiap dokumen dihitung sebagai satu pembacaan kuota. Untuk radius kecil di area padat, ini bisa boros.

Ia juga tidak bekerja baik di dekat garis bujur 180° atau kutub. Untuk aplikasi Indonesia, itu bukan masalah.

Pendekatan 2: geohash

Cara yang lebih efisien: simpan geohash — string yang mewakili area geografis, di mana lokasi berdekatan punya awalan yang sama.

dart
// Menyimpan
final hash = Geohash.encode(lintang, bujur, precision: 9);
await ref.set({
  'lokasi': GeoPoint(lintang, bujur),
  'geohash': hash,   // misalnya "qqguw7d8k"
});

Karena lokasi berdekatan punya awalan sama, kamu bisa memakai kueri rentang string — yang bisa dilakukan Firestore:

dart
_db.collection('polls')
   .where('geohash', isGreaterThanOrEqualTo: awalan)
   .where('geohash', isLessThanOrEqualTo: '$awalan~')

Paket geoflutterfire_plus menangani seluruh kerumitannya:

yaml
dependencies:
  geoflutterfire_plus: ^0.0.30
dart
import 'package:geoflutterfire_plus/geoflutterfire_plus.dart';

// Menyimpan
final geoFirePoint = GeoFirePoint(GeoPoint(lintang, bujur));
await _db.collection('polls').add({
  ...poll.toMap(),
  'geo': geoFirePoint.data,   // menyimpan geopoint DAN geohash
});

// Mencari
final aliran = GeoCollectionReference(_db.collection('polls'))
    .subscribeWithin(
  center: GeoFirePoint(GeoPoint(lintang, bujur)),
  radiusInKm: 10,
  field: 'geo',
  geopointFrom: (data) => (data['geo'] as Map)['geopoint'] as GeoPoint,
);

Untuk aplikasi yang benar-benar bergantung pada pencarian geografis, ini pendekatan yang direkomendasikan.

Pendekatan 3: filter di aplikasi

Kalau jumlah datamu kecil — di bawah beberapa ratus dokumen — cara paling sederhana adalah mengambil semuanya lalu menyaring di aplikasi.

dart
Stream<List<PollDenganJarak>> pollTerdekatSederhana({
  required double lintang,
  required double bujur,
  required double radiusKm,
}) {
  return _db
      .collection('polls')
      .orderBy('dibuatPada', descending: true)
      .limit(200)
      .snapshots()
      .map((snapshot) {
    final hasil = <PollDenganJarak>[];

    for (final doc in snapshot.docs) {
      final lokasi = doc.data()['lokasi'] as GeoPoint?;
      if (lokasi == null) continue;

      final jarak = hitungJarakKm(
        lintang1: lintang,
        bujur1: bujur,
        lintang2: lokasi.latitude,
        bujur2: lokasi.longitude,
      );

      if (jarak <= radiusKm) {
        hasil.add(
          PollDenganJarak(poll: Poll.dariDokumen(doc), jarakKm: jarak),
        );
      }
    }

    hasil.sort((a, b) => a.jarakKm.compareTo(b.jarakKm));
    return hasil;
  });
}

Sederhana dan bekerja waktu nyata. Tetapi ia membaca 200 dokumen setiap kali ada perubahan, jadi hanya cocok untuk data kecil.

Memilih pendekatan

Jumlah dokumenRekomendasi
< 500Filter di aplikasi — paling sederhana
500 – 50.000Geohash dengan geoflutterfire_plus
> 50.000Layanan khusus: Algolia, Typesense, atau Cloud SQL

Menampilkan banyak penanda

dart
// lib/presentation/screens/layar_peta_poll.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:cloud_firestore/cloud_firestore.dart' as fs;
import '../../data/repositories/poll_lokasi_repository.dart';
import '../../data/services/layanan_lokasi.dart';
import '../../utils/jarak.dart';

class LayarPetaPoll extends StatefulWidget {
  const LayarPetaPoll({super.key});

  @override
  State<LayarPetaPoll> createState() => _LayarPetaPollState();
}

class _LayarPetaPollState extends State<LayarPetaPoll> {
  final _layananLokasi = LayananLokasi();
  final _repo = PollLokasiRepository();

  GoogleMapController? _pengendaliPeta;
  LatLng? _posisiSaya;
  Set<Marker> _penanda = {};
  double _radiusKm = 10;
  bool _memuat = true;
  String? _kesalahan;

  @override
  void initState() {
    super.initState();
    _muat();
  }

  @override
  void dispose() {
    _pengendaliPeta?.dispose();
    super.dispose();
  }

  Future<void> _muat() async {
    setState(() {
      _memuat = true;
      _kesalahan = null;
    });

    try {
      final lokasi = await _layananLokasi.lokasiSekarang();

      final terdekat = await _repo.pollTerdekat(
        lintang: lokasi.lintang,
        bujur: lokasi.bujur,
        radiusKm: _radiusKm,
      );

      if (!mounted) return;

      setState(() {
        _posisiSaya = LatLng(lokasi.lintang, lokasi.bujur);
        _penanda = {
          for (final item in terdekat)
            Marker(
              markerId: MarkerId(item.poll.id),
              position: LatLng(
                item.poll.lokasi!.latitude,
                item.poll.lokasi!.longitude,
              ),
              icon: BitmapDescriptor.defaultMarkerWithHue(
                item.poll.unggulan
                    ? BitmapDescriptor.hueOrange
                    : BitmapDescriptor.hueAzure,
              ),
              infoWindow: InfoWindow(
                title: item.poll.judul,
                snippet:
                    '${item.poll.totalSuara} suara · ${formatJarak(item.jarakKm)}',
                onTap: () => Navigator.of(context)
                    .pushNamed('/poll', arguments: item.poll.id),
              ),
            ),
        };
        _memuat = false;
      });

      await _pengendaliPeta?.animateCamera(
        CameraUpdate.newLatLngZoom(_posisiSaya!, _zoomUntuk(_radiusKm)),
      );
    } on KesalahanLokasi catch (e) {
      if (!mounted) return;
      setState(() {
        _kesalahan = e.pesan;
        _memuat = false;
      });
    }
  }

  /// Perkiraan tingkat zoom yang pas untuk radius tertentu.
  double _zoomUntuk(double radiusKm) {
    if (radiusKm <= 1) return 15;
    if (radiusKm <= 5) return 13;
    if (radiusKm <= 10) return 12;
    if (radiusKm <= 25) return 11;
    return 10;
  }

  @override
  Widget build(BuildContext context) {
    if (_kesalahan != null) {
      return Scaffold(
        appBar: AppBar(title: const Text('Polling Terdekat')),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.location_off, size: 48),
                const SizedBox(height: 12),
                Text(_kesalahan!, textAlign: TextAlign.center),
                const SizedBox(height: 16),
                ElevatedButton(
                  onPressed: _muat,
                  child: const Text('Coba Lagi'),
                ),
              ],
            ),
          ),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Polling Terdekat'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _memuat ? null : _muat,
          ),
        ],
      ),
      body: Stack(
        children: [
          GoogleMap(
            initialCameraPosition: CameraPosition(
              target: _posisiSaya ?? const LatLng(-6.2088, 106.8456),
              zoom: _zoomUntuk(_radiusKm),
            ),
            onMapCreated: (pengendali) => _pengendaliPeta = pengendali,
            markers: _penanda,
            circles: {
              if (_posisiSaya != null)
                Circle(
                  circleId: const CircleId('radius'),
                  center: _posisiSaya!,
                  radius: _radiusKm * 1000,   // dalam meter
                  fillColor: Colors.blue.withValues(alpha: 0.1),
                  strokeColor: Colors.blue.withValues(alpha: 0.4),
                  strokeWidth: 2,
                ),
            },
            myLocationEnabled: true,
            myLocationButtonEnabled: true,
            zoomControlsEnabled: false,
          ),

          if (_memuat)
            const Positioned.fill(
              child: ColoredBox(
                color: Color(0x55000000),
                child: Center(child: CircularProgressIndicator()),
              ),
            ),

          // Pengatur radius
          Positioned(
            left: 16,
            right: 16,
            bottom: 24,
            child: Card(
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 8,
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        const Text('Radius pencarian'),
                        Text(
                          formatJarak(_radiusKm),
                          style: const TextStyle(
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    ),
                    Slider(
                      value: _radiusKm,
                      min: 1,
                      max: 50,
                      divisions: 49,
                      label: formatJarak(_radiusKm),
                      onChanged: (nilai) =>
                          setState(() => _radiusKm = nilai),
                      // Muat ulang hanya setelah selesai menggeser,
                      // bukan pada setiap perubahan
                      onChangeEnd: (_) => _muat(),
                    ),
                    Text(
                      '${_penanda.length} polling ditemukan',
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Perhatikan onChangeEnd pada Slider. Kalau kamu memuat ulang pada onChanged, setiap gerakan jari memicu kueri Firestore — puluhan pembacaan hanya untuk satu geseran. onChangeEnd memicu sekali setelah pengguna melepas.

Penanda kustom

Penanda bawaan cukup, tetapi ikon kustom membuat aplikasi terasa lebih matang.

dart
import 'dart:ui' as ui;
import 'package:flutter/services.dart';

Future<BitmapDescriptor> penandaDariAset(String jalur, int lebar) async {
  final data = await rootBundle.load(jalur);

  final kodek = await ui.instantiateImageCodec(
    data.buffer.asUint8List(),
    targetWidth: lebar,
  );

  final frame = await kodek.getNextFrame();
  final bytes = await frame.image.toByteData(format: ui.ImageByteFormat.png);

  return BitmapDescriptor.bytes(bytes!.buffer.asUint8List());
}

Memakainya:

dart
late BitmapDescriptor _ikonPoll;

@override
void initState() {
  super.initState();
  _muatIkon();
}

Future<void> _muatIkon() async {
  _ikonPoll = await penandaDariAset('assets/ikon/penanda_poll.png', 100);
  if (mounted) setState(() {});
}

Pengelompokan penanda

Ratusan penanda di satu peta menjadi tidak terbaca. Solusinya mengelompokkannya.

yaml
dependencies:
  google_maps_cluster_manager_2: ^3.1.0
dart
class ItemPoll with ClusterItem {
  ItemPoll(this.poll);

  final Poll poll;

  @override
  LatLng get location =>
      LatLng(poll.lokasi!.latitude, poll.lokasi!.longitude);
}

late ClusterManager<ItemPoll> _pengelola;

@override
void initState() {
  super.initState();
  _pengelola = ClusterManager<ItemPoll>(
    _daftarItem,
    (penanda) => setState(() => _penanda = penanda),
    markerBuilder: _bangunPenanda,
  );
}
   TANPA pengelompokan          DENGAN pengelompokan
   ───────────────────          ────────────────────
     📍📍📍📍                        ⬤ 12
     📍📍📍📍📍                      (satu lingkaran
     📍📍📍📍                         dengan angka)
     tidak terbaca                jelas dan ringan

Menampilkan jarak di daftar

Peta bagus, tetapi daftar sering lebih praktis. Menampilkan jarak di sana membuat konteksnya jelas.

dart
// lib/presentation/widgets/kartu_poll_terdekat.dart
import 'package:flutter/material.dart';
import '../../data/repositories/poll_lokasi_repository.dart';
import '../../utils/jarak.dart';

class KartuPollTerdekat extends StatelessWidget {
  const KartuPollTerdekat({super.key, required this.item});

  final PollDenganJarak item;

  @override
  Widget build(BuildContext context) {
    final poll = item.poll;

    return Card(
      child: ListTile(
        leading: poll.urlGambar != null
            ? ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: Image.network(
                  poll.urlGambar!,
                  width: 56,
                  height: 56,
                  fit: BoxFit.cover,
                  errorBuilder: (_, __, ___) =>
                      const Icon(Icons.image_not_supported),
                ),
              )
            : const CircleAvatar(child: Icon(Icons.poll)),
        title: Text(
          poll.judul,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
        subtitle: Row(
          children: [
            const Icon(Icons.near_me, size: 14),
            const SizedBox(width: 4),
            Text(formatJarak(item.jarakKm)),
            const SizedBox(width: 12),
            const Icon(Icons.how_to_vote, size: 14),
            const SizedBox(width: 4),
            Text('${poll.totalSuara}'),
          ],
        ),
        trailing: poll.unggulan
            ? const Icon(Icons.star, color: Colors.amber)
            : null,
        onTap: () =>
            Navigator.of(context).pushNamed('/poll', arguments: poll.id),
      ),
    );
  }
}

Alur lengkap

Privasi lokasi

Ini bagian yang sering diabaikan dan punya konsekuensi nyata.

Jelaskan alasannya sebelum meminta izin. Dialog izin sistem tidak memberi konteks. Tampilkan penjelasan singkat lebih dulu — tingkat penerimaannya jauh lebih tinggi.

dart
Future<bool> _jelaskanLaluMinta(BuildContext context) async {
  final setuju = await showDialog<bool>(
    context: context,
    builder: (ctx) => AlertDialog(
      title: const Text('Izin Lokasi'),
      content: const Text(
        'Kami memakai lokasimu untuk menampilkan polling di sekitarmu. '
        'Lokasi persismu tidak pernah ditampilkan ke pengguna lain.',
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(ctx, false),
          child: const Text('Nanti'),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(ctx, true),
          child: const Text('Lanjutkan'),
        ),
      ],
    ),
  );

  return setuju ?? false;
}

Jangan simpan lokasi lebih presisi dari yang dibutuhkan. Kalau aplikasimu menampilkan "polling dalam 10 km", menyimpan koordinat dengan presisi meter adalah risiko yang tidak perlu.

dart
/// Membulatkan koordinat ke presisi sekitar 1 km.
GeoPoint kaburkan(double lintang, double bujur) {
  return GeoPoint(
    (lintang * 100).round() / 100,
    (bujur * 100).round() / 100,
  );
}

Beri pengguna kendali. Sediakan opsi mematikan berbagi lokasi, dan hormati pilihan itu.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Peta dengan Radius — ⭐⭐ · 45–60 menit

Tantangan: Tampilkan peta berisi belasan lokasi yang kamu simpan sendiri di Firestore sebagai GeoPoint, lalu tambahkan penggeser "tampilkan dalam radius sekian kilometer" yang benar-benar menyaring penanda di peta dan daftar sekaligus.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│         🗺  (peta)                  │
│     📍       ⊙┄┄┄┄┐  ← radius 5km  │
│         📍   ┆  📍 ┆               │
│              └┄┄┄┄┘                │
│  Radius:  ──●───────────  5 km     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Geser ke 20 km → penanda di luar lingkaran sebelumnya ikut muncul, tapi kueri baru hanya dijalankan sekali setelah jari dilepas (onChangeEnd), bukan berkali-kali sepanjang gerakan geser.

Kriteria selesai:

  • Lokasi disimpan sebagai GeoPoint, bukan dua field double terpisah.
  • Penyaringan jarak memakai salah satu dari tiga pendekatan di bab ini, dan kamu bisa menjelaskan kenapa memilih yang itu untuk jumlah data yang kamu punya.
  • Penggeser memakai onChangeEnd, bukan onChanged, dan kamu bisa menjelaskan berapa banyak kueri yang dihemat karenanya.
  • Lingkaran radius di peta digambar dengan satuan yang benar.
  • Aplikasi menjelaskan alasan sebelum meminta izin lokasi, bukan langsung memunculkan dialog sistem begitu layar terbuka.

Petunjuk: Kalau kamu memilih pendekatan kotak pembatas, ada satu detail geometris yang mudah terlewat: satu derajat lintang selalu sekitar 111 kilometer, tetapi satu derajat bujur menyempit semakin dekat ke kutub. Mengabaikannya membuat kotakmu terlalu lebar di lintang tinggi — untuk Indonesia yang dekat khatulistiwa efeknya kecil, tetapi kodenya tetap salah dan akan menggigit kalau aplikasimu dipakai di tempat lain. Soal onChangeEnd: onChanged dipanggil pada setiap pergeseran kecil, jadi menggeser dari 1 ke 20 kilometer bisa memicu puluhan kueri dalam satu gerakan jempol. Dan Circle pada GoogleMap memakai meter — nilai 5 berarti lima meter, bukan lima kilometer.

Variasi 2: Titik Daur Ulang — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun peta titik pengumpulan sampah daur ulang dengan penanda kustom yang bentuknya berbeda menurut jenis sampah yang diterima, jendela info yang menampilkan jam buka, dan tombol yang membuka aplikasi navigasi ke titik itu.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│         🗺  (peta)                  │
│    ♻️(plastik)     🔋(baterai)     │
│          🗑(organik)                │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Tap ikon ♻️ → info window muncul tepat di posisi itu, peta tidak ikut bergeser ke posisi yang membingungkan:

text
┌───────────────────────────┐
│ TPS Plastik RW 05          │
│ Buka: 08:00 - 17:00        │
│        [ Navigasi ]        │
└───────────────────────────┘

Kriteria selesai:

  • Penanda kustom dibuat dari widget atau gambar, bukan hanya mengganti warnanya.
  • Ikon penanda dibuat sekali dan dipakai ulang, bukan dibangun ulang untuk setiap penanda.
  • Mengetuk penanda menampilkan detail tanpa menggeser peta ke posisi yang membingungkan.
  • Tombol navigasi menangani kasus aplikasi peta tidak terpasang.

Petunjuk: Membangun ikon penanda kustom melibatkan penggambaran ke gambar, yang mahal — melakukannya di dalam perulangan untuk lima puluh penanda akan membuat peta terasa berat saat pertama dimuat. Buat satu ikon per jenis di awal, lalu bagikan referensinya. Untuk membuka navigasi, skema URL berbeda antara Android dan iOS, dan keduanya butuh koordinat yang dikodekan dengan benar.

Variasi 3: Privasi Lokasi — ⭐⭐⭐ · 45–60 menit

Tantangan: Ambil aplikasi berbasis lokasi mana pun yang sudah kamu buat, lalu rancang ulang penanganan lokasinya dengan privasi sebagai prioritas: presisi yang dikurangi, kendali pengguna, dan penjelasan yang jujur.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Pelacakan Lokasi          [●──○]  │
│  Presisi: ~100m (3 desimal)        │
│                                     │
│  "Kami simpan lokasi kotamu saja,  │
│   selama kamu aktif memakai fitur  │
│   pencarian terdekat."             │
│                                     │
│       [ Hapus Riwayat Lokasi ]     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Lokasi yang disimpan dibulatkan ke presisi yang benar-benar dibutuhkan fitur, bukan presisi penuh dari GPS.
  • Pengguna bisa mematikan pelacakan lokasi sepenuhnya, dan aplikasi tetap berfungsi dalam mode terbatas.
  • Ada layar yang menjelaskan data lokasi apa yang disimpan, berapa lama, dan untuk apa.
  • Pengguna bisa menghapus seluruh riwayat lokasinya dalam satu aksi.

Petunjuk: Satu desimal koordinat kira-kira setara sebelas kilometer, dan setiap desimal tambahan membaginya sepuluh. Untuk fitur "ada berapa titik daur ulang di dekatku", tiga desimal sudah lebih dari cukup — menyimpan tujuh desimal berarti kamu menyimpan lokasi seseorang dengan ketelitian sentimeter tanpa alasan. Mengurangi presisi adalah salah satu perlindungan privasi termurah yang ada: satu baris kode, dan risiko kebocoran datamu turun drastis.

Variasi 4: Kueri Jarak yang Efisien — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Isi Firestore dengan dua ribu lokasi tersebar, lalu terapkan ketiga pendekatan pencarian berbasis jarak dari bab ini pada data yang sama. Ukur jumlah pembacaan dan waktu masing-masing, lalu pilih satu untuk dipakai.

Kriteria selesai:

  • Ketiga pendekatan berjalan pada data yang sama dan menghasilkan hasil yang benar.
  • Jumlah pembacaan Firestore per pencarian tercatat untuk masing-masing.
  • Kamu bisa menyebutkan pada jumlah data berapa pendekatan paling sederhana berhenti masuk akal.
  • Pendekatan terpilih tetap benar di dekat garis bujur 180 derajat atau kamu mencatat batasannya secara eksplisit.

Petunjuk: Menyaring di aplikasi berarti membaca seluruh koleksi setiap kali — untuk dua ribu dokumen itu dua ribu pembacaan per pencarian, yang menjadi mahal dengan sangat cepat. Kotak pembatas memangkasnya drastis tetapi tetap membaca dokumen di sudut kotak yang sebenarnya di luar radius. Geohash memberi hasil terbaik dengan biaya kerumitan tambahan. Angka yang kamu ukur sendiri akan jauh lebih meyakinkan daripada penjelasan mana pun.

Ikhtisar

  • GeoPoint adalah tipe bawaan Firestore untuk koordinat — lebih baik daripada dua field double terpisah.
  • Periksa dua hal untuk lokasi: layanan GPS aktif dan izin diberikan. Keduanya bisa gagal terpisah.
  • Firestore tidak bisa melakukan kueri jarak. Ada tiga pendekatan: kotak pembatas (sederhana, boros kuota), geohash (efisien, pakai geoflutterfire_plus), atau filter di aplikasi (hanya untuk data kecil).
  • Rumus Haversine menghitung jarak antara dua koordinat; geolocator menyediakannya siap pakai.
  • 1 derajat lintang ≈ 111 km di mana pun, tetapi 1 derajat bujur menyempit mendekati kutub — perhitungan kotak pembatas harus memperhitungkannya.
  • Pakai onChangeEnd pada Slider, bukan onChanged, agar tidak memicu puluhan kueri saat menggeser.
  • Circle pada GoogleMap memakai satuan meter, bukan kilometer.
  • Ratusan penanda butuh pengelompokan agar peta tetap terbaca dan ringan.
  • Jelaskan alasan sebelum meminta izin lokasi — tingkat penerimaannya jauh lebih tinggi daripada langsung menampilkan dialog sistem.
  • Jangan simpan lokasi lebih presisi dari yang dibutuhkan, dan beri pengguna cara mematikannya.

Berikutnya: Bab 33 — Clean Architecture MVVM, penutup Bagian IV.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/8_google-maps-integration-and-proximity-filtering.md (4 video: Google Maps setup, displaying maps, implementing 'Locate Me' & sending location to Firebase, displaying proximity-based polls) dan 1_flutter_core/12_fav-place-app_using-native-device-feature.md (video 14–26). Lihat PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.