Lesson 40 minNEW

Project 2 — CLI Notes Application

00:00 / 00:00

Project 2 — CLI Notes Application

In this project we'll build a CLI Notes App — a command-line tool where you can create, read, search, and delete notes. Every note is automatically saved to a JSON file, so your data persists between sessions. This project introduces file handling, JSON serialization, and robust error handling.


🎯 What We're Building

FeatureDescription
📝 Create NoteAdd a note with a title and content
📖 Read All NotesList all saved notes with previews
🔍 Search NotesFind notes by keyword in title or body
🗑️ Delete NoteRemove a note by its ID
💾 Auto-PersistAll changes saved to notes.json automatically

🧠 Concepts Used

  • ✅ File handling (dart:io)
  • ✅ JSON encoding & decoding (dart:convert)
  • ✅ Classes with named constructors
  • ✅ Exception handling (try/catch/finally)
  • DateTime formatting
  • List CRUD operations
  • String search with contains()

📁 Project Structure

cli_notes/
├── bin/
│   └── main.dart           ← CLI menu & entry point
├── lib/
│   ├── note.dart           ← Note model class
│   └── notes_manager.dart  ← Business logic + file I/O
├── notes.json              ← Auto-created at runtime
└── pubspec.yaml

Step 1 — Create the Project

bash
dart create cli_notes cd cli_notes

Step 2 — The Note Model Class

Create lib/note.dart:

dart
// lib/note.dart import 'dart:convert'; class Note { final String id; // Unique identifier final String title; // Note title final String content; // Note body/content final DateTime createdAt; // Timestamp Note({ required this.id, required this.title, required this.content, required this.createdAt, }); // Named constructor: create a brand-new note with auto-generated id & timestamp factory Note.create({ required String title, required String content, }) { return Note( id: DateTime.now().millisecondsSinceEpoch.toString(), title: title, content: content, createdAt: DateTime.now(), ); } // Deserialize from a JSON map (used when loading from file) factory Note.fromMap(Map<String, dynamic> map) { return Note( id: map['id'] as String, title: map['title'] as String, content: map['content'] as String, createdAt: DateTime.parse(map['createdAt'] as String), ); } // Serialize to a JSON map (used when saving to file) Map<String, dynamic> toMap() { return { 'id': id, 'title': title, 'content': content, 'createdAt': createdAt.toIso8601String(), }; } // Convenience: convert directly to JSON string String toJson() => jsonEncode(toMap()); // A short preview of the content (first 60 characters) String get preview { if (content.length <= 60) return content; return '${content.substring(0, 60)}...'; } // Human-readable creation date String get formattedDate { return '${createdAt.day.toString().padLeft(2, '0')}/' '${createdAt.month.toString().padLeft(2, '0')}/' '${createdAt.year} ' '${createdAt.hour.toString().padLeft(2, '0')}:' '${createdAt.minute.toString().padLeft(2, '0')}'; } String toString() => 'Note(id: $id, title: "$title")'; }

💡 Key Design: We use two factory constructors — Note.create() for new notes and Note.fromMap() for loading saved notes. This is a standard Dart pattern for model classes.


Step 3 — The NotesManager Class

Create lib/notes_manager.dart:

dart
// lib/notes_manager.dart import 'dart:convert'; import 'dart:io'; import 'note.dart'; class NotesManager { final String _filePath; final List<Note> _notes = []; NotesManager({String filePath = 'notes.json'}) : _filePath = filePath; // ─── INITIALIZATION ─────────────────────────────────────────── /// Must be called once at startup to load existing notes. Future<void> initialize() async { await _loadFromFile(); } // ─── CRUD ───────────────────────────────────────────────────── /// Create and store a new note. Future<Note> createNote({ required String title, required String content, }) async { final note = Note.create(title: title, content: content); _notes.add(note); await _saveToFile(); return note; } /// Get all notes (newest first). List<Note> getAllNotes() { final sorted = List<Note>.from(_notes); sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); return sorted; } /// Search notes by keyword — checks title AND content. List<Note> searchNotes(String query) { final lower = query.toLowerCase(); return _notes.where((note) { return note.title.toLowerCase().contains(lower) || note.content.toLowerCase().contains(lower); }).toList(); } /// Delete a note by its ID. /// Returns true if deleted, false if not found. Future<bool> deleteNote(String id) async { final initialLength = _notes.length; _notes.removeWhere((note) => note.id == id); if (_notes.length < initialLength) { await _saveToFile(); return true; } return false; } /// Find a single note by ID. Note? getNoteById(String id) { try { return _notes.firstWhere((note) => note.id == id); } catch (_) { return null; } } int get count => _notes.length; // ─── FILE I/O ───────────────────────────────────────────────── /// Load notes from the JSON file on disk. Future<void> _loadFromFile() async { final file = File(_filePath); // If no file exists yet, that's fine — start with empty list if (!await file.exists()) { return; } try { final content = await file.readAsString(); // Guard against empty files if (content.trim().isEmpty) { return; } final List<dynamic> jsonList = jsonDecode(content); _notes.clear(); _notes.addAll(jsonList.map((item) => Note.fromMap(item))); print('📂 Loaded ${_notes.length} note(s) from $_filePath'); } on FormatException catch (e) { // The JSON file is malformed print('⚠️ Warning: Could not parse notes file. Starting fresh.'); print(' Details: $e'); } on IOException catch (e) { // A disk/permissions error occurred print('⚠️ Warning: Could not read notes file.'); print(' Details: $e'); } } /// Save all notes to the JSON file. Future<void> _saveToFile() async { final file = File(_filePath); try { final data = _notes.map((n) => n.toMap()).toList(); // Use prettyPrint for human-readable JSON const encoder = JsonEncoder.withIndent(' '); await file.writeAsString(encoder.convert(data)); } on IOException catch (e) { print('❌ Error: Could not save notes to file.'); print(' Details: $e'); } } }

💡 Exception Strategy: We catch FormatException (bad JSON) and IOException (disk errors) separately so we can give users a clear, specific error message rather than a generic crash.


Step 4 — The CLI Entry Point

Create bin/main.dart:

