Flutter

Upgrade Task Manager dengan Local Storage

Tutorial upgrade Project Task Manager agar data tidak hilang: tambah toJson/fromJson, simpan List<Task> ke shared_preferences, load saat app dibuka, dan update storage saat add/edit/delete.

15 dari 57 materi Project fluttertask-managershared-preferenceslocal-storagejson

Upgrade Task Manager dengan Local Storage

Di Project Task Manager sebelumnya, data task masih disimpan di List<Task> biasa. Artinya, data akan hilang saat aplikasi ditutup. Pada materi ini, kita upgrade Task Manager supaya data tetap tersimpan memakai shared_preferences.

Yang akan dipelajari:

  • menambahkan toJson dan fromJson ke model Task
  • menyimpan List<Task> ke local storage
  • membaca task saat aplikasi dibuka
  • update storage saat tambah task
  • update storage saat edit task
  • update storage saat hapus task
  • membuat service agar kode lebih rapi

1. Gambaran Alur

Sebelum local storage:

User tambah task
-> task masuk ke List
-> UI update
-> aplikasi ditutup
-> List hilang

Setelah local storage:

User tambah task
-> task masuk ke List
-> List disimpan ke shared_preferences
-> UI update
-> aplikasi ditutup
-> aplikasi dibuka lagi
-> task dibaca dari shared_preferences
-> UI menampilkan task lama

Prinsip penting:

State di memory tetap dipakai untuk UI.
Local storage dipakai agar data tidak hilang.

Jadi UI tidak membaca langsung ke storage setiap saat. Storage dibaca saat awal, lalu data dimasukkan ke state. Ketika state berubah, storage ikut diperbarui.


2. Install Package

Tambahkan package:

flutter pub add shared_preferences

Lalu:

flutter pub get

Import yang akan dipakai:

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';

3. Struktur Folder

Struktur project yang disarankan:

lib/
  main.dart
  models/
    task.dart
  pages/
    task_list_page.dart
    task_form_page.dart
  services/
    task_storage_service.dart
  widgets/
    task_card.dart
    task_status_chip.dart

File baru yang akan dibuat:

lib/services/task_storage_service.dart

File yang akan diubah:

lib/models/task.dart
lib/pages/task_list_page.dart

4. Update Model Task

Buka file:

lib/models/task.dart

Contoh model awal:

enum TaskStatus {
  todo,
  progress,
  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,
  });
}

Tambahkan toJson, fromJson, dan copyWith:

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),
    );
  }
}

Penjelasan:

  • toJson() mengubah object Task menjadi Map.
  • fromJson() mengubah Map menjadi object Task.
  • status.name menyimpan enum sebagai string, misalnya todo.
  • TaskStatus.values.byName(...) mengubah string menjadi enum.
  • createdAt.toIso8601String() menyimpan tanggal sebagai string.
  • DateTime.parse(...) mengubah string kembali menjadi DateTime.

5. Kenapa Enum Disimpan sebagai String

Enum tidak bisa langsung disimpan ke JSON.

Kurang tepat:

'status': status,

Lebih aman:

'status': status.name,

Jika status bernilai:

TaskStatus.todo

Maka status.name menghasilkan:

todo

Saat membaca:

TaskStatus.values.byName('todo')

Hasilnya:

TaskStatus.todo

6. Membuat TaskStorageService

Buat file:

lib/services/task_storage_service.dart

Isi:

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);
  }
}

Penjelasan:

  • _tasksKey adalah key untuk menyimpan semua task.
  • saveTasks mengubah List<Task> menjadi JSON string.
  • loadTasks membaca JSON string lalu mengubahnya menjadi List<Task>.
  • clearTasks menghapus data task dari storage.

Kenapa dibuat service?

Supaya logic storage tidak bercampur dengan UI. Page cukup memanggil:

storageService.saveTasks(tasks);
storageService.loadTasks();

7. Memahami saveTasks

Bagian penting:

final jsonList = tasks.map((task) => task.toJson()).toList();

Ini mengubah:

List<Task>

menjadi:

List<Map<String, dynamic>>

Lalu:

final jsonString = jsonEncode(jsonList);

Ini mengubah list map menjadi string.

Baru disimpan:

await prefs.setString(_tasksKey, jsonString);

Alur:

List<Task>
-> List<Map>
-> String JSON
-> shared_preferences

8. Memahami loadTasks

Bagian penting:

final jsonString = prefs.getString(_tasksKey);

