Skip to content

Bab 16 — Fitur Native Perangkat

Ponsel punya kamera, GPS, sensor, dan galeri. Bab ini membahas cara Flutter mengaksesnya lewat paket, dan — yang sama pentingnya — konfigurasi izin yang harus kamu tulis di sisi Android dan iOS agar semuanya berfungsi.

Kita akan membangun aplikasi Tempat Favorit: pengguna memotret sebuah lokasi, aplikasi mencatat koordinat GPS-nya, menampilkan pratinjau peta, dan menyimpannya.

Bagaimana plugin bekerja

Berbeda dari paket biasa yang hanya berisi kode Dart, plugin mengandung kode asli untuk tiap platform.

┌─────────────────────────────────────────────────────────┐
│              KODE DART KAMU                             │
│         ImagePicker().pickImage(...)                    │
└──────────────────────────┬──────────────────────────────┘
                           │  Platform Channel
        ┌──────────────────┴──────────────────┐
        ▼                                     ▼
┌──────────────────┐                 ┌──────────────────┐
│  Kotlin/Java     │                 │  Swift/Obj-C     │
│  (Android)       │                 │  (iOS)           │
│  Intent kamera   │                 │  UIImagePicker   │
└──────────────────┘                 └──────────────────┘

Konsekuensinya: plugin butuh konfigurasi di berkas platform, dan mengubahnya membutuhkan hot restart penuh — bukan hot reload. Bahkan sering kali kamu harus menghentikan aplikasi dan menjalankan flutter run lagi.

Mengambil foto dengan image_picker

yaml
# pubspec.yaml
dependencies:
  image_picker: ^1.1.2

Konfigurasi iOS

Tambahkan penjelasan izin ke ios/Runner/Info.plist. Tanpa ini aplikasi akan ditolak App Store dan crash saat kamera dibuka.

xml
<key>NSCameraUsageDescription</key>
<string>Aplikasi membutuhkan kamera untuk memotret tempat favoritmu.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Aplikasi membutuhkan akses galeri untuk memilih foto tempat.</string>

<key>NSMicrophoneUsageDescription</key>
<string>Aplikasi membutuhkan mikrofon untuk merekam video.</string>

Tulis penjelasan yang jujur dan spesifik. Apple menolak aplikasi dengan deskripsi generik seperti "Aplikasi butuh kamera".

Konfigurasi Android

Untuk image_picker, Android biasanya tidak butuh deklarasi izin tambahan. Tetapi kalau kamu ingin menulis ke penyimpanan bersama di Android 12 ke bawah, tambahkan di android/app/src/main/AndroidManifest.xml:

xml
<manifest ...>
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />
    ...
</manifest>

Pastikan juga minSdkVersion cukup tinggi di android/app/build.gradle:

gradle
android {
    defaultConfig {
        minSdkVersion 21
    }
}

Widget pemilih gambar

dart
// lib/widgets/pemilih_gambar.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class PemilihGambar extends StatefulWidget {
  const PemilihGambar({super.key, required this.saatGambarDipilih});

  final void Function(File gambar) saatGambarDipilih;

  @override
  State<PemilihGambar> createState() => _PemilihGambarState();
}

class _PemilihGambarState extends State<PemilihGambar> {
  File? _gambarTerpilih;

  Future<void> _ambil(ImageSource sumber) async {
    final pemilih = ImagePicker();

    final berkas = await pemilih.pickImage(
      source: sumber,
      maxWidth: 600,      // perkecil agar hemat memori & penyimpanan
      imageQuality: 85,   // kompresi JPEG
    );

    // Bernilai null kalau pengguna membatalkan
    if (berkas == null) return;

    setState(() {
      _gambarTerpilih = File(berkas.path);
    });

    widget.saatGambarDipilih(_gambarTerpilih!);
  }

