Flutter Intermediate Lesson 129 of 135 4 min read
Notes App in Flutter with Riverpod (Project with Source Code)
Build a notes app in Flutter with Riverpod: a Notifier, search, derived providers and an edit screen with immutable state. Full runnable source code.
On this page
A notes app with two screens, search and editing, using Riverpod the way real projects do: a Notifier owns the data, small providers hold UI choices, and derived providers combine them.
The full app
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() => runApp(const ProviderScope(child: NotesApp()));
// ---------- Model (immutable) ----------
class Note {
const Note({required this.id, required this.title, required this.body, required this.updated});
final int id;
final String title;
final String body;
final DateTime updated;
Note copyWith({String? title, String? body}) => Note(
id: id,
title: title ?? this.title,
body: body ?? this.body,
updated: DateTime.now(),
);
}
// ---------- State ----------
class NotesNotifier extends Notifier<List<Note>> {
int _nextId = 3;
@override
List<Note> build() => [
Note(id: 1, title: 'Welcome', body: 'Tap a note to edit it.', updated: DateTime.now()),
Note(id: 2, title: 'Riverpod', body: 'State lives in providers, not widgets.', updated: DateTime.now()),
];
int add() {
final note = Note(id: _nextId++, title: '', body: '', updated: DateTime.now());
state = [note, ...state];
return note.id;
}
void save(int id, {required String title, required String body}) {
state = [
for (final n in state)
if (n.id == id) n.copyWith(title: title, body: body) else n,
];
}
void delete(int id) => state = state.where((n) => n.id != id).toList();
}
final notesProvider = NotifierProvider<NotesNotifier, List<Note>>(NotesNotifier.new);
class SearchNotifier extends Notifier<String> {
@override
String build() => '';
void set(String value) => state = value;
}
final searchProvider = NotifierProvider<SearchNotifier, String>(SearchNotifier.new);
// Derived: recalculated only when notes or the search change.
final visibleNotesProvider = Provider<List<Note>>((ref) {
final query = ref.watch(searchProvider).toLowerCase();
final notes = ref.watch(notesProvider);
final filtered = query.isEmpty
? notes
: notes.where((n) => '${n.title} ${n.body}'.toLowerCase().contains(query)).toList();
return [...filtered]..sort((a, b) => b.updated.compareTo(a.updated));
});
// ---------- UI ----------
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.amber),
home: const NotesListPage(),
);
}
}
class NotesListPage extends ConsumerWidget {
const NotesListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notes = ref.watch(visibleNotesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: SearchBar(
hintText: 'Search notes',
leading: const Icon(Icons.search),
onChanged: (v) => ref.read(searchProvider.notifier).set(v),
),
),
),
),
body: notes.isEmpty
? const Center(child: Text('No notes'))
: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final n = notes[i];
return ListTile(
title: Text(n.title.isEmpty ? '(untitled)' : n.title),
subtitle: Text(n.body, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => ref.read(notesProvider.notifier).delete(n.id),
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => EditNotePage(id: n.id)),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
final id = ref.read(notesProvider.notifier).add();
Navigator.push(context, MaterialPageRoute(builder: (_) => EditNotePage(id: id)));
},
child: const Icon(Icons.add),
),
);
}
}
class EditNotePage extends ConsumerStatefulWidget {
const EditNotePage({super.key, required this.id});
final int id;
@override
ConsumerState<EditNotePage> createState() => _EditNotePageState();
}
class _EditNotePageState extends ConsumerState<EditNotePage> {
late final Note _note = ref.read(notesProvider).firstWhere((n) => n.id == widget.id);
late final _title = TextEditingController(text: _note.title);
late final _body = TextEditingController(text: _note.body);
@override
void dispose() {
_title.dispose();
_body.dispose();
super.dispose();
}
void _save() {
ref.read(notesProvider.notifier).save(widget.id, title: _title.text, body: _body.text);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit note'),
actions: [IconButton(icon: const Icon(Icons.check), onPressed: _save)],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _title,
style: Theme.of(context).textTheme.titleLarge,
decoration: const InputDecoration(hintText: 'Title', border: InputBorder.none),
),
Expanded(
child: TextField(
controller: _body,
maxLines: null,
expands: true,
decoration: const InputDecoration(hintText: 'Write something...', border: InputBorder.none),
),
),
],
),
),
);
}
}
How it works
NotesNotifieris the single place that changes notes. It always replacesstatewith a new list, which is how Riverpod knows something changed.searchProviderholds the search text. It is separate because it is a UI choice, not data.visibleNotesProvidercombines the two. Widgets watch this one provider and get exactly what to show; it recalculates only when notes or the search change.ConsumerWidget/ConsumerStatefulWidgetgive widgets areftowatch(rebuild on change) orread(just call a method).- The edit screen copies the note into text controllers and saves on ✓, so typing does not rebuild the list on every key press.
Make it yours
- Save notes with shared_preferences or SQLite inside the notifier.
- Add colours or pinned notes.
- Rewrite the edit page with hooks_riverpod and
useTextEditingController. - Write a unit test for
NotesNotifierwith aProviderContainer.