dart
// bin/main.dart import 'dart:io'; import '../lib/notes_manager.dart'; late NotesManager manager; Future<void> main() async { manager = NotesManager(filePath: 'notes.json'); // Load existing notes from disk before we do anything await manager.initialize(); printBanner(); while (true) { printMenu(); final choice = prompt('Enter your choice'); switch (choice?.trim()) { case '1': await handleCreate(); case '2': handleReadAll(); case '3': handleSearch(); case '4': await handleDelete(); case '5': print('\n👋 See you later!\n'); exit(0); default: print('\n⚠️ Invalid option. Choose 1–5.\n'); } } } // ─── HANDLERS ───────────────────────────────────────────────────── Future<void> handleCreate() async { print('\n──── New Note ────'); final title = prompt('Title'); if (title == null || title.trim().isEmpty) { print('❌ Title cannot be empty.'); return; } print(' 👉 Content (press Enter twice when done):'); final contentLines = <String>[]; String? line; while ((line = stdin.readLineSync()) != null && line!.isNotEmpty) { contentLines.add(line); } if (contentLines.isEmpty) { print('❌ Content cannot be empty.'); return; } final note = await manager.createNote( title: title.trim(), content: contentLines.join('\n'), ); print('\n✅ Note created! ID: ${note.id}'); } void handleReadAll() { print('\n──── All Notes (${manager.count} total) ────\n'); final notes = manager.getAllNotes(); if (notes.isEmpty) { print('📭 You have no notes yet. Create one with option 1!'); return; } for (final note in notes) { printNoteCard(note); } } void handleSearch() { print('\n──── Search Notes ────'); final query = prompt('Search keyword'); if (query == null || query.trim().isEmpty) { print('❌ Please enter a search term.'); return; } final results = manager.searchNotes(query.trim()); print('\n 🔍 Found ${results.length} result(s) for "$query":'); if (results.isEmpty) { print(' No notes match your search.'); return; } for (final note in results) { printNoteCard(note); } } Future<void> handleDelete() async { print('\n──── Delete Note ────'); // Show notes first so the user can pick an ID final notes = manager.getAllNotes(); if (notes.isEmpty) { print('📭 No notes to delete.'); return; } print(' Your notes:'); for (final note in notes) { print(' ID: ${note.id} | "${note.title}"'); } final id = prompt('\nEnter note ID to delete'); if (id == null || id.trim().isEmpty) { print('❌ No ID entered.'); return; } final confirm = prompt('Are you sure? (y/n)'); if (confirm?.toLowerCase() != 'y') { print('🚫 Delete cancelled.'); return; } final deleted = await manager.deleteNote(id.trim()); if (deleted) { print('✅ Note deleted successfully.'); } else { print('❌ Note with ID "${id.trim()}" not found.'); } } // ─── DISPLAY HELPERS ────────────────────────────────────────────── void printNoteCard(note) { print('┌─────────────────────────────────────┐'); print('│ 📝 ${note.title.padRight(35)}│'); print('│ 🕒 ${note.formattedDate.padRight(35)}│'); print('│ 🔑 ID: ${note.id.padRight(31)}│'); print('├─────────────────────────────────────┤'); print('│ ${note.preview.padRight(37)}│'); print('└─────────────────────────────────────┘'); print(''); } // ─── UTILITIES ──────────────────────────────────────────────────── String? prompt(String message) { stdout.write(' 👉 $message: '); return stdin.readLineSync(); } void printBanner() { print(''' ╔═══════════════════════════════════════╗ ║ 📒 DART CLI NOTES APP ║ ║ Your thoughts, always safe on disk ║ ╚═══════════════════════════════════════╝ '''); } void printMenu() { print(''' ┌─── MENU ────────────────────────────┐ │ 1. 📝 Create Note │ │ 2. 📖 Read All Notes │ │ 3. 🔍 Search Notes │ │ 4. 🗑️ Delete Note │ │ 5. 🚪 Exit │ └─────────────────────────────────────┘'''); }

Step 5 — Run the App

bash
dart run bin/main.dart

After creating a few notes, check notes.json in your project root — it will look like:

json
[ { "id": "1720000000000", "title": "My first note", "content": "Dart is awesome!", "createdAt": "2025-01-15T10:30:00.000" } ]

🔍 Deep Dive: Exception Handling

This project shows three layers of error handling:

dart
// Layer 1: Input validation (before doing anything) if (title.trim().isEmpty) { print('❌ Title cannot be empty.'); return; } // Layer 2: Business logic error (note not found) final deleted = await manager.deleteNote(id); if (!deleted) { print('❌ Note not found.'); } // Layer 3: Infrastructure errors (disk I/O) try { await file.writeAsString(data); } on IOException catch (e) { print('❌ Could not save: $e'); }

💡 Rule of thumb: Validate user input early (fail fast), handle business errors with return values, and catch infrastructure errors with try/catch.


💡 Key Takeaways

ConceptWhere Used
factory constructorNote.create(), Note.fromMap()
File classReading/writing notes.json
jsonEncode / jsonDecodeSerializing notes to/from JSON
try/catch with typed exceptions_loadFromFile(), _saveToFile()
List.where()Searching notes
List.removeWhere()Deleting a note
JsonEncoder.withIndent()Pretty-printed JSON output
async/awaitAll file operations

🧪 Challenges

  1. View Single Note — Add option to view a note's full content by ID
  2. Edit Note — Update the title or content of an existing note
  3. Tags System — Add a list of tags to each note and filter by tag
  4. Export to Markdown — Write each note to its own .md file
  5. Sort Options — Let user sort by newest, oldest, or alphabetically

Summary

You've built a fully functional persistent notes application in Dart! Key achievements:

  • File persistence — notes survive app restarts via JSON
  • Error handling — graceful recovery from bad input and disk errors
  • Factory constructors — clean object creation patterns
  • Separation of concernsNote (data) and NotesManager (logic) are cleanly separated

Up next: we'll go beyond the local machine and hit a real web API to build a Weather App!

WhatsApp