  void _pilihSumber() {
    showModalBottomSheet(
      context: context,
      builder: (ctx) => SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ListTile(
              leading: const Icon(Icons.camera_alt),
              title: const Text('Ambil Foto'),
              onTap: () {
                Navigator.pop(ctx);
                _ambil(ImageSource.camera);
              },
            ),
            ListTile(
              leading: const Icon(Icons.photo_library),
              title: const Text('Pilih dari Galeri'),
              onTap: () {
                Navigator.pop(ctx);
                _ambil(ImageSource.gallery);
              },
            ),
          ],
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // Tampilan bawaan: tombol ambil foto
    Widget isi = TextButton.icon(
      onPressed: _pilihSumber,
      icon: const Icon(Icons.camera),
      label: const Text('Ambil Foto'),
    );

    // Kalau sudah ada gambar, tampilkan pratinjaunya
    if (_gambarTerpilih != null) {
      isi = GestureDetector(
        onTap: _pilihSumber,
        child: Image.file(
          _gambarTerpilih!,
          fit: BoxFit.cover,
          width: double.infinity,
          height: double.infinity,
        ),
      );
    }

    return Container(
      decoration: BoxDecoration(
        border: Border.all(
          width: 1,
          color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
        ),
        borderRadius: BorderRadius.circular(12),
      ),
      height: 250,
      width: double.infinity,
      alignment: Alignment.center,
      clipBehavior: Clip.antiAlias,
      child: isi,
    );
  }
}

Perkecil gambar sejak awal

maxWidth: 600 dan imageQuality: 85 bukan sekadar penghematan penyimpanan. Foto kamera modern bisa berukuran 12 megapiksel; memuatnya ke memori sebagai bitmap membutuhkan puluhan megabita dan bisa membuat aplikasi terhenti karena kehabisan memori. Selalu perkecil pada titik pengambilan.

Image.file() adalah yang kamu pakai untuk menampilkan berkas lokal — berbeda dari Image.network dan Image.asset.

Mendapatkan lokasi

yaml
dependencies:
  location: ^7.0.1
  http: ^1.2.2

Sebagai alternatif, paket geolocator juga sangat populer dan sedikit lebih lengkap dalam hal penanganan izin.

Konfigurasi Android

xml
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest ...>
    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission
        android:name="android.permission.ACCESS_COARSE_LOCATION" />
    ...
</manifest>

Konfigurasi iOS

xml
<!-- ios/Runner/Info.plist -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Aplikasi membutuhkan lokasi untuk mencatat tempat favoritmu.</string>

Meminta izin dan mengambil koordinat

Ada dua hal yang harus diperiksa dan keduanya bisa gagal secara terpisah: layanan lokasi aktif dan izin diberikan.

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

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

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

  @override
  String toString() => pesan;
}

class LayananLokasi {
  final Location _location = Location();