Jika belum ada data:

if (jsonString == null) {
  return [];
}

Jika ada data:

final jsonList = jsonDecode(jsonString) as List<dynamic>;

Lalu ubah setiap item menjadi Task:

return jsonList
    .map((item) => Task.fromJson(item as Map<String, dynamic>))
    .toList();

Alur:

shared_preferences
-> String JSON
-> List<dynamic>
-> List<Task>

9. Update TaskListPage

Buka file:

lib/pages/task_list_page.dart

Tambahkan service:

final taskStorageService = TaskStorageService();

Tambahkan state loading:

bool isLoading = true;

Contoh struktur:

class _TaskListPageState extends State<TaskListPage> {
  final taskStorageService = TaskStorageService();

  final List<Task> tasks = [];
  TaskStatus? selectedStatus;
  String searchQuery = '';
  bool isLoading = true;

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

  Future<void> loadTasks() async {
    final savedTasks = await taskStorageService.loadTasks();

    setState(() {
      tasks
        ..clear()
        ..addAll(savedTasks);
      isLoading = false;
    });
  }
}

Penjelasan:

  • initState() dipanggil saat halaman pertama kali dibuat.
  • loadTasks() membaca data dari storage.
  • tasks.clear() menghapus isi list saat ini.
  • tasks.addAll(savedTasks) mengisi list dari storage.
  • isLoading = false menandakan proses load selesai.

10. Tampilkan Loading Saat Membaca Storage

Di build, tambahkan kondisi:

if (isLoading) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('Task Manager'),
    ),
    body: const Center(
      child: CircularProgressIndicator(),
    ),
  );
}

Tujuannya:

  • user tahu aplikasi sedang membaca data
  • UI tidak langsung menampilkan empty state sebelum load selesai

Tanpa loading, user bisa melihat “Belum ada task” sebentar walaupun sebenarnya task masih sedang dibaca.


11. Simpan Storage Saat Add Task

Sebelumnya:

setState(() {
  tasks.add(result);
});

Ubah menjadi:

setState(() {
  tasks.add(result);
});

await taskStorageService.saveTasks(tasks);

Contoh lengkap:

Future<void> openCreatePage() async {
  final result = await Navigator.push<Task>(
    context,
    MaterialPageRoute(
      builder: (context) => const TaskFormPage(),
    ),
  );

  if (result == null) return;

  setState(() {
    tasks.add(result);
  });

  await taskStorageService.saveTasks(tasks);
}

Alur:

Form mengembalikan task
-> task ditambahkan ke List
-> List disimpan ke local storage

12. Simpan Storage Saat Edit Task

Sebelumnya:

setState(() {
  tasks[index] = result;
});

Tambahkan save:

setState(() {
  tasks[index] = result;
});

await taskStorageService.saveTasks(tasks);

Contoh lengkap:

Future<void> openEditPage(Task task) async {
  final result = await Navigator.push<Task>(
    context,
    MaterialPageRoute(
      builder: (context) => TaskFormPage(task: task),
    ),
  );

  if (result == null) return;

  final index = tasks.indexWhere((item) => item.id == result.id);
  if (index == -1) return;

  setState(() {
    tasks[index] = result;
  });

  await taskStorageService.saveTasks(tasks);
}

Setiap edit selesai, storage harus ditimpa dengan list terbaru.


13. Simpan Storage Saat Update Status

Sebelumnya:

setState(() {
  tasks[index] = tasks[index].copyWith(status: status);
});

Ubah function menjadi async:

Future<void> updateTaskStatus(int id, TaskStatus status) async {
  final index = tasks.indexWhere((task) => task.id == id);
  if (index == -1) return;

  setState(() {
    tasks[index] = tasks[index].copyWith(status: status);
  });

  await taskStorageService.saveTasks(tasks);
}

Pemanggilan:

onStatusChanged: (status) {
  updateTaskStatus(task.id, status);
},

Tidak perlu await di UI jika tidak ada aksi setelah update. Tapi function tetap async agar bisa menyimpan data.


14. Simpan Storage Saat Delete Task

Sebelumnya:

setState(() {
  tasks.removeWhere((item) => item.id == task.id);
});

Tambahkan save:

setState(() {
  tasks.removeWhere((item) => item.id == task.id);
});

await taskStorageService.saveTasks(tasks);

Contoh lengkap:

