Journaling is a deeply personal experience where one writes his/her thoughts, feelings, and reflections. So creating a seamless and secure way to record and store entries is critical for any journal app. Last week I focused on the login and authentication of the the app. For the second phase of building a mental health journal app, I turned my attention to building the heart of the app: The journal entry.
My focus was on building a rich text editor where users can freely express themselves and implementing a tagging system to help them organize their thoughts, and working on securely storing entries in Firebase Firestore to ensure data is both safe and easily accessible. I also added a user profile screen, enabling users to view and update their personal information.
In this post I walk you through how I implemented these features, from setting up the Flutter Quill editor to integrating the user profile. Let’s get started!
Designing the UI
For the User Interface, I started with the dashboard, a scaffold or landing page of the app, with tabs for each of the main features of the app. In this case the main features of the app are the Journal, Insights, and Profile.
For the second phase of the building this journal app I primarily focused on the journal and profile features. Let’s talk a look at the Journal feature.
Journal Tab
The journal feature is the main feature of the app and consist of three screens: the home screen, the editor screen, and the detail screen
Journal Home Screen
The journal home screen is the main screen of the feature and consists of
- Search Bar: This gives users the option to search a specific journal entry they’re looking for
- Entries List Section: A list of journal entry cards, each card with the title, content preview, the date it was created, and colored circle icon to indicate the mood of the entry
- Floating Action Button: a floating action button to create a new journal entry
Journal Editor Screen
The editor screen is the heart of the mental health journal app, a space where users can express themselves, organize their thoughts, and reflect. I created a clean and intuitive UI that includes
- Title Section: A text field for the title,
- A Rich Text Editor: Using the Flutter Quill package, I added a feature rich text editor that supports formatting options such as bold, italic, and bullet points
- Mood Selector Section: Below the editor, I added a drop down menu to select the mood when writing down thoughts. This allows users to log and track their emotional state when creating a journal entry. It’s a way for users to reflect on how they’re feeling at the moment and can later be used to generate mood trends and insights.
- Tags Section: Below the mood section, users can add tags which serves as a way to categorize and organize their journal entries
Journal Entry Detail Screen
Tapping on one of the journal entries leads you to the detail screen, which displays the title and content of the entry, the date it was created, the mood, and tags.
Profile Tab
The user profile screen plays a crucial role in allowing users to manage their personal information and preferences within the app. This section outlines how I integrated the profile feature, which includes displaying user details, enabling profile editing, and managing account settings.
The profile screen provides the following functionalities:
- Viewing Profile Information: users can view their profile picture, name and email.
- Editing Profile Information: users can update their personal details through the “Editing Profile” button.
- Account Management: options to change the password, manage notification preferences, and view the privacy policy are included
- Account Deletion and Sign-Out: Users can securely log out or delete their accounts if needed
The Development Process
Following clean architecture and Test Driven Development, I started by implementing the logic of the journal feature.
Domain Layer
The first step was to create the JournalEntry entity…
import 'package:equatable/equatable.dart';
class JournalEntry extends Equatable {
const JournalEntry({
required this.id,
required this.userId,
required this.content,
required this.dateCreated,
required this.tags,
required this.sentiment,
this.title,
this.titleLowercase,
});
JournalEntry.empty()
: this(
id: '',
userId: '',
content: '',
dateCreated: DateTime.now(),
tags: const [],
sentiment: '',
);
final String id;
final String userId;
final String? title;
final String? titleLowercase;
final String content;
final DateTime dateCreated;
final List<String> tags;
final String sentiment;
@override
List<Object?> get props => [
id,
userId,
title,
titleLowercase,
content,
dateCreated,
tags,
sentiment,
];
@override
String toString() => '''
JournalEntry {
id: $id,
userId: $userId,
title: $title,
title_lowercase: $titleLowercase
content: $content,
dateCreated: $dateCreated,
tags: $tags,
sentiment: $sentiment,
}
''';
}
As well as the repository contract, where I defined the methods for the logic of the feature
abstract class JournalRepository {
const JournalRepository();
ResultVoid createEntry({
required JournalEntry entry,
});
ResultVoid updateEntry({
required String entryId,
required UpdateEntryAction action,
required dynamic entryData,
});
ResultVoid deleteEntry({required String id});
ResultFuture<List<JournalEntry>> searchEntries(String query);
ResultStream<List<JournalEntry>> getEntries({
required String userId,
required JournalEntry? lastEntry,
required int paginationSize,
});
}
In the previous blog post I explained that ResultVoid and ResultFuture are types I’ve created to reduce repetition. This week I added ResultStream to handle.
I wanted the UI to react immediately to new, modified, or deleted journal entries, so I made getEntries return a Stream instead of Future .
import 'package:dartz/dartz.dart';
import 'package:mental_health_journal_app/core/errors/failures.dart';
typedef ResultFuture<T> = Future<Either<Failure, T>>;
typedef ResultStream<T> = Stream<Either<Failure, T>>;
typedef ResultVoid = ResultFuture<void>;
typedef DataMap = Map<String, dynamic>;
Following TDD I then created tests before implementing the use cases for those method.
Data Layer
In the data layer I wrote the implementation for the journal repository following Test Driven Development.
class JournalRepositoryImpl implements JournalRepository {
JournalRepositoryImpl(this._remoteDataSource);
final JournalRemoteDataSource _remoteDataSource;
@override
ResultVoid createEntry({
required JournalEntry entry,
}) async {
...
}
@override
ResultVoid deleteEntry({required String id}) async {
...
}
@override
ResultFuture<List<JournalEntry>> searchEntries(String query) async {
...
}
@override
ResultVoid updateEntry({
required String entryId,
required UpdateEntryAction action,
required dynamic entryData,
}) async {
...
}
@override
ResultStream<List<JournalEntry>> getEntries({
required String userId,
required JournalEntry? lastEntry,
required int paginationSize,
}) {
return _remoteDataSource
.getEntries(
userId: userId,
lastEntry: lastEntry,
paginationSize: paginationSize,
)
.transform(
StreamTransformer<List<JournalEntryModel>, Either<Failure, List<JournalEntry>>>.fromHandlers(
handleData: (entries, sink) {
sink.add(Right(entries));
},
handleError: (error, stackTrace, sink) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
if (error is GetEntriesException) {
sink.add(Left(GetEntriesFailure.fromException(error)));
} else {
sink.add(
Left(
GetEntriesFailure(
message: error.toString(),
statusCode: 505,
),
),
);
}
},
),
);
}
}
Because there might potentially have a lot of entries, I decided to incorporate pagination in getEntries. This improves the apps performance and creates a better user experience by reducing the loading time.
In the remote data source, the getEntries method fetches and returns entries from Firebase Firestore, ordering it from most recent to least recent.
abstract class JournalRemoteDataSource {
Future<void> createEntry({
required JournalEntry entry,
});
Future<void> updateEntry({
required String entryId,
required UpdateEntryAction action,
required dynamic entryData,
});
Future<void> deleteEntry({required String entryId});
Future<List<JournalEntry>> searchEntries(String query);
Stream<List<JournalEntryModel>> getEntries({
required String userId,
required JournalEntry? lastEntry,
required int paginationSize,
});
}
class JournalRemoteDataSourceImpl implements JournalRemoteDataSource {
JournalRemoteDataSourceImpl({
required FirebaseAuth authClient,
required FirebaseFirestore firestoreClient,
}) : _authClient = authClient,
_firestoreClient = firestoreClient;
final FirebaseFirestore _firestoreClient;
final FirebaseAuth _authClient;
@override
Future<void> createEntry({required JournalEntry entry}) async {
...
}
@override
Future<void> deleteEntry({required String entryId}) async {
return _entries.doc(entryId).delete();
}
@override
Future<List<JournalEntry>> searchEntries(String query) async {
...
}
@override
Stream<List<JournalEntryModel>> getEntries({
required String userId,
required JournalEntry? lastEntry,
required int paginationSize,
}) {
try {
var entriesQuery = _entries
.where('userId', isEqualTo: userId)
.orderBy(
'dateCreated',
descending: true,
)
.limit(paginationSize);
if (lastEntry != null) {
entriesQuery = entriesQuery.startAfter([lastEntry.dateCreated]);
}
final entriesStream = entriesQuery.snapshots().map(
(snapshot) => snapshot.docs
.map(
(doc) => JournalEntryModel.fromMap(doc.data()),
)
.toList(),
);
return entriesStream.handleError(_handleStreamError);
} on FirebaseException catch (e, s) {
debugPrintStack(stackTrace: s);
throw GetEntriesException(
message: e.message ?? 'Unknown error occurred',
statusCode: '501',
);
} on GetEntriesException {
rethrow;
} catch (e, s) {
debugPrintStack(stackTrace: s);
throw GetEntriesException(
message: e.toString(),
statusCode: '505',
);
}
}
void _handleStreamError(dynamic error) {
if (error is FirebaseException) {
debugPrintStack(stackTrace: error.stackTrace, label: error.code);
} else {
debugPrint(error.toString());
throw GetEntriesException(
message: error.toString(),
statusCode: '505',
);
}
}
@override
Future<void> updateEntry({
required String entryId,
required UpdateEntryAction action,
required dynamic entryData,
}) async {
...
}
Presentation Layer
When dealing with streams, I have found that cubit are easier to test. So I created a journal cubit to handle the app state.
class JournalCubit extends Cubit<JournalState> {
JournalCubit({
required CreateJournalEntry createJournalEntry,
required DeleteJournalEntry deleteJournalEntry,
required UpdateJournalEntry updateJournalEntry,
required GetJournalEntries getJournalEntries,
}) : _createJournalEntry = createJournalEntry,
_deleteJournalEntry = deleteJournalEntry,
_updateJournalEntry = updateJournalEntry,
_getJournalEntries = getJournalEntries,
super(JournalInitial());
final CreateJournalEntry _createJournalEntry;
final DeleteJournalEntry _deleteJournalEntry;
final UpdateJournalEntry _updateJournalEntry;
final GetJournalEntries _getJournalEntries;
Future<void> createEntry({required JournalEntry entry}) async {
emit(const JournalLoading());
final result = await _createJournalEntry(entry);
result.fold(
(failure) => emit(JournalError(message: failure.message)),
(success) => emit(const EntryCreated()),
);
}
Future<void> deleteEntry({required String entryId}) async {
emit(const JournalLoading());
final result = await _deleteJournalEntry(entryId);
result.fold(
(failure) => emit(JournalError(message: failure.message)),
(success) => emit(const EntryDeleted()),
);
}
Future<void> updateEntry({
required String entryId,
required UpdateEntryAction action,
required dynamic entryData,
}) async {
emit(const JournalLoading());
final result = await _updateJournalEntry(
UpdateJournalEntryParams(
entryId: entryId,
action: action,
entryData: entryData,
),
);
result.fold(
(failure) => emit(JournalError(message: failure.message)),
(success) => emit(const EntryUpdated()),
);
}
StreamSubscription<Either<Failure, List<JournalEntry>>>? subscription;
List<JournalEntry> _entriesList = [];
JournalEntry? _lastEntry;
bool _hasReachedEnd = false;
void getEntries({required String userId, bool loadMore = false}) {
if (_hasReachedEnd && loadMore) return;
emit(const JournalLoading());
subscription?.cancel();
subscription = _getJournalEntries(
GetJournalEntriesParams(
userId: userId,
lastEntry: loadMore ? _lastEntry : null,
paginationSize: 10,
),
).listen(
(result) {
result.fold(
(failure) {
debugPrint(failure.message);
emit(JournalError(message: failure.message));
subscription?.cancel();
},
(entries) async {
if (loadMore) {
_entriesList.addAll(entries);
} else {
_entriesList = entries;
}
_hasReachedEnd = entries.isEmpty || entries.length < 10;
if (entries.isNotEmpty) {
_lastEntry = entries.last;
}
emit(
EntriesFetched(
entries: _entriesList,
hasReachedEnd: _hasReachedEnd,
),
);
},
);
},
onError: (dynamic error) {
emit(
const JournalError(
message: 'Failed to fetch entries',
),
);
},
onDone: () {
subscription?.cancel();
},
);
}
@override
Future<void> close() {
subscription?.cancel();
return super.close();
}
}
Implementing the Rich Text Editor
For the text editor, I used the flutter_quill package because of its rich formatting options and ease of integration. Here are the basic of how I set it up:
Added the package to pubspec.yaml:
dependencies:
flutter_quill: ^10.8.5
Created a QuillController to manage the editor’s content
final _contentController = QuillController.basic();
Added a toolbar to enable formatting options
QuillSimpleToolbar(
controller: _contentController,
configurations: const QuillSimpleToolbarConfigurations(
multiRowsDisplay: false,
),
)
Integrated the editor into the journal entry screen using QuillEditor.basic
QuillEditor.basic(
controller: _contentController,
configurations: QuillEditorConfigurations(
minHeight: 250,
maxHeight: 250,
onTapOutside: (event, focusNode) {
focusNode.unfocus();
},
placeholder: 'Start writing your thoughts here..',
),
),
You can checkout the full code for the journal entry screen and the rest of the project here.
Testing
To make sure the journal feature is clean and maintainable, like the authentication feature, I wrote unit tests for every layer, following TDD.
Challenges and Lessons Learned
The biggest challenge for me ended up being fetching the list of entries.
I initially used bloc instead of cubit. While writing tests for it was a little more difficult with the GetJournalEntries use case returning a stream, I manage to do it.
However I kept running into a bug; I kept receiving duplicate entries. I spent a lot of time trying to figure out where it was coming from. I changed the bloc to a cubit and while this simplified the code, the bug persisted.
After a few days I figured out that the issue was at the data layer, with how I was implementing pagination. In the orderBy() method I passed ‘dateCreated’, but passed the last entry’s id in the startAfter() method instead of the dateCreated. When those don’t match Firebase just send you data from the begin of the list, hence the duplicate entries.
class JournalRemoteDataSourceImpl implements JournalRemoteDataSource {
JournalRemoteDataSourceImpl({
required FirebaseAuth authClient,
required FirebaseFirestore firestoreClient,
}) : _authClient = authClient,
_firestoreClient = firestoreClient;
...
@override
Stream<List<JournalEntryModel>> getEntries({
required String userId,
required JournalEntry? lastEntry,
required int paginationSize,
}) {
try {
var entriesQuery = _entries
.where('userId', isEqualTo: userId)
.orderBy(
'dateCreated',
descending: true,
)
.limit(paginationSize);
if (lastEntry != null) {
entriesQuery = entriesQuery.startAfter([lastEntry.id]); // This was the sourse of the bug. It should be lastEntry.dateCreated
}
final entriesStream = entriesQuery.snapshots().map(
(snapshot) => snapshot.docs
.map(
(doc) => JournalEntryModel.fromMap(doc.data()),
)
.toList(),
);
return entriesStream.handleError(_handleStreamError);
} on FirebaseException catch (e, s) {
debugPrintStack(stackTrace: s);
throw GetEntriesException(
message: e.message ?? 'Unknown error occurred',
statusCode: '501',
);
} on GetEntriesException {
rethrow;
} catch (e, s) {
debugPrintStack(stackTrace: s);
throw GetEntriesException(
message: e.toString(),
statusCode: '505',
);
}
}
Conclusion
Building an intuitive and secure journal entry system was a key step in developing this mental health app and was pretty fun. With features like a rich text editor, tagging for organization, secure storage, and user profiles, users can journal freely while ensuring their data is safe,
In the next steps of our journey, we’ll expand the app’s functionality to offer deeper insights into users’ mental health. This includes integrating sentiment analysis for journal entries, enabling the app to automatically classify moods as positive, neutral, or negative. These insights will provide users with a clearer understanding of their emotional patterns over time.
Additionally, we’ll build an analytics dashboard that visualizes trends using intuitive charts and graphs. This feature will empower users to see their emotional highs and lows, identify triggers, and celebrate progress in their mental health journey.