  Future<HasilLokasi> ambilLokasiSekarang() async {
    // 1. Periksa apakah layanan lokasi (GPS) aktif
    var layananAktif = await _location.serviceEnabled();
    if (!layananAktif) {
      layananAktif = await _location.requestService();
      if (!layananAktif) {
        throw KesalahanLokasi('Layanan lokasi tidak diaktifkan.');
      }
    }

    // 2. Periksa izin aplikasi
    var izin = await _location.hasPermission();
    if (izin == PermissionStatus.denied) {
      izin = await _location.requestPermission();
      if (izin != PermissionStatus.granted) {
        throw KesalahanLokasi('Izin lokasi ditolak.');
      }
    }

    // 3. Ditolak permanen — pengguna harus ke pengaturan
    if (izin == PermissionStatus.deniedForever) {
      throw KesalahanLokasi(
        'Izin lokasi ditolak permanen. Aktifkan lewat Pengaturan.',
      );
    }

    // 4. Ambil koordinatnya
    final data = await _location.getLocation();

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

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

Urutan pemeriksaan itu penting. Banyak aplikasi hanya memeriksa izin dan lupa bahwa GPS bisa saja dimatikan sepenuhnya — hasilnya aplikasi menggantung tanpa penjelasan.

Google Maps API

Untuk mengubah koordinat menjadi alamat dan menampilkan peta, kamu butuh kunci API Google.

Mendapatkan kunci

  1. Buka console.cloud.google.com.
  2. Buat proyek baru.
  3. Aktifkan tiga API: Maps SDK for Android, Maps SDK for iOS, Geocoding API, dan Maps Static API.
  4. Buat kredensial → API Key.
  5. Batasi kuncinya — ini penting. Batasi berdasarkan aplikasi (nama paket + sidik jari SHA-1 untuk Android, bundle ID untuk iOS) dan berdasarkan API yang boleh dipakai.

Jangan commit kunci API ke repositori publik

Kunci yang bocor akan dipakai orang lain, dan tagihannya jatuh ke kamu. Simpan di berkas yang masuk .gitignore, atau lewat --dart-define:

bash
flutter run --dart-define=MAPS_API_KEY=AIza...
dart
const kunciMaps = String.fromEnvironment('MAPS_API_KEY');

Geocoding: koordinat menjadi alamat

Geocoding API menerjemahkan koordinat menjadi alamat yang bisa dibaca manusia.

dart
// lib/services/layanan_geocoding.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class LayananGeocoding {
  LayananGeocoding(this.kunciApi);

  final String kunciApi;

  Future<String> alamatDari(double lintang, double bujur) async {
    final url = Uri.parse(
      'https://maps.googleapis.com/maps/api/geocode/json'
      '?latlng=$lintang,$bujur&key=$kunciApi&language=id',
    );

    final respons = await http.get(url);

    if (respons.statusCode >= 400) {
      return 'Alamat tidak diketahui';
    }

    final data = json.decode(respons.body) as Map<String, dynamic>;
    final hasil = data['results'] as List<dynamic>;

    if (hasil.isEmpty) return 'Alamat tidak ditemukan';

    return hasil[0]['formatted_address'] as String;
  }
}

Parameter language=id membuat Google mengembalikan alamat dalam Bahasa Indonesia.

Pratinjau peta statis

Sebelum menampilkan peta interaktif yang berat, sering lebih baik menampilkan gambar peta statis. Maps Static API mengembalikan sebuah gambar PNG:

dart
String urlPratinjauPeta(double lintang, double bujur, String kunciApi) {
  return 'https://maps.googleapis.com/maps/api/staticmap'
      '?center=$lintang,$bujur'
      '&zoom=16'
      '&size=600x300'
      '&maptype=roadmap'
      '&markers=color:red%7Clabel:A%7C$lintang,$bujur'
      '&key=$kunciApi';
}

Menampilkannya cukup dengan Image.network:

dart
Image.network(
  urlPratinjauPeta(lokasi.lintang, lokasi.bujur, kunciMaps),
  fit: BoxFit.cover,
  width: double.infinity,
)

Ini jauh lebih ringan daripada memuat SDK peta penuh, dan cukup untuk pratinjau.

Peta interaktif

yaml
dependencies:
  google_maps_flutter: ^2.10.0

Konfigurasi Android — di dalam tag <application> pada AndroidManifest.xml:

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

Konfigurasi iOS — di ios/Runner/AppDelegate.swift:

swift
import UIKit
import Flutter
import GoogleMaps

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

Layar peta yang memungkinkan pengguna memilih lokasi dengan mengetuk:

dart
// lib/screens/layar_peta.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import '../models/tempat.dart';

class LayarPeta extends StatefulWidget {
  const LayarPeta({
    super.key,
    this.lokasiAwal = const LokasiTempat(
      lintang: -6.2088,
      bujur: 106.8456,
      alamat: 'Jakarta',
    ),
    this.bolehPilih = true,
  });

  final LokasiTempat lokasiAwal;
  final bool bolehPilih;

