In this tutorial, we will be creating a cool-looking material Flutter Note app, just like in the image below.
Flutter employs the use of Dart Programming Language and it is pre-loaded with some awesome Material Design packages which makes apps created with Flutter have an incredible feel to it.
In case you haven’t set up a supported IDE and installed Flutter on your computer, click here to get it done, using the official documentation.
We’re going to start by creating a new project and this tutorial makes use of Android Studio.
If you have Flutter properly installed on your computer, there should be an option to Start a new Flutter project for our Flutter note app.
When you open Android Studio, just like the photo below.
Next, select the Flutter Application below.
After that configure your application with a name and also set your application’s package name. Follow the necessary prompts and click on Finish when done. This will be followed by android studio generating the necessary files needed for our Flutter Note app. Once this is complete.
Create a new package under the lib folder and name it Model. Under our model package, we are going to create a new file and this will be called Note.dart
We start by creating the object of our note application, considering the fact that Dart employs the OOP design, similar to what we have in Java and Kotlin.
import 'dart:convert';
import 'package:flutter/material.dart';
class Note {
int id;
String title;
String content;
DateTime date_created;
DateTime date_last_edited;
Color note_color;
int is_archived = 0;
Note(this.id, this.title, this.content, this.date_created, this.date_last_edited,this.note_color);
Map<String, dynamic> toMap(bool forUpdate) {
var data = {
// 'id': id, since id is auto incremented in the database we don't need to send it to the insert query.
'title': utf8.encode(title),
'content': utf8.encode( content ),
'date_created': epochFromDate( date_created ),
'date_last_edited': epochFromDate( date_last_edited ),
'note_color': note_color.value,
'is_archived': is_archived // for later use for integrating archiving
};
if(forUpdate){
data["id"] = this.id;
}
return data;
}
// Converting the date time object into int representing seconds passed after midnight 1st Jan, 1970 UTC
int epochFromDate(DateTime dt) {
return dt.millisecondsSinceEpoch ~/ 1000 ;
}
void archiveThisNote() {
is_archived = 1;
}
// overriding toString() of the note class to print a better debug description of this custom class
@override toString() {
return {
'id': id,
'title': title,
'content': content ,
'date_created': epochFromDate( date_created ),
'date_last_edited': epochFromDate( date_last_edited ),
'note_color': note_color.toString(),
'is_archived':is_archived
}.toString();
}
}
From the foregoing, our note class contains the variables that will be stored for a single note in our database.
Each note has an id, a title, and content, and also the date created and last edited is also saved, as well as a value for the color and an int to show whether the note is archived or not.
We also have a toMap
function that takes in a boolean “forupdate”
and returns a map. The reason for this is that we do not want to send the id to the database when creating a note, as the id is auto-incremented by default.
The method creates a map of the note attributes using key-value pairs and maps the values to their respective pairs.
We also check to see if the boolean “forupdate”
is true, is this is true then the function assumes we are simply fetching an already saved note from the database and adding the id to the map, but for new notes, this is not the case.
The second method “epochfromDate”
is basically for Converting the date time object into int representing seconds passed after midnight 1st Jan 1970 UTC.
The next method “archiveThisNote”
is only called whenever a user archives a note, and the value of “is_archived” is set to 1.
By default, this value is kept at 0.
The next step is to create a new file for our SQLite Class which will contain a list of the SQLite queries and initialization of our database. Create a new file inside our Model package and name it “SqliteHandler.dart”
.
But before we proceed with writing codes for our SqliteHandler class. Please note that there are several packages that are being used in this project and you should add the your “pubspec.yaml”
.
Below is a list of the packages and click here to learn how to add packages to your flutter project. This is what the dependency block of our pubspec.yaml
file.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
flutter_staggered_grid_view: ^0.2.7
auto_size_text: ^1.1.2
sqflite:
path:
intl: ^0.15.7
share: ^0.6.1
In our SqliteHandler class, we will start by adding the following imports at the top of our class
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqlite_api.dart';
import 'dart:async';
import 'Note.dart';
For the sake of brevity here’s the rest of the code to complete this class. Some methods have been commented on above them to explain briefly what they do.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqlite_api.dart';
import 'dart:async';
import 'Note.dart';
class NotesDBHandler {
final databaseName = "notes.db";
final tableName = "notes";
final fieldMap = {
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
"title": "BLOB",
"content": "BLOB",
"date_created": "INTEGER",
"date_last_edited": "INTEGER",
"note_color": "INTEGER",
"is_archived": "INTEGER"
};
static Database _database;
Future<Database> get database async {
if (_database != null)
return _database;
_database = await initDB();
return _database;
}
initDB() async {
var path = await getDatabasesPath();
var dbPath = join(path, 'notes.db');
// ignore: argument_type_not_assignable
Database dbConnection = await openDatabase(
dbPath, version: 1, onCreate: (Database db, int version) async {
print("executing create query from onCreate callback");
await db.execute(_buildCreateQuery());
});
await dbConnection.execute(_buildCreateQuery());
_buildCreateQuery();
return dbConnection;
}
// build the create query dynamically using the column:field dictionary.
String _buildCreateQuery() {
String query = "CREATE TABLE IF NOT EXISTS ";
query += tableName;
query += "(";
fieldMap.forEach((column, field){
print("$column : $field");
query += "$column $field,";
});
query = query.substring(0, query.length-1);
query += " )";
return query;
}
static Future<String> dbPath() async {
String path = await getDatabasesPath();
return path;
}
Future<int> insertNote(Note note, bool isNew) async {
// Get a reference to the database
final Database db = await database;
print("insert called");
// Insert the Notes into the correct table.
await db.insert('notes',
isNew ? note.toMap(false) : note.toMap(true),
conflictAlgorithm: ConflictAlgorithm.replace,
);
if (isNew) {
// get latest note which isn't archived, limit by 1
var one = await db.query("notes", orderBy: "date_last_edited desc",
where: "is_archived = ?",
whereArgs: [0],
limit: 1);
int latestId = one.first["id"] as int;
return latestId;
}
return note.id;
}
Future<bool> copyNote(Note note) async {
final Database db = await database;
try {
await db.insert("notes",note.toMap(false), conflictAlgorithm: ConflictAlgorithm.replace);
} catch(Error) {
print(Error);
return false;
}
return true;
}
Future<bool> archiveNote(Note note) async {
if (note.id != -1) {
final Database db = await database;
int idToUpdate = note.id;
db.update("notes", note.toMap(true), where: "id = ?",
whereArgs: [idToUpdate]);
}
}
Future<bool> deleteNote(Note note) async {
if(note.id != -1) {
final Database db = await database;
try {
await db.delete("notes",where: "id = ?",whereArgs: [note.id]);
return true;
} catch (Error){
print("Error deleting ${note.id}: ${Error.toString()}");
return false;
}
}
}
Future<List<Map<String,dynamic>>> selectAllNotes() async {
final Database db = await database;
// query all the notes sorted by last edited
var data = await db.query("notes", orderBy: "date_last_edited desc",
where: "is_archived = ?",
whereArgs: [0]);
return data;
}
}
- fieldmap: This creates an object with all the database columns names which will be used in our SQL create statement.
- get database: This is the background process that ensures that our db class is a singleton.
- initDB: Creates the name and file for the database by getting the default path and appending “note.db” to it
- insertNote: checks whether the note is a new note or and old note using a ternary conditional statement, based on this evaluation, the toMap function in our “Note.dart” file is called passed with true or false. This method has a return value which is the id of the note.
The next class inside our Model will be the Utility.dart
file. After creating it, add the following code to handle things like extracting our date from a date object and parsing it into a string.
import 'package:intl/intl.dart';
import 'package:flutter/material.dart';
class CentralStation {
static bool _updateNeeded ;
static final fontColor = Color(0xff595959);
static final borderColor = Color(0xffd3d3d3) ;
static init() {
if (_updateNeeded == null)
_updateNeeded = true;
}
static bool get updateNeeded {
init();
if (_updateNeeded) {
return true;
} else {
return false;
}
}
static set updateNeeded(value){
_updateNeeded = value;
}
static String stringForDatetime(DateTime dt){
var dtInLocal = dt.toLocal();
//DateTime.fromMillisecondsSinceEpoch( 1490489845 * 1000).toLocal(); //year: 1490489845 //>day: 1556152819 //month: 1553561845 //<day: 1556174419
var now = DateTime.now().toLocal();
var dateString = "Edited ";
var diff = now.difference(dtInLocal);
if(now.day == dtInLocal.day){ // creates format like: 12:35 PM,
var todayFormat = DateFormat("h:mm a");
dateString += todayFormat.format(dtInLocal);
} else if ( (diff.inDays) == 1 || (diff.inSeconds < 86400 && now.day != dtInLocal.day)) {
var yesterdayFormat = DateFormat("h:mm a");
dateString += "Yesterday, " + yesterdayFormat.format(dtInLocal) ;
} else if(now.year == dtInLocal.year && diff.inDays > 1){
var monthFormat = DateFormat("MMM d");
dateString += monthFormat.format(dtInLocal);
} else {
var yearFormat = DateFormat("MMM d y");
dateString += yearFormat.format(dtInLocal);
}
return dateString;
}
}
Create two more packages inside your lib folder and name them View and ViewControllers.
Inside the View package create a new file and name it ColorSlider.dart
import 'package:flutter/material.dart';
class ColorSlider extends StatefulWidget {
final void Function(Color) callBackColorTapped ;
final Color noteColor ;
ColorSlider({@required this.callBackColorTapped, @required this.noteColor});
@override
_ColorSliderState createState() => _ColorSliderState();
}
class _ColorSliderState extends State<ColorSlider> {
final colors = [
Color(0xffffffff), // classic white
Color(0xfff28b81), // light pink
Color(0xfff7bd02), // yellow
Color(0xfffbf476), // light yellow
Color(0xffcdff90), // light green
Color(0xffa7feeb), // turquoise
Color(0xffcbf0f8), // light cyan
Color(0xffafcbfa), // light blue
Color(0xffd7aefc), // plum
Color(0xfffbcfe9), // misty rose
Color(0xffe6c9a9), // light brown
Color(0xffe9eaee) // light gray
];
final Color borderColor = Color(0xffd3d3d3);
final Color foregroundColor = Color(0xff595959);
final _check = Icon(Icons.check);
Color noteColor;
int indexOfCurrentColor;
@override void initState() {
super.initState();
this.noteColor = widget.noteColor;
indexOfCurrentColor = colors.indexOf(noteColor);
}
@override
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.horizontal,
children:
List.generate(colors.length, (index)
{
return
GestureDetector(
onTap: ()=> _colorChangeTapped(index),
child: Padding(
padding: EdgeInsets.only(left: 6, right: 6),
child:Container(
child: new CircleAvatar(
child: _checkOrNot(index),
foregroundColor: foregroundColor,
backgroundColor: colors[index],
),
width: 38.0,
height: 38.0,
padding: const EdgeInsets.all(1.0), // border width
decoration: new BoxDecoration(
color: borderColor, // border color
shape: BoxShape.circle,
)
) )
);
})
,);
}
void _colorChangeTapped(int indexOfColor) {
setState(() {
noteColor = colors[indexOfColor];
indexOfCurrentColor = indexOfColor;
widget.callBackColorTapped(colors[indexOfColor]);
});
}
Widget _checkOrNot(int index){
if (indexOfCurrentColor == index) {
return _check;
}
return null;
}
}
In the build method which returns a widget, we inflate a Listview widget with a set of colors, which are defined in the colors array.
The listview widget contains a scrollDirection widget which simply helps us add another property to our container widget telling it that we want this listview to scroll horizontally.
Also the Gesture Detector widget has an ontap()
property which calls the function _colorChangeTapped(index) whenever a color is being tapped on and this function gets the index of the color and assigns that value to the notecolor variable.
We are going to go ahead and create a bottom sheet that will have this color slider. In another tutorial, we are going to explore how to fully customize and implement a bottom sheet in Flutter. For now, simply create new dart file under the Views directory and name it MoreOptionsSheet.dart.
import 'package:flutter/material.dart';
import 'ColorSlider.dart';
import '../Model/Utility.dart';
enum moreOptions { delete, share, copy }
class MoreOptionsSheet extends StatefulWidget {
final Color color;
final DateTime date_last_edited;
final void Function(Color) callBackColorTapped;
final void Function(moreOptions) callBackOptionTapped;
const MoreOptionsSheet(
{Key key,
this.color,
this.date_last_edited,
this.callBackColorTapped,
this.callBackOptionTapped})
: super(key: key);
@override
_MoreOptionsSheetState createState() => _MoreOptionsSheetState();
}
class _MoreOptionsSheetState extends State<MoreOptionsSheet> {
var note_color;
@override
void initState() {
note_color = widget.color;
}
@override
Widget build(BuildContext context) {
return Container(
color: this.note_color,
child: new Wrap(
children: <Widget>[
new ListTile(
leading: new Icon(Icons.delete),
title: new Text('Delete permanently'),
onTap: () {
Navigator.of(context).pop();
widget.callBackOptionTapped(moreOptions.delete);
}),
new ListTile(
leading: new Icon(Icons.content_copy),
title: new Text('Duplicate'),
onTap: () {
Navigator.of(context).pop();
widget.callBackOptionTapped(moreOptions.copy);
}),
new ListTile(
leading: new Icon(Icons.share),
title: new Text('Share'),
onTap: () {
Navigator.of(context).pop();
widget.callBackOptionTapped(moreOptions.share);
}),
new Padding(
padding: EdgeInsets.only(left: 10, right: 10),
child: SizedBox(
height: 44,
width: MediaQuery.of(context).size.width,
child: ColorSlider(
callBackColorTapped: _changeColor,
// call callBack from notePage here
noteColor: note_color, // take color from local variable
),
),
),
new Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 44,
child: Center(
child: Text(CentralStation.stringForDatetime(
widget.date_last_edited))),
)
],
mainAxisAlignment: MainAxisAlignment.center,
),
new ListTile()
],
),
);
}
void _changeColor(Color color) {
setState(() {
this.note_color = color;
widget.callBackColorTapped(color);
});
}
}
This class simply has four main fields which include two variables, one for the color selected by the user and a date field, it also has two functions which are simple expressions, one is responsible for creating the share, copy and delete options by using an enum interface.
The other one is a function that calls the _changeColor
method that takes a color parameter and sets it as the note color.
The moreoptions
sheet is a statefull widget and hence extends the statefull widget class, the statefull widget class has three important methods that always have to be overridden, The createState, initState and the build function.
For the scope of this article, we will focus on the build function, which returns a widget. We build our display by using a Container Widget that takes in an array of tiles as an array in its child property.
Our array of widgets is made up of four tiles, which are the copy, share, delete, and the color slider palettes. Each of these shares similar characteristics which include an icon, a text, and a callback that performs the action when it is being clicked on.
Inside our ViewControllers package, we are going to create a new file called NotePage.dart and add the following codes. Check the comments for a brief overview of each functions.
import 'package:flutter/material.dart';
import '../Model/Note.dart';
import '../Model/SqliteHandler.dart';
import 'dart:async';
import '../Model/Utility.dart';
import '../Views/MoreOptionsSheet.dart';
import 'package:share/share.dart';
import 'package:flutter/services.dart';
class NotePage extends StatefulWidget {
final Note noteInEditing;
//constructor that takes a Note object
NotePage(this.noteInEditing);
@override
_NotePageState createState() => _NotePageState();
}
class _NotePageState extends State<NotePage> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
var note_color;
bool _isNewNote = false;
final _titleFocus = FocusNode();
final _contentFocus = FocusNode();
String _titleFrominitial ;
String _contentFromInitial;
DateTime _lastEditedForUndo;
var _editableNote;
// the timer variable responsible to call persistData function every 5 seconds and cancel the timer when the page pops.
Timer _persistenceTimer;
final GlobalKey<ScaffoldState> _globalKey = new GlobalKey<ScaffoldState>();
@override
void initState() {
_editableNote = widget.noteInEditing;
_titleController.text = _editableNote.title;
_contentController.text = _editableNote.content;
note_color = _editableNote.note_color;
_lastEditedForUndo = widget.noteInEditing.date_last_edited;
_titleFrominitial = widget.noteInEditing.title;
_contentFromInitial = widget.noteInEditing.content;
if (widget.noteInEditing.id == -1) {
_isNewNote = true;
}
_persistenceTimer = new Timer.periodic(Duration(seconds: 5), (timer) {
// call insert query here
print("5 seconds passed");
print("editable note id: ${_editableNote.id}");
_persistData();
});
}
@override
Widget build(BuildContext context) {
if(_editableNote.id == -1 && _editableNote.title.isEmpty) {
FocusScope.of(context).requestFocus(_titleFocus);
}
return WillPopScope(
child: Scaffold(
key: _globalKey,
appBar: AppBar(brightness: Brightness.light,
leading: BackButton(
color: Colors.black,
),
actions: _archiveAction(context),
elevation: 1,
backgroundColor: note_color,
title: _pageTitle(),
),
body: _body(context),
),
onWillPop: _readyToPop,
);
}
Widget _body(BuildContext ctx) {
return
Container(
color: note_color,
padding: EdgeInsets.only(left: 16, right: 16, top: 12),
child:
SafeArea(child:
Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Flexible(
child: Container(
padding: EdgeInsets.all(5),
// decoration: BoxDecoration(border: Border.all(color: CentralStation.borderColor,width: 1 ),borderRadius: BorderRadius.all(Radius.circular(10)) ),
child: EditableText(
onChanged: (str) => {updateNoteObject()},
maxLines: null,
controller: _titleController,
focusNode: _titleFocus,
style: TextStyle(
color: Colors.black,
fontSize: 22,
fontWeight: FontWeight.bold),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.blue),
),
),
Divider(color: CentralStation.borderColor,),
Flexible( child: Container(
padding: EdgeInsets.all(5),
// decoration: BoxDecoration(border: Border.all(color: CentralStation.borderColor,width: 1),borderRadius: BorderRadius.all(Radius.circular(10)) ),
child: EditableText(
onChanged: (str) => {updateNoteObject()},
maxLines: 300, // line limit extendable later
controller: _contentController,
focusNode: _contentFocus,
style: TextStyle(color: Colors.black, fontSize: 20),
backgroundCursorColor: Colors.red,
cursorColor: Colors.blue,
)
)
)
],
),
left: true,right: true,top: false,bottom: false,
)
)
;
}
//returns a new text with "New Note" or "Edit Note" based on the value of _editableNote.id
Widget _pageTitle() {
return Text(_editableNote.id == -1 ? "New Note" : "Edit Note");
}
List<Widget> _archiveAction(BuildContext context) {
List<Widget> actions = [];
if (widget.noteInEditing.id != -1) {
actions.add(Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
child: GestureDetector(
onTap: () => _undo(),
child: Icon(
Icons.undo,
color: CentralStation.fontColor,
),
),
),
));
}
actions += [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
child: GestureDetector(
onTap: () => _archivePopup(context),
child: Icon(
Icons.archive,
color: CentralStation.fontColor,
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
child: GestureDetector(
onTap: () => bottomSheet(context),
child: Icon(
Icons.more_vert,
color: CentralStation.fontColor,
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
child: GestureDetector(
onTap: () => { _saveAndStartNewNote(context) },
child: Icon(
Icons.add,
color: CentralStation.fontColor,
),
),
),
)
];
return actions;
}
//responsible for opening the moreOptionsSheet Class and its widgets
void bottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (BuildContext ctx) {
return MoreOptionsSheet(
color: note_color,
callBackColorTapped: _changeColor,
callBackOptionTapped: bottomSheetOptionTappedHandler,
date_last_edited: _editableNote.date_last_edited,
);
});
}
//saves data as the user makes changes and saves and updates this value whenever it changes
void _persistData() {
updateNoteObject();
if (_editableNote.content.isNotEmpty) {
var noteDB = NotesDBHandler();
if (_editableNote.id == -1) {
Future<int> autoIncrementedId =
noteDB.insertNote(_editableNote, true); // for new note
// set the id of the note from the database after inserting the new note so for next persisting
autoIncrementedId.then((value) {
_editableNote.id = value;
});
} else {
noteDB.insertNote(
_editableNote, false); // for updating the existing note
}
}
}
// this function will ne used to save the updated editing value of the note to the local variables as user types
void updateNoteObject() {
_editableNote.content = _contentController.text;
_editableNote.title = _titleController.text;
_editableNote.note_color = note_color;
print("new content: ${_editableNote.content}");
print(widget.noteInEditing);
print(_editableNote);
print("same title? ${_editableNote.title == _titleFrominitial}");
print("same content? ${_editableNote.content == _contentFromInitial}");
if (!(_editableNote.title == _titleFrominitial &&
_editableNote.content == _contentFromInitial) ||
(_isNewNote)) {
// No changes to the note
// Change last edit time only if the content of the note is mutated in compare to the note which the page was called with.
_editableNote.date_last_edited = DateTime.now();
print("Updating date_last_edited");
CentralStation.updateNeeded = true;
}
}
//Handles callbacks on the MoreOptionsSheet
void bottomSheetOptionTappedHandler(moreOptions tappedOption) {
print("option tapped: $tappedOption");
switch (tappedOption) {
case moreOptions.delete:
{
if (_editableNote.id != -1) {
_deleteNote(_globalKey.currentContext);
} else {
_exitWithoutSaving(context);
}
break;
}
case moreOptions.share:
{
if (_editableNote.content.isNotEmpty) {
Share.share("${_editableNote.title}\n${_editableNote.content}");
}
break;
}
case moreOptions.copy : {
_copy();
break;
}
}
}
//deletes a saved note from the database when the user selects delete from the bottom sheet
void _deleteNote(BuildContext context) {
if (_editableNote.id != -1) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Confirm ?"),
content: Text("This note will be deleted permanently"),
actions: <Widget>[
FlatButton(
onPressed: () {
_persistenceTimer.cancel();
var noteDB = NotesDBHandler();
Navigator.of(context).pop();
noteDB.deleteNote(_editableNote);
CentralStation.updateNeeded = true;
Navigator.of(context).pop();
},
child: Text("Yes")),
FlatButton(
onPressed: () => {Navigator.of(context).pop()},
child: Text("No"))
],
);
});
}
}
//responsible for responding whenever the user selects on a color by changing the color and saving the color
//value to the database
void _changeColor(Color newColorSelected) {
print("note color changed");
setState(() {
note_color = newColorSelected;
_editableNote.note_color = newColorSelected;
});
if (_editableNote.id != -1) {
var noteDB = NotesDBHandler();
_editableNote.note_color = note_color;
noteDB.insertNote(_editableNote, false);
}
CentralStation.updateNeeded = true;
}
//this function is called whenever the user clicks on the plus icon to add a new note from
//an already existing note.
void _saveAndStartNewNote(BuildContext context){
_persistenceTimer.cancel();
var emptyNote = new Note(-1, "", "", DateTime.now(), DateTime.now(), Colors.white);
Navigator.of(context).pop();
Navigator.push(context, MaterialPageRoute(builder: (ctx) => NotePage(emptyNote)));
}
Future<bool> _readyToPop() async {
_persistenceTimer.cancel();
//show saved toast after calling _persistData function.
_persistData();
return true;
}
//build a pop up for whenever a user clicks on the archive icon,
// this prompt asks the user if he is sure before proceeding to archive the note
void _archivePopup(BuildContext context) {
if (_editableNote.id != -1) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Confirm ?"),
content: Text("This note will be archived"),
actions: <Widget>[
FlatButton(
onPressed: () => _archiveThisNote(context),
child: Text("Yes")),
FlatButton(
onPressed: () => {Navigator.of(context).pop()},
child: Text("No"))
],
);
});
} else {
_exitWithoutSaving(context);
}
}
//this function is called whenever a user clicks on a new note but no value is entered
void _exitWithoutSaving(BuildContext context) {
_persistenceTimer.cancel();
CentralStation.updateNeeded = false;
Navigator.of(context).pop();
}
//responsible for archiving the note
void _archiveThisNote(BuildContext context) {
Navigator.of(context).pop();
// set archived flag to true and send the entire note object in the database to be updated
_editableNote.is_archived = 1;
var noteDB = NotesDBHandler();
noteDB.archiveNote(_editableNote);
// update will be required to remove the archived note from the staggered view
CentralStation.updateNeeded = true;
_persistenceTimer.cancel(); // shutdown the timer
Navigator.of(context).pop(); // pop back to staggered view
// TODO: OPTIONAL show the toast of deletion completion
Scaffold.of(context).showSnackBar(new SnackBar(content: Text("deleted")));
}
//this function duplicates a note with the selected id whenever
//copy is tapped on from the bottom sheet
void _copy(){
var noteDB = NotesDBHandler();
Note copy = Note(-1,
_editableNote.title,
_editableNote.content,
DateTime.now(),
DateTime.now(),
_editableNote.note_color) ;
var status = noteDB.copyNote(copy);
status.then((query_success){
if (query_success){
CentralStation.updateNeeded = true;
Navigator.of(_globalKey.currentContext).pop();
}
});
}
//undo changes made to the text using FLutter's TextController method
void _undo() {
_titleController.text = _titleFrominitial;// widget.noteInEditing.title;
_contentController.text = _contentFromInitial;// widget.noteInEditing.content;
_editableNote.date_last_edited = _lastEditedForUndo;// widget.noteInEditing.date_last_edited;
}
}
Inside the View package create another file and name it StaggeredTiles.dart and add the code below
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import '../ViewControllers/NotePage.dart';
import '../Model/Note.dart';
import '../Model/Utility.dart';
class MyStaggeredTile extends StatefulWidget {
final Note note;
MyStaggeredTile(this.note);
@override
_MyStaggeredTileState createState() => _MyStaggeredTileState();
}
class _MyStaggeredTileState extends State<MyStaggeredTile> {
String _content ;
double _fontSize ;
Color tileColor ;
String title;
@override
Widget build(BuildContext context) {
_content = widget.note.content;
_fontSize = _determineFontSizeForContent();
tileColor = widget.note.note_color;
title = widget.note.title;
return GestureDetector(
onTap: ()=> _noteTapped(context),
child: Container(
decoration: BoxDecoration(
border: tileColor == Colors.white ? Border.all(color: CentralStation.borderColor) : null,
color: tileColor,
borderRadius: BorderRadius.all(Radius.circular(8))),
padding: EdgeInsets.all(8),
child: constructChild(),) ,
);
}
void _noteTapped(BuildContext ctx) {
CentralStation.updateNeeded = false;
Navigator.push(ctx, MaterialPageRoute(builder: (ctx) => NotePage(widget.note)));
}
Widget constructChild() {
List<Widget> contentsOfTiles = [];
if(widget.note.title.length != 0) {
contentsOfTiles.add(
AutoSizeText(
title,
style: TextStyle(fontSize: _fontSize,fontWeight: FontWeight.bold),
maxLines: widget.note.title.length == 0 ? 1 : 3,
textScaleFactor: 1.5,
),
);
contentsOfTiles.add(Divider(color: Colors.transparent,height: 6,),);
}
contentsOfTiles.add(
AutoSizeText(
_content,
style: TextStyle(fontSize: _fontSize),
maxLines: 10,
textScaleFactor: 1.5,
)
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: contentsOfTiles
);
}
double _determineFontSizeForContent() {
int charCount = _content.length + widget.note.title.length ;
double fontSize = 20 ;
if (charCount > 110 ) { fontSize = 12; }
else if (charCount > 80) { fontSize = 14; }
else if (charCount > 50) { fontSize = 16; }
else if (charCount > 20) { fontSize = 18; }
return fontSize;
}
}
This class basically has four fields
String _content ;
double _fontSize ;
Color tileColor ;
String title;
These properties are used to customize the look and feel of each note on the main app screen.
void _noteTapped(BuildContext ctx) {
CentralStation.updateNeeded = false;
Navigator.push(ctx, MaterialPageRoute(builder: (ctx) => NotePage(widget.note)));
}
The function above sets the action on each note been tapped on the screen.
There are two more files that needs to be created inside the ViewControllers package. The first is the StaggeredView.dart file.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../Model/Note.dart';
import '../Model/SqliteHandler.dart';
import '../Model/Utility.dart';
import '../Views/StaggeredTiles.dart';
import 'HomePage.dart';
class StaggeredGridPage extends StatefulWidget {
final notesViewType;
const StaggeredGridPage({Key key, this.notesViewType}) : super(key: key);
@override
_StaggeredGridPageState createState() => _StaggeredGridPageState();
}
class _StaggeredGridPageState extends State<StaggeredGridPage> {
//instance of our Sqlite class
var noteDB = NotesDBHandler();
//a map which will be used in inflating our staggered grid view
List<Map<String, dynamic>> _allNotesInQueryResult = [];
viewType notesViewType ;
@override
void initState() {
super.initState();
this.notesViewType = widget.notesViewType;
}
@override void setState(fn) {
super.setState(fn);
this.notesViewType = widget.notesViewType;
}
@override
Widget build(BuildContext context) {
GlobalKey _stagKey = GlobalKey();
print("update needed?: ${CentralStation.updateNeeded}");
if(CentralStation.updateNeeded) { retrieveAllNotesFromDatabase(); }
return Container(child: Padding(padding: _paddingForView(context) , child:
new StaggeredGridView.count(key: _stagKey,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
crossAxisCount: _colForStaggeredView(context),
children: List.generate(_allNotesInQueryResult.length, (i){ return _tileGenerator(i); }),
staggeredTiles: _tilesForView() ,
),
)
);
}
int _colForStaggeredView(BuildContext context) {
if (widget.notesViewType == viewType.List)
return 1;
// for width larger than 600 on grid mode, return 3 irrelevant of the orientation to accommodate more notes horizontally
return MediaQuery.of(context).size.width > 600 ? 3:2 ;
}
List<StaggeredTile> _tilesForView() { // Generate staggered tiles for the view based on the current preference.
return List.generate(_allNotesInQueryResult.length,(index){ return StaggeredTile.fit(1); }
) ;
}
EdgeInsets _paddingForView(BuildContext context){
double width = MediaQuery.of(context).size.width;
double padding ;
double top_bottom = 8;
if (width > 500) {
padding = ( width ) * 0.05 ; // 5% padding of width on both side
} else {
padding = 8;
}
return EdgeInsets.only(left: padding, right: padding, top: top_bottom, bottom: top_bottom);
}
//gets the values of the notes for each of the fields in the grid
MyStaggeredTile _tileGenerator(int i){
return MyStaggeredTile( Note(
_allNotesInQueryResult[i]["id"],
_allNotesInQueryResult[i]["title"] == null ? "" : utf8.decode(_allNotesInQueryResult[i]["title"]),
_allNotesInQueryResult[i]["content"] == null ? "" : utf8.decode(_allNotesInQueryResult[i]["content"]),
DateTime.fromMillisecondsSinceEpoch(_allNotesInQueryResult[i]["date_created"] * 1000),
DateTime.fromMillisecondsSinceEpoch(_allNotesInQueryResult[i]["date_last_edited"] * 1000),
Color(_allNotesInQueryResult[i]["note_color"] ))
);
}
//carries out the queries to get the notes from the database
void retrieveAllNotesFromDatabase() {
// queries for all the notes from the database ordered by latest edited note. excludes archived notes.
var _testData = noteDB.selectAllNotes();
_testData.then((value){
setState(() {
this._allNotesInQueryResult = value;
CentralStation.updateNeeded = false;
});
});
}
}
Check the comments for a brief explanations on what each of the fields are responsible for.
The last file to be created inside the ViewControllers package is the HomePage.dart file.
import 'package:flutter/material.dart';
import 'StagerredView.dart';
import '../Model/Note.dart';
import 'NotePage.dart';
import '../Model/Utility.dart';
enum viewType {
List,
Staggered
}
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var notesViewType ;
@override void initState() {
notesViewType = viewType.Staggered;
}
@override
Widget build(BuildContext context) {
return
Scaffold(
resizeToAvoidBottomPadding: false,
appBar: AppBar(brightness: Brightness.light,
actions: _appBarActions(),
elevation: 1,
backgroundColor: Colors.white,
centerTitle: true,
title: Text("Notes"),
),
body: SafeArea(child: _body(), right: true, left: true, top: true, bottom: true,),
bottomSheet: _bottomBar(),
);
}
Widget _body() {
print(notesViewType);
return Container(child: StaggeredGridPage(notesViewType: notesViewType,));
}
//Contains a FlatButton widget that is responsible for calling the _newNoteTapped function to take us to
//a new page to create a new note
Widget _bottomBar() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
FlatButton(
child: Text(
"New Note\n",
style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold),
),
onPressed: () => _newNoteTapped(context),
)
],
);
}
/* responsible for creating a new route using the Navigator.push class*/
void _newNoteTapped(BuildContext ctx) {
// "-1" id indicates the note is not new
var emptyNote = new Note(-1, "", "", DateTime.now(), DateTime.now(), Colors.white);
Navigator.push(ctx,MaterialPageRoute(builder: (ctx) => NotePage(emptyNote)));
}
//sets the viewType to either grid or list based on the noteViewType value
void _toggleViewType(){
setState(() {
CentralStation.updateNeeded = true;
if(notesViewType == viewType.List)
{
notesViewType = viewType.Staggered;
} else {
notesViewType = viewType.List;
}
});
}
List<Widget> _appBarActions() {
return [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: InkWell(
child: GestureDetector(
onTap: () => _toggleViewType() ,
child: Icon(
notesViewType == viewType.List ? Icons.developer_board : Icons.view_headline,
color: CentralStation.fontColor,
),
),
),
),
];
}
}
This class basically creates a widget and carries out a database query to fetch all notes from the database and generates a staggered grid list of tiles with the information from the database.
At this point, you might think you have finished creating everything but if you try to run your application.
you will notice that nothing happens, this is because we are yet to apply all of our codes to our app’s entry point and this should be the last thing to do before running your app.
Similar to our main method in Java and onCreate
in Android Studio, flutter has a main method which is the applications entry point. This can be found in our main.dart
file.
This is what our main.dart
file should look like to be able to use all the codes we have written out.
import 'package:flutter/material.dart';
import 'ViewControllers/HomePage.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
fontFamily: "Roboto",
iconTheme: IconThemeData(color: Colors.black),
primaryTextTheme: TextTheme(
title: TextStyle(color: Colors.black),
),
primarySwatch: Colors.blue,
),
home: HomePage(),
);
}
}
First, notice the main method which uses the runApp() to call MyApp() method.
The build method uses a MaterialApp which is helpful because it gives us access to a whole lot of easy customizations on our applications, like the appbar.
the title, and the body of our application can easily be tweaked using the MaterialApp widget.
debugShowCheckedModeBanner: false
This line of code is responsible for removing the debug label on the top right corner of the screen of our application. As seen in the image below
fontFamily: "Roboto",
iconTheme: IconThemeData(color: Colors.black),
primaryTextTheme: TextTheme(
title: TextStyle(color: Colors.black),
),
primarySwatch: Colors.blue,
),
The following codes are mainly responsible for the appearance of our application, the fontFamily property specifies the type of font used in our application. iconTheme: specifies a theme for our icons and primarySwatch
is similar to the primary Color.
home: HomePage()
This line tells android studio that the main body of the page should utilize the HomePage class we created in the ViewControllers package.
Now Run your Flutter note app and launch the note application to start showing off to your friends.
Conclusion
Building applications with flutter is a lot easier and flutter comes with lots of strings attached to make you stay on it.
The UI looks a lot better since Flutter comes pre-loaded with a modern react-style framework, a 2D rendering engine, and lots of other packages which basically reduces the time it takes to build an application from scratch.
You can find the complete source code for our flutter note app here