Building a Notes App in Flutter – Part 2: Local Persistence & Polish

Welcome back! In Part 1, we built a working notes UI with in-memory state. The app looks great — but every time you close it, your notes disappear.

In Part 2, we fix that. By the end of this article, your notes will survive app restarts using local storage. We’ll also add the ability to delete notes and display timestamps — and we’ll keep the code clean using a simple storage service.

What you’ll build in Part 2:

  • Persistent notes using the shared_preferences package
  • A NotesStorage service class to keep UI code clean
  • Delete notes with a swipe or button
  • Timestamps displayed on each note

1. Choosing a Storage Approach

Flutter has several local storage options. Here’s a quick comparison so you understand the trade-offs:

PackageBest forComplexity
shared_preferencesSmall key-value data, simple lists⭐ Beginner-friendly
HiveStructured objects, faster reads⭐⭐ Intermediate
sqfliteRelational data, complex queries⭐⭐⭐ Advanced

For our simple notes app, shared_preferences is the perfect fit. We’ll store notes as a list of JSON strings. If your app ever grows to hundreds of notes with search and filtering, that’s when you’d graduate to Hive or sqflite.


2. Add the shared_preferences Package

Open pubspec.yaml and add the dependency under dependencies:

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.3.0

Then run this in your terminal to install it:

flutter pub get

You may need to stop and restart the app after adding a new package.


3. Update the Note Model for JSON

To store notes in shared_preferences, we need to convert them to and from JSON strings. Update lib/models/note.dart:

import 'dart:convert';

class Note {
  final String id;
  String title;
  String content;
  final DateTime createdAt;

  Note({
    required this.id,
    required this.title,
    required this.content,
    required this.createdAt,
  });

  // Convert a Note into a Map (for JSON encoding)
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'content': content,
      'createdAt': createdAt.toIso8601String(),
    };
  }

  // Create a Note from a Map (for JSON decoding)
  factory Note.fromJson(Map<String, dynamic> json) {
    return Note(
      id: json['id'] as String,
      title: json['title'] as String,
      content: json['content'] as String,
      createdAt: DateTime.parse(json['createdAt'] as String),
    );
  }

  // Helper to encode a Note directly to a JSON string
  String toJsonString() => jsonEncode(toJson());

  // Helper to decode a Note directly from a JSON string
  static Note fromJsonString(String jsonString) {
    return Note.fromJson(jsonDecode(jsonString) as Map<String, dynamic>);
  }
}

We added two conversion methods:

  • toJsonString() — turns a Note into a string we can store.
  • fromJsonString() — turns a stored string back into a Note.

4. Create the Storage Service

Instead of calling shared_preferences directly inside our UI widgets, we’ll create a dedicated service class. This keeps our code organized and easy to change later (e.g., if you ever swap shared_preferences for Hive, you only change one file).

Create lib/services/notes_storage.dart:

import 'package:shared_preferences/shared_preferences.dart';
import '../models/note.dart';

class NotesStorage {
  static const String _storageKey = 'notes_list';

  // Load all notes from storage
  Future<List<Note>> loadNotes() async {
    final prefs = await SharedPreferences.getInstance();
    final List<String> jsonStrings = prefs.getStringList(_storageKey) ?? [];

    return jsonStrings.map((s) => Note.fromJsonString(s)).toList();
  }

  // Save the full notes list to storage
  Future<void> saveNotes(List<Note> notes) async {
    final prefs = await SharedPreferences.getInstance();
    final List<String> jsonStrings = notes.map((n) => n.toJsonString()).toList();

    await prefs.setStringList(_storageKey, jsonStrings);
  }
}

This is intentionally simple. Two methods — loadNotes() and saveNotes() — are all we need. The UI doesn’t need to know anything about JSON or shared_preferences keys.


5. Update the Notes List Screen

Now we connect the storage service to our UI. Replace the contents of lib/screens/notes_list_page.dart:

import 'package:flutter/material.dart';
import '../models/note.dart';
import '../services/notes_storage.dart';
import 'note_editor_page.dart';

class NotesListPage extends StatefulWidget {
  const NotesListPage({super.key});

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  final NotesStorage _storage = NotesStorage();
  List<Note> _notes = [];
  bool _isLoading = true;

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

  Future<void> _loadNotes() async {
    final notes = await _storage.loadNotes();
    setState(() {
      _notes = notes;
      _isLoading = false;
    });
  }

  Future<void> _saveNotes() async {
    await _storage.saveNotes(_notes);
  }

  void _openEditor({Note? note, int? index}) async {
    final result = await Navigator.push<Note>(
      context,
      MaterialPageRoute(
        builder: (context) => NoteEditorPage(existingNote: note),
      ),
    );

    if (result != null) {
      setState(() {
        if (index != null) {
          _notes[index] = result;
        } else {
          _notes.insert(0, result); // Add new notes to the top
        }
      });
      await _saveNotes(); // Persist after every change
    }
  }

