Flutter
Task Manager Online per User
Tutorial menggabungkan Firebase Auth dan Cloud Firestore untuk membuat Task Manager online yang datanya terpisah berdasarkan user login.
Task Manager Online per User
Materi ini adalah lanjutan dari Firebase Authentication, Cloud Firestore Dasar, dan Firestore CRUD.
Di materi sebelumnya kita sudah belajar:
- membuat user login
- membaca
uiduser dari Firebase Auth - membuat model
Task - melakukan create, read, update, dan delete data di Firestore
Sekarang kita akan menggabungkan semuanya menjadi aplikasi yang lebih nyata: Task Manager online per user.
Artinya:
- user A hanya melihat task milik user A
- user B hanya melihat task milik user B
- task tersimpan di Firestore
- data tetap ada walaupun aplikasi ditutup
- task bisa dibaca secara realtime
1. Gambaran Fitur
Kita akan membuat fitur:
- cek user yang sedang login
- menambah task dengan
userId - membaca task berdasarkan
uid - update judul task
- toggle selesai atau belum selesai
- hapus task
- logout
- menampilkan loading dan pesan error sederhana
Alur sederhananya:
User login
-> Firebase Auth memberikan uid
-> App menyimpan task ke Firestore dengan field userId
-> App membaca task dengan query where userId == uid
-> User hanya melihat task miliknya sendiri
uid adalah identitas unik user dari Firebase Auth. Inilah yang akan kita pakai untuk memisahkan data antar user.
2. Kenapa Harus per User?
Kalau task tidak diberi userId, semua data bisa bercampur.
Contoh data yang kurang aman:
{
"title": "Belajar Flutter",
"isDone": false
}
Masalahnya: kita tidak tahu task ini milik siapa.
Data yang lebih benar:
{
"title": "Belajar Flutter",
"isDone": false,
"userId": "uid_user_login",
"createdAt": "server_timestamp"
}
Dengan userId, aplikasi bisa melakukan query:
Ambil task yang userId-nya sama dengan uid user login.
Ini adalah pola penting saat membuat aplikasi multi-user.
3. Struktur Collection Firestore
Untuk pemula, kita gunakan struktur collection global:
tasks
taskId_1
title: "Belajar Flutter"
isDone: false
userId: "abc123"
createdAt: Timestamp
updatedAt: Timestamp
taskId_2
title: "Belajar Firebase"
isDone: true
userId: "xyz789"
createdAt: Timestamp
updatedAt: Timestamp
Kelebihan struktur ini:
- mudah dipahami
- mudah di-query dengan
where - cocok untuk belajar CRUD
- mudah dikembangkan dengan filter status
Alternatif lain adalah subcollection per user:
users
userId
tasks
taskId
Struktur subcollection juga bagus, tetapi untuk materi ini kita mulai dari collection tasks agar konsep query where userId == uid lebih jelas.
4. Package yang Dibutuhkan
Pastikan package berikut sudah ada:
flutter pub add firebase_core
flutter pub add firebase_auth
flutter pub add cloud_firestore
Lalu pastikan Firebase sudah diinisialisasi di main.dart:
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
Tanpa Firebase.initializeApp, Firebase Auth dan Firestore belum bisa digunakan.
5. Struktur Folder
Gunakan struktur folder sederhana:
lib/
models/
task.dart
repositories/
task_repository.dart
pages/
online_task_page.dart
Pembagian tanggung jawabnya:
models/task.dart: bentuk data taskrepositories/task_repository.dart: semua operasi Firestorepages/online_task_page.dart: tampilan dan interaksi user
Pola seperti ini membuat kode lebih rapi. Halaman UI tidak perlu langsung memanggil Firestore terlalu banyak.
6. Membuat Model Task
Buat file:
lib/models/task.dart
Isi:
import 'package:cloud_firestore/cloud_firestore.dart';
class Task {
final String id;
final String title;
final bool isDone;
final String userId;
final DateTime? createdAt;
final DateTime? updatedAt;
const Task({
required this.id,
required this.title,
required this.isDone,
required this.userId,
this.createdAt,
this.updatedAt,
});
factory Task.fromFirestore(DocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data();
if (data == null) {
throw Exception('Task tidak ditemukan');
}
return Task(
id: doc.id,
title: data['title'] ?? '',
isDone: data['isDone'] ?? false,
userId: data['userId'] ?? '',
createdAt: (data['createdAt'] as Timestamp?)?.toDate(),
updatedAt: (data['updatedAt'] as Timestamp?)?.toDate(),
);
}
Map<String, dynamic> toCreateJson() {
return {
'title': title,
'isDone': isDone,
'userId': userId,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
};
}
Map<String, dynamic> toUpdateJson() {
return {
'title': title,
'isDone': isDone,
'updatedAt': FieldValue.serverTimestamp(),
};
}
Task copyWith({
String? id,
String? title,
bool? isDone,
String? userId,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
isDone: isDone ?? this.isDone,
userId: userId ?? this.userId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
Penjelasan:
idberasal dari document id Firestoretitleadalah judul taskisDonemenyimpan status selesai atau belumuserIdmenyimpan pemilik taskcreatedAtmenyimpan waktu task dibuatupdatedAtmenyimpan waktu task terakhir diubahfromFirestoremengubah document Firestore menjadi object DarttoCreateJsondipakai saat membuat task barutoUpdateJsondipakai saat mengubah taskcopyWithdipakai untuk membuat object baru dari object lama dengan sebagian data diubah
Kenapa createdAt tidak ikut di toUpdateJson?
Karena waktu pembuatan task tidak boleh berubah saat task diedit. Yang berubah hanya updatedAt.
7. Membaca User yang Sedang Login
Firebase Auth menyediakan user login lewat:
FirebaseAuth.instance.currentUser
Contoh:
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
throw Exception('User belum login');
}
final uid = user.uid;
Catatan penting:
currentUserbisanullkalau user belum login- jangan membuat task kalau user belum login
- gunakan
uidsebagaiuserId
8. Membuat TaskRepository
Buat file:
lib/repositories/task_repository.dart
Isi:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../models/task.dart';
class TaskRepository {
final FirebaseFirestore _firestore;
final FirebaseAuth _auth;
TaskRepository({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_auth = auth ?? FirebaseAuth.instance;
CollectionReference<Map<String, dynamic>> get _tasksCollection {
return _firestore.collection('tasks');
}
String get _currentUserId {
final user = _auth.currentUser;
if (user == null) {
throw Exception('User belum login');
}
return user.uid;
}
Stream<List<Task>> watchMyTasks() {
final uid = _currentUserId;
return _tasksCollection
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs.map(Task.fromFirestore).toList();
});
}
Future<void> addTask(String title) async {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw Exception('Judul task tidak boleh kosong');
}
final task = Task(
id: '',
title: trimmedTitle,
isDone: false,
userId: _currentUserId,
);
await _tasksCollection.add(task.toCreateJson());
}
Future<void> updateTaskTitle({
required String taskId,
required String title,
}) async {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw Exception('Judul task tidak boleh kosong');
}
await _tasksCollection.doc(taskId).update({
'title': trimmedTitle,
'updatedAt': FieldValue.serverTimestamp(),
});
}
Future<void> toggleTask(Task task) async {
await _tasksCollection.doc(task.id).update({
'isDone': !task.isDone,
'updatedAt': FieldValue.serverTimestamp(),
});
}
Future<void> deleteTask(String taskId) async {
await _tasksCollection.doc(taskId).delete();
}
}
Penjelasan:
_tasksCollectionmenunjuk ke collectiontasks_currentUserIdmengambiluiduser loginwatchMyTasksmembaca task milik user login secara realtimeaddTaskmembuat task baru dan otomatis mengisiuserIdupdateTaskTitlehanya mengubah judul tasktoggleTaskmembalik status selesaideleteTaskmenghapus task
Kenapa query memakai where('userId', isEqualTo: uid)?
Karena aplikasi hanya boleh mengambil task yang pemiliknya sama dengan user login.
9. Membuat Halaman Task Manager Online
Buat file:
lib/pages/online_task_page.dart
Isi:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../repositories/task_repository.dart';
class OnlineTaskPage extends StatefulWidget {
const OnlineTaskPage({super.key});
@override
State<OnlineTaskPage> createState() => _OnlineTaskPageState();
}
class _OnlineTaskPageState extends State<OnlineTaskPage> {
final TaskRepository _repository = TaskRepository();
final TextEditingController _titleController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
Future<void> _addTask() async {
try {
await _repository.addTask(_titleController.text);
_titleController.clear();
} catch (error) {
_showMessage(error.toString());
}
}
Future<void> _editTask(Task task) async {
final controller = TextEditingController(text: task.title);
final newTitle = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Edit Task'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Judul task',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Simpan'),
),
],
);
},
);
controller.dispose();
if (newTitle == null) return;
try {
await _repository.updateTaskTitle(
taskId: task.id,
title: newTitle,
);
} catch (error) {
_showMessage(error.toString());
}
}
Future<void> _logout() async {
await FirebaseAuth.instance.signOut();
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
final user = FirebaseAuth.instance.currentUser;
if (user == null) {
return const Scaffold(
body: Center(
child: Text('Silakan login terlebih dahulu'),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager Online'),
actions: [
IconButton(
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Task baru',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTask(),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addTask,
child: const Text('Tambah'),
),
],
),
),
Expanded(
child: StreamBuilder<List<Task>>(
stream: _repository.watchMyTasks(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
final tasks = snapshot.data ?? [];
if (tasks.isEmpty) {
return const Center(
child: Text('Belum ada task'),
);
}
return ListView.separated(
itemCount: tasks.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
leading: Checkbox(
value: task.isDone,
onChanged: (_) => _repository.toggleTask(task),
),
title: Text(
task.title,
style: TextStyle(
decoration: task.isDone
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
subtitle: Text('Owner: ${task.userId}'),
onTap: () => _editTask(task),
trailing: IconButton(
onPressed: () => _repository.deleteTask(task.id),
icon: const Icon(Icons.delete),
),
);
},
);
},
),
),
],
),
);
}
}
Penjelasan bagian penting:
StreamBuilderdipakai karena data Firestore bisa berubah realtime- saat task baru ditambah, list otomatis berubah
- saat task dihapus dari device lain, list juga ikut berubah
snapshot.connectionStatedipakai untuk menampilkan loadingsnapshot.hasErrordipakai untuk menampilkan errortasks.isEmptydipakai untuk empty stateCheckboxmemanggiltoggleTask- tap item membuka dialog edit
- tombol delete menghapus task
10. Menyambungkan Halaman ke main.dart
Contoh sederhana:
import 'package:flutter/material.dart';
import 'pages/online_task_page.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Task Manager Online',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const OnlineTaskPage(),
);
}
}
Untuk aplikasi sungguhan, biasanya home diarahkan ke halaman auth wrapper:
jika user login -> OnlineTaskPage
jika belum login -> LoginPage
Tetapi untuk fokus materi ini, kita cukup menampilkan OnlineTaskPage.
11. Membuat Auth Wrapper Sederhana
Agar aplikasi otomatis pindah halaman berdasarkan status login, gunakan authStateChanges.
Contoh:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'login_page.dart';
import 'online_task_page.dart';
class AuthWrapper extends StatelessWidget {
const AuthWrapper({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (snapshot.data == null) {
return const LoginPage();
}
return const OnlineTaskPage();
},
);
}
}
Lalu di main.dart:
home: const AuthWrapper(),
Dengan pola ini:
- user belum login akan melihat
LoginPage - user sudah login akan masuk ke
OnlineTaskPage - saat logout, halaman otomatis kembali ke login
12. Query Data Milik User Login
Bagian paling penting ada di sini:
return _tasksCollection
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.snapshots();
Query ini artinya:
- cari document di collection
tasks - ambil hanya document dengan
userIdsama denganuid - urutkan dari yang terbaru
- pantau perubahan secara realtime
Kalau query ini tidak memakai where, semua task dari semua user bisa muncul.
Firebase Security Rules juga harus dibuat sesuai query ini. Rules bukan filter otomatis. Query aplikasi tetap harus menuliskan batasan data yang benar.
13. Index Firestore
Query gabungan seperti ini kadang membutuhkan index:
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
Kalau Firestore meminta index, biasanya error akan menampilkan link untuk membuat index otomatis.
Langkahnya:
- jalankan aplikasi
- buka halaman task
- jika muncul error index, salin atau klik link dari error
- buka Firebase Console
- buat index
- tunggu status index menjadi enabled
- jalankan ulang fitur
Ini normal di Firestore. Query yang semakin spesifik sering membutuhkan index.
14. Security Rules Dasar
Untuk tahap belajar, gunakan rules yang membatasi user hanya bisa membaca dan mengubah task miliknya.
Contoh:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /tasks/{taskId} {
allow read: if request.auth != null
&& resource.data.userId == request.auth.uid;
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
allow update, delete: if request.auth != null
&& resource.data.userId == request.auth.uid;
}
}
}
Penjelasan:
request.auth != nullmemastikan user sudah loginrequest.auth.uidadalah uid user loginresource.dataadalah data lama yang sudah ada di Firestorerequest.resource.dataadalah data baru yang akan ditulisallow readmemastikan user hanya membaca task miliknyaallow creatememastikan user hanya bisa membuat task denganuserIdmiliknya sendiriallow update, deletememastikan user hanya bisa mengubah atau menghapus task miliknya
Materi security rules akan kita bahas lebih detail di bagian berikutnya, karena rules adalah bagian penting untuk keamanan aplikasi.
15. Validasi Data Sederhana
Selain membatasi user, kita juga bisa validasi bentuk data.
Contoh rules yang lebih ketat:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /tasks/{taskId} {
function isSignedIn() {
return request.auth != null;
}
function isOwner() {
return resource.data.userId == request.auth.uid;
}
function isCreatingOwnTask() {
return request.resource.data.userId == request.auth.uid;
}
function hasValidTaskFields() {
return request.resource.data.keys().hasOnly([
'title',
'isDone',
'userId',
'createdAt',
'updatedAt'
])
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 100
&& request.resource.data.isDone is bool
&& request.resource.data.userId is string;
}
allow read: if isSignedIn() && isOwner();
allow create: if isSignedIn()
&& isCreatingOwnTask()
&& hasValidTaskFields();
allow update: if isSignedIn()
&& isOwner()
&& hasValidTaskFields()
&& request.resource.data.userId == resource.data.userId;
allow delete: if isSignedIn() && isOwner();
}
}
}
Rules ini lebih panjang, tetapi lebih aman karena:
- field dibatasi
- title harus string
- title tidak boleh kosong
- title maksimal 100 karakter
isDoneharus booleanuserIdtidak boleh diganti saat update
16. Menampilkan Filter Task
Setelah task per user berjalan, kita bisa menambahkan filter.
Contoh enum:
enum TaskFilter {
all,
active,
done,
}
Modifikasi repository:
Stream<List<Task>> watchMyTasks({
TaskFilter filter = TaskFilter.all,
}) {
final uid = _currentUserId;
Query<Map<String, dynamic>> query = _tasksCollection
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true);
if (filter == TaskFilter.active) {
query = query.where('isDone', isEqualTo: false);
}
if (filter == TaskFilter.done) {
query = query.where('isDone', isEqualTo: true);
}
return query.snapshots().map((snapshot) {
return snapshot.docs.map(Task.fromFirestore).toList();
});
}
Dengan filter ini:
all: tampilkan semua taskactive: tampilkan task yang belum selesaidone: tampilkan task yang sudah selesai
Kalau query meminta index baru, buat index dari link error yang diberikan Firestore.
17. Error yang Sering Muncul
User belum login
Penyebab:
FirebaseAuth.instance.currentUserbernilainull- halaman task dibuka sebelum login
Solusi:
- gunakan
AuthWrapper - arahkan user ke
LoginPage
Permission denied
Penyebab:
- security rules menolak request
userIdtidak sama denganrequest.auth.uid- user belum login
Solusi:
- cek rules Firestore
- cek field
userId - cek apakah user sudah login
The query requires an index
Penyebab:
- query memakai kombinasi
wheredanorderBy - Firestore membutuhkan index tambahan
Solusi:
- buka link index dari error
- buat index di Firebase Console
- tunggu sampai enabled
Type Timestamp error
Penyebab:
- field
createdAtatauupdatedAtbelum ada - data lama tidak punya timestamp
Solusi:
- gunakan nullable timestamp
- parsing dengan aman:
createdAt: (data['createdAt'] as Timestamp?)?.toDate(),
18. Checklist Testing Manual
Gunakan checklist ini untuk memastikan fitur berjalan:
- user bisa login
- user bisa tambah task
- task muncul di list
- task tersimpan di Firestore
- field
userIdsama dengan uid user login - task tetap ada setelah aplikasi ditutup
- task bisa diedit
- task bisa ditandai selesai
- task bisa dihapus
- user bisa logout
- user lain tidak melihat task user sebelumnya
- rules menolak akses data yang bukan milik user
Testing dengan dua akun sangat disarankan.
Contoh:
- login dengan akun A
- buat task “Belajar Firebase”
- logout
- login dengan akun B
- pastikan task akun A tidak muncul
- buat task akun B
- logout
- login lagi dengan akun A
- pastikan task akun A masih ada
19. Kesimpulan
Di materi ini kita sudah belajar membuat Task Manager online per user.
Poin penting:
- data user harus dipisahkan dengan
uid - task perlu menyimpan field
userId - query harus memakai
where('userId', isEqualTo: uid) - Firestore realtime cocok untuk aplikasi task
- Security Rules tetap wajib untuk keamanan
- UI sebaiknya tidak langsung terlalu banyak berisi logic Firestore
- repository membantu memisahkan logic data dari tampilan
Setelah memahami materi ini, kamu sudah mulai masuk ke pola aplikasi Flutter yang lebih nyata.
20. Selanjutnya
Materi berikutnya yang paling cocok adalah Firestore Security Rules Dasar.
Alasannya:
- kita sudah punya data per user
- sekarang perlu memastikan data benar-benar aman
- user tidak boleh membaca atau mengubah data milik user lain
- rules perlu dipahami sebelum aplikasi dipakai publik
Referensi resmi: