Series: This is Part 1 of 2. Read Part 2 → Local Persistence & Polish
Most Flutter tutorials teach you widgets. This one teaches you to think in Flutter.
There’s a big difference between following along with a counter app and actually understanding why Flutter works the way it does. By the end of this article, you won’t just have a notes app — you’ll understand the mental model: widget trees, state ownership, and screen-to-screen data flow. These are the concepts every Flutter developer uses every single day.
Prerequisites: Basic programming knowledge (variables, functions, conditionals). Zero Flutter or Dart experience needed.
What you’ll have by the end of this article: A real, working two-screen notes app with a list view, a note editor, and full navigation between them — all wired up with proper in-memory state.
Table of Contents
1. Setting Up Flutter
Head to the official Flutter installation page and follow the guide for your OS. You’ll install the Flutter SDK and either Android Studio or VS Code as your editor. Both work well — VS Code with the Flutter extension is slightly lighter for beginners.
Once installed, run the health check:
flutter doctor
This checks your environment and flags anything missing — missing Xcode, Android SDK, license agreements. Fix every item before moving on, otherwise you’ll hit confusing errors later.
Create and run the project
flutter create notes_app
cd notes_app
flutter run
You should see the default counter app. That’s your green light — everything is wired up correctly. We’ll delete all of it and start fresh.
Project structure at a glance
notes_app/
├── lib/ ← All your Dart code lives here
│ └── main.dart ← App entry point
├── android/ ← Android platform files (leave alone for now)
├── ios/ ← iOS platform files (leave alone for now)
└── pubspec.yaml ← Dependencies and project metadata
Everything you write goes inside lib/. The platform folders only matter when you need native integrations.
2. Core Concepts: Widgets, State, and the Tree
Flutter has one rule that governs everything: every piece of UI is a widget. Buttons, text, padding, layout columns — all widgets. They nest inside each other to form a widget tree, and Flutter uses that tree to render the screen.
There are two kinds of widgets. Understanding the difference is the most important thing you’ll learn today:
| Widget Type | Can Change Over Time? | Has setState()? |
Common Use Cases |
|---|---|---|---|
| StatelessWidget | No | No | Static labels, icons, layout shells, the root MaterialApp |
| StatefulWidget | Yes | Yes | Lists that grow, forms, screens with user interaction |
The rule of thumb: if a user action can change what the widget displays, make it a StatefulWidget. Otherwise, keep it StatelessWidget — it’s simpler and slightly more efficient.
3. The App Entry Point
Open lib/main.dart and replace the entire file with this:
import 'package:flutter/material.dart';
import 'screens/notes_list_page.dart';
void main() {
runApp(const NotesApp());
}
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber),
useMaterial3: true,
),
home: const NotesListPage(),
);
}
}
| Code | What it does |
|---|---|
main() |
Dart’s entry point — execution always starts here |
runApp() |
Inflates the given widget and makes it the root of the UI |
MaterialApp |
Sets up Material Design theming, navigation, and the first screen |
home: |
The widget shown when the app first launches |
4. Create the Note Model
Create a new file at lib/models/note.dart. This defines the shape of a single note across the whole app:
class Note {
final String id; // Unique identifier — used to tell notes apart
String title; // Mutable so we can edit it later
String content; // The body text of the note
final DateTime createdAt; // Set once at creation, never changes
Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
});
}
id and createdAt are final — they’re set once and never change. title and content are mutable because the user can edit them. In Part 2, we’ll add toJson() and fromJson() methods here so we can persist notes to storage.
5. Build the Notes List Screen
Create lib/screens/notes_list_page.dart. This is the home screen — it owns the notes list and is responsible for keeping it up to date:
import 'package:flutter/material.dart';
import '../models/note.dart';
import 'note_editor_page.dart';
class NotesListPage extends StatefulWidget {
const NotesListPage({super.key});
@override
State<NotesListPage> createState() => _NotesListPageState();
}
class _NotesListPageState extends State<NotesListPage> {
// The single source of truth for all notes in memory
final List<Note> _notes = [];
/// Opens the editor. Pass [note] + [index] to edit, or nothing to create new.
void _openEditor({Note? note, int? index}) async {
final result = await Navigator.push<Note>(
context,
MaterialPageRoute(
builder: (context) => NoteEditorPage(existingNote: note),
),
);
// result is null if the user closed the editor without saving
if (result != null) {
setState(() {
if (index != null) {
_notes[index] = result; // Replace existing note
} else {
_notes.insert(0, result); // New notes appear at the top
}
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Notes'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: _notes.isEmpty ? _buildEmptyState() : _buildNotesList(),
floatingActionButton: FloatingActionButton(
onPressed: () => _openEditor(),
tooltip: 'New Note',
child: const Icon(Icons.add),
),
);
}
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.note_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No notes yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Tap + to create your first note',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
Widget _buildNotesList() {
// ListView.builder is lazy — it only renders widgets currently on screen.
// Always use this instead of Column + SingleChildScrollView for lists.
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _notes.length,
itemBuilder: (context, index) {
final note = _notes[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
title: Text(
note.title.isEmpty ? 'Untitled' : note.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: note.content.isNotEmpty
? Text(
note.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: () => _openEditor(note: note, index: index),
),
);
},
);
}
}
ListView.builder?It only creates the widget cells currently visible on screen — not all of them upfront. A
Column inside a SingleChildScrollView would build every single note widget at once, even if you have 500 notes. ListView.builder is always the right choice for lists of unknown length.
6. Build the Note Editor Screen
Create lib/screens/note_editor_page.dart. This screen handles both creating a new note and editing an existing one — the same widget, two modes:
import 'package:flutter/material.dart';
import '../models/note.dart';
class NoteEditorPage extends StatefulWidget {
// null = creating a new note, non-null = editing an existing one
final Note? existingNote;
const NoteEditorPage({super.key, this.existingNote});
@override
State<NoteEditorPage> createState() => _NoteEditorPageState();
}
class _NoteEditorPageState extends State<NoteEditorPage> {
late TextEditingController _titleController;
late TextEditingController _contentController;
@override
void initState() {
super.initState();
// Pre-fill with existing content when editing, empty when creating
_titleController = TextEditingController(
text: widget.existingNote?.title ?? '',
);
_contentController = TextEditingController(
text: widget.existingNote?.content ?? '',
);
}
@override
void dispose() {
// Critical: always dispose controllers or you'll leak memory
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
void _saveNote() {
final title = _titleController.text.trim();
final content = _contentController.text.trim();
// Don't save if both fields are empty — just close
if (title.isEmpty && content.isEmpty) {
Navigator.pop(context);
return;
}
final note = Note(
// Keep the original ID when editing, generate a new one for new notes
id: widget.existingNote?.id
?? DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
content: content,
// Keep the original creation date when editing
createdAt: widget.existingNote?.createdAt ?? DateTime.now(),
);
// Pop the screen and return the note to whoever pushed this route
Navigator.pop(context, note);
}
@override
Widget build(BuildContext context) {
final isEditing = widget.existingNote != null;
return Scaffold(
appBar: AppBar(
title: Text(isEditing ? 'Edit Note' : 'New Note'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.check_rounded),
tooltip: 'Save note',
onPressed: _saveNote,
),
],
),
body: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
children: [
// Title field — large, bold, no border
TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: 'Title',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
textCapitalization: TextCapitalization.sentences,
autofocus: true, // Keyboard opens immediately
),
const Divider(height: 1),
const SizedBox(height: 8),
// Content field — fills remaining space
Expanded(
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Write your note here...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
),
style: const TextStyle(fontSize: 16, height: 1.6),
maxLines: null, // Grows as you type
expands: true, // Fills the Expanded parent
textAlignVertical: TextAlignVertical.top,
textCapitalization: TextCapitalization.sentences,
),
),
],
),
),
);
}
}
| Pattern | Why it matters |
|---|---|
initState() initializes controllers |
Runs once on widget creation — the right place for setup work, not build() |
dispose() cleans up controllers |
Prevents memory leaks when the screen is removed from the stack |
Navigator.pop(context, note) |
Returns the saved note as a value back to the list screen |
maxLines: null + expands: true |
Makes the content field fill all remaining vertical space naturally |
autofocus: true on title |
Keyboard opens immediately when the screen loads — better UX |
7. Wire Up the File Structure
Your complete lib/ structure should now look like this:
lib/
├── main.dart ← App entry point & theme
├── models/
│ └── note.dart ← Note data model
└── screens/
├── notes_list_page.dart ← Home screen (owns the notes list)
└── note_editor_page.dart ← Create / edit screen
This structure separates data (models/) from UI (screens/). In Part 2 we’ll add a services/ folder for storage logic — keeping that out of the UI code too.
8. Run It and Test
flutter run
Run through this checklist to make sure everything is working:
| Action | Expected Result |
|---|---|
| Open the app | Empty state icon + “Tap + to create your first note” |
| Tap the + FAB | Editor opens with keyboard focused on the title field |
| Type a title + content, tap ✓ | Note appears as a card at the top of the list |
| Tap a note in the list | Editor opens pre-filled with that note’s content |
| Edit and save | List updates with the new content |
| Close and reopen the app | Notes are gone — expected, we fix this in Part 2 |
9. Recap & What’s Next
| Concept | What you built |
|---|---|
| Project setup | Flutter installed, project created, default app running |
| Widgets & the widget tree | Nested Scaffold, AppBar, ListView, Card, TextField |
| StatefulWidget + setState | Notes list that updates live when you add or edit a note |
| Navigation + data passing | Navigator.push / pop with a return value between screens |
| TextEditingController lifecycle | Initialized in initState, disposed in dispose |
In Part 2, we wire up shared_preferences so notes survive app restarts. We’ll also add swipe-to-delete, timestamps, and a clean NotesStorage service class — and cover the async patterns that make it all work.

Pingback: Flutter Tutorial for Beginners Step by Step (2026)
Pingback: Handling User Input in Flutter: TextField, Forms and Validation for Beginners (2026)
Pingback: Flutter Stateless vs Stateful Widgets Explained (Lifecycle & Interview Q&A 2026)
Pingback: Flutter pubspec.yaml Explained for Beginners (2026 Complete Guide)
Pingback: Bottom Navigation Bar in Flutter: 3-Screen App for Beginners
Pingback: Hot Reload vs Hot Restart in Flutter (With When to Use Which)
Pingback: Flutter Layout Made Easy: Row, Column, Flex and Expanded for Beginners (2026) - Flutter for beginners