  @override
  State<LayarPeta> createState() => _LayarPetaState();
}

class _LayarPetaState extends State<LayarPeta> {
  LatLng? _lokasiDipilih;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.bolehPilih ? 'Pilih Lokasi' : 'Lokasi Tempat'),
        actions: [
          if (widget.bolehPilih)
            IconButton(
              icon: const Icon(Icons.save),
              onPressed: _lokasiDipilih == null
                  ? null   // nonaktif sampai pengguna memilih
                  : () => Navigator.of(context).pop(_lokasiDipilih),
            ),
        ],
      ),
      body: GoogleMap(
        initialCameraPosition: CameraPosition(
          target: LatLng(
            widget.lokasiAwal.lintang,
            widget.lokasiAwal.bujur,
          ),
          zoom: 16,
        ),
        onTap: widget.bolehPilih
            ? (posisi) => setState(() => _lokasiDipilih = posisi)
            : null,
        markers: {
          if (_lokasiDipilih != null || !widget.bolehPilih)
            Marker(
              markerId: const MarkerId('m1'),
              position: _lokasiDipilih ??
                  LatLng(
                    widget.lokasiAwal.lintang,
                    widget.lokasiAwal.bujur,
                  ),
            ),
        },
        myLocationEnabled: true,
        myLocationButtonEnabled: true,
      ),
    );
  }
}

Perhatikan onPressed: _lokasiDipilih == null ? null : ... — tombol simpan otomatis abu-abu sampai pengguna benar-benar memilih sesuatu. Pola dari Bab 6.

Menggabungkan semuanya

Widget input lokasi yang menyediakan dua cara: ambil lokasi saat ini, atau pilih di peta.

dart
// lib/widgets/input_lokasi.dart
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import '../models/tempat.dart';
import '../services/layanan_lokasi.dart';
import '../services/layanan_geocoding.dart';
import '../screens/layar_peta.dart';

const _kunciMaps = String.fromEnvironment('MAPS_API_KEY');

class InputLokasi extends StatefulWidget {
  const InputLokasi({super.key, required this.saatLokasiDipilih});

  final void Function(LokasiTempat lokasi) saatLokasiDipilih;

  @override
  State<InputLokasi> createState() => _InputLokasiState();
}

class _InputLokasiState extends State<InputLokasi> {
  final _layananLokasi = LayananLokasi();
  final _geocoding = LayananGeocoding(_kunciMaps);

  LokasiTempat? _lokasiTersimpan;
  var _sedangMemuat = false;

  String get _urlPratinjau {
    if (_lokasiTersimpan == null) return '';
    return 'https://maps.googleapis.com/maps/api/staticmap'
        '?center=${_lokasiTersimpan!.lintang},${_lokasiTersimpan!.bujur}'
        '&zoom=16&size=600x300&maptype=roadmap'
        '&markers=color:red%7Clabel:A%7C'
        '${_lokasiTersimpan!.lintang},${_lokasiTersimpan!.bujur}'
        '&key=$_kunciMaps';
  }

  /// Langkah bersama: ubah koordinat jadi alamat, simpan, laporkan ke induk.
  Future<void> _simpanTempat(double lintang, double bujur) async {
    final alamat = await _geocoding.alamatDari(lintang, bujur);

    if (!mounted) return;

    setState(() {
      _lokasiTersimpan = LokasiTempat(
        lintang: lintang,
        bujur: bujur,
        alamat: alamat,
      );
      _sedangMemuat = false;
    });

    widget.saatLokasiDipilih(_lokasiTersimpan!);
  }

