Master Flutter Development: Build a Scalable NoteApp with Go Router, BloC, and Get It for Clean and Maintainable Code
In this tutorial, you will learn how to build a NoteApp from scratch using Flutter. We will cover implementing declarative navigation with Go Router, state management using BloC, and how to manage dependencies with Get It. Step by step, you will create an efficient, modern, and scalable note-taking app that is easy to expand in the future.
Have you ever wondered, “How can I build a well-structured, scalable Flutter app?” The answer lies in implementing BloC, Get It, and Go Router in your app development process.
Why BloC?
BloC helps you manage the app’s state in an organized way by separating business logic from the UI, ensuring your app remains scalable and easy to maintain as it grows.
Why Get It?
Get It simplifies dependency management, making your app more modular and easier to maintain, especially as the project becomes more complex.
Why Go Router?
Go Router simplifies navigation with a declarative approach, ensuring that your app’s routing is cleaner, more flexible, and easier to manage.
By mastering these three concepts, you’ll be equipped to build efficient, maintainable, and scalable Flutter apps. Let’s dive in and start building your dream app!
Step 1: Prepare a New Flutter Project
- Open a Terminal
Use your preferred terminal application. On Windows, you can use Command Prompt, PowerShell, or the integrated terminal in Visual Studio Code. For macOS/Linux, use the built-in Terminal. - Create a Flutter Project
Run the following command in the terminal to create a new Flutter project namednoteapp
:
flutter create noteapp
Navigate to the Project Directory
After the project is created, move into the project directory by running:
cd noteapp
Step 2: Open the Project Folder
- Using Visual Studio Code (Recommended)
If you are using VS Code, you can open the project folder directly from the terminal by running:
code .
2. Using Android Studio
- Open Android Studio.
- Go to File > Open.
- Navigate to the
noteapp
folder and click OK.
3. Using Other Editors
If you are using another code editor, open the noteapp
folder following the editor's specific instructions for opening projects.
Step 3: Add Dependencies to pubspec.yaml
Open the pubspec.yaml
file and add all the required dependencies under the dependencies section.
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.0.0
get_it: ^7.0.0
go_router: ^6.0.0
path: ^1.8.0
path_provider: ^2.0.2
provider: ^6.0.5
sqflite: ^2.0.0
sqflite_common_ffi: ^2.0.0
sqflite_common_ffi_web: ^0.4.5+3
sqlite3_flutter_libs: ^0.5.26
universal_platform: ^1.1.0
intl: ^0.19.0
Here is a brief explanation of the function of each dependency:
- flutter_bloc: Manages the state of the application using the BLoC (Business Logic Component) pattern to separate business logic from UI.
- get_it: Provides a simple way to manage dependency injection, making it easier to access services and objects throughout the app.
- go_router: A routing package that simplifies navigation and handling of routes in Flutter apps.
- path: Allows manipulation of file and directory paths in a way that works across different platforms (Android, iOS, Web).
- path_provider: Provides a way to access commonly used locations on the device’s file system, such as document and temporary directories.
- provider: A state management solution that allows data to be shared and updated across widgets efficiently.
- sqflite: A plugin for using SQLite databases to store data locally in your app.
- sqflite_common_ffi: Provides SQLite database support for desktop and web platforms using Foreign Function Interface (FFI).
- sqlite3_flutter_libs: Offers SQLite 3 library support across all platforms (including mobile, desktop, and web) for Flutter apps.
- universal_platform: Detects the platform the app is running on (Android, iOS, Web, or Desktop) to handle platform-specific code or features.
- intl: Supports internationalization by formatting dates, numbers, and other locale-specific data based on cultural settings.
Don’t forget to save after adding dependencies
Run the following command to set up sqflite_common_ffi_web
: After adding the dependency, run the following command to configure sqflite_common_ffi_web
in your project.
dart run sqflite_common_ffi_web:setup
After that, run the command flutter pub get
to download and install all the dependencies.
flutter pub get
Step 4: Set Up Project Structure
Create the following folder structure to organize code based on responsibilities:
lib/
├── bloc/
│ ├── note_bloc.dart # BLoC file for handling note events and states
│ ├── note_event.dart # Events related to note actions
│ └── note_state.dart # States for managing note UI behavior
│
├── di/
│ └── locator.dart # Service locator for dependency injection
│
├── model/
│ ├── database_helper.dart # Database helper for managing local database operations
│ └── note_model.dart # Data model representing a note
│
├── routes/
│ └── go_router.dart # Route management using GoRouter
│
├── screen/
│ ├── add_note_screen.dart # Screen for adding a new note
│ ├── detail_note_screen.dart # Screen for viewing note details
│ ├── edit_note_screen.dart # Screen for editing an existing note
│ └── list_note_screen.dart # Screen displaying the list of notes
│
└── main.dart # Main entry point of the application
Step 5: Define the Note Model
Create a new file named note_model.dart
in the lib
folder and copy the following code:
// The Note class represents a note with fields like id, title, content, and timestamps.
class Note {
int? id;
String title;
String content;
DateTime createdAt;
DateTime updatedAt;
// Constructor for creating a new Note object. If no date is provided, it defaults to the current date and time.
Note({
this.id,
required this.title,
required this.content,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
// Converts the Note object into a Map for storage or database operations
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'content': content,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
// Creates a Note object from a Map (e.g., fetched from a database or API)
factory Note.fromMap(Map<String, dynamic> map) {
return Note(
id: map['id'],
title: map['title'],
content: map['content'],
createdAt: DateTime.parse(map['createdAt']),
updatedAt: DateTime.parse(map['updatedAt']),
);
}
// Returns a new Note object with updated fields, if provided
Note copyWith({
int? id,
String? title,
String? content,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Note(
id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Returns the createdAt field as a formatted string (e.g., "dd/mm/yyyy hh:mm")
String get formattedCreatedAt {
return '${createdAt.day}/${createdAt.month}/${createdAt.year} ${createdAt.hour}:${createdAt.minute}';
}
// Returns the updatedAt field as a formatted string (e.g., "dd/mm/yyyy hh:mm")
String get formattedUpdatedAt {
return '${updatedAt.day}/${updatedAt.month}/${updatedAt.year} ${updatedAt.hour}:${updatedAt.minute}';
}
}
Explanation:
- The
Note
class has attributes to store information such astitle
,content
, and thecreatedAt
andupdatedAt
timestamps. - The
toMap()
method converts aNote
object into a map to be stored in a database or sent in JSON format. - The
fromMap()
method is used to create aNote
object from a map. - The
copyWith()
method is used to create a copy of theNote
object and update specific values. - The
formattedCreatedAt
andformattedUpdatedAt
are properties used to display the timestamps in a more readable format. - To create the
DatabaseHelper
class, follow these steps:
Step 6: Create Database Helper for CRUD Operations
- Create a new file named
database_helper.dart
inside thelib
folder. - Copy and paste the following code into the file:
import 'package:flutter/foundation.dart';
import '../model/note_model.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
// Get the database instance
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('notes.db');
return _database!;
}
// Initialize the database
Future<Database> _initDB(String filePath) async {
String? path = filePath;
if (!kIsWeb) {
final dbPath = await getApplicationDocumentsDirectory();
path = join(dbPath.path, filePath);
}
return await openDatabase(
path,
version: 2,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
// Create the database schema (tables)
Future _createDB(Database db, int version) async {
await db.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
''');
}
// Handle database upgrades (migrations)
Future _upgradeDB(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE notes ADD COLUMN createdAt TEXT');
await db.execute('ALTER TABLE notes ADD COLUMN updatedAt TEXT');
}
}
// Retrieve a note by its ID
Future<Note?> getNoteById(int id) async {
final db = await instance.database;
final result = await db.query(
'notes',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
return Note.fromMap(result.first);
}
return null;
}
// Insert a new note
Future<int> create(Map<String, dynamic> note) async {
final db = await instance.database;
return await db.insert('notes', note);
}
// Retrieve all notes
Future<List<Map<String, dynamic>>> readAllNotes() async {
final db = await instance.database;
final result = await db.query('notes');
debugPrint('Database result: $result');
return result;
}
// Update an existing note
Future<int> update(Map<String, dynamic> note) async {
final db = await instance.database;
final id = note['id'];
return await db.update('notes', note, where: 'id = ?', whereArgs: [id]);
}
// Delete a note by its ID
Future<int> delete(int id) async {
final db = await instance.database;
return await db.delete('notes', where: 'id = ?', whereArgs: [id]);
}
}
Explanation:
- Singleton Pattern:
DatabaseHelper
uses the singleton pattern to ensure that there is only one instance of this class managing the database connection. - database function: Initializes and provides a database instance that can be used for database operations.
- _initDB function: Initializes the database by checking the platform and determining the location for the database file storage (whether on the device or the web).
- _createDB function: Creates the
notes
table in the database if it doesn't already exist. - _upgradeDB function: Handles database version upgrades if there are changes to the table structure (such as adding new columns).
- CRUD Functions:
- getNoteById: Retrieves a note by its ID.
- create: Adds a new note to the database.
- readAllNotes: Reads all the notes in the database.
- update: Updates an existing note.
- delete: Deletes a note by its ID.
Step 7: Add BloC States for Notes
Now, add the necessary BLoC states. Create note_state.dart
in the lib/bloc/
folder:
import '../model/note_model.dart';
// Abstract base class for note states
abstract class NoteState {}
// Represents the loading state while notes are being fetched
class NotesLoading extends NoteState {}
// Represents the state when notes have been successfully loaded
class NotesLoaded extends NoteState {
final List<Note> notes;
NotesLoaded(this.notes); // Constructor to initialize the loaded notes
}
// Represents the state when an error occurs while fetching notes
class NotesError extends NoteState {
final String error;
NotesError(this.error); // Constructor to initialize the error message
}
Explanation:
- NoteState serves as the base class for all states related to the note-taking application. This class cannot be instantiated directly but is extended by other classes representing specific states.
- NotesLoading indicates that the application is in the process of loading note data, such as fetching data from a database or API. It is used to display a loading indicator (loading spinner) in the UI.
- NotesLoaded signifies that the note data has been successfully loaded. It accepts a parameter of a list of notes (
List<Note>
) that are ready to be displayed in the UI. - NotesError indicates that an error occurred while loading the note data, such as a network or database error. It accepts an error message that will be displayed to the user.
Step 8: Define the Note Events
Here, we define events that trigger state changes in the app.
import '../model/note_model.dart';
// Abstract base class for note events
abstract class NoteEvent {}
// Event to load the list of notes
class LoadNotes extends NoteEvent {}
// Event to add a new note
class AddNote extends NoteEvent {
final Note note;
AddNote(this.note); // Constructor to initialize the note to be added
}
// Event to update an existing note
class UpdateNote extends NoteEvent {
final Note note;
UpdateNote(this.note); // Constructor to initialize the note to be updated
}
// Event to delete a note
class DeleteNote extends NoteEvent {
final Note note;
DeleteNote(this.note); // Constructor to initialize the note to be deleted
}
Explanation:
- NoteEvent: This is an abstract class that defines the blueprint for all events related to notes. Other specific events (like
AddNote
,UpdateNote
, etc.) will extend this class. - LoadNotes: This event is typically dispatched to fetch and load all notes (e.g., when the app starts or refreshes the list of notes).
- AddNote: This event is used when a new note needs to be added. It takes a
Note
object as a parameter. - UpdateNote: This event is used when an existing note needs to be updated. It also takes a
Note
object as a parameter. - DeleteNote: This event is triggered to delete an existing note. The
Note
object to be deleted is passed as a parameter.
Step 9: Implement BloC for Note Management
ou have already created NoteEvent
and NoteState
. Now, let’s implement the BloC to handle these events and manage the state.
Create note_bloc.dart
inside the lib/bloc/
folder:
import 'dart:developer';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'note_event.dart';
import 'note_state.dart';
import '../model/database_helper.dart';
import '../model/note_model.dart';
class NoteBloc extends Bloc<NoteEvent, NoteState> {
final DatabaseHelper _databaseHelper;
NoteBloc(this._databaseHelper) : super(NotesLoading()) {
on<LoadNotes>(_onLoadNotes);
on<AddNote>(_onAddNote);
on<UpdateNote>(_onUpdateNote);
on<DeleteNote>(_onDeleteNote);
}
// Helper function to load and emit notes
Future<void> _loadAndEmitNotes(Emitter<NoteState> emit) async {
try {
final data = await _databaseHelper.readAllNotes();
final notes = data.map((note) => Note.fromMap(note)).toList();
debugPrint('Loaded notes: $notes');
emit(NotesLoaded(notes));
} catch (e) {
emit(NotesError("Failed to load notes: $e"));
}
}
// Event handler to load notes
Future<void> _onLoadNotes(LoadNotes event, Emitter<NoteState> emit) async {
log('Loading notes...');
emit(NotesLoading());
await _loadAndEmitNotes(emit);
}
// Event handler to add a new note
Future<void> _onAddNote(AddNote event, Emitter<NoteState> emit) async {
try {
final newNote = event.note.copyWith(
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await _databaseHelper.create(newNote.toMap());
debugPrint('Note added successfully');
await _loadAndEmitNotes(emit);
} catch (e) {
emit(NotesError("Failed to add note: $e"));
}
}
// Event handler to update an existing note
Future<void> _onUpdateNote(UpdateNote event, Emitter<NoteState> emit) async {
try {
final updatedNote = event.note.copyWith(
updatedAt: DateTime.now(),
);
await _databaseHelper.update(updatedNote.toMap());
debugPrint('Note with ID ${event.note.id} updated successfully');
await _loadAndEmitNotes(emit);
} catch (e) {
emit(NotesError("Failed to update note: $e"));
}
}
// Event handler to delete a note
Future<void> _onDeleteNote(DeleteNote event, Emitter<NoteState> emit) async {
try {
await _databaseHelper.delete(event.note.id ?? 0);
debugPrint('Note with ID ${event.note.id} deleted successfully');
await _loadAndEmitNotes(emit);
} catch (e) {
emit(NotesError("Failed to delete note: $e"));
}
}
}
Explanation:
- NoteBloc: The class is extended from
Bloc<NoteEvent, NoteState>
, which means it takes inNoteEvent
and outputsNoteState
. - LoadNotes: When
LoadNotes
event is triggered, it fetches all notes from the database and emits theNotesLoaded
state with the fetched list. - AddNote, UpdateNote, DeleteNote: These events perform the respective CRUD operations and then trigger a
LoadNotes
event to refresh the list of notes.
Step 10: Set Up Dependency Injection with Get It
To manage dependencies, we will use Get It for clean and scalable dependency injection.
Create locator.dart
inside the lib/di/
folder to set up the service locator:
import 'package:get_it/get_it.dart';
import '../model/database_helper.dart';
import '../bloc/note_bloc.dart';
final locator = GetIt.instance;
void setupLocator() {
// Register DatabaseHelper as a singleton to be used throughout the app
locator.registerLazySingleton(() => DatabaseHelper.instance);
// Register NoteBloc with its dependency, DatabaseHelper
locator.registerFactory(() => NoteBloc(locator<DatabaseHelper>()));
}
Explanation:
- Get It: A singleton is used to register and provide instances of classes that need to be accessed globally, like
DatabaseHelper
andNoteBloc
.
Make sure to call setupLocator()
inside your main.dart
to initialize the dependencies.
Step 11: Set Up Go Router for Navigation
Now let’s handle navigation with Go Router to implement declarative routing.
Create go_router.dart
inside the lib/routes/
folder:
import 'package:go_router/go_router.dart';
import '../screen/list_note_screen.dart';
import '../screen/add_note_screen.dart';
import '../screen/detail_note_screen.dart';
import '../screen/edit_note_screen.dart';
class AppRouter {
final GoRouter router = GoRouter(
routes: [
// Route to the list of notes screen
GoRoute(
path: '/',
builder: (context, state) => const ListNoteScreen(),
),
// Route to the add new note screen
GoRoute(
path: '/add-note',
builder: (context, state) => const AddNoteScreen(),
),
// Route to the detail view of a specific note
GoRoute(
path: '/detail-note/:id',
builder: (context, state) {
final id = int.tryParse(state.params['id']!) ?? 0;
return DetailNoteScreen(id: id);
},
),
// Route to edit an existing note
GoRoute(
path: '/edit-note/:id',
builder: (context, state) {
final id = int.tryParse(state.params['id'] ?? '0') ?? 0;
final title = state.queryParams['title'] ?? '';
final content = state.queryParams['content'] ?? '';
return EditNoteScreen(
id: id,
title: title,
content: content,
);
},
),
],
);
}
Explanation:
- GoRouter: Defines the routes and navigation logic. The
initialLocation
is set to the home screen, and different routes are defined for adding, editing, and viewing notes.
Step 12: Implement Screens
You will now implement the main screens for the note-taking app: ListNoteScreen
, AddNoteScreen
, EditNoteScreen
, and DetailNoteScreen
.
list_note_screen.dart
(Display all notes)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../bloc/note_bloc.dart';
import '../bloc/note_event.dart';
import '../bloc/note_state.dart';
import 'package:go_router/go_router.dart';
class ListNoteScreen extends StatefulWidget {
const ListNoteScreen({super.key});
@override
ListNoteScreenState createState() => ListNoteScreenState();
}
class ListNoteScreenState extends State<ListNoteScreen> {
bool _isHovered = false; // To track hover state for the add button
bool _isHoveredDelete = false; // To track hover state for delete icon
@override
Widget build(BuildContext context) {
// Load notes when the screen is first displayed
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NoteBloc>().add(LoadNotes());
});
return Scaffold(
appBar: AppBar(
title: const Text(
'My Notes',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Colors.orange.shade600, // Soft orange color
foregroundColor: Colors.white, // White text
elevation: 4, // Elevation for shadow effect
centerTitle: true, // Center the title
),
floatingActionButton: MouseRegion(
onEnter: (_) {
// Add hover effect when mouse enters the floating button area
setState(() {
_isHovered = true;
});
},
onExit: (_) {
// Remove hover effect when mouse leaves
setState(() {
_isHovered = false;
});
},
child: FloatingActionButton(
onPressed: () {
context.push('/add-note'); // Navigate to the add note screen
},
backgroundColor: _isHovered
? Colors.orange.shade900
: Colors.orange.shade700, // Change color on hover
child: const Icon(Icons.add, color: Colors.white),
),
),
body: BlocBuilder<NoteBloc, NoteState>(
builder: (context, state) {
if (state is NotesLoading) {
return const Center(child: CircularProgressIndicator()); // Show loading indicator
} else if (state is NotesLoaded) {
final notes = state.notes;
return notes.isEmpty
? const Center(child: Text('No notes available')) // If no notes, show this message
: ListView.separated(
padding: const EdgeInsets.all(8.0),
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return GestureDetector(
onTap: () {
context.push(
'/detail-note/${note.id}', // Navigate to detail page of the note
extra: {
'title': note.title,
'content': note.content
},
);
},
child: Card(
elevation: 6, // Strong shadow effect for the card
margin: const EdgeInsets.symmetric(vertical: 8.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // Rounded corners
),
color: Colors.white,
child: ListTile(
contentPadding: const EdgeInsets.all(16.0),
title: Text(
note.title,
style: TextStyle(
fontSize: 20, // Larger title font size
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
note.content.length > 30
? '${note.content.substring(0, 30)}...' // Show snippet of content
: note.content,
style: const TextStyle(
fontSize: 16, // Smaller content text
color: Colors.black54, // Lighter text color
),
),
const SizedBox(height: 8), // Space between content and date
Text(
'Created at: ${DateFormat('dd MMM yyyy, hh:mm a').format(note.createdAt)}', // Formatted creation date
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
trailing: MouseRegion(
onEnter: (_) {
setState(() {
_isHoveredDelete = true; // Hover effect for delete button
});
},
onExit: (_) {
setState(() {
_isHoveredDelete = false; // Remove hover effect
});
},
child: IconButton(
icon: const Icon(Icons.delete),
iconSize: 24,
onPressed: () {
context
.read<NoteBloc>()
.add(DeleteNote(note)); // Dispatch delete event
},
color: _isHoveredDelete
? Colors.orange.shade900
: Colors.orange.shade700, // Change delete icon color on hover
),
),
),
),
);
},
separatorBuilder: (context, index) => const Divider(), // Divider between notes
);
} else if (state is NotesError) {
return Center(child: Text('Error: ${state.error}')); // Show error message
}
return const Center(child: Text('No notes available')); // Default message if no state is matched
},
),
);
}
}
add_note_screen.dart
(Add a new note)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/note_bloc.dart';
import '../bloc/note_event.dart';
import '../model/note_model.dart';
import 'package:go_router/go_router.dart';
class AddNoteScreen extends StatelessWidget {
const AddNoteScreen({super.key});
@override
Widget build(BuildContext context) {
final TextEditingController titleController = TextEditingController(); // Controller for title input
final TextEditingController contentController = TextEditingController(); // Controller for content input
final Color customOrange = Colors.orange.shade700; // Custom orange color for styling
return Scaffold(
appBar: AppBar(
title: const Text(
'Add Notes',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: customOrange, // Custom color for AppBar
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
context.go('/'); // Navigate back to the home screen
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 4, // Card shadow effect
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), // Rounded corners for the card
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// Title input field
TextField(
controller: titleController,
decoration: InputDecoration(
labelText: 'Title',
labelStyle: TextStyle(color: customOrange), // Label color
filled: true,
fillColor: Colors.orange.shade50, // Light orange background
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), // Rounded corners for input field
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: customOrange), // Border color on focus
),
),
),
const SizedBox(height: 16.0), // Spacer between title and content fields
// Content input field
TextField(
controller: contentController,
maxLines: 5, // Allow multi-line input for content
decoration: InputDecoration(
labelText: 'Description',
labelStyle: TextStyle(color: customOrange), // Label color
filled: true,
fillColor: Colors.orange.shade50, // Light orange background
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), // Rounded corners for input field
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: customOrange), // Border color on focus
),
),
),
],
),
),
),
const SizedBox(height: 32.0), // Spacer between input fields and button
// Save button
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // Rounded corners for the button
),
backgroundColor: customOrange, // Custom color for the button
),
onPressed: () {
final title = titleController.text.trim(); // Get title text
final content = contentController.text.trim(); // Get content text
if (title.isEmpty || content.isEmpty) {
// Show snack bar if title or content is empty
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Title and Description cannot be empty!'),
backgroundColor: Colors.red, // Red background for error message
duration: Duration(seconds: 2),
),
);
} else {
// Create a new note if both title and content are provided
final note = Note(
title: title,
content: content,
createdAt: DateTime.now(), // Current date and time
updatedAt: DateTime.now(), // Current date and time
);
context.read<NoteBloc>().add(AddNote(note)); // Dispatch AddNote event
context.pop(); // Navigate back to the previous screen
}
},
child: const Text(
'Save Notes',
style: TextStyle(
color: Colors.white, // White text color
fontWeight: FontWeight.bold, // Bold text style
),
),
),
],
),
),
);
}
}
edit_note_screen.dart
(Edit an existing note)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../bloc/note_bloc.dart';
import '../bloc/note_event.dart';
import '../model/note_model.dart';
class EditNoteScreen extends StatefulWidget {
final int? id;
final String? title;
final String? content;
final DateTime? createdAt;
final DateTime? updatedAt;
const EditNoteScreen({
super.key,
this.id,
this.title,
this.content,
this.createdAt,
this.updatedAt,
});
@override
EditNoteScreenState createState() => EditNoteScreenState();
}
class EditNoteScreenState extends State<EditNoteScreen> {
// Variables to store note data and controllers for text fields
Note? _note;
late TextEditingController _titleController;
late TextEditingController _contentController;
@override
void initState() {
super.initState();
// Initializing controllers for title and content input
_titleController = TextEditingController();
_contentController = TextEditingController();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Fetching the note data passed from the previous screen using GoRouter
final noteData = GoRouter.of(context)
.routerDelegate
.currentConfiguration
.extra as Map<String, String>?;
// Initializing the note object with the data (from parameters or GoRouter)
_note = Note(
id: widget.id,
title: noteData?['title'] ?? widget.title ?? '',
content: noteData?['content'] ?? widget.content ?? '',
createdAt: widget.createdAt ?? DateTime.now(),
updatedAt: widget.updatedAt,
);
// Updating the controllers with the note's current data
_titleController.text = _note?.title ?? '';
_contentController.text = _note?.content ?? '';
}
@override
void dispose() {
// Disposing controllers when no longer needed
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Define custom colors for the app theme
final Color customOrange = Colors.orange.shade700;
final Color darkOrange = Colors.orange.shade900;
return Scaffold(
appBar: AppBar(
title: Text(
// Set title based on whether it's an edit or add action
_note?.id == null ? 'Add Notes' : 'Edit Notes',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: customOrange,
elevation: 4,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
color: Colors.white,
onPressed: () {
context.pop(); // Go back to the previous screen
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
const SizedBox(height: 16),
// Title input field
TextField(
controller: _titleController,
decoration: InputDecoration(
labelText: 'Title',
labelStyle: TextStyle(color: customOrange),
filled: true,
fillColor: Colors.orange.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: customOrange),
),
),
),
const SizedBox(height: 16),
// Content input field
TextField(
controller: _contentController,
maxLines: 5,
decoration: InputDecoration(
labelText: 'Description',
labelStyle: TextStyle(color: customOrange),
filled: true,
fillColor: Colors.orange.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: customOrange),
),
),
),
const SizedBox(height: 16),
// Display creation and update dates if note exists
if (_note != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Created on: ${_note!.formattedCreatedAt}',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 8),
Text(
'Last updated: ${_note!.formattedUpdatedAt}',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
],
),
),
),
const SizedBox(height: 32),
// Save or Update Button
ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return darkOrange;
}
return customOrange;
}),
foregroundColor: WidgetStateProperty.all(Colors.white),
overlayColor:
WidgetStateProperty.all(Colors.orange.withOpacity(0.1)),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(vertical: 16.0),
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
elevation: WidgetStateProperty.resolveWith((states) {
return states.contains(WidgetState.pressed) ? 2 : 4;
}),
),
onPressed: _saveNote, // Call save or update function
child: Text(
_note?.id == null ? 'Add Notes' : 'Update Notes',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
// Function to handle saving or updating the note
void _saveNote() {
final title = _titleController.text.trim();
final content = _contentController.text.trim();
// Show an error if either title or content is empty
if (title.isEmpty || content.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Title and Description cannot be empty!'),
backgroundColor: Colors.red,
),
);
return;
}
// Create or update the note
final updatedNote = Note(
id: _note?.id, // Keep the same ID if updating
title: title,
content: content,
createdAt: _note?.createdAt ?? DateTime.now(), // Keep existing creation date
updatedAt: DateTime.now(), // Set updated time
);
// Dispatch the appropriate event to the Bloc
if (updatedNote.id == null) {
// Add a new note
BlocProvider.of<NoteBloc>(context).add(AddNote(updatedNote));
} else {
// Update the existing note
BlocProvider.of<NoteBloc>(context).add(UpdateNote(updatedNote));
}
// After saving, pop the screen and navigate to the note's detail page
context.pop(); // Close the Edit screen
context.go(
'/detail-note/${updatedNote.id}', // Navigate to the detail screen
extra: updatedNote, // Pass the updated note data
);
}
}
detail_note_screen.dart
(View a single note)
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/database_helper.dart';
import '../model/note_model.dart';
import 'package:intl/intl.dart';
class DetailNoteScreen extends StatefulWidget {
final int id; // The ID of the note to display
const DetailNoteScreen({super.key, required this.id});
@override
DetailNoteScreenState createState() => DetailNoteScreenState();
}
class DetailNoteScreenState extends State<DetailNoteScreen> {
Note? note; // The note to display
bool isLoading = true; // Loading state to show progress indicator
String errorMessage = ''; // Error message for failed data retrieval
@override
void initState() {
super.initState();
_fetchNoteData(); // Fetch the note data when the screen is initialized
}
// Fetch note data from the database by ID
Future<void> _fetchNoteData() async {
try {
final fetchedNote = await DatabaseHelper.instance.getNoteById(widget.id);
setState(() {
note = fetchedNote;
isLoading = false;
if (fetchedNote == null) {
errorMessage = 'No notes available'; // If no note is found
}
});
} catch (e) {
setState(() {
errorMessage = 'Error: $e'; // If there's an error fetching the note
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final Color customOrange = Colors.orange.shade700;
// Helper function to format the date
String formatDate(DateTime date) {
return DateFormat('EEE, MMM d, yyyy - hh:mm a').format(date);
}
return Scaffold(
appBar: AppBar(
title: const Text(
'Detail Notes',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: customOrange,
elevation: 4,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
context.go('/'); // Go back to the home screen
},
),
),
body: isLoading
? const Center(child: CircularProgressIndicator()) // Show loading indicator
: errorMessage.isNotEmpty
? Center(child: Text(errorMessage)) // Show error message if any
: note == null
? const Center(child: Text("No notes available")) // Handle no note case
: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
note?.title ?? '',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: customOrange,
),
),
const SizedBox(height: 10),
Text(
note?.content ?? '',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 20),
Divider(color: Colors.grey.shade400),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Created At:",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey.shade600,
),
),
Text(
formatDate(note!.createdAt),
style: const TextStyle(fontSize: 14),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Updated At:",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey.shade600,
),
),
Text(
formatDate(note!.updatedAt),
style: const TextStyle(fontSize: 14),
),
],
),
],
),
const SizedBox(height: 20),
Center(
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: note != null
? () {
context.push(
'/edit-note/${note?.id ?? 0}',
extra: {
'title': note?.title ?? '',
'content': note?.content ?? '',
},
); // Navigate to EditNoteScreen
}
: null,
icon: const Icon(Icons.edit,
size: 20, color: Colors.white),
label: const Text(
"Edit Notes",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: customOrange,
padding: const EdgeInsets.symmetric(
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
],
),
),
);
}
}
Step 13: App Initialization and Platform Configuration
This step configures platform-specific settings, initializes dependencies with GetIt, manages state using Provider, and sets up navigation with GoRouter. It ensures the app works across desktop, mobile, and web platforms, with proper database access and routing in place.
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart';
import 'package:universal_platform/universal_platform.dart';
import 'di/locator.dart';
import 'routes/go_router.dart';
import 'package:provider/provider.dart';
import 'bloc/note_bloc.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
// Initialize sqflite for desktop and web platforms
if (UniversalPlatform.isWindows ||
UniversalPlatform.isLinux ||
UniversalPlatform.isMacOS) {
sqfliteFfiInit(); // For desktop
}
// Set the appropriate database depending on the platform
if (kIsWeb) {
log('Running on Web');
databaseFactory = databaseFactoryFfiWeb; // For web
} else {
databaseFactory = databaseFactoryFfi; // For mobile and other desktop platforms
}
setupLocator(); // Initialize GetIt (dependency injection)
runApp(const NoteApp());
}
class NoteApp extends StatelessWidget {
const NoteApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Use Provider to inject the NoteBloc using GetIt
Provider<NoteBloc>(
create: (_) => locator<NoteBloc>(),
dispose: (_, bloc) => bloc.close(), // Make sure to close the bloc when done
),
],
child: MaterialApp.router(
// Use the configured router with GoRouter
routerConfig: AppRouter().router,
debugShowCheckedModeBanner: false, // Remove the debug banner
),
);
}
}
Explanation:
- Platform Spesifik (SQLite Initialization):
Di bagian pertama kode, aplikasi memeriksa platform menggunakanUniversalPlatform.isWindows
,UniversalPlatform.isLinux
,UniversalPlatform.isMacOS
, dankIsWeb
. Berdasarkan platform yang terdeteksi, aplikasi menginisialisasi SQLite database dengan konfigurasi yang sesuai. Untuk desktop, digunakansqfliteFfiInit()
, sedangkan untuk web, digunakandatabaseFactoryFfiWeb
untuk memastikan aplikasi berjalan dengan baik di masing-masing platform. - GetIt (Dependency Injection):
FungsisetupLocator()
digunakan untuk menginisialisasi GetIt, yang merupakan library dependency injection. Di dalam widgetNoteApp
,Provider
digunakan untuk menyuntikkanNoteBloc
yang telah diatur di GetIt ke dalam widget-tree. Ini memungkinkanNoteBloc
untuk diakses di seluruh aplikasi tanpa harus menginisialisasi objek tersebut berulang kali. - Provider (State Management):
Provider<NoteBloc>
digunakan untuk mengelola stateNoteBloc
dalam aplikasi. Di dalam provider,create
menyuntikkan objekNoteBloc
yang diambil dari GetIt, sementaradispose
memastikan objek tersebut ditutup dengan benar menggunakanbloc.close()
, menghindari kebocoran memori saat objek tidak lagi digunakan. - GoRouter (Routing):
Aplikasi menggunakanMaterialApp.router
untuk mendukung navigasi menggunakan GoRouter. PropertirouterConfig
diatur denganAppRouter().router
, yang memuat konfigurasi rute aplikasi. GoRouter mempermudah navigasi antar halaman dengan pengelolaan rute yang lebih fleksibel dan deklaratif. - MaterialApp:
MaterialApp.router
adalah widget utama yang digunakan untuk membangun antarmuka aplikasi. PengaturandebugShowCheckedModeBanner: false
menghilangkan banner debug yang muncul di mode pengembangan, memberikan tampilan yang lebih bersih saat aplikasi dijalankan dalam mode produksi.
Step 14. Testing and Debugging
Make sure to test each functionality thoroughly (add, edit, view, delete) and debug any issues you might face. You can also adjust the UI for better visual alignment based on your preferences.
Step 15: Launching the App
Once you’ve thoroughly tested and debugged your app, it’s time to run it. You can follow these steps to launch the app on your emulator or physical device:
- Run on Emulator/Physical Device:
- Ensure you have an emulator or a physical device connected.
- Run the following command in your terminal:
flutter run
This will build and run the app on your selected device.
2. Build for Production:
- When you’re ready to release the app, run the following command to build the production version:
flutter build apk
Or for iOS:
flutter build ios
3. Deployment:
- Once the app is built successfully, you can upload it to the Play Store (for Android) or App Store (for iOS) by following the respective guidelines for app deployment.
Conclusion
In this tutorial, we’ve successfully built a scalable and maintainable NoteApp using Flutter by leveraging Go Router, BloC, and Get It. These powerful tools enabled us to design a well-structured app that is easy to navigate, maintain, and expand as the project grows.
- BloC helped us manage the app’s state efficiently, ensuring that business logic is separated from the UI. This makes the app scalable and easier to maintain, especially as more features are added.
- Get It simplified dependency management, allowing us to keep our code modular and clean, which is crucial for handling more complex projects in the future.
- Go Router provided a declarative navigation approach, making routing cleaner, more flexible, and easier to manage, ensuring seamless transitions between screens in our app.
By mastering these tools, you now have the foundation to build efficient, maintainable, and scalable Flutter apps. This NoteApp serves as a great starting point for future enhancements, and with the knowledge gained from this tutorial, you are well-equipped to handle more complex Flutter projects.
Feel free to explore the full source code and make improvements or contributions. You can find the code in my GitHub repository:
Built with Go Router, BloC, and Get It, this project showcases the power of clean architecture in creating high-quality applications.
Do you have a great app idea and need a collaborator to bring it to life? I’m available for partnerships through Upwork. Together, we can build innovative apps, refine user experiences, or perfect your design concepts. Let’s create something extraordinary! Feel free to reach out to me via Upwork or visit my portfolio to learn more.
Thank you for joining me on this learning journey. I believe the future of app development is in your hands — and I’m here to help you achieve it. Together, let’s make your vision a reality! 🚀