Tampilan
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.2Konfigurasi 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.2Sebagai 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
- Buka console.cloud.google.com.
- Buat proyek baru.
- Aktifkan tiga API: Maps SDK for Android, Maps SDK for iOS, Geocoding API, dan Maps Static API.
- Buat kredensial → API Key.
- 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.0Konfigurasi 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.1dart
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.6Contoh 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.
denieddanpermanentlyDeniedditangani berbeda — yang kedua menawarkan tombol menuju pengaturan sistem, bukan meminta ulang yang tidak akan pernah muncul.- Foto diperkecil di titik pengambilan dengan
maxWidthdanimageQuality, 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.plistditulis 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.xmldanInfo.plist, serta hot restart penuh setelah diubah. - iOS wajib punya deskripsi izin (
NSCameraUsageDescription,NSLocationWhenInUseUsageDescription) yang jujur dan spesifik — App Store menolak deskripsi generik. image_pickermengembalikannullkalau pengguna membatalkan. Selalu periksa.- Selalu perkecil gambar dengan
maxWidthdanimageQualitypada 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) daripermanentlyDenied(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=idpada Geocoding API mengembalikan alamat berbahasa Indonesia. - Android 11 ke atas butuh blok
<queries>agarurl_launcherbisa 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.