  Future<void> _ambilLokasiSekarang() async {
    setState(() => _sedangMemuat = true);

    try {
      final hasil = await _layananLokasi.ambilLokasiSekarang();
      await _simpanTempat(hasil.lintang, hasil.bujur);
    } on KesalahanLokasi catch (e) {
      if (!mounted) return;
      setState(() => _sedangMemuat = false);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.pesan)),
      );
    }
  }

  Future<void> _pilihDiPeta() async {
    final dipilih = await Navigator.of(context).push<LatLng>(
      MaterialPageRoute(builder: (ctx) => const LayarPeta()),
    );

    if (dipilih == null) return;

    setState(() => _sedangMemuat = true);
    await _simpanTempat(dipilih.latitude, dipilih.longitude);
  }

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;

    Widget pratinjau = Text(
      'Belum ada lokasi dipilih',
      textAlign: TextAlign.center,
      style: Theme.of(context).textTheme.bodyLarge!.copyWith(
            color: skema.onSurface,
          ),
    );

    if (_lokasiTersimpan != null) {
      pratinjau = Image.network(
        _urlPratinjau,
        fit: BoxFit.cover,
        width: double.infinity,
        height: double.infinity,
      );
    }

    if (_sedangMemuat) {
      pratinjau = const CircularProgressIndicator();
    }

    return Column(
      children: [
        Container(
          height: 170,
          width: double.infinity,
          alignment: Alignment.center,
          clipBehavior: Clip.antiAlias,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(12),
            border: Border.all(
              width: 1,
              color: skema.primary.withValues(alpha: 0.3),
            ),
          ),
          child: pratinjau,
        ),
        const SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextButton.icon(
              onPressed: _sedangMemuat ? null : _ambilLokasiSekarang,
              icon: const Icon(Icons.location_on),
              label: const Text('Lokasi Saya'),
            ),
            TextButton.icon(
              onPressed: _sedangMemuat ? null : _pilihDiPeta,
              icon: const Icon(Icons.map),
              label: const Text('Pilih di Peta'),
            ),
          ],
        ),
        if (_lokasiTersimpan != null) ...[
          const SizedBox(height: 8),
          Text(
            _lokasiTersimpan!.alamat,
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ],
      ],
    );
  }
}

Alur lengkapnya:

Menangani izin yang ditolak

Pengguna berhak menolak. Aplikasi yang baik menangani penolakan dengan anggun, bukan dengan crash atau layar kosong.

yaml
dependencies:
  permission_handler: ^11.3.1
dart
import 'package:permission_handler/permission_handler.dart';

Future<bool> mintaIzinKamera(BuildContext context) async {
  final status = await Permission.camera.request();

  if (status.isGranted) return true;

  if (status.isPermanentlyDenied) {
    // Pengguna memilih "Jangan tanya lagi" — hanya bisa diperbaiki
    // lewat pengaturan sistem
    if (!context.mounted) return false;

    final buka = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('Izin Kamera Diperlukan'),
        content: const Text(
          'Aktifkan izin kamera lewat Pengaturan untuk memakai fitur ini.',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx, false),
            child: const Text('Nanti'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(ctx, true),
            child: const Text('Buka Pengaturan'),
          ),
        ],
      ),
    );

    if (buka == true) await openAppSettings();
  }

  return false;
}

Perbedaan antara denied dan permanentlyDenied penting: yang pertama masih bisa diminta ulang, yang kedua hanya bisa diperbaiki lewat pengaturan sistem.

Fitur native lainnya

Beberapa paket yang sering dibutuhkan:

yaml
dependencies:
  # Membuka URL, telepon, email, WhatsApp
  url_launcher: ^6.3.1

  # Berbagi teks/berkas ke aplikasi lain
  share_plus: ^10.1.2

  # Informasi perangkat (model, versi OS)
  device_info_plus: ^11.2.0

  # Versi dan nama aplikasi
  package_info_plus: ^8.1.2

  # Memeriksa koneksi internet
  connectivity_plus: ^6.1.0

  # Getaran
  vibration: ^2.0.0

  # Memilih berkas apa pun
  file_picker: ^8.1.6

Contoh url_launcher:

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

Future<void> bukaSitus(String url) async {
  final uri = Uri.parse(url);
  if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
    throw Exception('Tidak bisa membuka $url');
  }
}

Future<void> telepon(String nomor) async {
  await launchUrl(Uri.parse('tel:$nomor'));
}

Future<void> kirimEmail(String alamat, String subjek) async {
  await launchUrl(
    Uri(
      scheme: 'mailto',
      path: alamat,
      queryParameters: {'subject': subjek},
    ),
  );
}

Di Android 11 ke atas, url_launcher butuh deklarasi tambahan agar bisa mendeteksi aplikasi yang tersedia:

