Flutter
Upgrade Task Manager dengan Provider dan Local Storage
Tutorial upgrade Task Manager agar memakai Provider untuk state management dan shared_preferences untuk local storage.
Upgrade Task Manager dengan Provider dan Local Storage
Pada materi sebelumnya, Task Manager sudah bisa menyimpan data ke local storage memakai shared_preferences. Namun logic state masih banyak berada di halaman. Di materi ini, kita upgrade struktur project agar state dan logic task dipindahkan ke TaskProvider.
Target:
- UI lebih bersih.
- Logic CRUD pindah ke Provider.
- Local storage tetap dikelola oleh service.
- Data task di-load saat provider dibuat.
- Storage update otomatis saat add/edit/delete/update status.
1. Gambaran Arsitektur
Sebelum memakai Provider:
TaskListPage
-> menyimpan List<Task>
-> load storage
-> add/edit/delete
-> save storage
-> filter/search
-> render UI
Masalahnya, page menjadi terlalu banyak tanggung jawab.
Setelah memakai Provider:
TaskListPage
-> membaca state dari TaskProvider
-> memanggil action TaskProvider
TaskProvider
-> menyimpan List<Task>
-> mengelola filter/search
-> memanggil TaskStorageService
TaskStorageService
-> save/load shared_preferences
Pembagian tugas:
| Bagian | Tugas |
|---|---|
TaskListPage | Menampilkan UI |
TaskProvider | Mengelola state dan logic task |
TaskStorageService | Menyimpan dan membaca local storage |
Task | Bentuk data |
2. Struktur Folder
Struktur yang disarankan:
lib/
main.dart
models/
task.dart
pages/
task_list_page.dart
task_form_page.dart
providers/
task_provider.dart
services/
task_storage_service.dart
widgets/
task_card.dart
task_status_chip.dart
File baru:
lib/providers/task_provider.dart
File yang tetap dipakai:
lib/services/task_storage_service.dart
3. Install Package
Pastikan package berikut sudah ada:
flutter pub add provider
flutter pub add shared_preferences
Lalu:
flutter pub get
Import Provider:
import 'package:provider/provider.dart';
4. Model Task
Model Task tetap memakai toJson dan fromJson.
enum TaskStatus {
todo,
progress,
done,
}
extension TaskStatusLabel on TaskStatus {
String get label {
switch (this) {
case TaskStatus.todo:
return 'Todo';
case TaskStatus.progress:
return 'Progress';
case TaskStatus.done:
return 'Done';
}
}
}
class Task {
final int id;
final String title;
final String description;
final TaskStatus status;
final DateTime createdAt;
const Task({
required this.id,
required this.title,
required this.description,
required this.status,
required this.createdAt,
});
Task copyWith({
String? title,
String? description,
TaskStatus? status,
}) {
return Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
status: status ?? this.status,
createdAt: createdAt,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'status': status.name,
'createdAt': createdAt.toIso8601String(),
};
}
factory Task.fromJson(Map<String, dynamic> json) {
return Task(
id: json['id'] as int,
title: json['title'] as String,
description: json['description'] as String,
status: TaskStatus.values.byName(json['status'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
5. TaskStorageService
Service tetap bertugas menyimpan dan membaca data.
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/task.dart';
class TaskStorageService {
static const _tasksKey = 'tasks';
Future<void> saveTasks(List<Task> tasks) async {
final prefs = await SharedPreferences.getInstance();
final jsonList = tasks.map((task) => task.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_tasksKey, jsonString);
}
Future<List<Task>> loadTasks() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_tasksKey);
if (jsonString == null) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((item) => Task.fromJson(item as Map<String, dynamic>))
.toList();
}
Future<void> clearTasks() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tasksKey);
}
}
Kenapa service tidak digabung ke provider?
Supaya tanggung jawabnya jelas:
Provider -> state dan logic aplikasi
Service -> detail teknis storage
Jika nanti pindah dari shared_preferences ke Hive atau Firestore, kita bisa mengubah service tanpa banyak mengganggu UI.
6. Membuat TaskProvider
Buat file:
lib/providers/task_provider.dart
Isi:
import 'package:flutter/foundation.dart';
import '../models/task.dart';
import '../services/task_storage_service.dart';
class TaskProvider extends ChangeNotifier {
final TaskStorageService _storageService;
TaskProvider({
TaskStorageService? storageService,
}) : _storageService = storageService ?? TaskStorageService() {
loadTasks();
}
final List<Task> _tasks = [];
TaskStatus? _selectedStatus;
String _searchQuery = '';
bool _isLoading = true;
String? _errorMessage;
List<Task> get tasks => List.unmodifiable(_tasks);
TaskStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
}
Penjelasan:
_tasksprivate agar tidak diubah langsung dari UI.tasksmengembalikanList.unmodifiable._isLoadingdipakai saat membaca storage._errorMessagedipakai jika load/save gagal.loadTasks()dipanggil saat provider dibuat.
7. Load Task Saat Provider Dibuat
Tambahkan function:
Future<void> loadTasks() async {
try {
_isLoading = true;
_errorMessage = null;
notifyListeners();
final savedTasks = await _storageService.loadTasks();
_tasks
..clear()
..addAll(savedTasks);
} catch (error) {
_errorMessage = 'Gagal membaca task';
} finally {
_isLoading = false;
notifyListeners();
}
}
Penjelasan:
trydipakai untuk menangani error.- Saat load dimulai,
isLoadingdibuattrue. - Jika berhasil, data storage masuk ke
_tasks. - Jika gagal,
_errorMessagediisi. finallymemastikan loading selesai.notifyListeners()membuat UI update.
8. Helper saveCurrentTasks
Tambahkan helper:
Future<void> _saveCurrentTasks() async {
try {
await _storageService.saveTasks(_tasks);
} catch (error) {
_errorMessage = 'Gagal menyimpan task';
notifyListeners();
}
}
Kenapa private?
Karena UI tidak perlu memanggil save storage langsung. UI cukup memanggil addTask, updateTask, atau deleteTask. Provider yang mengurus kapan storage harus disimpan.
9. Computed State: visibleTasks dan emptyMessage
Tambahkan:
List<Task> get visibleTasks {
final filteredByStatus = _selectedStatus == null
? _tasks
: _tasks.where((task) => task.status == _selectedStatus).toList();
final query = _searchQuery.toLowerCase();
if (query.isEmpty) {
return filteredByStatus;
}
return filteredByStatus.where((task) {
final title = task.title.toLowerCase();
final description = task.description.toLowerCase();
return title.contains(query) || description.contains(query);
}).toList();
}
String get emptyMessage {
if (_searchQuery.isNotEmpty) {
return 'Task tidak ditemukan';
}
if (_selectedStatus != null) {
return 'Belum ada task dengan status ini';
}
return 'Belum ada task';
}
Penjelasan:
visibleTasksadalah hasil akhir setelah filter dan search.- UI tidak perlu menghitung filter sendiri.
emptyMessagemembuat pesan kosong sesuai kondisi.
10. Add Task + Save Storage
Tambahkan:
Future<void> addTask({
required String title,
required String description,
TaskStatus status = TaskStatus.todo,
}) async {
final task = Task(
id: DateTime.now().millisecondsSinceEpoch,
title: title,
description: description,
status: status,
createdAt: DateTime.now(),
);
_tasks.add(task);
notifyListeners();
await _saveCurrentTasks();
}
Alur:
UI memanggil addTask
-> provider membuat Task baru
-> Task masuk ke _tasks
-> notifyListeners
-> UI update
-> provider menyimpan _tasks ke storage
11. Update Task + Save Storage
Tambahkan:
Future<void> updateTask(Task updatedTask) async {
final index = _tasks.indexWhere((task) => task.id == updatedTask.id);
if (index == -1) return;
_tasks[index] = updatedTask;
notifyListeners();
await _saveCurrentTasks();
}
Penjelasan:
- task dicari berdasarkan
id - jika tidak ditemukan, function berhenti
- jika ditemukan, task lama diganti
- storage disimpan ulang
12. Update Status + Save Storage
Tambahkan:
Future<void> updateStatus(int id, TaskStatus status) async {
final index = _tasks.indexWhere((task) => task.id == id);
if (index == -1) return;
_tasks[index] = _tasks[index].copyWith(status: status);
notifyListeners();
await _saveCurrentTasks();
}
Kenapa memakai copyWith?
Karena model Task immutable. Saat status berubah, kita membuat object baru dari object lama.
13. Delete Task + Save Storage
Tambahkan:
Future<void> deleteTask(int id) async {
_tasks.removeWhere((task) => task.id == id);
notifyListeners();
await _saveCurrentTasks();
}
Alur:
Task dihapus dari _tasks
-> UI update
-> storage ditimpa dengan list terbaru
Jika task dihapus dari UI tapi storage tidak di-update, task akan muncul lagi saat aplikasi dibuka ulang.
14. Filter dan Search
Tambahkan:
void setStatusFilter(TaskStatus? status) {
_selectedStatus = status;
notifyListeners();
}
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
Kenapa filter/search tidak perlu save storage?
Karena filter dan search hanya memengaruhi tampilan, bukan data utama.
Tambah/edit/delete -> data berubah -> save storage
Filter/search -> tampilan berubah -> tidak perlu save storage
15. TaskProvider Lengkap
Gabungan final:
import 'package:flutter/foundation.dart';
import '../models/task.dart';
import '../services/task_storage_service.dart';
class TaskProvider extends ChangeNotifier {
final TaskStorageService _storageService;
TaskProvider({
TaskStorageService? storageService,
}) : _storageService = storageService ?? TaskStorageService() {
loadTasks();
}
final List<Task> _tasks = [];
TaskStatus? _selectedStatus;
String _searchQuery = '';
bool _isLoading = true;
String? _errorMessage;
List<Task> get tasks => List.unmodifiable(_tasks);
TaskStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
List<Task> get visibleTasks {
final filteredByStatus = _selectedStatus == null
? _tasks
: _tasks.where((task) => task.status == _selectedStatus).toList();
final query = _searchQuery.toLowerCase();
if (query.isEmpty) {
return filteredByStatus;
}
return filteredByStatus.where((task) {
final title = task.title.toLowerCase();
final description = task.description.toLowerCase();
return title.contains(query) || description.contains(query);
}).toList();
}
String get emptyMessage {
if (_searchQuery.isNotEmpty) {
return 'Task tidak ditemukan';
}
if (_selectedStatus != null) {
return 'Belum ada task dengan status ini';
}
return 'Belum ada task';
}
Future<void> loadTasks() async {
try {
_isLoading = true;
_errorMessage = null;
notifyListeners();
final savedTasks = await _storageService.loadTasks();
_tasks
..clear()
..addAll(savedTasks);
} catch (error) {
_errorMessage = 'Gagal membaca task';
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> _saveCurrentTasks() async {
try {
await _storageService.saveTasks(_tasks);
} catch (error) {
_errorMessage = 'Gagal menyimpan task';
notifyListeners();
}
}
Future<void> addTask({
required String title,
required String description,
TaskStatus status = TaskStatus.todo,
}) async {
final task = Task(
id: DateTime.now().millisecondsSinceEpoch,
title: title,
description: description,
status: status,
createdAt: DateTime.now(),
);
_tasks.add(task);
notifyListeners();
await _saveCurrentTasks();
}
Future<void> updateTask(Task updatedTask) async {
final index = _tasks.indexWhere((task) => task.id == updatedTask.id);
if (index == -1) return;
_tasks[index] = updatedTask;
notifyListeners();
await _saveCurrentTasks();
}
Future<void> updateStatus(int id, TaskStatus status) async {
final index = _tasks.indexWhere((task) => task.id == id);
if (index == -1) return;
_tasks[index] = _tasks[index].copyWith(status: status);
notifyListeners();
await _saveCurrentTasks();
}
Future<void> deleteTask(int id) async {
_tasks.removeWhere((task) => task.id == id);
notifyListeners();
await _saveCurrentTasks();
}
void setStatusFilter(TaskStatus? status) {
_selectedStatus = status;
notifyListeners();
}
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
}
16. Daftarkan Provider di main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'pages/task_list_page.dart';
import 'providers/task_provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => TaskProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Task Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const TaskListPage(),
);
}
}
Sekarang TaskProvider tersedia untuk halaman di bawah MyApp.
17. Membaca Provider di TaskListPage
Contoh struktur:
class TaskListPage extends StatelessWidget {
const TaskListPage({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<TaskProvider>();
if (provider.isLoading) {
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager'),
),
body: const Center(
child: CircularProgressIndicator(),
),
);
}
if (provider.errorMessage != null) {
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager'),
),
body: Center(
child: Text(provider.errorMessage!),
),
);
}
final visibleTasks = provider.visibleTasks;
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager'),
),
body: Column(
children: [
// Search, filter, dan list task
],
),
);
}
}
Penjelasan:
context.watch<TaskProvider>()membuat UI rebuild saat provider berubah.- Loading dan error dibaca dari provider.
- UI tidak lagi menyimpan
List<Task>sendiri.
18. Search dengan Provider
TextField(
decoration: const InputDecoration(
labelText: 'Cari task',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (value) {
context.read<TaskProvider>().setSearchQuery(value);
},
)
Gunakan read karena action ini hanya memanggil function.
19. Filter dengan Provider
final provider = context.watch<TaskProvider>();
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ChoiceChip(
label: const Text('Semua'),
selected: provider.selectedStatus == null,
onSelected: (_) {
context.read<TaskProvider>().setStatusFilter(null);
},
),
const SizedBox(width: 8),
...TaskStatus.values.map((status) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(status.label),
selected: provider.selectedStatus == status,
onSelected: (_) {
context.read<TaskProvider>().setStatusFilter(status);
},
),
);
}),
],
),
)
Filter tidak perlu save storage karena hanya mengatur tampilan.
20. List Task dengan Provider
Expanded(
child: visibleTasks.isEmpty
? Center(
child: Text(provider.emptyMessage),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: visibleTasks.length,
itemBuilder: (context, index) {
final task = visibleTasks[index];
return TaskCard(
task: task,
onEdit: () => openEditPage(context, task),
onDelete: () => confirmDelete(context, task),
onStatusChanged: (status) {
context.read<TaskProvider>().updateStatus(
task.id,
status,
);
},
);
},
),
)
Perhatikan:
- data diambil dari
provider.visibleTasks - update status dipanggil lewat provider
- setelah status berubah, provider akan save storage
21. Add Task dari Form
Jika TaskFormPage mengembalikan object Task, kamu bisa memakai:
Future<void> openCreatePage(BuildContext context) async {
final result = await Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (context) => const TaskFormPage(),
),
);
if (result == null || !context.mounted) return;
await context.read<TaskProvider>().addTask(
title: result.title,
description: result.description,
status: result.status,
);
}
Catatan:
Jika form langsung mengirim title, description, dan status, maka provider bisa dipanggil langsung dari form. Tetapi untuk pemula, pola form mengembalikan Task masih mudah dipahami.
22. Edit Task dari Form
Future<void> openEditPage(BuildContext context, Task task) async {
final result = await Navigator.push<Task>(
context,
MaterialPageRoute(
builder: (context) => TaskFormPage(task: task),
),
);
if (result == null || !context.mounted) return;
await context.read<TaskProvider>().updateTask(result);
}
Alur:
User edit task
-> TaskFormPage mengembalikan Task baru
-> provider.updateTask dipanggil
-> _tasks diperbarui
-> notifyListeners
-> storage disimpan ulang
23. Delete Task dengan Konfirmasi
Future<void> confirmDelete(BuildContext context, Task task) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Hapus task?'),
content: Text('Task "${task.title}" akan dihapus.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Hapus'),
),
],
);
},
);
if (result != true || !context.mounted) return;
await context.read<TaskProvider>().deleteTask(task.id);
}
Setelah deleteTask dipanggil:
task dihapus dari provider
-> UI update
-> storage update
24. Kenapa UI Jadi Lebih Bersih
Sebelumnya, UI harus mengurus:
- list task
- loading
- error
- load storage
- save storage
- filter
- search
- add
- edit
- delete
Setelah Provider:
UI hanya:
-> menampilkan data
-> menerima input
-> memanggil action provider
Logic pindah ke provider:
Provider:
-> menyimpan state
-> menghitung visibleTasks
-> save/load storage
-> CRUD
Ini membuat project lebih mudah dikembangkan.
25. Testing Manual
Test yang perlu dilakukan:
- Buka aplikasi.
- Pastikan loading muncul sebentar.
- Tambah task.
- Tutup dan buka aplikasi.
- Pastikan task masih ada.
- Edit task.
- Tutup dan buka aplikasi.
- Pastikan edit tersimpan.
- Ubah status task.
- Tutup dan buka aplikasi.
- Pastikan status tersimpan.
- Hapus task.
- Tutup dan buka aplikasi.
- Pastikan task tidak muncul lagi.
- Coba filter dan search.
- Pastikan filter/search tidak menghapus data asli.
26. Kesalahan Umum
Lupa notifyListeners
Jika state berubah tapi UI tidak update, cek apakah notifyListeners() sudah dipanggil.
Lupa save storage
Jika data muncul tapi hilang saat restart, kemungkinan action provider belum memanggil _saveCurrentTasks().
UI masih mengubah list langsung
Jangan lakukan:
provider.tasks.add(task);
Karena tasks seharusnya hanya dibaca. Gunakan:
context.read<TaskProvider>().addTask(...);
Filter/search ikut disimpan
Filter dan search tidak wajib disimpan ke storage. Yang disimpan adalah data task.
27. Checklist Upgrade Provider Storage
Pastikan sudah selesai:
- Ada
TaskStorageService. - Ada
TaskProvider. - Provider load task saat dibuat.
- Provider punya
isLoading. - Provider punya
errorMessage. - Provider punya
visibleTasks. - Provider punya
emptyMessage. - Provider save storage saat add.
- Provider save storage saat edit.
- Provider save storage saat update status.
- Provider save storage saat delete.
- UI membaca task dari provider.
- UI tidak lagi menyimpan
List<Task>sendiri. - Data tetap ada setelah aplikasi ditutup.
Jika checklist ini aman, kamu sudah punya Task Manager yang lebih rapi dengan Provider dan local storage.
28. Lanjutan Setelah Ini
Langkah berikutnya:
- Upgrade Task Manager dengan Riverpod + Local Storage.
- Upgrade Task Manager dengan Cubit + Local Storage.
- Simpan task ke Hive/Isar.
- Sinkron task ke Firestore.
- Tambahkan login user.
Urutan yang disarankan:
Provider + shared_preferences
-> Riverpod + shared_preferences
-> Cubit + shared_preferences
-> Firestore