Future<void> confirmDelete(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) return;

  setState(() {
    tasks.removeWhere((item) => item.id == task.id);
  });

  await taskStorageService.saveTasks(tasks);
}

Setelah task dihapus dari memory, storage harus ikut diperbarui.


15. Membuat Helper saveCurrentTasks

Agar tidak menulis:

await taskStorageService.saveTasks(tasks);

berulang-ulang, buat helper:

Future<void> saveCurrentTasks() async {
  await taskStorageService.saveTasks(tasks);
}

Lalu gunakan:

setState(() {
  tasks.add(result);
});

await saveCurrentTasks();

Ini membuat kode lebih mudah dibaca.


16. Contoh TaskListPage Ringkas

Contoh potongan penting:

class _TaskListPageState extends State<TaskListPage> {
  final taskStorageService = TaskStorageService();

  final List<Task> tasks = [];
  TaskStatus? selectedStatus;
  String searchQuery = '';
  bool isLoading = true;

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

  Future<void> loadTasks() async {
    final savedTasks = await taskStorageService.loadTasks();

    setState(() {
      tasks
        ..clear()
        ..addAll(savedTasks);
      isLoading = false;
    });
  }

  Future<void> saveCurrentTasks() async {
    await taskStorageService.saveTasks(tasks);
  }

  Future<void> addTask(Task task) async {
    setState(() {
      tasks.add(task);
    });

    await saveCurrentTasks();
  }

  Future<void> updateTask(Task updatedTask) async {
    final index = tasks.indexWhere((task) => task.id == updatedTask.id);
    if (index == -1) return;

    setState(() {
      tasks[index] = updatedTask;
    });

    await saveCurrentTasks();
  }

  Future<void> deleteTask(int id) async {
    setState(() {
      tasks.removeWhere((task) => task.id == id);
    });

    await saveCurrentTasks();
  }
}

Dengan pola ini, setiap perubahan data selalu diikuti penyimpanan storage.


17. Testing Manual

Setelah selesai, lakukan test manual:

  1. Jalankan aplikasi.
  2. Tambahkan beberapa task.
  3. Tutup aplikasi.
  4. Buka lagi.
  5. Pastikan task masih ada.
  6. Edit task.
  7. Tutup dan buka lagi.
  8. Pastikan hasil edit masih ada.
  9. Hapus task.
  10. Tutup dan buka lagi.
  11. Pastikan task yang dihapus tidak muncul lagi.

Jika semua berhasil, local storage sudah bekerja.


18. Error yang Sering Terjadi

Data tidak muncul setelah app dibuka

Cek:

  • apakah loadTasks() dipanggil di initState
  • apakah key storage sama
  • apakah saveTasks() berhasil dipanggil
  • apakah parsing JSON error

Data muncul saat ditambah, tapi hilang saat restart

Kemungkinan:

  • hanya menambah data ke List
  • lupa memanggil saveTasks

Error saat parsing enum

Penyebab:

TaskStatus.values.byName(json['status'] as String)

akan error jika string status tidak cocok.

Pastikan value yang disimpan berasal dari:

status.name

Error saat parsing DateTime

Pastikan tanggal disimpan dengan:

createdAt.toIso8601String()

Dan dibaca dengan:

DateTime.parse(json['createdAt'] as String)

19. Checklist Upgrade

Pastikan sudah selesai:

  • Task punya toJson.
  • Task punya fromJson.
  • TaskStatus disimpan dengan status.name.
  • DateTime disimpan dengan toIso8601String.
  • Ada TaskStorageService.
  • Bisa menyimpan List<Task> ke shared_preferences.
  • Bisa load task saat app dibuka.
  • Ada loading state saat membaca storage.
  • Storage update saat add task.
  • Storage update saat edit task.
  • Storage update saat update status.
  • Storage update saat delete task.
  • Data tetap ada setelah aplikasi ditutup dan dibuka lagi.

Jika checklist ini aman, Task Manager sudah punya local persistence dasar.


20. Lanjutan Setelah Ini

Setelah memakai shared_preferences, langkah berikutnya:

  1. Upgrade Task Manager dengan Provider + local storage.
  2. Upgrade Task Manager dengan Riverpod + local storage.
  3. Simpan task ke Hive/Isar untuk data lokal yang lebih besar.
  4. Sinkronkan task ke Firestore.
  5. Tambahkan login agar task tersimpan per user.

Urutan paling enak:

setState + shared_preferences
-> Provider + shared_preferences
-> Riverpod + shared_preferences
-> Firestore