xml
<!-- android/app/src/main/AndroidManifest.xml, di luar <application> -->
<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.DIAL" />
        <data android:scheme="tel" />
    </intent>
</queries>

Latihan Mandiri

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

Variasi 1: Pencatat Lokasi Parkir — ⭐⭐ · 45–60 menit

Tantangan: Buat pencatat lokasi parkir: satu tombol menyimpan posisi GPS saat ini beserta foto sekitar, dan layar utama menampilkan lokasi terakhir lengkap dengan alamatnya dan jarak dari posisimu sekarang. Yang diuji di sini adalah jalur ketika izin ditolak, bukan jalur ketika semuanya lancar.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│    [ 📍 Simpan Lokasi Sekarang ]   │
│  ┌─────────────────────────────┐  │
│  │▓▓▓▓ (foto sekitar) ▓▓▓▓▓▓▓ │  │
│  │ Jl. Merdeka No. 10           │  │
│  │ 250 m dari posisimu          │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

...ketika izin ditolak permanen, kartu di atas diganti pesan yang mengarah ke pengaturan sistem, bukan tombol "coba lagi" yang tidak akan pernah memunculkan dialog:

text
┌───────────────────────────────┐
│ ⚠ Izin lokasi ditolak permanen │
│      [ Buka Pengaturan ]       │
└───────────────────────────────┘

Kriteria selesai:

  • Ketersediaan layanan GPS dan status izin diperiksa sebagai dua hal terpisah, dengan pesan berbeda untuk masing-masing.
  • denied dan permanentlyDenied ditangani berbeda — yang kedua menawarkan tombol menuju pengaturan sistem, bukan meminta ulang yang tidak akan pernah muncul.
  • Foto diperkecil di titik pengambilan dengan maxWidth dan imageQuality, lalu disalin ke direktori dokumen dan tetap ada setelah aplikasi ditutup lalu dibuka lagi.
  • Membatalkan kamera tidak membuat aplikasi berhenti atau menyimpan entri kosong.
  • Deskripsi izin di Info.plist ditulis spesifik dan jujur, bukan kalimat generik.

Petunjuk: Foto yang hilang setelah aplikasi ditutup adalah gejala paling khas dari bab ini: image_picker menaruh hasilnya di direktori sementara, yang boleh dibersihkan sistem kapan saja. Salin berkasnya ke getApplicationDocumentsDirectory() segera setelah diambil, dan simpan jalurnya. Untuk menguji jalur izin yang ditolak, kamu tidak perlu menebak — cabut izinnya dari pengaturan perangkat, lalu buka aplikasi lagi. Lakukan itu tiga kali dengan pilihan berbeda (izinkan, tolak, tolak permanen) dan kamu akan menemukan cabang yang lupa kamu tulis.

Variasi 2: Papan Momen — ⭐⭐ · 30–45 menit

Tantangan: Bangun papan momen yang menerima gambar dari kamera maupun galeri, menampilkannya sebagai kisi, dan mengizinkan satu gambar dijadikan sampul. Tangani seluruh jalur yang bisa gagal: izin ditolak, pengguna membatalkan, dan berkas yang dipilih ternyata tidak bisa dibaca.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  ┌────┐ ┌────┐ ┌────┐             │
│  │▓▓★▓│ │▓▓▓▓│ │▓▓▓▓│   ★=sampul  │
│  └────┘ └────┘ └────┘             │
│  ┌────┐ ┌────┐                    │
│  │▓▓▓▓│ │  +  │  ← tambah gambar  │
│  └────┘ └────┘                    │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Tap "+" → bottom sheet memilih sumber gambar; membatalkan bottom sheet ini sendiri (tap di luar) tidak melakukan apa pun:

text
┌─────────────────────────────┐
│  📷  Ambil dari Kamera        │
│  🖼   Pilih dari Galeri        │
└─────────────────────────────┘