  void _deleteNote(int index) async {
    setState(() {
      _notes.removeAt(index);
    });
    await _saveNotes();
  }

  String _formatDate(DateTime date) {
    final now = DateTime.now();
    final diff = now.difference(date);

    if (diff.inDays == 0) return 'Today';
    if (diff.inDays == 1) return 'Yesterday';
    if (diff.inDays < 7) return '${diff.inDays} days ago';
    return '${date.day}/${date.month}/${date.year}';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Notes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet.nTap + to create one!',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontSize: 16, color: Colors.grey),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return Dismissible(
                      key: Key(note.id),
                      direction: DismissDirection.endToStart,
                      background: Container(
                        alignment: Alignment.centerRight,
                        padding: const EdgeInsets.symmetric(horizontal: 20),
                        color: Colors.red,
                        child: const Icon(Icons.delete, color: Colors.white),
                      ),
                      onDismissed: (_) => _deleteNote(index),
                      child: ListTile(
                        title: Text(
                          note.title.isEmpty ? 'Untitled' : note.title,
                          style: const TextStyle(fontWeight: FontWeight.bold),
                        ),
                        subtitle: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            if (note.content.isNotEmpty)
                              Text(
                                note.content,
                                maxLines: 2,
                                overflow: TextOverflow.ellipsis,
                              ),
                            const SizedBox(height: 4),
                            Text(
                              _formatDate(note.createdAt),
                              style: const TextStyle(
                                fontSize: 11,
                                color: Colors.grey,
                              ),
                            ),
                          ],
                        ),
                        onTap: () => _openEditor(note: note, index: index),
                      ),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _openEditor(),
        tooltip: 'New Note',
        child: const Icon(Icons.add),
      ),
    );
  }
}

What changed from Part 1:

  • Loading on startupinitState() now calls _loadNotes(), which fetches saved notes from storage before the first frame renders.
  • Loading indicator — while notes are being loaded, we show a CircularProgressIndicator instead of an empty screen.
  • Auto-save — every time a note is added, edited, or deleted, _saveNotes() is called immediately after.
  • Swipe to delete — wrapping each ListTile in a Dismissible widget gives us swipe-to-delete for free. Swipe left on any note to reveal the red delete background.
  • Timestamps — each note now shows a human-friendly date label (Today, Yesterday, X days ago, or a full date).

6. Final File Structure

Your lib/ folder should now look like this:

lib/
├── main.dart
├── models/
│   └── note.dart
├── screens/
│   ├── notes_list_page.dart
│   └── note_editor_page.dart
└── services/
    └── notes_storage.dart

This “structure by feature type” layout is a solid foundation. As your app grows, you can evolve it into a feature-first structure — but for a beginner project this size, it’s clean and easy to navigate.


7. Run and Test Persistence

Run the app:

flutter run

Try this to verify persistence is working:

  1. Create two or three notes.
  2. Completely close the app (don’t just background it — fully quit it).
  3. Reopen the app.
  4. Your notes should still be there. ✅

Also test swipe-to-delete: swipe any note from right to left to delete it, then close and reopen the app to confirm the deletion was also persisted.


Best Practices to Carry Forward

Before we wrap up, here are a few habits worth building from the start:

Keep build methods lean

Never do heavy work (I/O, network calls, expensive calculations) inside build(). The build method can be called many times per second. Do that work in initState(), dedicated service classes, or triggered by user actions.

Always use ListView.builder for lists

Unlike putting widgets in a Column, ListView.builder only creates the widgets that are currently visible on screen. This keeps scrolling smooth even with hundreds of items.

Dispose your controllers

Any time you create a TextEditingController, AnimationController, or similar object, always override dispose() and clean them up. Forgetting this causes memory leaks.

Separate concerns

Our NotesStorage service is a small example of this principle. UI widgets shouldn’t know how data is stored — they should just call a service. This makes it trivial to swap out shared_preferences for Hive or a remote API later without touching your UI code.


What to Learn Next

You’ve built a fully functional, persistent notes app from scratch. Here’s where to go next depending on what interests you:

  • State management — For larger apps, look into Provider or Riverpod to manage state more cleanly across many screens.
  • Better local storage — Try Hive or sqflite when you need structured data, relationships, or faster queries.
  • Search and filtering — Add a search bar to the notes list by filtering _notes with a search query in state.
  • Cloud sync — Explore Firebase Firestore to sync notes across devices.
  • Official Flutter docs — The Flutter learning pathway is excellent and free.

Series Recap

Over these two articles, you went from zero to a fully working notes app:

  • Part 1 — Flutter setup, widgets, layouts, multi-screen navigation, in-memory state.
  • Part 2 — Local persistence with shared_preferences, a storage service, swipe-to-delete, and timestamps.

That covers the full beginner foundation: project structure, UI building blocks, navigation, state management, and data persistence. Everything you build from here will use these same concepts — just at a larger scale. Happy coding!

Show 5 Comments

5 Comments

Leave a Reply