Kriteria selesai:

  • Pengguna memilih sumber lewat bottom sheet, dan membatalkan bottom sheet itu sendiri tidak melakukan apa pun.
  • Gambar diperkecil di titik pengambilan, dan aplikasi tidak pernah kehabisan memori meski dua puluh gambar ditambahkan berturut-turut.
  • Izin galeri dan kamera diminta terpisah, hanya saat benar-benar dibutuhkan.
  • Menghapus gambar dari papan juga menghapus berkasnya.

Petunjuk: Kehabisan memori adalah risiko nyata di sini: foto kamera modern bisa berukuran belasan megapiksel, dan menampilkan dua puluh di antaranya sekaligus tanpa diperkecil akan menghabiskan ratusan megabita. maxWidth dan imageQuality di titik pengambilan menyelesaikannya di sumbernya — jauh lebih efektif daripada mencoba mengecilkannya saat ditampilkan.

Variasi 3: Jembatan ke Aplikasi Lain — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun kartu kontak darurat yang bisa langsung menelepon, mengirim pesan, membuka peta ke alamat tertentu, mengirim email dengan subjek yang sudah terisi, dan membagikan seluruh informasinya ke aplikasi lain. Setiap aksi harus menangani kasus aplikasi tujuan tidak terpasang.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Kontak Darurat: Ayah              │
│  📞 0812-xxx-xxx                   │
│  [📞 Telepon] [💬 SMS]  [🗺 Peta]  │
│      [✉ Email]      [↗ Bagikan]   │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Tap 🗺 Peta di perangkat tanpa aplikasi peta terpasang → pesan jelas, bukan aplikasi diam atau crash:

text
┌─────────────────────────────┐
│ ⚠ Tidak ada aplikasi peta    │
│   yang terpasang             │
└─────────────────────────────┘

Kriteria selesai:

  • Setiap aksi memeriksa lebih dulu apakah tujuannya bisa dibuka, dan menampilkan pesan yang jelas kalau tidak.
  • Android 11 ke atas ditangani dengan blok <queries> yang benar di manifest.
  • Teks yang mengandung spasi dan karakter khusus tetap terkirim utuh ke aplikasi tujuan.
  • Berbagi menyertakan teks dan setidaknya satu berkas.

Petunjuk: Android 11 memperketat kemampuan aplikasi melihat aplikasi lain yang terpasang, sehingga pemeriksaan "bisa dibuka" akan selalu mengembalikan salah tanpa deklarasi <queries> — dan gejalanya membingungkan karena kodenya benar dan berjalan mulus di Android lama. Untuk karakter khusus, ingat bahwa komponen URL harus dikodekan; menyusun URL dengan penyambungan string mentah akan patah pada tanda tanya atau ampersand pertama.

Variasi 4: Pelacak Rute Lari — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun pelacak lari yang mengikuti posisi secara berkelanjutan, menghitung jarak tempuh dan kecepatan rata-rata, dan menampilkan lintasannya. Pelacakan harus bisa dijeda dan dilanjutkan, dan tidak boleh menguras baterai lebih dari yang diperlukan.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│      ╭─╮                           │
│     ╱   ╲___                       │
│    ╱        ╲__●  ← posisi sekarang│
│   ╱                                │
│  Jarak: 3.2 km   Kecepatan: 8:45/km│
│       [ Jeda ]    [ Selesai ]      │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Memakai aliran posisi, bukan pemanggilan posisi berulang lewat timer.
  • Ketelitian dan jarak minimum antar pembaruan disetel sadar, dan kamu bisa menjelaskan pengaruhnya terhadap baterai.
  • Langganan aliran dibatalkan saat layar ditutup dan saat pelacakan dijeda.
  • Titik dengan ketelitian buruk disaring, sehingga lintasan tidak melompat ratusan meter.

Petunjuk: Aliran posisi memberimu pembaruan hanya ketika ada perubahan yang berarti, sementara timer memaksa GPS bekerja terus-menerus — perbedaannya sangat terasa di baterai. Untuk lompatan lintasan, setiap pembacaan datang dengan perkiraan ketelitiannya; buang yang ketelitiannya di atas ambang tertentu, dan buang juga yang menyiratkan kecepatan yang mustahil bagi manusia.

Variasi 5: Orkestrasi Izin — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun layar pengaturan yang menampilkan status seluruh izin yang dipakai aplikasi (kamera, lokasi, notifikasi, penyimpanan) dalam satu tempat, dengan tombol untuk meminta masing-masing dan penjelasan kenapa tiap izin dibutuhkan. Status harus selalu akurat, termasuk setelah pengguna mengubah izin dari pengaturan sistem lalu kembali.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  📷 Kamera         ✓ Diberikan     │
│  📍 Lokasi         ✗ Ditolak[Minta]│
│  🔔 Notifikasi   ⚠ Ditolak Permanen│
│                    [ Pengaturan ]  │
│  💾 Penyimpanan    ✓ Diberikan     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Ubah izin "Lokasi" jadi diizinkan dari pengaturan sistem, lalu kembali ke layar ini (tanpa membuka ulang aplikasi) → statusnya otomatis berubah jadi "✓ Diberikan", bukan tetap menampilkan "✗ Ditolak" yang basi.

Kriteria selesai:

  • Status disegarkan ketika aplikasi kembali ke layar depan, bukan hanya saat layar pertama dibuka.
  • Setiap izin punya penjelasan yang ditampilkan sebelum dialog sistem muncul.
  • Izin yang ditolak permanen menampilkan tombol menuju pengaturan, bukan tombol minta ulang.
  • Perbedaan perilaku Android dan iOS ditangani, dan izin yang tidak relevan untuk platform tertentu tidak ditampilkan.

Petunjuk: Kriteria pertama membutuhkan pengamatan siklus hidup aplikasi — WidgetsBindingObserver memberi tahu kapan aplikasi kembali aktif, dan itulah saat yang tepat memeriksa ulang seluruh status. Tanpa itu, layarmu akan terus menampilkan "ditolak" padahal pengguna baru saja mengizinkannya di pengaturan, dan mereka akan menyimpulkan aplikasimu rusak.

Ikhtisar

  • Plugin berisi kode asli tiap platform, sehingga butuh konfigurasi di AndroidManifest.xml dan Info.plist, serta hot restart penuh setelah diubah.
  • iOS wajib punya deskripsi izin (NSCameraUsageDescription, NSLocationWhenInUseUsageDescription) yang jujur dan spesifik — App Store menolak deskripsi generik.
  • image_picker mengembalikan null kalau pengguna membatalkan. Selalu periksa.
  • Selalu perkecil gambar dengan maxWidth dan imageQuality pada titik pengambilan, atau aplikasi berisiko kehabisan memori.
  • Image.file() untuk berkas lokal, Image.network() untuk URL, Image.asset() untuk aset.
  • Untuk lokasi, periksa dua hal: apakah layanan GPS aktif, dan apakah izin diberikan. Keduanya bisa gagal secara terpisah.
  • Bedakan denied (bisa diminta ulang) dari permanentlyDenied (harus lewat pengaturan sistem).
  • Batasi kunci API Google berdasarkan aplikasi dan API, dan jangan commit ke repositori — pakai --dart-define.
  • Maps Static API jauh lebih ringan daripada peta interaktif untuk sekadar pratinjau.
  • Parameter language=id pada Geocoding API mengembalikan alamat berbahasa Indonesia.
  • Android 11 ke atas butuh blok <queries> agar url_launcher bisa mendeteksi aplikasi tujuan.

Bagian II selesai. Berikutnya: Bab 17 — Pengantar State Management, tempat kita menyelesaikan masalah berbagi data antar layar.

Transkrip asli

Disintesis dari 1_flutter_core/12_fav-place-app_using-native-device-feature.md (29 video: image picker, kamera, location, Google Maps API, geocoding, map screen) dan 4_flutter_ai-chatbot-n-firebase/8_google-maps-integration-and-proximity-filtering.md. Lihat PDF Flutter Core dan PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.